@ngockhoale/ukit 3.4.67 → 3.4.69
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 +21 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plan-count.mjs — CR-11: an owner-stated plan count is delivered as that many real plan
|
|
3
|
+
// units, never as group labels inside one PLAN.md.
|
|
4
|
+
//
|
|
5
|
+
// A "real plan unit" is a directory carrying its own PLAN.md **and** INDEX.md, where both
|
|
6
|
+
// files are non-empty regular files with a recognisable markdown heading (`# …`):
|
|
7
|
+
// - the audited root itself (canonical `docs/AI_HANDOFF/` or a queued root) counts once
|
|
8
|
+
// when it has both files;
|
|
9
|
+
// - each `queued/<slug>/` under it with both files counts once. Units are deduplicated by
|
|
10
|
+
// realpath, so a symlinked alias of an already-counted directory is not a second unit.
|
|
11
|
+
// A queued dir with only some of the files (or files that are empty / headingless) is
|
|
12
|
+
// reported as incomplete and never satisfies the count.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node .claude/ukit/index/plan-count.mjs <handoff-root> <expected-count>
|
|
16
|
+
//
|
|
17
|
+
// Output:
|
|
18
|
+
// `PLAN_COUNT: ok (<found>/<expected>)` or
|
|
19
|
+
// `plan count: <found> real plan units found, owner stated <expected>` plus one
|
|
20
|
+
// `plan unit incomplete -> <dir>` line per queued dir that has only some of the files.
|
|
21
|
+
// Exit 0 = satisfied, 1 = count not met, 2 = usage.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
function isDirectRun() {
|
|
29
|
+
try {
|
|
30
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HEADING = /^#{1,6}\s+\S/m;
|
|
37
|
+
|
|
38
|
+
// A recognisable planning file: regular (not a symlink), non-empty, carries a heading.
|
|
39
|
+
function isRealPlanFile(file) {
|
|
40
|
+
try {
|
|
41
|
+
const st = fs.lstatSync(file);
|
|
42
|
+
if (!st.isFile() || st.size === 0) return false;
|
|
43
|
+
return HEADING.test(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasAnyPlanFile(dir) {
|
|
50
|
+
return fs.existsSync(path.join(dir, 'PLAN.md')) || fs.existsSync(path.join(dir, 'INDEX.md'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPlanUnit(dir) {
|
|
54
|
+
return isRealPlanFile(path.join(dir, 'PLAN.md')) && isRealPlanFile(path.join(dir, 'INDEX.md'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function main() {
|
|
58
|
+
const root = process.argv[2];
|
|
59
|
+
const expectedRaw = process.argv[3];
|
|
60
|
+
const expected = Number(expectedRaw);
|
|
61
|
+
if (!root || !Number.isInteger(expected) || expected < 1) {
|
|
62
|
+
console.log('PLAN_COUNT: usage error — expected `<root> <positive-integer>`');
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const units = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const incomplete = [];
|
|
69
|
+
const addUnit = (dir) => {
|
|
70
|
+
let real;
|
|
71
|
+
try {
|
|
72
|
+
real = fs.realpathSync(dir);
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (seen.has(real)) return;
|
|
77
|
+
seen.add(real);
|
|
78
|
+
units.push(dir);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (isPlanUnit(root)) addUnit(root);
|
|
82
|
+
|
|
83
|
+
let slugs;
|
|
84
|
+
try {
|
|
85
|
+
slugs = fs.readdirSync(path.join(root, 'queued'));
|
|
86
|
+
} catch {
|
|
87
|
+
slugs = [];
|
|
88
|
+
}
|
|
89
|
+
for (const slug of slugs.sort()) {
|
|
90
|
+
const dir = path.join(root, 'queued', slug);
|
|
91
|
+
let stat;
|
|
92
|
+
try {
|
|
93
|
+
stat = fs.statSync(dir); // follows symlinks: a symlinked unit dir is still a directory
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!stat.isDirectory()) continue;
|
|
98
|
+
if (isPlanUnit(dir)) addUnit(dir);
|
|
99
|
+
else if (hasAnyPlanFile(dir)) incomplete.push(dir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const dir of incomplete) {
|
|
103
|
+
console.log(`plan unit incomplete -> ${dir} (a real unit needs its own non-empty PLAN.md and INDEX.md with a heading)`);
|
|
104
|
+
}
|
|
105
|
+
if (units.length < expected) {
|
|
106
|
+
console.log(`plan count: ${units.length} real plan units found, owner stated ${expected}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
console.log(`PLAN_COUNT: ok (${units.length}/${expected})`);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isDirectRun()) {
|
|
114
|
+
main();
|
|
115
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ready-gate.mjs — CR-06: `ready` is a verdict, verified from disk, never from memory.
|
|
3
|
+
//
|
|
4
|
+
// Verifies that every task currently marked `ready` (task file `- Status:` line and/or the
|
|
5
|
+
// INDEX.md row) has BOTH gates recorded on disk:
|
|
6
|
+
// 1. a tool-written `Validator: ok <YYYY-MM-DD>` (or `Validator: dismissed <YYYY-MM-DD> — reason`)
|
|
7
|
+
// line inside the task's `## Discussion` section — a verdict held only in session
|
|
8
|
+
// memory does not count (compaction-safe);
|
|
9
|
+
// 2. `PLAN_REVIEW: Approved` in the root's PLAN.md.
|
|
10
|
+
// It also flags index/task status mismatches (one side flipped, the other did not).
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// node .claude/ukit/index/ready-gate.mjs [handoff-root] (default: docs/AI_HANDOFF)
|
|
14
|
+
//
|
|
15
|
+
// Output:
|
|
16
|
+
// `READY_GATE: ok` when every ready task is evidenced;
|
|
17
|
+
// otherwise one named `tasks not ready: <gate> -> <TASK-ID>` / `index/task mismatch -> <TASK-ID>`
|
|
18
|
+
// line per problem. Exit 0 = gates pass, exit 1 = gate failure (the flip step must stop),
|
|
19
|
+
// exit 2 = usage error.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import process from 'node:process';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
function isDirectRun() {
|
|
27
|
+
try {
|
|
28
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const READY_STATUSES = new Set(['ready']);
|
|
35
|
+
|
|
36
|
+
function readFileSafe(file) {
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(file, 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fileStatus(markdown) {
|
|
45
|
+
const m = /^-\s*Status:\s*`?([a-z_]+)`?/im.exec(String(markdown || ''));
|
|
46
|
+
return m ? m[1].toLowerCase() : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function discussionSection(markdown) {
|
|
50
|
+
const text = String(markdown || '');
|
|
51
|
+
const heading = /^##\s+Discussion\s*$/m.exec(text);
|
|
52
|
+
if (!heading) return null;
|
|
53
|
+
const rest = text.slice(heading.index + heading[0].length);
|
|
54
|
+
const boundary = /^##\s|^-{3,}\s*$/m.exec(rest);
|
|
55
|
+
return boundary ? rest.slice(0, boundary.index) : rest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Only the exact line shapes count, anchored to a line start (CR tolerated for CRLF files):
|
|
59
|
+
// - `Validator: ok <YYYY-MM-DD> ukit@<ver>` — written by task-budget-validator --record only;
|
|
60
|
+
// - `Validator: dismissed <YYYY-MM-DD> — <reason>` — the hand-written documented override.
|
|
61
|
+
// Forged 2-field lines, headings, quoted prose and mid-sentence mentions do not satisfy the gate.
|
|
62
|
+
const VALIDATOR_OK_LINE = /^Validator: ok \d{4}-\d{2}-\d{2} ukit@\S+[ \t\r]*$/m;
|
|
63
|
+
const VALIDATOR_DISMISSED_LINE = /^Validator: dismissed \d{4}-\d{2}-\d{2} — \S.*?[ \t\r]*$/m;
|
|
64
|
+
|
|
65
|
+
function hasValidatorRecord(markdown) {
|
|
66
|
+
const section = discussionSection(markdown);
|
|
67
|
+
if (section == null) return false;
|
|
68
|
+
return VALIDATOR_OK_LINE.test(section) || VALIDATOR_DISMISSED_LINE.test(section);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// INDEX.md is a markdown table whose header row locates the Status column; rows before the
|
|
72
|
+
// table or with fewer cells are ignored (history prose, not rows).
|
|
73
|
+
function indexStatuses(root) {
|
|
74
|
+
const raw = readFileSafe(path.join(root, 'INDEX.md'));
|
|
75
|
+
if (raw == null) return null;
|
|
76
|
+
const statuses = new Map();
|
|
77
|
+
const lines = raw.split('\n');
|
|
78
|
+
let statusCol = -1;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line.startsWith('|')) continue;
|
|
81
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
|
82
|
+
if (statusCol < 0) {
|
|
83
|
+
statusCol = cells.findIndex((c) => /^status$/i.test(c));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (cells.length <= statusCol) continue;
|
|
87
|
+
const id = cells[0];
|
|
88
|
+
if (!/^TASK-/i.test(id)) continue;
|
|
89
|
+
statuses.set(id, cells[statusCol].toLowerCase());
|
|
90
|
+
}
|
|
91
|
+
return statuses;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listTaskFiles(root) {
|
|
95
|
+
const tasksDir = path.join(root, 'tasks');
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(tasksDir);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return entries
|
|
103
|
+
.filter((f) => /^TASK-.*\.md$/i.test(f))
|
|
104
|
+
.map((f) => path.join(tasksDir, f))
|
|
105
|
+
.sort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function main() {
|
|
109
|
+
const root = process.argv[2] || 'docs/AI_HANDOFF';
|
|
110
|
+
if (process.argv[3]) {
|
|
111
|
+
console.log(`READY_GATE: usage error — expected at most one root argument, got ${process.argv.length - 2}`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const problems = [];
|
|
116
|
+
const indexStatusesMap = indexStatuses(root);
|
|
117
|
+
if (indexStatusesMap == null) {
|
|
118
|
+
console.log(`tasks not ready: index missing -> ${path.join(root, 'INDEX.md')}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const plan = readFileSafe(path.join(root, 'PLAN.md'));
|
|
123
|
+
// The planner footer line `PLAN_REVIEW: Approved by <reviewer model>` (line start only).
|
|
124
|
+
const planApproved = plan != null && /^PLAN_REVIEW: Approved by \S/m.test(plan);
|
|
125
|
+
let anyReady = false;
|
|
126
|
+
|
|
127
|
+
for (const file of listTaskFiles(root)) {
|
|
128
|
+
// Normalise the TASK- prefix so `task-001.md` and `TASK-001` name the same INDEX row.
|
|
129
|
+
const id = path.basename(file, '.md').replace(/^task-/i, 'TASK-');
|
|
130
|
+
const markdown = readFileSafe(file);
|
|
131
|
+
if (markdown == null) continue;
|
|
132
|
+
const status = fileStatus(markdown);
|
|
133
|
+
const indexed = indexStatusesMap.get(id);
|
|
134
|
+
indexStatusesMap.delete(id);
|
|
135
|
+
|
|
136
|
+
const claimedReady = READY_STATUSES.has(status) || READY_STATUSES.has(indexed);
|
|
137
|
+
if (!claimedReady) continue;
|
|
138
|
+
anyReady = true;
|
|
139
|
+
|
|
140
|
+
if (status !== indexed) {
|
|
141
|
+
problems.push(`index/task mismatch -> ${id} (file=${status || 'none'} index=${indexed || 'none'})`);
|
|
142
|
+
}
|
|
143
|
+
if (!hasValidatorRecord(markdown)) {
|
|
144
|
+
problems.push(`tasks not ready: validator -> ${id} (no recorded Validator: ok/dismissed line in ## Discussion)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// INDEX rows pointing at a task file that no longer exists: a ready row without its file
|
|
149
|
+
// is unverifiable evidence.
|
|
150
|
+
for (const [id, status] of indexStatusesMap) {
|
|
151
|
+
if (READY_STATUSES.has(status)) {
|
|
152
|
+
problems.push(`index/task mismatch -> ${id} (index=ready, task file missing)`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (anyReady && !planApproved) {
|
|
157
|
+
problems.push('tasks not ready: plan review (PLAN.md has no PLAN_REVIEW: Approved)');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (problems.length > 0) {
|
|
161
|
+
for (const p of problems.sort()) console.log(p);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
console.log('READY_GATE: ok');
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isDirectRun()) {
|
|
169
|
+
main();
|
|
170
|
+
}
|
|
@@ -7,9 +7,136 @@ function parseArgs(argv) {
|
|
|
7
7
|
return {
|
|
8
8
|
dryRun: argv.includes('--dry-run'),
|
|
9
9
|
quiet: argv.includes('--quiet'),
|
|
10
|
+
sanitize: argv.includes('--sanitize'),
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
// LA-02: the sanitizer removes ONLY rules that satisfy BOTH gates:
|
|
15
|
+
// (a) the rule token is in the SAME denylist the auto-allow hook enforces,
|
|
16
|
+
// and (b) the rule has proven UKit provenance: present in managedRules
|
|
17
|
+
// AND (record.added === true OR an audit-log auto_add event).
|
|
18
|
+
// The denylist is parsed out of auto-allow-bash.sh (the marked
|
|
19
|
+
// UKIT:AUTO-ALLOW:DENYLIST block) so hook and sanitizer share ONE definition.
|
|
20
|
+
const DENYLIST_BEGIN_MARKER = 'UKIT:AUTO-ALLOW:DENYLIST:BEGIN';
|
|
21
|
+
const DENYLIST_END_MARKER = 'UKIT:AUTO-ALLOW:DENYLIST:END';
|
|
22
|
+
|
|
23
|
+
function hookPathForScript() {
|
|
24
|
+
return path.resolve(
|
|
25
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
26
|
+
'..',
|
|
27
|
+
'..',
|
|
28
|
+
'hooks',
|
|
29
|
+
'auto-allow-bash.sh',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function loadHookDenyTokens() {
|
|
34
|
+
const hookPath = hookPathForScript();
|
|
35
|
+
let source;
|
|
36
|
+
try {
|
|
37
|
+
source = await fs.readFile(hookPath, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`auto-allow hook not found (${hookPath}) — refusing to sanitize without the shared denylist`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const start = source.indexOf(DENYLIST_BEGIN_MARKER);
|
|
44
|
+
const end = source.indexOf(DENYLIST_END_MARKER);
|
|
45
|
+
if (start === -1 || end === -1 || end < start) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${DENYLIST_BEGIN_MARKER} block not found in auto-allow-bash.sh — refusing to sanitize`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const block = source.slice(start, end);
|
|
51
|
+
const tokens = new Set();
|
|
52
|
+
const assignment = /^UKIT_DENY_[A-Z_]+='([^']*)'$/gm;
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = assignment.exec(block)) !== null) {
|
|
55
|
+
for (const token of match[1].split(/\s+/)) {
|
|
56
|
+
if (token) tokens.add(token);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (tokens.size === 0) {
|
|
60
|
+
throw new Error('auto-allow denylist block parsed as empty — refusing to sanitize');
|
|
61
|
+
}
|
|
62
|
+
return tokens;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The audit log rotates, so an absent/unreadable file simply yields no
|
|
66
|
+
// audit-side proof; record.added === true remains the other provenance path.
|
|
67
|
+
async function loadAuditAutoAddRules(auditPath) {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = await fs.readFile(auditPath, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return new Set();
|
|
73
|
+
}
|
|
74
|
+
const rules = new Set();
|
|
75
|
+
for (const line of raw.split('\n')) {
|
|
76
|
+
if (!line.trim()) continue;
|
|
77
|
+
try {
|
|
78
|
+
const event = JSON.parse(line);
|
|
79
|
+
if (event?.event === 'auto_add' && typeof event.rule === 'string') {
|
|
80
|
+
rules.add(event.rule);
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Unparseable lines carry no proof — skipped, never fatal.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return rules;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Classify each allow rule as REMOVE (both gates pass) or SKIP (with reason).
|
|
90
|
+
function classifyAllowRules({ allow, managedRules, auditRules, denyTokens }) {
|
|
91
|
+
const removals = [];
|
|
92
|
+
const skips = [];
|
|
93
|
+
for (const rule of allow) {
|
|
94
|
+
const parsed = /^Bash\(([^:]+):\*\)$/.exec(String(rule));
|
|
95
|
+
if (!parsed) {
|
|
96
|
+
skips.push({ rule, reason: 'not a simple Bash(<token>:*) rule — left in place' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const token = parsed[1];
|
|
100
|
+
const base = token.includes('/') ? token.slice(token.lastIndexOf('/') + 1) : token;
|
|
101
|
+
if (!denyTokens.has(base)) {
|
|
102
|
+
skips.push({ rule, reason: 'not a UKit auto-allow denylist token — left in place' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const meta = managedRules[rule];
|
|
106
|
+
const hasManaged = Boolean(meta) && typeof meta === 'object';
|
|
107
|
+
const proven = hasManaged && (meta.added === true || auditRules.has(rule));
|
|
108
|
+
if (proven) {
|
|
109
|
+
removals.push({
|
|
110
|
+
rule,
|
|
111
|
+
via: meta.added === true ? 'managedRules added:true' : 'audit-log auto_add',
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
skips.push({
|
|
115
|
+
rule,
|
|
116
|
+
reason:
|
|
117
|
+
'unproven, left in place — no managedRules record with added:true and no audit-log '
|
|
118
|
+
+ 'auto_add event. NOTE: rules minted before UKit recorded provenance cannot be '
|
|
119
|
+
+ 'proven; run the full reset (this script without --sanitize) to clear ALL '
|
|
120
|
+
+ 'managed rules.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { removals, skips };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Preserve the original file's indent and trailing-newline style; JSON.parse
|
|
128
|
+
// already preserves key order, so stringify only replaces the removed array
|
|
129
|
+
// entries.
|
|
130
|
+
function serializeLike(originalText, value) {
|
|
131
|
+
if (typeof originalText !== 'string') {
|
|
132
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
133
|
+
}
|
|
134
|
+
const indentMatch = /^[ \t]+/m.exec(originalText);
|
|
135
|
+
const indent = indentMatch ? Math.min(indentMatch[0].length, 10) : 2;
|
|
136
|
+
const trailing = originalText.endsWith('\n') ? '\n' : '';
|
|
137
|
+
return JSON.stringify(value, null, indent) + trailing;
|
|
138
|
+
}
|
|
139
|
+
|
|
13
140
|
// W3-03 fail-closed read: a state file that exists but will not parse must
|
|
14
141
|
// abort the reset — the previous `{}` fallback let a corrupt/JSONC
|
|
15
142
|
// settings.local.json be rewritten as an empty object, wiping the whole allow
|
|
@@ -30,12 +157,14 @@ async function readJson(filePath, fallback) {
|
|
|
30
157
|
|
|
31
158
|
// Same atomic shape as the auto-allow hook's writeJsonAtomic: tmp + rename so
|
|
32
159
|
// readers (Claude Code re-parses settings.local.json to grant permissions)
|
|
33
|
-
// see old-or-new, never a torn file.
|
|
34
|
-
|
|
160
|
+
// see old-or-new, never a torn file. `text` (optional) overrides the default
|
|
161
|
+
// 2-space serialization — the sanitizer passes serializeLike(...) output to
|
|
162
|
+
// preserve the file's original indent and key order.
|
|
163
|
+
async function writeJsonAtomic(filePath, value, text) {
|
|
35
164
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
36
165
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
37
166
|
try {
|
|
38
|
-
await fs.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n');
|
|
167
|
+
await fs.writeFile(tempPath, typeof text === 'string' ? text : JSON.stringify(value, null, 2) + '\n');
|
|
39
168
|
await fs.rename(tempPath, filePath);
|
|
40
169
|
} catch (error) {
|
|
41
170
|
try {
|
|
@@ -118,9 +247,169 @@ async function applyReset({ settingsLocalPath, usagePath, auditPath }) {
|
|
|
118
247
|
return outcome.value;
|
|
119
248
|
}
|
|
120
249
|
|
|
250
|
+
// LA-02 sanitizer. Reports every decision; writes settings.local.json ONLY
|
|
251
|
+
// when at least one proven denylist rule is removed, so untouched state stays
|
|
252
|
+
// byte-identical. Dry-run lists planned removals and writes nothing.
|
|
253
|
+
function reportSanitizePlan({ mode, removals, skips }) {
|
|
254
|
+
if (removals.length === 0) {
|
|
255
|
+
console.log('[UKit] Sanitize: no proven UKit-minted denylist rule(s) to remove.');
|
|
256
|
+
} else {
|
|
257
|
+
console.log(
|
|
258
|
+
`[UKit] Sanitize: ${mode} ${removals.length} auto-minted denylist rule(s) `
|
|
259
|
+
+ 'from .claude/settings.local.json.',
|
|
260
|
+
);
|
|
261
|
+
console.log(`${mode === 'Would remove' ? 'Would remove' : 'Removed'}:`);
|
|
262
|
+
for (const { rule, via } of removals) {
|
|
263
|
+
console.log(`- ${rule} (provenance: ${via})`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (skips.length > 0) {
|
|
267
|
+
console.log('Kept (skipped):');
|
|
268
|
+
for (const { rule, reason } of skips) {
|
|
269
|
+
console.log(`- ${rule}: ${reason}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function sanitizePlanFromState({ settings, usage, auditPath, denyTokens }) {
|
|
275
|
+
const auditRules = await loadAuditAutoAddRules(auditPath);
|
|
276
|
+
const allow = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
|
|
277
|
+
const managedRules =
|
|
278
|
+
usage?.managedRules && typeof usage.managedRules === 'object' ? usage.managedRules : {};
|
|
279
|
+
return classifyAllowRules({ allow, managedRules, auditRules, denyTokens });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function applySanitize({ settingsLocalPath, usagePath, auditPath, denyTokens }) {
|
|
283
|
+
const lockModulePath = path.join(runtimeDirForScript(), 'async-lock.mjs');
|
|
284
|
+
let lockModule = null;
|
|
285
|
+
try {
|
|
286
|
+
lockModule = await import(pathToFileURL(lockModulePath).href);
|
|
287
|
+
} catch {
|
|
288
|
+
lockModule = null;
|
|
289
|
+
}
|
|
290
|
+
if (!lockModule || typeof lockModule.withAsyncLock !== 'function') {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'async-lock.mjs is unavailable — refusing to write permission state unlocked (degraded install)',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
await fs.mkdir(path.dirname(usagePath), { recursive: true });
|
|
296
|
+
|
|
297
|
+
const outcome = await lockModule.withAsyncLock(usagePath, {}, async () => {
|
|
298
|
+
// Re-read BOTH state files inside the critical section — a racing
|
|
299
|
+
// hook write may have landed between preflight and lock acquisition.
|
|
300
|
+
const settingsState = await readJson(settingsLocalPath, {});
|
|
301
|
+
if (settingsState.corrupt) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to overwrite it`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const usageState = await readJson(usagePath, {});
|
|
307
|
+
if (usageState.corrupt) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const settings = settingsState.value && typeof settingsState.value === 'object' ? settingsState.value : {};
|
|
313
|
+
const usage = usageState.value && typeof usageState.value === 'object' ? usageState.value : {};
|
|
314
|
+
|
|
315
|
+
const { removals, skips } = await sanitizePlanFromState({ settings, usage, auditPath, denyTokens });
|
|
316
|
+
if (removals.length === 0) {
|
|
317
|
+
return { removals, skips, changed: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const removalSet = new Set(removals.map((entry) => entry.rule));
|
|
321
|
+
settings.permissions =
|
|
322
|
+
settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : {};
|
|
323
|
+
settings.permissions.allow = (Array.isArray(settings.permissions.allow)
|
|
324
|
+
? settings.permissions.allow
|
|
325
|
+
: []
|
|
326
|
+
).filter((rule) => !removalSet.has(rule));
|
|
327
|
+
|
|
328
|
+
let originalText = null;
|
|
329
|
+
try {
|
|
330
|
+
originalText = await fs.readFile(settingsLocalPath, 'utf8');
|
|
331
|
+
} catch {
|
|
332
|
+
originalText = null;
|
|
333
|
+
}
|
|
334
|
+
await fs.mkdir(path.dirname(settingsLocalPath), { recursive: true });
|
|
335
|
+
await writeJsonAtomic(settingsLocalPath, settings, serializeLike(originalText, settings));
|
|
336
|
+
|
|
337
|
+
const managedRules =
|
|
338
|
+
usage.managedRules && typeof usage.managedRules === 'object' ? usage.managedRules : {};
|
|
339
|
+
for (const { rule } of removals) {
|
|
340
|
+
delete managedRules[rule];
|
|
341
|
+
}
|
|
342
|
+
usage.lastSanitizedAt = new Date().toISOString();
|
|
343
|
+
await writeJsonAtomic(usagePath, usage);
|
|
344
|
+
|
|
345
|
+
const lines = removals
|
|
346
|
+
.map(({ rule, via }) =>
|
|
347
|
+
JSON.stringify({ ts: new Date().toISOString(), event: 'sanitize_remove', rule, via }))
|
|
348
|
+
.join('\n');
|
|
349
|
+
await fs.mkdir(path.dirname(auditPath), { recursive: true });
|
|
350
|
+
await fs.appendFile(auditPath, lines + '\n', 'utf8');
|
|
351
|
+
|
|
352
|
+
return { removals, skips, changed: true };
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!outcome.ok) {
|
|
356
|
+
// Fail closed (TASK-028 policy): a contended lock means a hook write is in
|
|
357
|
+
// flight — never write permission state unlocked. Nothing was mutated.
|
|
358
|
+
throw new Error(
|
|
359
|
+
`could not acquire the permission-state lock (${outcome.reason}) — sanitize aborted, nothing was changed`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return outcome.value;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function runSanitize({ rootDir, dryRun, quiet }) {
|
|
366
|
+
// Fail closed BEFORE any state is touched: without the shared denylist the
|
|
367
|
+
// sanitizer must not guess what is removable.
|
|
368
|
+
const denyTokens = await loadHookDenyTokens();
|
|
369
|
+
|
|
370
|
+
const settingsLocalPath = path.join(rootDir, '.claude', 'settings.local.json');
|
|
371
|
+
const usagePath = path.join(rootDir, '.claude', 'ukit', 'permission-usage.json');
|
|
372
|
+
const auditPath = path.join(rootDir, '.claude', 'ukit', 'permission-audit.log');
|
|
373
|
+
|
|
374
|
+
if (dryRun) {
|
|
375
|
+
// Preflight reads for the report only; nothing is written in dry-run.
|
|
376
|
+
const settingsState = await readJson(settingsLocalPath, {});
|
|
377
|
+
if (settingsState.corrupt) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to plan against it`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const usageState = await readJson(usagePath, {});
|
|
383
|
+
if (usageState.corrupt) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const { removals, skips } = await sanitizePlanFromState({
|
|
389
|
+
settings: settingsState.value,
|
|
390
|
+
usage: usageState.value,
|
|
391
|
+
auditPath,
|
|
392
|
+
denyTokens,
|
|
393
|
+
});
|
|
394
|
+
if (!quiet) {
|
|
395
|
+
reportSanitizePlan({ mode: 'Would remove', removals, skips });
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const { removals, skips } = await applySanitize({ settingsLocalPath, usagePath, auditPath, denyTokens });
|
|
401
|
+
if (!quiet) {
|
|
402
|
+
reportSanitizePlan({ mode: 'Removed', removals, skips });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
121
406
|
async function main() {
|
|
122
|
-
const { dryRun, quiet } = parseArgs(process.argv.slice(2));
|
|
407
|
+
const { dryRun, quiet, sanitize } = parseArgs(process.argv.slice(2));
|
|
123
408
|
const rootDir = process.cwd();
|
|
409
|
+
if (sanitize) {
|
|
410
|
+
await runSanitize({ rootDir, dryRun, quiet });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
124
413
|
const settingsLocalPath = path.join(rootDir, '.claude', 'settings.local.json');
|
|
125
414
|
const usagePath = path.join(rootDir, '.claude', 'ukit', 'permission-usage.json');
|
|
126
415
|
const auditPath = path.join(rootDir, '.claude', 'ukit', 'permission-audit.log');
|