@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 +79 -0
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +59 -3
- package/src/core/hookChainDoctor.js +149 -0
- package/src/core/ompConfigMerge.js +19 -0
- package/src/core/permissionDoctor.js +319 -0
- package/src/core/permissionPolicy.js +179 -0
- package/src/core/runInstallPipeline.js +15 -0
- package/src/core/runtimeConfig.js +7 -0
- package/src/core/unattendedDoctor.js +9 -5
- package/src/render/buildVariables.js +10 -1
- package/templates/.claude/hooks/session-episode.sh +26 -1
- package/templates/.claude/settings.json +14 -6
- package/templates/.claude/ukit/skill-router-state.json +1 -0
- package/templates/.omp/config.yml +13 -7
- package/templates/.ukit/storage/cache/hook-latency/unknown.jsonl +4 -0
- package/templates/ukit/storage/config.json +2 -0
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
169
|
-
omp.exists && approvalMap.
|
|
172
|
+
'eval/bash policy matches compiled posture',
|
|
173
|
+
omp.exists && approvalMap.bash === 'allow' && approvalMap.eval === expectedEval,
|
|
170
174
|
'install-repairable',
|
|
171
|
-
|
|
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
|
-
|
|
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`
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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:
|
|
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`
|
|
65
|
-
# weaken eval safety: eval stays gated by
|
|
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": {
|