@ngockhoale/ukit 2.7.1 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -3
  3. package/manifests/hostCapabilities.yaml +0 -10
  4. package/manifests/instructionRules.yaml +0 -16
  5. package/manifests/platform.full.yaml +2 -16
  6. package/package.json +3 -5
  7. package/scripts/perf/audit-perf.mjs +4 -4
  8. package/scripts/perf/perf-findings.json +420 -0
  9. package/src/cli/adapters.js +10 -13
  10. package/src/cli/commands/doctor.js +60 -10
  11. package/src/cli/index.js +2 -2
  12. package/src/core/ompConfigMerge.js +19 -0
  13. package/src/core/opencodeSteer.js +37 -0
  14. package/src/core/permissionDoctor.js +319 -0
  15. package/src/core/permissionPolicy.js +179 -0
  16. package/src/core/projectImportant.js +14 -8
  17. package/src/core/runInstallPipeline.js +25 -0
  18. package/src/core/runtimeConfig.js +43 -4
  19. package/src/core/status.js +6 -4
  20. package/src/core/unattendedDoctor.js +9 -5
  21. package/src/manifest/selectItems.js +0 -1
  22. package/src/render/buildVariables.js +10 -1
  23. package/templates/.claude/agents/bug-debugger.md +1 -1
  24. package/templates/.claude/agents/feature-implementer.md +1 -1
  25. package/templates/.claude/agents/ukit-small-task-maintainer.md +2 -2
  26. package/templates/.claude/commands/ukit/handoff-create.md +4 -4
  27. package/templates/.claude/commands/ukit/handoff-fullstack.md +4 -4
  28. package/templates/.claude/commands/ukit/handoff-implement.md +2 -2
  29. package/templates/.claude/commands/ukit/handoff-review.md +2 -2
  30. package/templates/.claude/settings.json +14 -6
  31. package/templates/.claude/skills/update-status/SKILL.md +1 -1
  32. package/templates/.claude/ukit/index/route-task.mjs +7 -2
  33. package/templates/.claude/ukit/skill-router-state.json +1 -0
  34. package/templates/.codex/README.md +1 -1
  35. package/templates/.codex/settings.json +0 -1
  36. package/templates/.omp/agents/bug-debugger.md +1 -1
  37. package/templates/.omp/agents/feature-implementer.md +1 -1
  38. package/templates/.omp/agents/ukit-small-task-maintainer.md +2 -2
  39. package/templates/.omp/config.yml +13 -7
  40. package/templates/.ukit/storage/cache/hook-latency/unknown.jsonl +4 -0
  41. package/templates/AGENTS.md +4 -13
  42. package/templates/CLAUDE.md +1 -3
  43. package/templates/adapter-presets/claude/settings.local.json +0 -1
  44. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  45. package/templates/docs/AI_HANDOFF/RULES.md +2 -2
  46. package/templates/docs/PROJECT.md +1 -1
  47. package/templates/docs/UKIT_INTERNALS.md +1 -1
  48. package/templates/instructions/core.md +1 -3
  49. package/templates/instructions/layout.yaml +2 -4
  50. package/templates/instructions/overlays/agents.md +3 -10
  51. package/templates/ukit/storage/config.json +2 -10
  52. package/templates/adapter-presets/opencode/opencode.template.json +0 -1
@@ -23,10 +23,25 @@ function emptyReport() {
23
23
  addedModelRoleKeys: [],
24
24
  preservedTopLevelKeys: [],
25
25
  preservedPatterns: [],
26
+ // TASK-002 / FR-002 — every `{{token}}` left in the merged output. A token
27
+ // survives when renderTemplateString could not resolve it (unknown variable)
28
+ // or when it was spliced in verbatim from the existing file. The merge never
29
+ // fabricates a value; it reports the token so install can warn.
30
+ unresolvedTokens: [],
26
31
  parseError: null,
27
32
  };
28
33
  }
29
34
 
35
+ const UNRESOLVED_TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
36
+
37
+ function recordUnresolvedTokens(text, report) {
38
+ for (const match of text.matchAll(UNRESOLVED_TOKEN_PATTERN)) {
39
+ if (!report.unresolvedTokens.includes(match[1])) {
40
+ report.unresolvedTokens.push(match[1]);
41
+ }
42
+ }
43
+ }
44
+
30
45
  // Extract a top-level scalar map's value span: the `key:` line plus following
31
46
  // lines that are indented or blank. Stops before the next non-indented,
32
47
  // non-blank line — comments preceding the next top-level key stay with it.
@@ -72,10 +87,12 @@ export function mergeOmpConfig(existingText, renderedText) {
72
87
  existing = YAML.parse(existingText);
73
88
  } catch (error) {
74
89
  report.parseError = error?.message ?? String(error);
90
+ recordUnresolvedTokens(renderedText, report);
75
91
  return { text: renderedText, report };
76
92
  }
77
93
  if (!isPlainObject(existing)) {
78
94
  report.parseError = 'existing .omp/config.yml did not parse to a mapping';
95
+ recordUnresolvedTokens(renderedText, report);
79
96
  return { text: renderedText, report };
80
97
  }
81
98
 
@@ -86,6 +103,7 @@ export function mergeOmpConfig(existingText, renderedText) {
86
103
  // A broken template is not a migration problem — surface it as parseError so
87
104
  // the caller falls back to plain overwrite rather than crashing install.
88
105
  report.parseError = `rendered template failed to parse: ${error?.message ?? error}`;
106
+ recordUnresolvedTokens(renderedText, report);
89
107
  return { text: renderedText, report };
90
108
  }
91
109
 
@@ -242,6 +260,7 @@ export function mergeOmpConfig(existingText, renderedText) {
242
260
  }
243
261
 
244
262
  const text = lines.join('\n');
263
+ recordUnresolvedTokens(text, report);
245
264
  // "migrated" = the merge changed anything relative to what is on disk —
246
265
  // includes the approval-surface rewrite itself (legacy approvalMode, prompt
247
266
  // values) even when no user region needed splicing.
@@ -0,0 +1,37 @@
1
+ // SPEC FR-004 — OpenCode support was removed. Any leftover artifact is a user
2
+ // file: UKit never installs, repairs, merges, or deletes it (only `ukit
3
+ // uninstall` may). This helper is the single source of truth for detection and
4
+ // the canonical steer message shared by projectImportant, status, and doctor.
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ // Extensible list — detection is one existsSync per known filename, no parsing.
10
+ const OPENCODE_ARTIFACT_NAMES = ['opencode.json'];
11
+
12
+ /**
13
+ * @param {string} projectRoot
14
+ * @returns {string[]} absolute paths of detected OpenCode artifacts
15
+ */
16
+ export function findOpencodeArtifacts(projectRoot) {
17
+ return OPENCODE_ARTIFACT_NAMES
18
+ .map((name) => path.join(projectRoot, name))
19
+ .filter((candidate) => existsSync(candidate));
20
+ }
21
+
22
+ /**
23
+ * Canonical steer copy. Callers must print this verbatim so every surface
24
+ * (status line, doctor WARN row, stderr warning) reads identically.
25
+ *
26
+ * @param {string[]} paths detected artifact paths from findOpencodeArtifacts
27
+ * @returns {string}
28
+ */
29
+ export function opencodeSteerMessage(paths = []) {
30
+ const names = (Array.isArray(paths) ? paths : [])
31
+ .map((entry) => path.basename(String(entry)))
32
+ .filter(Boolean);
33
+ const fileList = names.length > 0 ? names.join(' ') : 'opencode.json';
34
+ return `[ukit] ${fileList} detected — OpenCode is no longer supported. `
35
+ + `ukit will not install, repair, or merge this file. `
36
+ + `Remove it manually: rm ${fileList}`;
37
+ }
@@ -0,0 +1,319 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { parse } from 'yaml';
4
+ import { pathExists, readJsonIfExists } from './fileOps.js';
5
+ import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
6
+ import {
7
+ compilePermissionPolicy,
8
+ PERMISSION_HOSTS,
9
+ CANONICAL_DENY,
10
+ } from './permissionPolicy.js';
11
+ import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
12
+
13
+ // TASK-006 / SPEC §5 FR-006 — unified permission report across claude/omp/codex
14
+ // UKit-managed surfaces. Reads the live surfaces, compiles the semantic policy
15
+ // (single source of truth — drift between render and doctor is impossible), and
16
+ // emits { declared, effective, hosts, checks, failures } for both the human
17
+ // doctor section and `--json`.
18
+ //
19
+ // FAIL classes (each → named failing check + report.failures entry + exit 1):
20
+ // unattended-prompt prompt/ask reachable while permissionMode=unattended
21
+ // deny-loss canonical deny entry missing from a host surface
22
+ // unsafe-eval-mismatch surface eval approval ≠ compiled evalPosture
23
+ // unrendered-token literal {{token}} left in a rendered artifact
24
+ // INFO: reachable prompts under interactive/safe-auto. SKIP: absent host surface.
25
+
26
+ const UNRESOLVED_TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
27
+
28
+ // Bounded scan list — UKit-managed rendered artifacts only (SPEC §6 perf).
29
+ const RENDERED_ARTIFACTS = [
30
+ '.omp/config.yml',
31
+ '.claude/settings.json',
32
+ '.claude/settings.local.json',
33
+ '.codex/settings.json',
34
+ '.codex/settings.local.json',
35
+ ];
36
+
37
+ async function readOmpConfig(projectRoot) {
38
+ const configPath = path.join(projectRoot, '.omp', 'config.yml');
39
+ try {
40
+ const text = await fs.readFile(configPath, 'utf8');
41
+ const parsed = parse(text);
42
+ return { exists: true, parsed: parsed && typeof parsed === 'object' ? parsed : {}, parseError: null };
43
+ } catch (error) {
44
+ if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
45
+ return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
46
+ }
47
+ }
48
+
49
+ async function readClaudeSurface(projectRoot) {
50
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
51
+ const settings = await readJsonIfExists(settingsPath);
52
+ const permissions = settings?.permissions;
53
+ if (!permissions || typeof permissions !== 'object') {
54
+ return { present: false, deny: [], allow: [], ask: [] };
55
+ }
56
+ const list = (key) => (Array.isArray(permissions[key]) ? permissions[key].filter((e) => typeof e === 'string') : []);
57
+ return {
58
+ present: true,
59
+ deny: list('deny'),
60
+ allow: list('allow'),
61
+ ask: list('ask'),
62
+ };
63
+ }
64
+
65
+ async function scanUnresolvedTokens(projectRoot) {
66
+ const hits = [];
67
+ for (const relPath of RENDERED_ARTIFACTS) {
68
+ let text;
69
+ try {
70
+ text = await fs.readFile(path.join(projectRoot, relPath), 'utf8');
71
+ } catch {
72
+ continue; // absent artifact — not a token hit
73
+ }
74
+ for (const match of text.matchAll(UNRESOLVED_TOKEN_PATTERN)) {
75
+ hits.push({ file: relPath, token: match[1] });
76
+ }
77
+ }
78
+ return hits;
79
+ }
80
+
81
+ export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
82
+ const runtimeConfig = await readJsonIfExists(
83
+ path.join(projectRoot, '.ukit', 'storage', 'config.json'),
84
+ );
85
+ const declaredRaw = runtimeConfig?.orchestration?.permissionMode;
86
+ const declared = declaredRaw === undefined ? 'unattended' : declaredRaw;
87
+ const modeValid = VALID_PERMISSION_MODES.has(declared);
88
+ const allowUnsafeEval = runtimeConfig?.orchestration?.allowUnsafeEval === true;
89
+
90
+ // --- Surface reads ---------------------------------------------------------
91
+ const omp = await readOmpConfig(projectRoot);
92
+ const ompTools = omp.parsed?.tools ?? {};
93
+ const approvalMap = ompTools.approval && typeof ompTools.approval === 'object' ? ompTools.approval : {};
94
+ const bashPatterns = Array.isArray(omp.parsed?.bash?.patterns) ? omp.parsed.bash.patterns : [];
95
+
96
+ const ompDeny = bashPatterns
97
+ .filter((e) => e && e.approval === 'deny' && typeof e.match === 'string')
98
+ .map((e) => e.match);
99
+ const ompAllow = bashPatterns
100
+ .filter((e) => e && e.approval === 'allow' && typeof e.match === 'string')
101
+ .map((e) => e.match);
102
+ // Reachable prompt/ask entries on the omp surface — named so the report can
103
+ // point at the offending key instead of a bare count.
104
+ const ompPromptEntries = [];
105
+ if (ompTools.approvalMode === 'prompt' || ompTools.approvalMode === 'ask') {
106
+ ompPromptEntries.push('tools.approvalMode');
107
+ }
108
+ for (const [key, value] of Object.entries(approvalMap)) {
109
+ if (value === 'prompt' || value === 'ask') ompPromptEntries.push(`tools.approval.${key}`);
110
+ }
111
+ for (const entry of bashPatterns) {
112
+ if (entry && (entry.approval === 'prompt' || entry.approval === 'ask')) {
113
+ ompPromptEntries.push(`bash.patterns:${entry.match ?? '?'}`);
114
+ }
115
+ }
116
+ const ompEvalSurface = approvalMap.eval;
117
+
118
+ const claude = await readClaudeSurface(projectRoot);
119
+ const codexPresent = await pathExists(path.join(projectRoot, '.codex'));
120
+
121
+ // --- Per-host compiled policy ---------------------------------------------
122
+ const hosts = {};
123
+ const policies = {};
124
+ const surfaceInput = {
125
+ omp: omp.exists
126
+ ? {
127
+ deny: ompDeny,
128
+ allow: ompAllow,
129
+ ask: ompPromptEntries,
130
+ // compile throws on non-allow/deny values — prompt/ask eval is already
131
+ // counted as a reachable prompt, so only feed the compiler a real
132
+ // posture value.
133
+ ...(ompEvalSurface === 'allow' || ompEvalSurface === 'deny'
134
+ ? { evalApproval: ompEvalSurface }
135
+ : {}),
136
+ }
137
+ : null,
138
+ claude: claude.present
139
+ ? { deny: claude.deny, allow: claude.allow, ask: claude.ask }
140
+ : null,
141
+ codex: codexPresent ? {} : null,
142
+ };
143
+
144
+ for (const host of PERMISSION_HOSTS) {
145
+ const present = surfaceInput[host] !== null;
146
+ if (!present) {
147
+ hosts[host] = { present: false, enforcement: host === 'codex' ? 'report-only' : 'enforced' };
148
+ continue;
149
+ }
150
+ let policy = null;
151
+ let compileError = null;
152
+ if (modeValid) {
153
+ try {
154
+ policy = compilePermissionPolicy({
155
+ permissionMode: declared,
156
+ allowUnsafeEval,
157
+ host,
158
+ surfaces: surfaceInput[host],
159
+ });
160
+ } catch (error) {
161
+ compileError = error?.message ?? String(error);
162
+ }
163
+ }
164
+ policies[host] = policy;
165
+
166
+ const surfaceDeny = surfaceInput[host]?.deny ?? [];
167
+ const denySet = new Set(surfaceDeny);
168
+ const denyMissing = CANONICAL_DENY
169
+ .map((c) => c[host])
170
+ .filter((entry) => typeof entry === 'string' && !denySet.has(entry));
171
+
172
+ const promptCount = host === 'omp'
173
+ ? ompPromptEntries.length
174
+ : (surfaceInput[host]?.ask ?? []).length;
175
+
176
+ hosts[host] = {
177
+ present: true,
178
+ enforcement: policy?.enforcement ?? (host === 'codex' ? 'report-only' : 'enforced'),
179
+ effectiveMode: policy?.effectiveMode ?? declared,
180
+ allowsPrompts: policy?.allowsPrompts ?? null,
181
+ promptCount,
182
+ promptEntries: surfaceInput[host]?.ask ?? [],
183
+ denyCount: surfaceDeny.length,
184
+ denyMissing,
185
+ evalPosture: policy?.evalPosture ?? null,
186
+ evalSurface: host === 'omp' ? (ompEvalSurface ?? null) : null,
187
+ warnings: policy?.warnings ?? [],
188
+ ...(compileError ? { compileError } : {}),
189
+ };
190
+ }
191
+
192
+ const unresolvedTokens = await scanUnresolvedTokens(projectRoot);
193
+ const legacyArtifacts = findOpencodeArtifacts(projectRoot);
194
+
195
+ // --- Checks ----------------------------------------------------------------
196
+ const failures = [];
197
+ const anySurface = PERMISSION_HOSTS.some((h) => hosts[h].present);
198
+ const totalPrompts = PERMISSION_HOSTS.reduce((n, h) => n + (hosts[h].promptCount ?? 0), 0);
199
+ const promptsFail = declared === 'unattended' && totalPrompts > 0;
200
+ // Report-only hosts (codex) have no permission surface by design — counting
201
+ // their canonical deny set as "missing" would make deny-loss FAIL permanent
202
+ // with an impossible remedy. Only enforced surfaces feed deny-loss.
203
+ const denyMissingAll = PERMISSION_HOSTS
204
+ .filter((h) => hosts[h].enforcement === 'enforced')
205
+ .flatMap((h) => (hosts[h].denyMissing ?? []).map((entry) => `${h}:${entry}`));
206
+ const evalMismatch = PERMISSION_HOSTS.some((h) =>
207
+ (hosts[h].warnings ?? []).includes('unsafe-eval-mismatch'));
208
+ const driftWarnings = PERMISSION_HOSTS.flatMap((h) =>
209
+ (hosts[h].warnings ?? [])
210
+ .filter((w) => w !== 'unsafe-eval-mismatch' && w !== 'prompt-reachable-under-unattended')
211
+ .map((w) => `${h}:${w}`));
212
+
213
+ if (promptsFail) failures.push('unattended-prompt');
214
+ if (denyMissingAll.length > 0) failures.push('deny-loss');
215
+ if (evalMismatch) failures.push('unsafe-eval-mismatch');
216
+ if (unresolvedTokens.length > 0) failures.push('unrendered-token');
217
+ if (!modeValid) failures.push('invalid-permission-mode');
218
+
219
+ const check = (label, passed, remediationClass, remedy, extra = {}) => ({
220
+ label,
221
+ passed,
222
+ failed: !passed,
223
+ remediationClass,
224
+ remedy,
225
+ ...extra,
226
+ });
227
+
228
+ const checks = [
229
+ check(
230
+ 'permission mode declared and valid',
231
+ modeValid,
232
+ 'owner-action',
233
+ `Set orchestration.permissionMode in .ukit/storage/config.json to one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`,
234
+ { detail: `declared: ${declared}${declaredRaw === undefined ? ' (default)' : ''} → effective: ${modeValid ? declared : 'unknown'}` },
235
+ ),
236
+ check(
237
+ 'no prompts reachable under unattended mode',
238
+ !promptsFail,
239
+ 'owner-action',
240
+ 'Run ukit install to strip prompt/ask approvals, or switch orchestration.permissionMode to interactive/safe-auto.',
241
+ {
242
+ ...(anySurface ? {} : { applicable: false, detail: 'no host permission surfaces installed' }),
243
+ ...(anySurface && !promptsFail && totalPrompts > 0
244
+ ? { severity: 'info' }
245
+ : {}),
246
+ detail: anySurface
247
+ ? `${totalPrompts} reachable prompt/ask entr(ies)${declared === 'unattended' ? '' : ` (mode ${declared} allows prompts)`}`
248
+ : 'no host permission surfaces installed',
249
+ },
250
+ ),
251
+ check(
252
+ 'deny coverage vs canonical set',
253
+ denyMissingAll.length === 0,
254
+ 'install-repairable',
255
+ 'Run ukit install to restore the canonical deny entries on host surfaces.',
256
+ {
257
+ ...(anySurface ? {} : { applicable: false }),
258
+ detail: denyMissingAll.length === 0
259
+ ? 'all canonical deny entries present'
260
+ : `missing: ${denyMissingAll.join(', ')}`,
261
+ },
262
+ ),
263
+ check(
264
+ 'eval posture matches allowUnsafeEval',
265
+ !evalMismatch,
266
+ 'install-repairable',
267
+ `Run ukit install to re-render eval approval (compiled posture: ${allowUnsafeEval ? 'allow' : 'deny'}), or flip orchestration.allowUnsafeEval.`,
268
+ {
269
+ ...(hosts.omp.present ? {} : { applicable: false, detail: '.omp/config.yml absent' }),
270
+ detail: hosts.omp.present
271
+ ? `knob=${allowUnsafeEval} → expected ${allowUnsafeEval ? 'allow' : 'deny'}, surface eval=${ompEvalSurface ?? 'absent'}`
272
+ : '.omp/config.yml absent',
273
+ },
274
+ ),
275
+ check(
276
+ 'no unresolved {{token}} in rendered artifacts',
277
+ unresolvedTokens.length === 0,
278
+ 'install-repairable',
279
+ 'Run ukit install to re-render and heal literal {{tokens}}; unknown tokens are reported, never fabricated.',
280
+ {
281
+ detail: unresolvedTokens.length === 0
282
+ ? 'no {{...}} literals in managed artifacts'
283
+ : unresolvedTokens.map((t) => `${t.file}:{{${t.token}}}`).join(', '),
284
+ },
285
+ ),
286
+ check(
287
+ 'no drift vs compiled policy',
288
+ driftWarnings.length === 0,
289
+ 'advisory',
290
+ 'Review the flagged surface entries against compilePermissionPolicy output.',
291
+ {
292
+ ...(driftWarnings.length > 0 ? { severity: 'warning' } : {}),
293
+ detail: driftWarnings.length === 0 ? 'surfaces consistent with compiled policy' : driftWarnings.join(', '),
294
+ },
295
+ ),
296
+ ];
297
+
298
+ if (legacyArtifacts.length > 0) {
299
+ checks.push({
300
+ label: opencodeSteerMessage(legacyArtifacts),
301
+ passed: true,
302
+ failed: false,
303
+ severity: 'warning',
304
+ remediationClass: 'advisory',
305
+ remedy: 'Remove it manually: rm opencode.json',
306
+ });
307
+ }
308
+
309
+ return {
310
+ declared,
311
+ effective: modeValid ? declared : 'unknown',
312
+ allowUnsafeEval,
313
+ hosts,
314
+ checks,
315
+ failures,
316
+ unresolvedTokens,
317
+ legacyArtifacts,
318
+ };
319
+ }
@@ -0,0 +1,179 @@
1
+ // UKIT_EPOLICY — pure compiler: orchestration.permissionMode +
2
+ // orchestration.allowUnsafeEval → deterministic per-host semantic policy for
3
+ // claude/omp/codex. No I/O: callers pass parsed surfaces (doctor reads files,
4
+ // tests pass fixtures). Consumed by doctor (FR-006) and the no-freeze matrix
5
+ // (FR-007) as the single semantic source of truth so host surfaces cannot
6
+ // drift. SPEC §5 FR-005 / §8 / §14.
7
+ import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
8
+
9
+ export const PERMISSION_HOSTS = Object.freeze(['claude', 'omp', 'codex']);
10
+ export const PERMISSION_MODES = VALID_PERMISSION_MODES;
11
+
12
+ // Precedence — higher tier always wins over a lower one for the same entry.
13
+ export const PERMISSION_PRECEDENCE = Object.freeze([
14
+ 'hard-deny', // catastrophic commands — deny regardless of any surface
15
+ 'protected-guard', // hook-guarded files/tools — outranks host-deny
16
+ 'host-deny', // surface deny rules (settings.json deny, bash.patterns deny)
17
+ 'explicit-allow', // surface allow rules
18
+ 'mode-fallback', // mode default action for unclassified entries
19
+ ]);
20
+
21
+ // Canonical deny set — semantic ids + per-host surface strings. Templates,
22
+ // doctor, and the matrix all assert against this so surfaces cannot lose an
23
+ // entry without detection. `hardDeny: true` entries are catastrophic-class
24
+ // (root/home/parent recursive deletes, dd/mkfs/fork-bomb) and are denied in
25
+ // every mode on every host, even report-only codex.
26
+ export const CANONICAL_DENY = Object.freeze([
27
+ { id: 'git-reset-hard', claude: 'Bash(git reset --hard:*)', omp: 'git reset --hard*', codex: 'git reset --hard' },
28
+ { id: 'git-push-force', claude: 'Bash(git push --force:*)', omp: 'git push --force*', codex: 'git push --force' },
29
+ { id: 'git-push-force-short', claude: 'Bash(git push -f:*)', omp: 'git push -f *', codex: 'git push -f' },
30
+ { id: 'git-push-force-with-lease', claude: 'Bash(git push --force-with-lease:*)', omp: 'git push --force-with-lease*', codex: 'git push --force-with-lease' },
31
+ { id: 'git-clean-fd', claude: 'Bash(git clean -fd:*)', omp: 'git clean -fd*', codex: 'git clean -fd' },
32
+ { id: 'git-checkout-dot', claude: 'Bash(git checkout .:*)', omp: 'git checkout .*', codex: 'git checkout .' },
33
+ { id: 'git-restore-dot', claude: 'Bash(git restore .:*)', omp: 'git restore .*', codex: 'git restore .' },
34
+ { id: 'rm-rf-root', claude: 'Bash(rm -rf /:*)', omp: 'rm -rf /*', codex: 'rm -rf /', hardDeny: true },
35
+ { id: 'rm-rf-home', claude: 'Bash(rm -rf ~:*)', omp: 'rm -rf ~*', codex: 'rm -rf ~', hardDeny: true },
36
+ { id: 'rm-rf-parent', claude: 'Bash(rm -rf ..:*)', omp: 'rm -rf ..', codex: 'rm -rf ..', hardDeny: true },
37
+ { id: 'rm-rf-dot', claude: 'Bash(rm -rf .:*)', omp: 'rm -rf .', codex: 'rm -rf .', hardDeny: true },
38
+ { id: 'rm-rf-any', claude: 'Bash(rm -rf:*)', omp: 'rm -rf *', codex: 'rm -rf' },
39
+ { id: 'dd-raw-device', claude: 'Bash(dd if=/dev/:*)', omp: 'dd if=/dev/*', codex: 'dd if=/dev/', hardDeny: true },
40
+ { id: 'write-raw-device', claude: 'Bash(*> /dev/sda:*)', omp: '*> /dev/sda*', codex: '> /dev/sda', hardDeny: true },
41
+ { id: 'mkfs', claude: 'Bash(mkfs:*)', omp: 'mkfs.*', codex: 'mkfs', hardDeny: true },
42
+ { id: 'fork-bomb', claude: 'Bash(:(){ :|:& };::*)', omp: ':(){ :|:& };:*', codex: ':(){ :|:& };:', hardDeny: true },
43
+ ]);
44
+
45
+ // Canonical protected-guard set — hook-enforced paths/tools that outrank any
46
+ // surface deny/allow. Semantic ids only; hook scripts remain the authority.
47
+ export const CANONICAL_PROTECTED = Object.freeze([
48
+ 'release-credentials', // npm tokens, signing keys, .env secrets
49
+ 'protected-files', // protect-files.sh guarded paths
50
+ 'stale-spec-guard', // spec/source drift guard on edits
51
+ ]);
52
+
53
+ const TIER_RANK = new Map(PERMISSION_PRECEDENCE.map((tier, i) => [tier, i]));
54
+
55
+ // Mode fallback for unclassified entries. `prompt` fallback is what makes a
56
+ // mode prompt-reachable; unattended never falls back to a prompt.
57
+ const MODE_FALLBACK = Object.freeze({
58
+ interactive: 'prompt',
59
+ 'safe-auto': 'prompt',
60
+ unattended: 'allow',
61
+ });
62
+
63
+ const MODE_PROMPT_CAPABLE = Object.freeze({
64
+ interactive: true,
65
+ 'safe-auto': true,
66
+ unattended: false,
67
+ });
68
+
69
+ // codex carries no UKit-managed permission block today — the compiler still
70
+ // returns a shaped policy so doctor/matrix can report it (SPEC §14).
71
+ const HOST_ENFORCEMENT = Object.freeze({
72
+ claude: 'enforced',
73
+ omp: 'enforced',
74
+ codex: 'report-only',
75
+ });
76
+
77
+ function policyError(message) {
78
+ return new Error(`UKIT_EPOLICY ${message}`);
79
+ }
80
+
81
+ /**
82
+ * compilePermissionPolicy({ permissionMode, allowUnsafeEval, host, surfaces? })
83
+ * → { mode, effectiveMode, host, enforcement, allowsPrompts, promptReachable,
84
+ * deny, allow, protections, evalPosture, rules, warnings }
85
+ *
86
+ * `surfaces` is an optional caller-parsed host surface:
87
+ * { deny: [...], allow: [...], protect: [...], ask: [...], evalApproval }
88
+ * Entries appearing in several tiers resolve to the highest precedence tier.
89
+ */
90
+ export function compilePermissionPolicy(input = {}) {
91
+ const { permissionMode, allowUnsafeEval = false, host, surfaces = {} } = input;
92
+
93
+ if (!VALID_PERMISSION_MODES.has(permissionMode)) {
94
+ throw policyError(`unknown permissionMode: ${JSON.stringify(permissionMode)} (expected one of: ${[...VALID_PERMISSION_MODES].join(', ')})`);
95
+ }
96
+ if (!PERMISSION_HOSTS.includes(host)) {
97
+ throw policyError(`unknown host: ${JSON.stringify(host)} (expected one of: ${PERMISSION_HOSTS.join(', ')})`);
98
+ }
99
+ if (allowUnsafeEval !== undefined && typeof allowUnsafeEval !== 'boolean') {
100
+ throw policyError(`allowUnsafeEval must be boolean, got: ${JSON.stringify(allowUnsafeEval)}`);
101
+ }
102
+ if (surfaces === null || typeof surfaces !== 'object' || Array.isArray(surfaces)) {
103
+ throw policyError('surfaces must be an object when provided');
104
+ }
105
+
106
+ const mode = permissionMode;
107
+ const effectiveMode = mode;
108
+ const enforcement = HOST_ENFORCEMENT[host];
109
+ const promptCapable = MODE_PROMPT_CAPABLE[mode];
110
+ const warnings = [];
111
+
112
+ // --- eval posture: allow iff the knob is explicitly true (fail closed). ---
113
+ const evalPosture = allowUnsafeEval === true ? 'allow' : 'deny';
114
+ if (surfaces.evalApproval !== undefined
115
+ && surfaces.evalApproval !== 'allow'
116
+ && surfaces.evalApproval !== 'deny') {
117
+ throw policyError(`surfaces.evalApproval must be 'allow' or 'deny', got: ${JSON.stringify(surfaces.evalApproval)}`);
118
+ }
119
+ if (surfaces.evalApproval !== undefined && surfaces.evalApproval !== evalPosture) {
120
+ warnings.push('unsafe-eval-mismatch');
121
+ }
122
+
123
+ // --- Tier collection. Each rule: { entry, tier, action, source }. ---
124
+ const byEntry = new Map();
125
+ const addRule = (entry, tier, action, source) => {
126
+ if (typeof entry !== 'string' || entry.length === 0) return;
127
+ const existing = byEntry.get(entry);
128
+ if (!existing || TIER_RANK.get(tier) < TIER_RANK.get(existing.tier)) {
129
+ byEntry.set(entry, { entry, tier, action, source });
130
+ }
131
+ };
132
+
133
+ for (const canonical of CANONICAL_DENY) {
134
+ const tier = canonical.hardDeny === true ? 'hard-deny' : 'host-deny';
135
+ addRule(canonical[host], tier, 'deny', `canonical:${canonical.id}`);
136
+ }
137
+ for (const guard of CANONICAL_PROTECTED) {
138
+ addRule(guard, 'protected-guard', 'deny', 'canonical-guard');
139
+ }
140
+
141
+ for (const entry of surfaces.protect ?? []) addRule(entry, 'protected-guard', 'deny', 'surface:protect');
142
+ for (const entry of surfaces.deny ?? []) addRule(entry, 'host-deny', 'deny', 'surface:deny');
143
+ for (const entry of surfaces.allow ?? []) addRule(entry, 'explicit-allow', 'allow', 'surface:allow');
144
+
145
+ // ask/prompt surface entries: reachable prompts only when the mode can prompt.
146
+ const askEntries = [...new Set(surfaces.ask ?? [])];
147
+ const promptReachable = [];
148
+ for (const entry of askEntries) {
149
+ if (promptCapable) {
150
+ addRule(entry, 'explicit-allow', 'prompt', 'surface:ask');
151
+ promptReachable.push(entry);
152
+ } else {
153
+ // Under unattended an ask entry is unreachable — flagged for doctor.
154
+ addRule(entry, 'host-deny', 'deny', 'surface:ask-unreachable');
155
+ warnings.push('prompt-reachable-under-unattended');
156
+ }
157
+ }
158
+
159
+ const rules = [...byEntry.values()];
160
+ const deny = rules.filter((r) => r.action === 'deny').map((r) => r.entry);
161
+ const allow = rules.filter((r) => r.action === 'allow').map((r) => r.entry);
162
+ const protections = rules.filter((r) => r.tier === 'protected-guard').map((r) => r.entry);
163
+
164
+ return {
165
+ mode,
166
+ effectiveMode,
167
+ host,
168
+ enforcement,
169
+ allowsPrompts: promptCapable,
170
+ promptReachable,
171
+ deny,
172
+ allow,
173
+ protections,
174
+ evalPosture,
175
+ rules,
176
+ warnings: [...new Set(warnings)],
177
+ fallback: MODE_FALLBACK[mode],
178
+ };
179
+ }
@@ -25,6 +25,7 @@ import {
25
25
  isSensitiveDataGateEnabled,
26
26
  loadSensitiveAllowlist,
27
27
  } from './sensitiveValueScanner.js';
28
+ import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
28
29
 
29
30
  export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
30
31
  export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
@@ -338,12 +339,14 @@ export async function renderProjectImportantForProject(projectRoot, options = {}
338
339
  * TASK-044 — adapter wiring facts for status/doctor (spec §13/§14).
339
340
  *
340
341
  * Returns install + wiring facts only — never file bodies. Claude wiring is
341
- * always expected; omp/Codex/OpenCode are checked only when installed, and an
342
- * absent adapter is `installed:false`, never a failure.
342
+ * always expected; omp/Codex are checked only when installed, and an
343
+ * absent adapter is `installed:false`, never a failure. A leftover
344
+ * `opencode.json` is no longer an adapter signal — it is detected and
345
+ * reported via `opencodeArtifacts` + a steer warning, never wired.
343
346
  *
344
347
  * @param {string} projectRoot
345
348
  * @param {{ trackedPaths?: string[] }} [options] install.json file list, if known
346
- * @returns {Promise<{claude: object, omp: object, codex: object, opencode: object, agentsFallback: boolean}>}
349
+ * @returns {Promise<{claude: object, omp: object, codex: object, opencodeArtifacts: string[], agentsFallback: boolean}>}
347
350
  */
348
351
  export async function inspectProjectImportantWiring(projectRoot, options = {}) {
349
352
  const trackedPaths = Array.isArray(options.trackedPaths) ? options.trackedPaths : [];
@@ -397,7 +400,7 @@ export async function inspectProjectImportantWiring(projectRoot, options = {}) {
397
400
  wired: runtimeModule && hookInstalled && hookExecutable && settingsWired,
398
401
  };
399
402
 
400
- // --- AGENTS.md owner-instructions pointer (shared Codex/OpenCode fallback) ---
403
+ // --- AGENTS.md owner-instructions pointer (shared Codex fallback) ---
401
404
  let agentsFallback = false;
402
405
  try {
403
406
  const agents = await readFile(agentsPath, 'utf8');
@@ -425,9 +428,12 @@ export async function inspectProjectImportantWiring(projectRoot, options = {}) {
425
428
  const codexInstalled = tracked('.codex/') || await exists(path.join(projectRoot, '.codex', 'settings.json'));
426
429
  const codex = { installed: codexInstalled, wired: codexInstalled && agentsFallback };
427
430
 
428
- // --- OpenCode fallback (only when opencode.json tracked/installed) ---
429
- const opencodeInstalled = tracked('opencode.json') || await exists(path.join(projectRoot, 'opencode.json'));
430
- const opencode = { installed: opencodeInstalled, wired: opencodeInstalled && agentsFallback };
431
+ // --- OpenCode steer (SPEC FR-005) — a leftover opencode.json is a user file:
432
+ // never wired, never installed/repaired. Detect it and steer toward removal.
433
+ const opencodeArtifacts = findOpencodeArtifacts(projectRoot);
434
+ if (opencodeArtifacts.length > 0) {
435
+ console.warn(opencodeSteerMessage(opencodeArtifacts));
436
+ }
431
437
 
432
- return { claude, omp, codex, opencode, agentsFallback };
438
+ return { claude, omp, codex, opencodeArtifacts, agentsFallback };
433
439
  }