@ngockhoale/ukit 2.7.2 → 2.7.4

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,85 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.4 - 2026-09-20
6
+
7
+ Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
8
+
9
+ - `session-episode.sh` held-stdin stall fixed twice over: gate-first
10
+ ordering — a pre-staging node probe evaluates
11
+ `learning.episodes.autoWrite === true`; gate off / missing / corrupt
12
+ config or absent node exits 0 immediately without touching stdin
13
+ (~2084ms → ~14–40ms). The gate-on staged-read + `ukit memory episode`
14
+ flow is unchanged. The gate probe itself is armed with an unref'd
15
+ `UKIT_HOOK_DEADLINE_MS` deadline (default 2s → exit 0, gate-off safe
16
+ default) so a wedged probe can never re-create the stall it prevents.
17
+ - SessionEnd residual spawn cost (`chain-sessionend-all`) resolved: the
18
+ background watchdog subshell in `session-episode.sh` inherited the
19
+ child's stdout/stderr pipes, blocking captured-stdio callers ~2s after
20
+ script exit. Watchdog now detaches stdio (`>/dev/null 2>&1 &`) —
21
+ residual 2050ms → ~40ms median; telemetry, per-script `:N` budgets,
22
+ and fail-open posture preserved.
23
+ - Regression guard `chainTimeoutSuffixes`: every `"<path>.sh":N` budget
24
+ suffix in template + installed `settings.json` is pinned against the
25
+ script's original standalone timeout — drift in either direction
26
+ (wrong N or missing suffix) fails the suite.
27
+ - `ukit doctor` gains a `Hook-chain checks` section
28
+ (`src/core/hookChainDoctor.js`): bounded scan of recent
29
+ `hook-latency/*.jsonl` rows reports fail-closed outcomes
30
+ (timeout/error) as an advisory warning with remedy — never blocks,
31
+ exit code untouched.
32
+
33
+ ## 2.7.3 - 2026-09-20
34
+
35
+ AI freeze audit remediation — cycle C38 (TASK-001..007).
36
+
37
+ - OpenCode residual sweep: `rg -i opencode` inventory classified into
38
+ keepers vs residuals; residual surfaces removed or steer-only, with a
39
+ zero-residual manifest test guarding regressions.
40
+ - Unresolved-template-token repair: `mergeOmpConfig` + `diffPlan` detect
41
+ literal `{{token}}` left in rendered artifacts, repair known tokens,
42
+ report unknown ones via `unresolvedTokens` (never fabricate); install
43
+ prints a repair line like the C35 migration line.
44
+ - `orchestration.allowUnsafeEval` (default `false`, optional-present):
45
+ only an explicit `true` renders `tools.approval.eval: allow` in
46
+ `.omp/config.yml` via the `omp.evalApproval` render variable; every
47
+ other state renders `deny` — fail-closed, never prompt. Migrations
48
+ never enable it; absence keeps the safe default.
49
+ - `src/core/permissionPolicy.js` — pure semantic compiler
50
+ `compilePermissionPolicy({ permissionMode, allowUnsafeEval, host,
51
+ surfaces? })`: canonical deny set + precedence tiers
52
+ (hard-deny > protected-guard > host-deny > explicit-allow >
53
+ mode-fallback), models `interactive`/`safe-auto`/`unattended` across
54
+ claude/omp/codex, throws `UKIT_EPOLICY` on unknown mode. Render,
55
+ doctor, and the matrix share it as single source of truth.
56
+ - `ukit doctor --permissions [--json]`: `src/core/permissionDoctor.js`
57
+ compiles the policy against live surfaces and reports FAIL classes
58
+ (unattended-prompt, deny-loss, unsafe-eval-mismatch,
59
+ unrendered-token) with exit 1; reachable prompts under
60
+ interactive/safe-auto report as INFO, absent host surfaces SKIP;
61
+ compact section in the default doctor run, full table under the flag.
62
+ - No-freeze matrix (`tests/manifest/c38NoFreezeMatrix.test.js`):
63
+ safe→allow, destructive→deny+recovery, unknown-in-unattended never
64
+ asks, truncated payloads fail closed, merge idempotent, zero `{{`
65
+ residuals.
66
+ - Canonical deny surfaces closed (R4.5 review): `templates/.claude/
67
+ settings.json` now denies the full canonical destructive set
68
+ (`git push -f`, `git clean -fd`, `git checkout .`, `git restore .`,
69
+ `rm -rf .`, `rm -rf`, `dd if=/dev/`, `*> /dev/sda`, `mkfs`,
70
+ fork-bomb) and the `gh pr create/comment` + `gh issue comment`
71
+ entries moved `ask`→`deny` — static surfaces can't express
72
+ ask-under-interactive-only, so deny is the only mode-safe posture
73
+ (unattended runs must never reach a prompt). `templates/.omp/
74
+ config.yml` gains `git push --force-with-lease*`. Report-only hosts
75
+ (codex) no longer count toward doctor `deny-loss`.
76
+ - `ukit-bridge.js` (omp): `ask` verdicts now degrade to `block` with a
77
+ recorded cause + recovery route instead of surfacing an interactive
78
+ prompt under unattended mode (audit row 61).
79
+ - Docs: `docs/UKIT_INTERNALS.md` gains a Permission Modes + Unsafe Eval
80
+ section — unattended ≠ all-destructive-allowed; a deny routes the
81
+ agent to the next safer alternative, and the host-internal-wait
82
+ guidance is unchanged.
83
+
5
84
  ## 2.7.2 - 2026-09-20
6
85
 
7
86
  OpenCode adapter removal — cycle C37 (TASK-001..004).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.2",
3
+ "version": "2.7.4",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,11 +24,13 @@ import {
24
24
  } from '../../core/projectImportant.js';
25
25
  import { runDocContractChecks } from '../../core/docContracts.js';
26
26
  import { inspectUnattendedMode } from '../../core/unattendedDoctor.js';
27
+ import { inspectPermissions } from '../../core/permissionDoctor.js';
28
+ import { inspectHookChainHealth } from '../../core/hookChainDoctor.js';
27
29
  import { findOpencodeArtifacts, opencodeSteerMessage } from '../../core/opencodeSteer.js';
28
30
 
29
31
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
30
- const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs']);
31
- const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway, --docs';
32
+ const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs', '--permissions', '--json']);
33
+ const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway, --docs, --permissions, --json';
32
34
 
33
35
  export function printDoctorHelp() {
34
36
  console.log('Usage: ukit doctor [options]');
@@ -40,6 +42,30 @@ export function printDoctorHelp() {
40
42
  console.log(' --skills Also print a skill word-count/budget report');
41
43
  console.log(' --gateway Live gateway probe (streaming + non-streaming); advisory only');
42
44
  console.log(' --docs Run doc-contract checks (manifests/documentation.yaml projects only)');
45
+ console.log(' --permissions Print the full per-host permission report table');
46
+ console.log(' --json Emit the permission report as JSON ({permissions:{...}})');
47
+ }
48
+
49
+ function printPermissionSection(permissionReport, { verbose }) {
50
+ console.log('[UKit] Permission report:');
51
+ console.log(`[UKit] declared: ${permissionReport.declared} → effective: ${permissionReport.effective} (allowUnsafeEval=${permissionReport.allowUnsafeEval})`);
52
+ for (const [host, info] of Object.entries(permissionReport.hosts)) {
53
+ if (!info.present) {
54
+ if (verbose) console.log(`[UKit] ${host}: SKIP (no UKit-managed surface)`);
55
+ continue;
56
+ }
57
+ console.log(`[UKit] ${host}: ${info.enforcement} | prompts=${info.promptCount} | deny=${info.denyCount} (missing ${info.denyMissing.length}) | eval=${info.evalSurface ?? info.evalPosture ?? 'n/a'}`);
58
+ }
59
+ for (const check of permissionReport.checks) {
60
+ if (check.applicable === false) {
61
+ if (verbose) console.log(`[UKit] - ${check.label} (skip: ${check.detail ?? 'not applicable'})`);
62
+ continue;
63
+ }
64
+ const sev = check.severity === 'warning' ? ' (warning)' : check.severity === 'info' ? ' (info)' : '';
65
+ console.log(`[UKit] ${check.passed ? '✓' : '✗'} ${check.label}${sev}`);
66
+ if (check.detail) console.log(`[UKit] detail: ${check.detail}`);
67
+ if (!check.passed && check.remedy) console.log(`[UKit] remedy: ${check.remedy}`);
68
+ }
43
69
  }
44
70
 
45
71
  export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir(), ompPath = 'omp' }) {
@@ -53,6 +79,15 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
53
79
  return;
54
80
  }
55
81
 
82
+ // TASK-006 / FR-006 — `--json` is a machine surface: emit ONLY the permission
83
+ // report object, exit 1 when any FAIL class fires.
84
+ if (argv.includes('--json')) {
85
+ const permissionReport = await inspectPermissions({ projectRoot, ompPath });
86
+ console.log(JSON.stringify({ permissions: permissionReport }, null, 2));
87
+ if (permissionReport.failures.length > 0) process.exitCode = 1;
88
+ return;
89
+ }
90
+
56
91
  const pathConfig = buildPathConfig({ packageRoot, projectRoot });
57
92
  const runtimePaths = buildRuntimePaths(projectRoot);
58
93
  const manifest = await loadManifest(pathConfig.manifestPath);
@@ -277,6 +312,24 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
277
312
  }
278
313
  }
279
314
 
315
+ // TASK-006 / FR-006 — unified permission report. Compact section by default,
316
+ // full per-host table under --permissions. FAIL classes block the exit code.
317
+ const permissionReport = await inspectPermissions({ projectRoot, ompPath });
318
+ printPermissionSection(permissionReport, { verbose: argv.includes('--permissions') });
319
+
320
+ // TASK-004 / FR-005 — hook-chain failure-taxonomy watch. Advisory only:
321
+ // warnings render as ✗ + (warning) but never enter the blocking set below.
322
+ const hookChainHealth = await inspectHookChainHealth({ projectRoot });
323
+ console.log('[UKit] Hook-chain checks:');
324
+ {
325
+ const sev = hookChainHealth.severity === 'warning' ? ' (warning)' : hookChainHealth.severity === 'info' ? ' (info)' : '';
326
+ console.log(`[UKit] ${ok(hookChainHealth.passed)} ${hookChainHealth.label}${sev}`);
327
+ if (hookChainHealth.detail) console.log(`[UKit] detail: ${hookChainHealth.detail}`);
328
+ if (hookChainHealth.remedy && (!hookChainHealth.passed || hookChainHealth.severity === 'warning')) {
329
+ console.log(`[UKit] remedy: ${hookChainHealth.remedy}`);
330
+ }
331
+ }
332
+
280
333
  if (runtimeConfigInspection.errors.length > 0) {
281
334
  console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
282
335
  }
@@ -428,7 +481,10 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
428
481
  const failedUnattendedChecks = unattended.checks.filter(
429
482
  (check) => check.applicable !== false && !check.passed,
430
483
  );
431
- const allFailedChecks = [...failedProjectChecks, ...failedUnattendedChecks];
484
+ const failedPermissionChecks = permissionReport.checks.filter(
485
+ (check) => check.applicable !== false && !check.passed && check.severity !== 'info' && check.severity !== 'warning',
486
+ );
487
+ const allFailedChecks = [...failedProjectChecks, ...failedUnattendedChecks, ...failedPermissionChecks];
432
488
  const blockingFailures = allFailedChecks.filter(
433
489
  (check) => check.remediationClass === 'install-repairable' || check.remediationClass === 'owner-action',
434
490
  );
@@ -0,0 +1,149 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+
4
+ // TASK-004 / SPEC §FR-005 — production-facing watch over the chain-runner
5
+ // failure taxonomy (report §7 residual risk). Scans recent
6
+ // `.ukit/storage/cache/hook-latency/*.jsonl` rows for `hook-chain-runner`
7
+ // entries and reports non-`ok` outcomes by failureKind.
8
+ //
9
+ // Posture: ADVISORY ONLY. A warning never enters doctor's blocking set —
10
+ // missing/clean telemetry is a pass, non-ok outcomes surface as
11
+ // severity:'warning' so doctor.js prints ✗ + (warning) without flipping the
12
+ // exit code (same convention as permissionDoctor drift checks).
13
+
14
+ const WINDOW_MS = 24 * 60 * 60 * 1000; // last 24h of telemetry files
15
+ const MAX_FILES = 32; // newest session files by mtime
16
+ const MAX_ROWS = 5000; // total rows parsed across all files
17
+ const MAX_BYTES_PER_FILE = 512 * 1024; // tail-read bound per file
18
+ const CHAIN_HOOK = 'hook-chain-runner';
19
+
20
+ // Row outcomes that mean a fail-closed gate tripped or a child script died.
21
+ const FAILURE_OUTCOMES = new Set([
22
+ 'timeout',
23
+ 'budget-exhausted',
24
+ 'output-overflow',
25
+ 'error',
26
+ 'signal',
27
+ ]);
28
+
29
+ function telemetryDirFor(projectRoot) {
30
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
31
+ }
32
+
33
+ async function recentTelemetryFiles(dir, now) {
34
+ let entries;
35
+ try {
36
+ entries = await fs.readdir(dir, { withFileTypes: true });
37
+ } catch {
38
+ return []; // dir absent — unknown, not failure
39
+ }
40
+ const candidates = [];
41
+ for (const entry of entries) {
42
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
43
+ const filePath = path.join(dir, entry.name);
44
+ try {
45
+ const stat = await fs.stat(filePath);
46
+ candidates.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
47
+ } catch {
48
+ // vanished between readdir/stat — skip
49
+ }
50
+ }
51
+ // Bound: only files touched inside the window, newest first, capped.
52
+ return candidates
53
+ .filter((f) => now - f.mtimeMs <= WINDOW_MS)
54
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
55
+ .slice(0, MAX_FILES);
56
+ }
57
+
58
+ async function tailLines(filePath, size) {
59
+ const handle = await fs.open(filePath, 'r');
60
+ try {
61
+ const length = Math.min(size, MAX_BYTES_PER_FILE);
62
+ const start = Math.max(0, size - length);
63
+ const buffer = Buffer.alloc(length);
64
+ await handle.read(buffer, 0, length, start);
65
+ let text = buffer.toString('utf8');
66
+ if (start > 0) {
67
+ // Drop the first (probably partial) line of a tail-read.
68
+ const nl = text.indexOf('\n');
69
+ text = nl >= 0 ? text.slice(nl + 1) : '';
70
+ }
71
+ return text.split('\n').filter((line) => line.length > 0);
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+
77
+ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
78
+ const dir = telemetryDirFor(projectRoot);
79
+ const files = await recentTelemetryFiles(dir, now);
80
+
81
+ const counts = { rowsScanned: 0, filesScanned: files.length, ok: 0 };
82
+ let rowsLeft = MAX_ROWS;
83
+
84
+ for (const file of files) {
85
+ if (rowsLeft <= 0) break;
86
+ let lines;
87
+ try {
88
+ lines = await tailLines(file.filePath, file.size);
89
+ } catch {
90
+ continue; // unreadable file — skip, never fatal
91
+ }
92
+ for (const line of lines) {
93
+ if (rowsLeft <= 0) break;
94
+ let row;
95
+ try {
96
+ row = JSON.parse(line);
97
+ } catch {
98
+ continue; // corrupt line ignored — regression guard
99
+ }
100
+ if (row?.hook !== CHAIN_HOOK) continue;
101
+ rowsLeft -= 1;
102
+ counts.rowsScanned += 1;
103
+ const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
104
+ counts[outcome] = (counts[outcome] ?? 0) + 1;
105
+ }
106
+ }
107
+
108
+ const label = 'hook-chain health';
109
+
110
+ if (counts.rowsScanned === 0) {
111
+ return {
112
+ label,
113
+ passed: true,
114
+ failed: false,
115
+ severity: 'info',
116
+ remediationClass: 'advisory',
117
+ detail: 'no telemetry — no hook-chain-runner rows in .ukit/storage/cache/hook-latency/ (unknown, not failure)',
118
+ counts,
119
+ };
120
+ }
121
+
122
+ const failureKinds = Object.keys(counts)
123
+ .filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
124
+ .sort();
125
+
126
+ if (failureKinds.length === 0) {
127
+ return {
128
+ label,
129
+ passed: true,
130
+ failed: false,
131
+ severity: 'info',
132
+ remediationClass: 'advisory',
133
+ detail: `${counts.rowsScanned} chain row(s) scanned across ${counts.filesScanned} file(s) — all outcome:ok`,
134
+ counts,
135
+ };
136
+ }
137
+
138
+ const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
139
+ return {
140
+ label,
141
+ passed: false,
142
+ failed: true,
143
+ severity: 'warning',
144
+ remediationClass: 'advisory',
145
+ detail: `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`,
146
+ remedy: 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.',
147
+ counts,
148
+ };
149
+ }
@@ -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,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
+ }
@@ -16,6 +16,7 @@ import { repairBrokenHooks } from './repairBrokenHooks.js';
16
16
  import { applyGatewayResilienceEnv } from './gatewayResilienceEnv.js';
17
17
  import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
18
18
  import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
19
+ import { loadRuntimeConfig } from './runtimeConfig.js';
19
20
 
20
21
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
21
22
  '.claude/skills/',
@@ -279,6 +280,10 @@ export async function runInstallPipeline({
279
280
  const stackContext = await detectStack(pathConfig.projectRoot);
280
281
  const projectContext = await detectProjectContext(pathConfig.projectRoot);
281
282
  const providerContext = await detectProviders(pathConfig.projectRoot);
283
+ // FR-004 — feeds the omp.evalApproval render variable. Missing/invalid config
284
+ // degrades to defaults (allowUnsafeEval: false) inside loadRuntimeConfig, so
285
+ // the rendered eval approval fails closed to `deny`.
286
+ const runtimeConfig = await loadRuntimeConfig(pathConfig.projectRoot);
282
287
 
283
288
  const variables = buildTemplateVariables({
284
289
  projectContext,
@@ -286,6 +291,7 @@ export async function runInstallPipeline({
286
291
  packageVersion,
287
292
  providerContext,
288
293
  withCodegraph,
294
+ runtimeConfig,
289
295
  });
290
296
 
291
297
  const plan = await buildInstallPlan({
@@ -331,6 +337,15 @@ export async function runInstallPipeline({
331
337
  console.log(`[UKit] .omp/config.yml: migrated approval surface (prompt→removed: ${removed}, preserved: ${preserved})`);
332
338
  }
333
339
 
340
+ // TASK-002 / FR-002: any `{{token}}` that survived into merged .omp/config.yml
341
+ // output means the render lane lacked the variable — warn instead of silently
342
+ // persisting the literal (the original autoCompactWindow bug mechanism).
343
+ for (const entry of diffResults) {
344
+ const tokens = entry?.migrationReport?.unresolvedTokens;
345
+ if (!tokens || tokens.length === 0) continue;
346
+ console.warn(`[UKit] Warning: .omp/config.yml contains unresolved template tokens: ${tokens.join(', ')} — report this to UKit maintainers; no value was fabricated.`);
347
+ }
348
+
334
349
  // BUG-C22-19: bound the .bak accumulation — every overwrite_with_backup write
335
350
  // adds one and nothing removed them. Advisory: a prune failure must never
336
351
  // fail the install.
@@ -143,6 +143,10 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
143
143
  orchestratorModel: 'claude-sonnet-5',
144
144
  advisorEnabled: true,
145
145
  permissionMode: 'unattended',
146
+ // FR-003: eval-subprocess gate for rendered omp approval (tools.approval.eval).
147
+ // Fail-closed: only an explicit user `true` renders `eval: allow`; absent,
148
+ // false, or an unreadable config all render `deny`. Migration never sets it.
149
+ allowUnsafeEval: false,
146
150
  contracts: buildConfigContracts(),
147
151
  modelTiers: {
148
152
  // ompRole is the .omp/config.yml modelRoles key this tier binds to.
@@ -407,6 +411,9 @@ export function validateRuntimeConfig(config) {
407
411
  && !VALID_PERMISSION_MODES.has(config.orchestration.permissionMode)) {
408
412
  errors.push(`orchestration.permissionMode must be one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`);
409
413
  }
414
+ if (config.orchestration.allowUnsafeEval !== undefined) {
415
+ pushBooleanError(errors, config.orchestration.allowUnsafeEval, 'orchestration.allowUnsafeEval');
416
+ }
410
417
  if (!isPlainObject(config.orchestration.contracts)) {
411
418
  errors.push('orchestration.contracts must be an object.');
412
419
  }
@@ -70,6 +70,8 @@ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
70
70
  const permissionModeValid = permissionModeRaw === undefined
71
71
  || VALID_PERMISSION_MODES.has(permissionModeRaw);
72
72
  const permissionMode = permissionModeRaw === undefined ? 'unattended' : permissionModeRaw;
73
+ const allowUnsafeEval = runtimeConfig?.orchestration?.allowUnsafeEval === true;
74
+ const expectedEval = allowUnsafeEval ? 'allow' : 'deny';
73
75
 
74
76
  const omp = await readOmpConfig(projectRoot);
75
77
  const tools = omp.parsed?.tools ?? {};
@@ -163,14 +165,16 @@ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
163
165
  'Run ukit install to pin tools.approvalMode: yolo in .omp/config.yml.',
164
166
  { detail: `value: ${effectiveApprovalMode} (source: ${approvalSource})` },
165
167
  ),
166
- // 3 — eval/bash policy allow
168
+ // 3 — eval/bash policy vs compiled posture (FR-004/FR-006): bash stays
169
+ // allow; eval matches the allowUnsafeEval knob (deny by default — fail
170
+ // closed), never prompt.
167
171
  check(
168
- "eval/bash policy 'allow'",
169
- omp.exists && approvalMap.eval === 'allow' && approvalMap.bash === 'allow',
172
+ 'eval/bash policy matches compiled posture',
173
+ omp.exists && approvalMap.bash === 'allow' && approvalMap.eval === expectedEval,
170
174
  'install-repairable',
171
- 'Run ukit install to restore tools.approval.eval/bash: allow.',
175
+ `Run ukit install to restore tools.approval.bash: allow and tools.approval.eval: ${expectedEval}.`,
172
176
  omp.exists
173
- ? {}
177
+ ? { detail: `eval expected ${expectedEval} (allowUnsafeEval=${allowUnsafeEval}), got ${approvalMap.eval ?? 'absent'}` }
174
178
  : { applicable: false, detail: '.omp/config.yml absent' },
175
179
  ),
176
180
  // 4 — zero prompt policies
@@ -13,12 +13,18 @@ UKit routing, memory, and skill activation remain primary. CodeGraph is the inde
13
13
  To disable: rerun \`ukit install\` without \`--with-codegraph\`.
14
14
  `;
15
15
 
16
- export function buildTemplateVariables({ projectContext, stackContext, packageVersion, providerContext, withCodegraph = false }) {
16
+ export function buildTemplateVariables({ projectContext, stackContext, packageVersion, providerContext, withCodegraph = false, runtimeConfig }) {
17
17
  // Derived, never hand-written: .claude/settings.json is read by Claude Code itself and so
18
18
  // cannot consult .ukit/storage/config.json at runtime. Baking the window in here keeps
19
19
  // compact.hardCapTokens the single number that retunes both.
20
20
  const { autoCompactWindow } = loadShippedCompactBudget();
21
21
 
22
+ // FR-004 — fail closed: `allow` only on an explicit user opt-in
23
+ // (`orchestration.allowUnsafeEval === true`). Missing, unreadable, or
24
+ // non-boolean config all resolve `deny` — never `prompt`/`ask`, which would
25
+ // dead-end under unattended (SPEC §14).
26
+ const evalApproval = runtimeConfig?.orchestration?.allowUnsafeEval === true ? 'allow' : 'deny';
27
+
22
28
  return {
23
29
  compact: {
24
30
  autoCompactWindow: String(autoCompactWindow),
@@ -58,6 +64,9 @@ export function buildTemplateVariables({ projectContext, stackContext, packageVe
58
64
  user: {
59
65
  profile: 'default',
60
66
  },
67
+ omp: {
68
+ evalApproval,
69
+ },
61
70
  codegraphSection: withCodegraph ? CODEGRAPH_SECTION : '',
62
71
  };
63
72
  }
@@ -15,6 +15,28 @@
15
15
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
16
16
  CONFIG_FILE="$PROJECT_ROOT/.ukit/storage/config.json"
17
17
 
18
+ # Gate-first fast path (TASK-001): evaluate learning.episodes.autoWrite BEFORE
19
+ # staging stdin. A producer that holds the pipe open would otherwise burn the
20
+ # full stdin-staging watchdog (measured ~2020ms) even though a gate-off run
21
+ # never inspects the payload — the same held-stdin stall TASK-235 fixed in
22
+ # project-important.sh. Missing/corrupt config or missing node resolves to 0,
23
+ # i.e. gate off → exit 0 without touching stdin.
24
+ __ukit_ep_gate="$(node -e '
25
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 2000;
26
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
27
+ const fs = require("fs");
28
+ try {
29
+ const config = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
30
+ process.stdout.write(config?.learning?.episodes?.autoWrite === true ? "1" : "0");
31
+ } catch {
32
+ process.stdout.write("0");
33
+ }
34
+ ' "$CONFIG_FILE" 2>/dev/null)" || __ukit_ep_gate="0"
35
+ if [ "$__ukit_ep_gate" != "1" ]; then
36
+ exit 0
37
+ fi
38
+ unset __ukit_ep_gate
39
+
18
40
  # Bounded stdin read (existing hook style): cap +1 byte in background so a
19
41
  # producer that never closes the pipe cannot park the session teardown.
20
42
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
@@ -23,7 +45,10 @@ if [ -e /dev/fd/0 ]; then
23
45
  head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
24
46
  UKIT_HEAD_PID=$!
25
47
  # UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload.
26
- ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) &
48
+ # TASK-002: detach the watchdog's inherited stdout/stderr — otherwise a
49
+ # captured-stdio caller (hook-chain-runner) sees the pipe held open ~2s
50
+ # after the script exits, inflating the SessionEnd residual to ~2000ms.
51
+ ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) >/dev/null 2>&1 &
27
52
  UKIT_WATCH_PID=$!
28
53
  wait "$UKIT_HEAD_PID" 2>/dev/null
29
54
  kill "$UKIT_WATCH_PID" 2>/dev/null
@@ -36,18 +36,26 @@
36
36
  "Bash(git worktree:*)",
37
37
  "Bash(jq:*)"
38
38
  ],
39
- "ask": [
40
- "Bash(gh pr create:*)",
41
- "Bash(gh pr comment:*)",
42
- "Bash(gh issue comment:*)"
43
- ],
44
39
  "deny": [
45
40
  "Bash(git reset --hard:*)",
46
41
  "Bash(git push --force:*)",
42
+ "Bash(git push -f:*)",
47
43
  "Bash(git push --force-with-lease:*)",
44
+ "Bash(git clean -fd:*)",
45
+ "Bash(git checkout .:*)",
46
+ "Bash(git restore .:*)",
48
47
  "Bash(rm -rf /:*)",
49
48
  "Bash(rm -rf ~:*)",
50
- "Bash(rm -rf ..:*)"
49
+ "Bash(rm -rf ..:*)",
50
+ "Bash(rm -rf .:*)",
51
+ "Bash(rm -rf:*)",
52
+ "Bash(dd if=/dev/:*)",
53
+ "Bash(*> /dev/sda:*)",
54
+ "Bash(mkfs:*)",
55
+ "Bash(:(){ :|:& };::*)",
56
+ "Bash(gh pr create:*)",
57
+ "Bash(gh pr comment:*)",
58
+ "Bash(gh issue comment:*)"
51
59
  ]
52
60
  },
53
61
  "hooks": {
@@ -0,0 +1 @@
1
+ {"fingerprint":"routefp-v3:VKbWKdhO0GSCeYRnXINdC_39mkvjp8UX9-pC9T_y3yQ","ts":1789910232582,"source":"skill-router","activeSkills":[],"routingContext":{"lastExplicitUserPromptText":"","taskType":null,"intentMode":null,"executionMode":null}}
@@ -36,14 +36,17 @@ tools:
36
36
  # TASK-004 hook-bridge deny chain, and this approval map still gate dangerous
37
37
  # ops — `allow` removes interactive prompts, not the deny checks.
38
38
  # Residual risk: yolo + all-allow IS dangerously autonomous and is a
39
- # deliberate, user-chosen posture (unattended mode). `eval` stays `allow`
40
- # (was `prompt` pre-TASK-001): bash.patterns never covers the eval tool, but
41
- # the TASK-004 bridge maps `eval` -> `Bash`, so every eval call is gated by
42
- # the same block-dangerous chain as a bash command.
39
+ # deliberate, user-chosen posture (unattended mode). `eval` renders from the
40
+ # `orchestration.allowUnsafeEval` knob in .ukit/storage/config.json — `allow`
41
+ # only on explicit user opt-in, else `deny` (never `prompt`/`ask`: a prompt
42
+ # dead-ends under unattended). bash.patterns never covers the eval tool —
43
+ # eval payloads (os.remove, shutil.rmtree, subprocess) bypass the text deny —
44
+ # but the TASK-004 bridge maps `eval` -> `Bash`, so an opted-in `allow` is
45
+ # still gated by the same block-dangerous chain as a bash command.
43
46
  approvalMode: yolo
44
47
  approval:
45
48
  bash: allow
46
- eval: allow
49
+ eval: "{{omp.evalApproval}}"
47
50
  task: allow
48
51
  read: allow
49
52
  grep: allow
@@ -61,8 +64,9 @@ bash:
61
64
  # allow is NOT a universal escape hatch: any compound command (`&&`, `;`, `|`) not itself caught
62
65
  # by a `deny`/`prompt` entry falls through to `tools.approvalMode` instead of being auto-allowed.
63
66
  # `bash.patterns` does not cover the `eval` tool at all — that is why TASK-004's bridge
64
- # separately maps `eval` -> `Bash`, and why `tools.approval.eval: allow` above does NOT
65
- # weaken eval safety: eval stays gated by the bridge's Bash chain (TASK-001).
67
+ # separately maps `eval` -> `Bash`, and why an opted-in `tools.approval.eval: allow`
68
+ # (orchestration.allowUnsafeEval) does NOT weaken eval safety: eval stays gated by
69
+ # the bridge's Bash chain (TASK-001). Default render is `deny` (fail-closed).
66
70
  patterns:
67
71
  - match: "rm -rf /*"
68
72
  approval: deny
@@ -74,6 +78,8 @@ bash:
74
78
  approval: deny
75
79
  - match: "git push --force*"
76
80
  approval: deny
81
+ - match: "git push --force-with-lease*"
82
+ approval: deny
77
83
  - match: "git push -f *"
78
84
  approval: deny
79
85
  - match: "git reset --hard*"
@@ -0,0 +1,4 @@
1
+ {"v":1,"ts":1789910222307,"hook":"hook-chain-runner","elapsedMs":2,"outcome":"error","hookEvent":"PreToolUse","toolName":"Edit","toolUseId":null,"budgetMs":28000,"budgetExhausted":false,"scripts":[{"scriptName":"protect-files.sh","code":1,"killed":false,"failureKind":"error","elapsedMs":2}]}
2
+ {"v":1,"ts":1789910232531,"hookEvent":"PreToolUse","toolName":"Edit","toolUseId":null,"hook":"protect-files.sh","elapsedMs":29,"outcome":"ok"}
3
+ {"v":1,"ts":1789910232602,"hookEvent":"PreToolUse","toolName":"Edit","toolUseId":null,"hook":"skill-router.sh","elapsedMs":44,"outcome":"ok"}
4
+ {"v":1,"ts":1789910232609,"hook":"hook-chain-runner","elapsedMs":580,"outcome":"ok","hookEvent":"PreToolUse","toolName":"Edit","toolUseId":null,"budgetMs":28000,"budgetExhausted":false,"scripts":[{"scriptName":"protect-files.sh","code":0,"killed":false,"failureKind":"ok","elapsedMs":510},{"scriptName":"skill-router.sh","code":0,"killed":false,"failureKind":"ok","elapsedMs":70}]}
@@ -89,6 +89,7 @@
89
89
  "orchestratorModel": "claude-sonnet-5",
90
90
  "advisorEnabled": true,
91
91
  "permissionMode": "unattended",
92
+ "allowUnsafeEval": false,
92
93
  "contracts": {
93
94
  "tiny-fix": {
94
95
  "maxReadPasses": 0,
@@ -550,6 +551,7 @@
550
551
  "orchestratorModel": "Model điều phối chất lượng cao để chọn execution layer. Đây là model quan trọng cho chất lượng completion, không nên dùng model quá yếu.",
551
552
  "advisorEnabled": "Cho phép UKit dùng advisor-style reasoning khi chọn layer trong tình huống mơ hồ.",
552
553
  "permissionMode": "Chế độ quyền của orchestration. Ba giá trị dành riêng: interactive, safe-auto, unattended. Chu kỳ này chỉ triển khai unattended.",
554
+ "allowUnsafeEval": "Mặc định false. Chỉ khi user tự bật true, .omp/config.yml mới render tools.approval.eval: allow; mọi trường hợp khác render deny (fail-closed, không bao giờ prompt). Residual risk: eval chạy payload subprocess (os.remove, shutil.rmtree) mà bash.patterns text deny không chặn được — chỉ dựa vào bridge eval->Bash của TASK-004.",
553
555
  "contracts": "Bộ contract cho 7 tầng điều phối: tiny-fix, local-fix, local-build, find-cause, shared-edit, map-impact, review-release. Khi mơ hồ, UKit nên lệch lên tầng an toàn hơn dù tốn token hơn."
554
556
  },
555
557
  "memory": {