@haposoft/cafekit 0.18.1 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- 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 +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- 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-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -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 +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- 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 +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -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 +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- 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/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- 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/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -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/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,501 @@
|
|
|
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
|
+
}
|
|
318
|
+
|
|
319
|
+
// Dynamic shell: a secret reached through a glob, a variable assigned in the same command, a sensitive name next to an
|
|
320
|
+
// unresolved expansion, a literal inside $(...) or backticks, a decoder feeding xargs or a shell, or xargs/find -exec fed a
|
|
321
|
+
// sensitive name. Pure indirection (`cat $FILE`) and ordinary globs stay quiet: every question here is a round trip
|
|
322
|
+
// through an approval prompt.
|
|
323
|
+
function dynamicFixture(host) {
|
|
324
|
+
const f = fixture(host);
|
|
325
|
+
fs.writeFileSync(path.join(f.root, '.env'), 'SECRET=1\n');
|
|
326
|
+
fs.mkdirSync(path.join(f.root, 'sub'));
|
|
327
|
+
fs.writeFileSync(path.join(f.root, 'sub/.env'), 'SECRET=2\n');
|
|
328
|
+
fs.writeFileSync(path.join(f.root, 'notes.txt'), 'n\n');
|
|
329
|
+
fs.writeFileSync(path.join(f.root, 'a.txt'), 'a\n');
|
|
330
|
+
fs.mkdirSync(path.join(f.root, 'pkg'));
|
|
331
|
+
fs.writeFileSync(path.join(f.root, 'pkg/package.json'), '{}\n');
|
|
332
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-home-'));
|
|
333
|
+
roots.push(home);
|
|
334
|
+
fs.mkdirSync(path.join(home, '.ssh'));
|
|
335
|
+
fs.writeFileSync(path.join(home, '.ssh/id_rsa'), 'k\n');
|
|
336
|
+
fs.writeFileSync(path.join(home, 'notes.txt'), 'n\n');
|
|
337
|
+
return { ...f, home };
|
|
338
|
+
}
|
|
339
|
+
const invokeHome = (f, command) => invoke(f, 'Bash', { command }, { env: { ...process.env, HOME: f.home, CLAUDE_PROJECT_DIR: '' } });
|
|
340
|
+
|
|
341
|
+
const dynamicAsk = [
|
|
342
|
+
'cat .e*', 'cat ./.e*', 'cat sub/.e*', 'cat */.e*', 'cat ~/.ssh/id_*', 'cat ~/.ssh/*',
|
|
343
|
+
'cd sub && cat .e*', 'f=.env; cat $f', 'export F=.env && cat "$F"', 'declare f=.env; cat $f',
|
|
344
|
+
'cat ${f:-.env}', 'less "$HOME/.env"', 'cat "$X/.env"', 'cat $(echo .env)', 'cat `echo .env`',
|
|
345
|
+
'cat .{env,txt}', 'cd "$D" && cat .e*', 'cd "$D" && cat *.pem', 'echo LmVudg== | base64 -d | xargs cat', 'printf .env | xargs cat',
|
|
346
|
+
'find . -name .env -exec cat {} \;', "find . -name '.e*' | xargs cat", 'base64 -d <<< Y2F0IC5lbnY= | sh',
|
|
347
|
+
];
|
|
348
|
+
const dynamicQuiet = [
|
|
349
|
+
'wc -l *.txt', 'ls *.txt', 'ls */package.json', 'wc -l */*.txt', 'du -sh */', 'cat $FILE', 'f=.env cat $f',
|
|
350
|
+
'echo $PATH', 'cd "$D" && wc -l *.txt', 'cat "$HOME/notes.txt"', 'git ls-files | xargs wc -l',
|
|
351
|
+
"find . -name '*.ts' -exec wc -l {} +", 'echo hi | base64', 'cat README.md $EXTRA', 'cat a.txt notes.txt | sort',
|
|
352
|
+
// Found while replaying real session commands: a class that is not a valid regular expression must not crash the hook.
|
|
353
|
+
"printf '[[validator-multi-contract-gap]]' | xargs cat", 'cat [z-a]*', 'cat [[x]',
|
|
354
|
+
// Replaying real session commands: a generic glob in a directory the hook cannot read is not a secret's name.
|
|
355
|
+
'cd "$D" && cat *', 'cd "$D" && cat *.json *.yaml', "printf '*' | xargs grep x",
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
for (const host of ['claude', 'codex']) {
|
|
359
|
+
const f = dynamicFixture(host);
|
|
360
|
+
for (const command of dynamicAsk) {
|
|
361
|
+
test(`privacy-paths ${host} dynamic shell asks: ${command}`, () => {
|
|
362
|
+
assert.equal(invokeHome(f, command)?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
for (const command of dynamicQuiet) {
|
|
366
|
+
test(`privacy-paths ${host} dynamic shell stays quiet: ${command}`, () => {
|
|
367
|
+
assert.equal(invokeHome(f, command), null);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
test('privacy-paths dynamic shell: a glob over more entries than the whole-command budget asks', () => {
|
|
373
|
+
const f = dynamicFixture('claude');
|
|
374
|
+
fs.mkdirSync(path.join(f.root, 'big'));
|
|
375
|
+
for (let index = 0; index < 6000; index += 1) fs.writeFileSync(path.join(f.root, `big/f${index}.txt`), '');
|
|
376
|
+
const output = invokeHome(f, 'cat big/* big/*/x */*');
|
|
377
|
+
assert.equal(output?.permissionDecision, 'ask');
|
|
378
|
+
assert.match(output.permissionDecisionReason, /budget/i);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('privacy-paths dynamic shell: glob expansion does not depend on path.matchesGlob', () => {
|
|
382
|
+
const f = dynamicFixture('claude');
|
|
383
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
384
|
+
const original = path.matchesGlob;
|
|
385
|
+
try {
|
|
386
|
+
path.matchesGlob = undefined;
|
|
387
|
+
const paths = sensitivePaths('Bash', { command: 'cat .e*' }, f.root);
|
|
388
|
+
assert.ok(paths.some((p) => path.basename(p) === '.env'), JSON.stringify(paths));
|
|
389
|
+
} finally {
|
|
390
|
+
path.matchesGlob = original;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('privacy-paths dynamic shell: a long chain of expansions is checked in linear time', () => {
|
|
395
|
+
const f = dynamicFixture('claude');
|
|
396
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
397
|
+
// Under the text cap, so the variables are really substituted rather than cut off by the budget.
|
|
398
|
+
const command = `a=x; cat ${'$a'.repeat(1500)}/.env`;
|
|
399
|
+
const started = Date.now();
|
|
400
|
+
const paths = sensitivePaths('Bash', { command }, f.root);
|
|
401
|
+
assert.ok(Date.now() - started < 2000, `took ${Date.now() - started} ms`);
|
|
402
|
+
assert.ok(paths.some((p) => p.endsWith('/.env') && !/budget/.test(p)), JSON.stringify(paths.map((p) => path.basename(p))));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Security review round 1: work outside the budget let a crafted command outrun the hook's timeout, which Claude Code
|
|
406
|
+
// treats as allow. Each of these must finish fast and still ask.
|
|
407
|
+
const quick = (f, command) => {
|
|
408
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
409
|
+
const started = Date.now();
|
|
410
|
+
const paths = sensitivePaths('Bash', { command }, f.root);
|
|
411
|
+
const ms = Date.now() - started;
|
|
412
|
+
assert.ok(ms < 2000, `${command.slice(0, 60)}… took ${ms} ms`);
|
|
413
|
+
return paths;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
test('privacy-paths dynamic shell: a glob with many stars against a long name is not exponential', () => {
|
|
417
|
+
const f = dynamicFixture('claude');
|
|
418
|
+
fs.writeFileSync(path.join(f.root, 'a'.repeat(40)), '');
|
|
419
|
+
assert.ok(quick(f, `cat ${'*a'.repeat(14)}*b .env`).length > 0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('privacy-paths dynamic shell: very long pipelines are analysed in linear time', () => {
|
|
423
|
+
const f = dynamicFixture('claude');
|
|
424
|
+
quick(f, `${'a|'.repeat(10000)}a`);
|
|
425
|
+
quick(f, 'true | a b c d e '.repeat(5000));
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('privacy-paths dynamic shell: substituted text and brace groups are capped by the budget', () => {
|
|
429
|
+
const f = dynamicFixture('claude');
|
|
430
|
+
assert.ok(quick(f, `b='{${','.repeat(200000)}'; cat $b .env`).length > 0);
|
|
431
|
+
const big = quick(f, `a=${'x'.repeat(100000)}; cat ${'$a'.repeat(1000)}`);
|
|
432
|
+
assert.ok(big.some((p) => /budget/.test(p)), JSON.stringify(big));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('privacy-paths dynamic shell: many literal path segments are charged to the budget', () => {
|
|
436
|
+
const f = dynamicFixture('claude');
|
|
437
|
+
fs.mkdirSync(path.join(f.root, 'big'));
|
|
438
|
+
for (let index = 0; index < 3000; index += 1) fs.mkdirSync(path.join(f.root, `big/d${index}`));
|
|
439
|
+
const paths = quick(f, `cat big/*/${'./'.repeat(2040)}x`);
|
|
440
|
+
assert.ok(paths.some((p) => /budget/.test(p)), JSON.stringify(paths));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const reviewAsk = [
|
|
444
|
+
'cat .env$X', 'cat .env${UNSET}', 'cat ~/.ssh/id_rsa$1', 'cat .en"$Y"v', 'f="$PWD/.env"; cat "$f"',
|
|
445
|
+
'f=$HOME/.ssh/id_rsa; cat $f', 'F=.env; G=$F; cat $G', 'x=.env; eval cat \\$x', "export x=.env; bash -c 'cat $x'",
|
|
446
|
+
'cat **/.e*', '__proto__=.env; cat $__proto__',
|
|
447
|
+
'if true; then cat .env; fi', 'for f in x; do cat .env; done', 'while false; do cat .env; done',
|
|
448
|
+
'{ cat .env; }', 'time cat .env', '! cat .env',
|
|
449
|
+
];
|
|
450
|
+
for (const host of ['claude', 'codex']) {
|
|
451
|
+
const f = dynamicFixture(host);
|
|
452
|
+
for (const command of reviewAsk) {
|
|
453
|
+
test(`privacy-paths ${host} review round 1 asks: ${command}`, () => {
|
|
454
|
+
assert.equal(invokeHome(f, command)?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Replaying real session commands after review round 1: after `cd` to an unknown directory a glob must not be expanded in
|
|
460
|
+
// the hook's own cwd, and a name that starts with an unknown value (`$g.*`) is not a secret's name.
|
|
461
|
+
test('privacy-paths dynamic shell: a glob after cd to an unknown directory is judged by name, not in the cwd', () => {
|
|
462
|
+
const f = dynamicFixture('claude');
|
|
463
|
+
fs.writeFileSync(path.join(f.root, 'credentials.json'), '{}\n');
|
|
464
|
+
const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
|
|
465
|
+
assert.deepEqual(sensitivePaths('Bash', { command: 'cd ~/elsewhere; cat *.json' }, f.root), []);
|
|
466
|
+
assert.deepEqual(sensitivePaths('Bash', { command: 'for g in a b; do cat graders/$g.* ; done' }, f.root), []);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Security review round 2.
|
|
470
|
+
const reviewAskRound2 = ['cat .e[[:alpha:]]v', 'cat .e[[:alnum:]]v', 'function f { cat .env; }; f', 'coproc cat .env'];
|
|
471
|
+
for (const host of ['claude', 'codex']) {
|
|
472
|
+
const f = dynamicFixture(host);
|
|
473
|
+
for (const command of reviewAskRound2) {
|
|
474
|
+
test(`privacy-paths ${host} review round 2 asks: ${command}`, () => {
|
|
475
|
+
assert.equal(invokeHome(f, command)?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
test('privacy-paths dynamic shell: ** over a tree too big for its slice is judged by name', () => {
|
|
481
|
+
const f = dynamicFixture('claude');
|
|
482
|
+
for (let index = 0; index < 3000; index += 1) {
|
|
483
|
+
fs.mkdirSync(path.join(f.root, `big/d${index}`), { recursive: true });
|
|
484
|
+
fs.writeFileSync(path.join(f.root, `big/d${index}/n.md`), '');
|
|
485
|
+
}
|
|
486
|
+
assert.deepEqual(quick(f, 'wc -l **/*.md'), []);
|
|
487
|
+
assert.ok(quick(f, 'cat **/.e*').some((p) => p.endsWith('.e*')));
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('privacy-paths dynamic shell: one unclosed [ after another is not rescanned, and long piped names are capped', () => {
|
|
491
|
+
const f = dynamicFixture('claude');
|
|
492
|
+
assert.ok(quick(f, `cat ${'['.repeat(16000)}*`).length >= 0);
|
|
493
|
+
assert.ok(quick(f, `printf %s '${'['.repeat(400000)}' | xargs cat`).some((p) => /budget/.test(p)));
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('privacy-paths dynamic shell: many assignments read by many operands stay within the budget', () => {
|
|
497
|
+
const f = dynamicFixture('claude');
|
|
498
|
+
const names = Array.from({ length: 300 }, (_, index) => index);
|
|
499
|
+
const command = [...names.map((n) => `v${n}=src/f${n}.txt`), ...names.map((n) => `wc -l "$v${n}"`)].join('; ');
|
|
500
|
+
assert.deepEqual(quick(f, command), []);
|
|
501
|
+
});
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// usage.cjs read the Claude Code OAuth token from the macOS Keychain to call an undocumented
|
|
4
4
|
// quota endpoint; the statusline now reads Claude Code's own rate_limits instead. Each case
|
|
5
5
|
// pins one way the hook could survive the removal: a fresh install, an upgrade on Claude
|
|
6
|
-
// (
|
|
7
|
-
// substring, so it must not take a user's own hook), an upgrade on omp (
|
|
8
|
-
//
|
|
6
|
+
// (the file goes only when its bytes match the ownership record; the settings prune matches
|
|
7
|
+
// by substring, so it must not take a user's own hook), an upgrade on omp (same hash check,
|
|
8
|
+
// tracker keys `.omp/…`), and the source lists themselves.
|
|
9
9
|
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
11
12
|
const fs = require('node:fs');
|
|
12
13
|
const os = require('node:os');
|
|
13
14
|
const path = require('node:path');
|
|
@@ -18,7 +19,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
|
|
|
18
19
|
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
19
20
|
const MANIFEST = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'), 'utf8'));
|
|
20
21
|
const SOURCE_SETTINGS = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'), 'utf8'));
|
|
21
|
-
const BRIDGE = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.
|
|
22
|
+
const BRIDGE = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.js'), 'utf8');
|
|
22
23
|
|
|
23
24
|
const CAFEKIT_USAGE_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/usage.cjs"';
|
|
24
25
|
const FOREIGN_USAGE_COMMAND = 'node ~/my-hooks/usage.cjs';
|
|
@@ -38,6 +39,7 @@ function install(root, platforms) {
|
|
|
38
39
|
const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
|
|
39
40
|
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
40
41
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
42
|
+
return result;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function commands(settings) {
|
|
@@ -55,7 +57,7 @@ test('fresh claude install ships no usage hook', () => {
|
|
|
55
57
|
});
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
test('upgrade
|
|
60
|
+
test('upgrade preserves an edited usage hook while removing its CafeKit entries', () => {
|
|
59
61
|
inTempProject((root) => {
|
|
60
62
|
install(root, ['claude']);
|
|
61
63
|
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
@@ -74,8 +76,9 @@ test('upgrade removes an installed usage hook and its CafeKit entries but keeps
|
|
|
74
76
|
});
|
|
75
77
|
fs.writeFileSync(settingsPath, JSON.stringify(before, null, 2));
|
|
76
78
|
|
|
77
|
-
install(root, ['claude']);
|
|
78
|
-
assert.equal(fs.
|
|
79
|
+
const upgrade = install(root, ['claude']);
|
|
80
|
+
assert.equal(fs.readFileSync(path.join(root, '.claude', 'hooks', 'usage.cjs'), 'utf8'), '// user edit\n');
|
|
81
|
+
assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
|
|
79
82
|
const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')));
|
|
80
83
|
assert.equal(after.includes(CAFEKIT_USAGE_COMMAND), false, 'both CafeKit entries are pruned');
|
|
81
84
|
assert.equal(after.includes(FOREIGN_USAGE_COMMAND), true, 'a hook outside .claude/hooks/ survives');
|
|
@@ -88,10 +91,11 @@ test('omp upgrade removes an installed usage hook and its manifest record', () =
|
|
|
88
91
|
install(root, ['omp']);
|
|
89
92
|
const hookPath = path.join(root, '.omp', 'hooks', 'usage.cjs');
|
|
90
93
|
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
91
|
-
|
|
94
|
+
const shipped = '// shipped by an older CafeKit\n';
|
|
95
|
+
fs.writeFileSync(hookPath, shipped);
|
|
92
96
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
93
97
|
// omp records keys relative to the project root (recordRoot '.').
|
|
94
|
-
manifest.files['.omp/hooks/usage.cjs'] = { sha256: '
|
|
98
|
+
manifest.files['.omp/hooks/usage.cjs'] = { sha256: crypto.createHash('sha256').update(shipped).digest('hex') };
|
|
95
99
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
96
100
|
|
|
97
101
|
install(root, ['omp']);
|
|
@@ -101,6 +105,22 @@ test('omp upgrade removes an installed usage hook and its manifest record', () =
|
|
|
101
105
|
});
|
|
102
106
|
});
|
|
103
107
|
|
|
108
|
+
test('omp upgrade keeps an edited usage hook and says so', () => {
|
|
109
|
+
inTempProject((root) => {
|
|
110
|
+
install(root, ['omp']);
|
|
111
|
+
const hookPath = path.join(root, '.omp', 'hooks', 'usage.cjs');
|
|
112
|
+
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
113
|
+
fs.writeFileSync(hookPath, '// user edit\n');
|
|
114
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
115
|
+
manifest.files['.omp/hooks/usage.cjs'] = { sha256: '0'.repeat(64) };
|
|
116
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
117
|
+
|
|
118
|
+
const upgrade = install(root, ['omp']);
|
|
119
|
+
assert.equal(fs.readFileSync(hookPath, 'utf8'), '// user edit\n');
|
|
120
|
+
assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
104
124
|
test('no runtime ships or bridges usage.cjs', () => {
|
|
105
125
|
assert.equal(MANIFEST.runtime.files.includes('hooks/usage.cjs'), false);
|
|
106
126
|
assert.equal(BRIDGE.includes("'usage.cjs'"), false);
|