@haposoft/cafekit 0.18.1 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -22,15 +22,18 @@ const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.
|
|
|
22
22
|
* The hooks an omp install runs: the Claude set the manifest ships, then `src/omp/hooks/`
|
|
23
23
|
* written over it. Composed once under a dotted folder so runtime-dir.cjs derives `.omp`.
|
|
24
24
|
*/
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
function composeOmp(root) {
|
|
26
|
+
for (const rel of MANIFEST.runtime.files.filter((f) => f.startsWith('hooks/'))) {
|
|
27
|
+
const target = path.join(root, '.omp', rel);
|
|
28
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
29
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude', rel), target);
|
|
30
|
+
}
|
|
31
|
+
fs.cpSync(path.join(PACKAGE_ROOT, 'src/omp/hooks'), path.join(root, '.omp', 'hooks'), { recursive: true });
|
|
32
|
+
fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), path.join(root, '.omp', 'scripts'), { recursive: true });
|
|
33
|
+
return path.join(root, '.omp', 'hooks');
|
|
31
34
|
}
|
|
32
|
-
fs.
|
|
33
|
-
|
|
35
|
+
const COMPOSED_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-composed-'));
|
|
36
|
+
const HOOKS = composeOmp(COMPOSED_ROOT);
|
|
34
37
|
|
|
35
38
|
let bridge;
|
|
36
39
|
test.before(async () => { bridge = await import(BRIDGE); });
|
|
@@ -49,10 +52,10 @@ function registered(eventName) {
|
|
|
49
52
|
.filter(Boolean);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
// Deliberate, named divergences
|
|
53
|
-
//
|
|
54
|
-
const CLAUDE_ONLY = {
|
|
55
|
-
const OMP_ONLY = {
|
|
55
|
+
// Deliberate, named divergences. Each must still be a real difference, so the test fails
|
|
56
|
+
// (and the entry must go) once omp catches up.
|
|
57
|
+
const CLAUDE_ONLY = {};
|
|
58
|
+
const OMP_ONLY = {};
|
|
56
59
|
|
|
57
60
|
test('the dispatch table mirrors settings.json for every event omp can deliver', () => {
|
|
58
61
|
const { DISPATCH } = bridge;
|
|
@@ -169,3 +172,54 @@ test('input injections flow back as a transform that keeps the user text intact'
|
|
|
169
172
|
assert.match(r.inject, /Secret handling/, 'secret-output-guardrail must inject on a credential prompt');
|
|
170
173
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
171
174
|
});
|
|
175
|
+
|
|
176
|
+
test('the omp session id is used for every event that does not carry its own', () => {
|
|
177
|
+
const ctx = { cwd: '/tmp/project', sessionManager: { getSessionId: () => 'omp-session-1' } };
|
|
178
|
+
assert.equal(bridge.shapePayload('input', { text: 'hi' }, ctx).session_id, 'omp-session-1');
|
|
179
|
+
assert.equal(bridge.shapePayload('tool_result', { toolName: 'edit', input: {} }, ctx).session_id, 'omp-session-1');
|
|
180
|
+
assert.equal(bridge.shapePayload('session_stop', { session_id: 'omp-session-1' }, ctx).session_id, 'omp-session-1',
|
|
181
|
+
'session_stop carries the same id omp hands extensions, so touch and state stay in one place');
|
|
182
|
+
assert.ok(!bridge.DISPATCH.session_start.hooks.includes('docs-sync.cjs'), 'docs-sync runs on demand, as on Claude');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
function ompPacket(root, name) {
|
|
186
|
+
const dir = path.join(root, 'specs', name);
|
|
187
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
+
fs.writeFileSync(path.join(dir, 'plan.md'), `# ${name}\nSpecs-Contract: process-first-ready-v1\n`);
|
|
189
|
+
fs.writeFileSync(path.join(dir, 'task-01-work.md'), ['# Task 01 — work', '', 'Status: pending', '',
|
|
190
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', ''].join('\n'));
|
|
191
|
+
return dir;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test('an omp edit inside a packet scopes the tollgate to it', async () => {
|
|
195
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-touch-'));
|
|
196
|
+
try {
|
|
197
|
+
const hooks = composeOmp(root);
|
|
198
|
+
const alpha = ompPacket(root, 'alpha');
|
|
199
|
+
ompPacket(root, 'beta');
|
|
200
|
+
const { spawnSync } = require('node:child_process');
|
|
201
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'],
|
|
202
|
+
['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
203
|
+
assert.equal(spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }).status, 0);
|
|
204
|
+
}
|
|
205
|
+
const session = (id) => ({ cwd: root, sessionManager: { getSessionId: () => id } });
|
|
206
|
+
|
|
207
|
+
const untouched = await bridge.runEvent('input', { text: 'Continue' }, session('omp-a'), { dir: hooks });
|
|
208
|
+
assert.doesNotMatch(untouched.inject, /Multiple active specs|Spec state changed/, 'a session that touched nothing hears nothing');
|
|
209
|
+
|
|
210
|
+
// A failed edit records nothing.
|
|
211
|
+
await bridge.runEvent('tool_result', { toolName: 'edit', input: { path: path.join(alpha, 'plan.md') }, content: [], isError: true },
|
|
212
|
+
session('omp-a'), { dir: hooks });
|
|
213
|
+
const afterFailure = await bridge.runEvent('input', { text: 'Continue' }, session('omp-a'), { dir: hooks });
|
|
214
|
+
assert.doesNotMatch(afterFailure.inject, /Multiple active specs|Spec state changed/, 'a failed edit records nothing');
|
|
215
|
+
|
|
216
|
+
await bridge.runEvent('tool_result', { toolName: 'edit', input: { path: path.join(alpha, 'plan.md') }, content: [], isError: false },
|
|
217
|
+
session('omp-a'), { dir: hooks });
|
|
218
|
+
// As on Claude, a session that touched one of two active packets is told about both.
|
|
219
|
+
const touched = await bridge.runEvent('input', { text: 'Continue' }, session('omp-a'), { dir: hooks });
|
|
220
|
+
assert.match(touched.inject, /Multiple active specs detected: alpha, beta/);
|
|
221
|
+
|
|
222
|
+
const other = await bridge.runEvent('input', { text: 'Continue' }, session('omp-b'), { dir: hooks });
|
|
223
|
+
assert.doesNotMatch(other.inject, /Multiple active specs|Spec state changed/, "another omp session's touch does not leak");
|
|
224
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
225
|
+
});
|
|
@@ -133,11 +133,9 @@ function expectedOverlay(name) {
|
|
|
133
133
|
source = source.replace(from, to);
|
|
134
134
|
};
|
|
135
135
|
if (name === 'privacy-block.cjs') {
|
|
136
|
-
edit("
|
|
137
|
-
"
|
|
138
|
-
+ "
|
|
139
|
-
edit(" permissionDecision: 'ask',\n permissionDecisionReason: 'Sensitive access could not be evaluated safely.'\n",
|
|
140
|
-
" permissionDecision: 'deny',\n permissionDecisionReason: 'Sensitive access could not be evaluated safely.'\n");
|
|
136
|
+
edit(" permissionDecision: 'ask',\n",
|
|
137
|
+
" // omp has no ask state on tool_call, so an ask under Claude becomes a denial here.\n"
|
|
138
|
+
+ " permissionDecision: 'deny',\n");
|
|
141
139
|
}
|
|
142
140
|
return source;
|
|
143
141
|
}
|
|
@@ -157,3 +155,11 @@ test('the fork differs from Claude only by its overlay', () => {
|
|
|
157
155
|
assert.match(phase, /claudeHookFiles/, 'the phase must install the Claude set first');
|
|
158
156
|
assert.match(phase, /overlayFiles/, 'the phase must write the overlay over it');
|
|
159
157
|
});
|
|
158
|
+
|
|
159
|
+
test('an empty stdin is denied under omp, where Claude asks', () => {
|
|
160
|
+
const result = spawnSync(process.execPath, [path.join(INSTALLED, 'privacy-block.cjs')], { input: '', encoding: 'utf8' });
|
|
161
|
+
assert.equal(result.status, 0, result.stderr);
|
|
162
|
+
const output = JSON.parse(result.stdout).hookSpecificOutput;
|
|
163
|
+
assert.equal(output.permissionDecision, 'deny');
|
|
164
|
+
assert.match(output.permissionDecisionReason, /Sensitive access could not be evaluated safely\./);
|
|
165
|
+
});
|
|
@@ -149,8 +149,15 @@ function composeCodexHookTree(root) {
|
|
|
149
149
|
path.join(hooksDir, 'lib', 'hook-context.cjs'));
|
|
150
150
|
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/privacy-state.cjs'),
|
|
151
151
|
path.join(hooksDir, 'lib', 'privacy-state.cjs'));
|
|
152
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/rules-reservation.cjs'),
|
|
153
|
+
path.join(hooksDir, 'lib', 'rules-reservation.cjs'));
|
|
154
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'),
|
|
155
|
+
path.join(hooksDir, 'lib', 'update-check.cjs'));
|
|
152
156
|
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
153
157
|
path.join(hooksDir, 'lib', 'runtime-path-safety.cjs'));
|
|
158
|
+
for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
|
|
159
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib', name), path.join(hooksDir, 'lib', name));
|
|
160
|
+
}
|
|
154
161
|
return path.join(hooksDir, 'session.cjs');
|
|
155
162
|
}
|
|
156
163
|
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test, after } = require('node:test');
|
|
9
|
+
const SOURCE = path.resolve(__dirname, '../../src');
|
|
10
|
+
const roots = [];
|
|
11
|
+
|
|
12
|
+
function fixture(host) {
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-paths-'));
|
|
14
|
+
roots.push(root);
|
|
15
|
+
if (host === 'codex') {
|
|
16
|
+
assert.equal(spawnSync('git', ['init', '-q', root]).status, 0);
|
|
17
|
+
const install = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
|
|
18
|
+
cwd: root, encoding: 'utf8',
|
|
19
|
+
});
|
|
20
|
+
assert.equal(install.status, 0, install.stderr);
|
|
21
|
+
} else {
|
|
22
|
+
fs.cpSync(path.join(SOURCE, 'claude/hooks'), path.join(root, '.claude/hooks'), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
return { root, hooks: path.join(root, `.${host}/hooks`) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function invoke(f, tool_name, tool_input, spawnOptions = {}) {
|
|
28
|
+
const env = { ...process.env };
|
|
29
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
30
|
+
const result = spawnSync(process.execPath, [path.join(f.hooks, 'privacy-block.cjs')], {
|
|
31
|
+
cwd: f.cwd || f.root, encoding: 'utf8', env,
|
|
32
|
+
input: JSON.stringify({ tool_name, tool_input, cwd: f.cwd || f.root, session_id: 'privacy-paths-probe' }),
|
|
33
|
+
...spawnOptions,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(result.status, 0, result.stderr);
|
|
36
|
+
assert.equal(result.stderr, '', 'hook must not emit a stack trace');
|
|
37
|
+
return result.stdout.trim() ? JSON.parse(result.stdout).hookSpecificOutput : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
after(() => {
|
|
41
|
+
for (const root of roots) {
|
|
42
|
+
const locked = path.join(root, 'locked');
|
|
43
|
+
if (fs.existsSync(locked)) fs.chmodSync(locked, 0o700);
|
|
44
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const cases = [
|
|
49
|
+
['Read sensitive', 'Read', { file_path: '.env' }, true],
|
|
50
|
+
['Grep sensitive glob', 'Grep', { pattern: 'x', glob: '.env*' }, true],
|
|
51
|
+
['NotebookEdit sensitive notebook_path', 'NotebookEdit', { notebook_path: '.env' }, true],
|
|
52
|
+
['MCP path', 'mcp__fs__read_file', { path: '.env' }, true],
|
|
53
|
+
['MCP nested file_path', 'mcp__x__y', { args: { file_path: 'certs/a.pem' } }, true],
|
|
54
|
+
['MCP file uri', 'mcp__fs__read', root => ({ uri: `file://${root}/.env` }), true],
|
|
55
|
+
['MCP source destination', 'mcp__fs__move_file', { source: '.env', destination: 'x' }, true],
|
|
56
|
+
['MCP relative_path', 'mcp__s__read', { relative_path: '.env' }, true],
|
|
57
|
+
['Bash sensitive operand', 'Bash', { command: 'cat .env' }, true],
|
|
58
|
+
['Bash env-file option', 'Bash', { command: 'docker compose --env-file .env up' }, true],
|
|
59
|
+
['shell argv bash -lc', 'shell', { command: ['bash', '-lc', 'cat .env'] }, true],
|
|
60
|
+
['patch Update File', 'apply_patch', { command: '*** Begin Patch\n*** Update File: .env\n*** End Patch' }, true],
|
|
61
|
+
['patch Move to', 'apply_patch', { command: '*** Begin Patch\n*** Update File: a.txt\n*** Move to: .env\n*** End Patch' }, true],
|
|
62
|
+
['Read ordinary file', 'Read', { file_path: 'app.ts' }, false],
|
|
63
|
+
['Read exempt .env.example', 'Read', { file_path: '.env.example' }, false],
|
|
64
|
+
['Grep ordinary glob', 'Grep', { pattern: 'API_KEY', glob: '*.ts' }, false],
|
|
65
|
+
['Bash ls', 'Bash', { command: 'ls' }, false],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
for (const host of ['claude', 'codex']) {
|
|
69
|
+
const f = fixture(host);
|
|
70
|
+
for (const [name, tool, input, sensitive] of cases) {
|
|
71
|
+
test(`privacy-paths ${host}: ${name}`, () => {
|
|
72
|
+
const output = invoke(f, tool, typeof input === 'function' ? input(f.root) : input);
|
|
73
|
+
if (!sensitive) return assert.equal(output, null);
|
|
74
|
+
assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
75
|
+
if (host === 'codex') {
|
|
76
|
+
assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
|
|
77
|
+
assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('privacy-paths codex: missing shared lib denies even Bash ls', () => {
|
|
84
|
+
const f = fixture('codex');
|
|
85
|
+
fs.rmSync(path.join(f.hooks, 'lib/privacy-paths.cjs'), { force: true });
|
|
86
|
+
const output = invoke(f, 'Bash', { command: 'ls' });
|
|
87
|
+
assert.equal(output?.permissionDecision, 'deny');
|
|
88
|
+
assert.match(output.permissionDecisionReason, /could not be evaluated safely/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('privacy-paths: unavailable glob matcher keeps sensitive children', () => {
|
|
92
|
+
const f = fixture('claude');
|
|
93
|
+
fs.writeFileSync(path.join(f.root, 'credentials.json'), '');
|
|
94
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
95
|
+
const original = path.matchesGlob;
|
|
96
|
+
try {
|
|
97
|
+
path.matchesGlob = undefined;
|
|
98
|
+
let paths;
|
|
99
|
+
assert.doesNotThrow(() => { paths = sensitivePaths('Grep', { glob: '*.ts' }, f.root); });
|
|
100
|
+
assert.ok(paths.includes(path.join(f.root, 'credentials.json')));
|
|
101
|
+
} finally {
|
|
102
|
+
path.matchesGlob = original;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const runtimeWrites = [
|
|
107
|
+
['Write runtime', 'Write', { file_path: '.claude/runtime.json' }],
|
|
108
|
+
['Edit runtime', 'Edit', { file_path: '.codex/runtime.json' }],
|
|
109
|
+
['NotebookEdit runtime', 'NotebookEdit', { notebook_path: '.omp/runtime.json' }],
|
|
110
|
+
['patch approval directory', 'apply_patch', { command: '*** Begin Patch\n*** Add File: .codex/hooks/.privacy/g.json\n*** End Patch' }],
|
|
111
|
+
['MCP runtime', 'mcp__fs__write_file', { path: '.claude/runtime.json' }],
|
|
112
|
+
['MultiEdit runtime', 'MultiEdit', { edits: [{ file_path: '.claude/runtime.json' }] }],
|
|
113
|
+
['Write backslash runtime', 'Write', { file_path: '.CLAUDE\\RUNTIME.JSON' }],
|
|
114
|
+
['symlink directory Write', 'Write', { file_path: 'c/runtime.json' }],
|
|
115
|
+
['symlink directory shell', 'Bash', { command: "echo '{}' > c/runtime.json" }],
|
|
116
|
+
['dangling approval symlink', 'Write', { file_path: 'links/lnk2' }],
|
|
117
|
+
['dangling runtime symlink', 'Write', { file_path: 'links/lnk-runtime' }],
|
|
118
|
+
['chained approval symlink', 'Bash', { command: 'echo x > links/t1' }],
|
|
119
|
+
['chained runtime symlink', 'Bash', { command: 'echo x > links/r1' }],
|
|
120
|
+
['looping write symlink', 'Bash', { command: 'echo x > links/loop1' }],
|
|
121
|
+
...[
|
|
122
|
+
'cp -r evil/.claude .', 'mv evil/.codex .', 'cp -r evil/.omp .', 'mv evil/.privacy .',
|
|
123
|
+
].map(command => [`runtime directory source ${command}`, 'Bash', { command }]),
|
|
124
|
+
['clobber redirect', 'Bash', { command: 'echo x >| .claude/runtime.json' }],
|
|
125
|
+
['quoted heredoc header tail', 'Bash', { command: "cat <<'EOF' > .claude/runtime.json\n{}\nEOF" }],
|
|
126
|
+
...[
|
|
127
|
+
'cd "$PWD/.claude" && echo \'{}\' > runtime.json',
|
|
128
|
+
'cd $(pwd)/.claude && cp /tmp/r runtime.json',
|
|
129
|
+
].map(command => [`dynamic directory ${command}`, 'Bash', { command }]),
|
|
130
|
+
...[
|
|
131
|
+
'cp /tmp/r.json .claude/runtime.json > /dev/null',
|
|
132
|
+
'cp /tmp/r.json .claude/runtime.json 2>&1',
|
|
133
|
+
'mv r .codex/runtime.json 2>/dev/null',
|
|
134
|
+
'install r .omp/runtime.json > /dev/null',
|
|
135
|
+
'ln r .claude/runtime.json 2>&1',
|
|
136
|
+
'cp /tmp/r.json .claude/runtime.json < /dev/null',
|
|
137
|
+
'cp /tmp/r.json .claude/runtime.json <<< x',
|
|
138
|
+
'cp a .claude/runtime.json 0</dev/null',
|
|
139
|
+
'cp a .claude/runtime.json 0< /dev/null',
|
|
140
|
+
'cp a .claude/runtime.json 3</dev/null',
|
|
141
|
+
].map(command => [`redirect operands ${command}`, 'Bash', { command }]),
|
|
142
|
+
...[
|
|
143
|
+
"echo '{}' > .claude/runtime.json", "echo '{}' > .CLAUDE/RUNTIME.JSON",
|
|
144
|
+
'echo x >> .claude/./runtime.json', 'tee .codex//runtime.json',
|
|
145
|
+
'sed -i s/a/b/ .omp/runtime.json', 'cp r.json .claude/',
|
|
146
|
+
'mv r.json .codex/runtime.json', 'cp t .codex/hooks/./.privacy/',
|
|
147
|
+
'cd .claude && cp /tmp/r.json runtime.json', 'echo x > sub/.claude/runtime.json',
|
|
148
|
+
'install r.json .omp/', 'ln r.json .codex/runtime.json',
|
|
149
|
+
'pushd .codex && echo x > runtime.json',
|
|
150
|
+
"sed -ni 's/a/b/p' .claude/runtime.json", 'cp -t.claude r.json',
|
|
151
|
+
'cd .claude && echo "$(echo x > runtime.json)"',
|
|
152
|
+
].map(command => [command, 'Bash', { command }]),
|
|
153
|
+
];
|
|
154
|
+
const runtimeAllowed = [
|
|
155
|
+
['Read runtime', 'Read', { file_path: '.claude/runtime.json' }],
|
|
156
|
+
['cat .codex/runtime.json', 'Bash', { command: 'cat .codex/runtime.json' }],
|
|
157
|
+
['jq .paths .claude/runtime.json', 'Bash', { command: 'jq .paths .claude/runtime.json' }],
|
|
158
|
+
['Write ordinary runtime', 'Write', { file_path: 'docs/runtime.json' }],
|
|
159
|
+
['Write resolved ordinary runtime', 'Write', { file_path: '.claude/../docs/runtime.json' }],
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const host of ['claude', 'codex']) {
|
|
163
|
+
const f = fixture(host);
|
|
164
|
+
fs.mkdirSync(path.join(f.root, '.claude'), { recursive: true });
|
|
165
|
+
fs.symlinkSync('.claude', path.join(f.root, 'c'), 'dir');
|
|
166
|
+
fs.mkdirSync(path.join(f.root, 'links'));
|
|
167
|
+
fs.symlinkSync('../.codex/hooks/.privacy/g.json', path.join(f.root, 'links/lnk2'));
|
|
168
|
+
fs.symlinkSync('../.claude/runtime.json', path.join(f.root, 'links/lnk-runtime'));
|
|
169
|
+
fs.symlinkSync('t2', path.join(f.root, 'links/t1'));
|
|
170
|
+
fs.symlinkSync('../.codex/hooks/.privacy/g.json', path.join(f.root, 'links/t2'));
|
|
171
|
+
fs.symlinkSync('r2', path.join(f.root, 'links/r1'));
|
|
172
|
+
fs.symlinkSync('../.claude/runtime.json', path.join(f.root, 'links/r2'));
|
|
173
|
+
fs.symlinkSync('loop2', path.join(f.root, 'links/loop1'));
|
|
174
|
+
fs.symlinkSync('loop1', path.join(f.root, 'links/loop2'));
|
|
175
|
+
for (const name of ['.claude', '.codex', '.omp', '.privacy']) {
|
|
176
|
+
const directory = path.join(f.root, 'evil', name);
|
|
177
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
178
|
+
fs.writeFileSync(path.join(directory, 'runtime.json'), '{}');
|
|
179
|
+
}
|
|
180
|
+
for (const [name, tool, input] of runtimeWrites) {
|
|
181
|
+
test(`privacy-paths ${host}: runtime writes ${name}`, () => {
|
|
182
|
+
const output = invoke(f, tool, input);
|
|
183
|
+
assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
184
|
+
assert.doesNotMatch(output.permissionDecisionReason, /could not be evaluated safely/);
|
|
185
|
+
if (host === 'codex') {
|
|
186
|
+
assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
|
|
187
|
+
assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
for (const [name, tool, input] of runtimeAllowed) {
|
|
192
|
+
test(`privacy-paths ${host}: runtime allows ${name}`, () => assert.equal(invoke(f, tool, input), null));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('privacy-paths claude: subdirectory switch cannot disable root privacy', () => {
|
|
197
|
+
const f = fixture('claude');
|
|
198
|
+
const cwd = path.join(f.root, 'sub');
|
|
199
|
+
fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
|
|
200
|
+
fs.writeFileSync(path.join(cwd, '.claude/runtime.json'), JSON.stringify({ privacyBlock: false }));
|
|
201
|
+
fs.writeFileSync(path.join(f.root, '.env'), '');
|
|
202
|
+
assert.equal(invoke({ ...f, cwd }, 'Read', { file_path: '../.env' })?.permissionDecision, 'ask');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('privacy-paths claude: root switch still allows a manual opt-out', () => {
|
|
206
|
+
const f = fixture('claude');
|
|
207
|
+
fs.writeFileSync(path.join(f.root, '.claude/runtime.json'), JSON.stringify({ privacyBlock: false }));
|
|
208
|
+
fs.writeFileSync(path.join(f.root, '.env'), '');
|
|
209
|
+
assert.equal(invoke(f, 'Read', { file_path: '.env' }), null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const recursiveSensitive = [
|
|
213
|
+
'grep -r API_KEY .', 'grep -rn API_KEY .', 'grep -Rin key .', 'grep -rl KEY .',
|
|
214
|
+
'grep --recursive KEY .', 'grep -d recurse KEY .', 'grep -R KEY', 'grep -r KEY src .',
|
|
215
|
+
"bash -c 'grep -rn KEY .'", 'echo . | xargs grep -r KEY',
|
|
216
|
+
'rg KEY .', 'rg --hidden KEY .', 'rg -u -u KEY .',
|
|
217
|
+
"rg --hidden -g '!*.ts' KEY .", "grep -r --include='*' KEY .",
|
|
218
|
+
'grep -r --include .env KEY .', "grep -r --include='.e*' KEY .",
|
|
219
|
+
'grep -R KEY linked', 'ag KEY .', 'rg -uu KEY .', 'rg -. KEY .',
|
|
220
|
+
'grep --dereference-recursive KEY .', 'grep --directories=recurse KEY .',
|
|
221
|
+
"find src -exec grep -rn KEY . \\;",
|
|
222
|
+
"rg --iglob 'CREDENTIALS.JSON' KEY .", "rg --glob-case-insensitive -g 'CREDENTIALS.JSON' KEY .",
|
|
223
|
+
"rg -g '*.ts' --iglob 'CREDENTIALS.JSON' KEY .", "rg -uug '*' KEY hidden",
|
|
224
|
+
];
|
|
225
|
+
const recursiveAllowed = [
|
|
226
|
+
'grep -r KEY src', 'grep -r --include=*.ts KEY .', "rg -g '*.ts' KEY .",
|
|
227
|
+
'grep -r KEY app.ts', 'grep -r KEY nope/', 'grep KEY .', 'grep -r KEY examples',
|
|
228
|
+
'rg --files src', 'grep -reKEY src',
|
|
229
|
+
];
|
|
230
|
+
const recursiveGrep = [
|
|
231
|
+
{ pattern: 'KEY' }, { pattern: 'KEY', path: '.' },
|
|
232
|
+
{ pattern: 'KEY', glob: '*' }, { pattern: 'KEY', glob: '.*' },
|
|
233
|
+
{ pattern: 'KEY', glob: '*.{env,json}' },
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const host of ['claude', 'codex']) {
|
|
237
|
+
const f = fixture(host);
|
|
238
|
+
for (const name of ['.env', 'credentials.json', '.env.example', 'app.ts']) {
|
|
239
|
+
fs.writeFileSync(path.join(f.root, name), '');
|
|
240
|
+
}
|
|
241
|
+
for (const dir of ['src', 'linked', 'examples', 'locked', 'hidden', 'a']) fs.mkdirSync(path.join(f.root, dir));
|
|
242
|
+
fs.symlinkSync('../.env', path.join(f.root, 'linked/notes.txt'));
|
|
243
|
+
fs.writeFileSync(path.join(f.root, 'examples/.env.example'), '');
|
|
244
|
+
fs.writeFileSync(path.join(f.root, 'hidden/.env'), '');
|
|
245
|
+
fs.writeFileSync(path.join(f.root, 'a/.env'), '');
|
|
246
|
+
fs.writeFileSync(path.join(f.root, 'aws-production-service-account-credentials-backup-2026.json'), '');
|
|
247
|
+
const wildcardDirectory = path.join(f.root, 'wildcard-count');
|
|
248
|
+
fs.mkdirSync(wildcardDirectory);
|
|
249
|
+
fs.writeFileSync(path.join(wildcardDirectory, `credentials${'a'.repeat(200)}b.json`), '');
|
|
250
|
+
fs.writeFileSync(path.join(f.root, 'locked/credentials.json'), '');
|
|
251
|
+
const locked = path.join(f.root, 'locked');
|
|
252
|
+
fs.chmodSync(locked, 0);
|
|
253
|
+
const probe = (name, tool, input, sensitive, options = {}) => test(`privacy-paths ${host}: recursive ${name}`, options, () => {
|
|
254
|
+
const output = invoke(f, tool, input, options.timeout
|
|
255
|
+
? { timeout: options.timeout - 1000, killSignal: 'SIGKILL' } : {});
|
|
256
|
+
if (!sensitive) return assert.equal(output, null);
|
|
257
|
+
assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
258
|
+
if (host === 'codex') {
|
|
259
|
+
assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
|
|
260
|
+
assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
for (const command of recursiveSensitive) probe(command, 'Bash', { command }, true);
|
|
264
|
+
for (const input of recursiveGrep) probe(`Grep ${JSON.stringify(input)}`, 'Grep', input, true);
|
|
265
|
+
for (const command of ["rg -g '/credentials.json' KEY", "rg -g '/*.json' KEY", "rg --hidden -g '/.e*' KEY"]) {
|
|
266
|
+
probe(`anchored ${command}`, 'Bash', { command }, true);
|
|
267
|
+
}
|
|
268
|
+
probe('anchored Grep glob', 'Grep', { pattern: 'KEY', glob: '/*.json' }, true);
|
|
269
|
+
probe('bounded glob shell range', 'Bash', { command: "rg -g '{1..100000}' -g '*' KEY ." }, true, { timeout: 10000 });
|
|
270
|
+
probe('bounded glob Grep range', 'Grep', { glob: '{1..100000}' }, true, { timeout: 10000 });
|
|
271
|
+
probe('bounded glob Grep length', 'Grep', { glob: 'x'.repeat(257) }, true, { timeout: 10000 });
|
|
272
|
+
probe('bounded combinations shell', 'Bash', {
|
|
273
|
+
command: `rg -g '${'{a,b}'.repeat(20)}' -g '*' KEY .`,
|
|
274
|
+
}, true, { timeout: 10000 });
|
|
275
|
+
probe('bounded combinations nested Grep', 'Grep', {
|
|
276
|
+
glob: '{a,{b,{c,{d,e}}}}'.repeat(12),
|
|
277
|
+
}, true, { timeout: 10000 });
|
|
278
|
+
probe('bounded combinations narrow Grep', 'Grep', {
|
|
279
|
+
path: '.', glob: '*.{ts,tsx}',
|
|
280
|
+
}, false, { timeout: 10000 });
|
|
281
|
+
probe('narrow grammar escaped braces shell', 'Bash', {
|
|
282
|
+
command: `rg -g '${'\\{a,b\\}'.repeat(20)}' -g '*' KEY .`,
|
|
283
|
+
}, true, { timeout: 10000 });
|
|
284
|
+
probe('narrow grammar repeated wildcards Grep', 'Grep', {
|
|
285
|
+
path: '.', globs: [`${'*?'.repeat(20)}z`, '*'],
|
|
286
|
+
}, true, { timeout: 10000 });
|
|
287
|
+
probe('narrow grammar extglob Grep', 'Grep', {
|
|
288
|
+
glob: '+(a|*)'.repeat(10),
|
|
289
|
+
}, true, { timeout: 10000 });
|
|
290
|
+
probe('total wildcard count in brace Grep', 'Grep', {
|
|
291
|
+
path: 'wildcard-count', globs: ['{*a*a*a*a*az,q}', '*'],
|
|
292
|
+
}, true, { timeout: 10000 });
|
|
293
|
+
for (const command of ['rg --unrestricted --unrestricted KEY a', 'rg -.n KEY a', 'ag -u KEY a', 'ag --unrestricted KEY a']) {
|
|
294
|
+
probe(`hidden flags ${command}`, 'Bash', { command }, true);
|
|
295
|
+
}
|
|
296
|
+
for (const input of [{ path: 1 }, { glob: ['*'] }, { globs: 5 }]) {
|
|
297
|
+
test(`privacy-paths ${host}: malformed Grep ${JSON.stringify(input)}`, () => {
|
|
298
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
299
|
+
let paths;
|
|
300
|
+
assert.doesNotThrow(() => { paths = sensitivePaths('Grep', input, f.root); });
|
|
301
|
+
assert.ok(paths.includes(path.join(f.root, 'credentials.json')));
|
|
302
|
+
const output = invoke(f, 'Grep', input);
|
|
303
|
+
assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
304
|
+
assert.doesNotMatch(output.permissionDecisionReason, /could not be evaluated safely/);
|
|
305
|
+
if (host === 'codex') {
|
|
306
|
+
assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
|
|
307
|
+
assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
for (const command of recursiveAllowed) probe(command, 'Bash', { command }, false);
|
|
312
|
+
probe('Grep narrow glob', 'Grep', { pattern: 'KEY', path: '.', glob: '*.ts' }, false);
|
|
313
|
+
probe('Grep narrow recursive glob', 'Grep', { pattern: 'KEY', path: '.', glob: '**/*.ts' }, false);
|
|
314
|
+
probe('grep -r KEY locked/', 'Bash', { command: 'grep -r KEY locked/' }, false, {
|
|
315
|
+
skip: typeof process.getuid === 'function' && process.getuid() === 0,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
@@ -38,6 +38,7 @@ function install(root, platforms) {
|
|
|
38
38
|
const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
|
|
39
39
|
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
40
40
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
41
|
+
return result;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function commands(settings) {
|
|
@@ -55,7 +56,7 @@ test('fresh claude install ships no usage hook', () => {
|
|
|
55
56
|
});
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
test('upgrade
|
|
59
|
+
test('upgrade preserves an edited usage hook while removing its CafeKit entries', () => {
|
|
59
60
|
inTempProject((root) => {
|
|
60
61
|
install(root, ['claude']);
|
|
61
62
|
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
@@ -74,8 +75,9 @@ test('upgrade removes an installed usage hook and its CafeKit entries but keeps
|
|
|
74
75
|
});
|
|
75
76
|
fs.writeFileSync(settingsPath, JSON.stringify(before, null, 2));
|
|
76
77
|
|
|
77
|
-
install(root, ['claude']);
|
|
78
|
-
assert.equal(fs.
|
|
78
|
+
const upgrade = install(root, ['claude']);
|
|
79
|
+
assert.equal(fs.readFileSync(path.join(root, '.claude', 'hooks', 'usage.cjs'), 'utf8'), '// user edit\n');
|
|
80
|
+
assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
|
|
79
81
|
const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')));
|
|
80
82
|
assert.equal(after.includes(CAFEKIT_USAGE_COMMAND), false, 'both CafeKit entries are pruned');
|
|
81
83
|
assert.equal(after.includes(FOREIGN_USAGE_COMMAND), true, 'a hook outside .claude/hooks/ survives');
|
package/bin/install.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { spawnSync } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
19
20
|
const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
|
|
20
21
|
const lock = require('./lib/lock');
|
|
21
22
|
const backup = require('./lib/backup');
|
|
@@ -45,6 +46,28 @@ const { runPostInstall } = require('./phases/post-install');
|
|
|
45
46
|
const { setupSkillDeps } = require('./phases/skills-setup');
|
|
46
47
|
const { printSummary } = require('./phases/summary');
|
|
47
48
|
|
|
49
|
+
function preflightTrackedRootFiles(ctx) {
|
|
50
|
+
const files = ['AGENTS.md'];
|
|
51
|
+
if (ctx.platforms.includes('claude')) files.push('CLAUDE.md');
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
try {
|
|
54
|
+
fs.lstatSync(file);
|
|
55
|
+
continue;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ENOENT') throw error;
|
|
58
|
+
}
|
|
59
|
+
const result = spawnSync('git', ['ls-files', '-v', '--error-unmatch', '--', file], {
|
|
60
|
+
cwd: process.cwd(), encoding: 'utf8', timeout: 3000, shell: false,
|
|
61
|
+
});
|
|
62
|
+
if (!result.error && result.status === 0) {
|
|
63
|
+
if (result.stdout[0]?.toUpperCase() === 'S') continue;
|
|
64
|
+
throw new Error(`${file} is tracked by git but missing from the working tree; restore it with \`git checkout -- ${file}\`, or \`git rm ${file}\` if the deletion is intended, then rerun the installer.`);
|
|
65
|
+
}
|
|
66
|
+
if (!result.error && (result.status === 1 || /not a git repository/i.test(result.stderr || ''))) continue;
|
|
67
|
+
ctx.ui.warn(`Could not confirm git state for ${file}; installing a fresh copy.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
/** Install a single platform: payload + runtime + metadata, under one spinner. */
|
|
49
72
|
function installPlatform(ctx, platformKey) {
|
|
50
73
|
const platform = PLATFORMS[platformKey];
|
|
@@ -175,6 +198,8 @@ async function main() {
|
|
|
175
198
|
await selectDocumentSkills(ctx);
|
|
176
199
|
if (ctx.cancelled) { lock.release(); process.exit(0); }
|
|
177
200
|
|
|
201
|
+
preflightTrackedRootFiles(ctx);
|
|
202
|
+
|
|
178
203
|
// ── Pre-run snapshot for rollback ─────────────────────
|
|
179
204
|
// Capture platform folders AND the root files the pipeline mutates
|
|
180
205
|
// (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
|