@haposoft/cafekit 0.18.0 → 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 +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- 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 +102 -1362
- 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-authority-decoupled.test.js +14 -19
- 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__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- 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 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- 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 +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -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 +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.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/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +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/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- 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-receipt.cjs +2 -30
- 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/spec-utils.cjs +6 -45
- 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 +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- 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/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// ambiguity is what left a repository of accumulated features permanently blocked, with
|
|
8
|
-
// the documented escape — an explicit feature target — unavailable.
|
|
3
|
+
// The Stop gate narrows an ambiguous scan by asking which packet still has unfinished
|
|
4
|
+
// work, so it can validate that packet's receipts. Counting finished history as ambiguity
|
|
5
|
+
// is what left a repository of accumulated features permanently blocked, with the
|
|
6
|
+
// documented escape — an explicit feature target — unavailable.
|
|
9
7
|
|
|
10
8
|
const assert = require('node:assert/strict');
|
|
11
9
|
const { spawnSync } = require('node:child_process');
|
|
@@ -38,7 +36,7 @@ function legacyPacket(root, name, { status, phase, taskStatus = 'done' } = {}) {
|
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/** A process-first packet: plan.md plus one flat task file. */
|
|
41
|
-
function workflowPacket(root, name, taskStatus) {
|
|
39
|
+
function workflowPacket(root, name, taskStatus, nextStatus = null) {
|
|
42
40
|
const dir = path.join(root, 'specs', name);
|
|
43
41
|
fs.mkdirSync(dir, { recursive: true });
|
|
44
42
|
fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
@@ -46,6 +44,12 @@ function workflowPacket(root, name, taskStatus) {
|
|
|
46
44
|
path.join(dir, 'task-01-x.md'),
|
|
47
45
|
`# Task 01\n\nStatus: ${taskStatus}\n\n## Dependencies\n- none\n`
|
|
48
46
|
);
|
|
47
|
+
if (nextStatus) {
|
|
48
|
+
fs.writeFileSync(
|
|
49
|
+
path.join(dir, 'task-02-y.md'),
|
|
50
|
+
`# Task 02\n\nStatus: ${nextStatus}\n\n## Dependencies\n- none\n`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
function recordActiveFeature(root, contents) {
|
|
@@ -63,34 +67,19 @@ function gateResolution(root) {
|
|
|
63
67
|
|
|
64
68
|
// ── The gate: which packet still has work ───────────────────────────────────────────
|
|
65
69
|
|
|
66
|
-
test('a legacy
|
|
70
|
+
test('a leftover legacy packet creates no ambiguity for the gate', () => {
|
|
67
71
|
withProject((root) => {
|
|
68
72
|
for (let index = 1; index <= 3; index += 1) {
|
|
69
|
-
legacyPacket(root, `
|
|
73
|
+
legacyPacket(root, `history-${index}`, { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
|
|
70
74
|
}
|
|
71
|
-
|
|
75
|
+
workflowPacket(root, 'active-now', 'pending');
|
|
72
76
|
const resolved = gateResolution(root);
|
|
73
77
|
assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
|
|
74
78
|
assert.equal(resolved.featureName, 'active-now');
|
|
79
|
+
assert.deepEqual(RESOLVER.findLegacyPackets(root, {}), ['history-1', 'history-2', 'history-3']);
|
|
75
80
|
});
|
|
76
81
|
});
|
|
77
82
|
|
|
78
|
-
test('a legacy registry written with the older vocabulary counts as finished', () => {
|
|
79
|
-
// Issue #79's repository was upgraded from an older release whose task_registry used
|
|
80
|
-
// `completed`. Today's validator allows only pending/in_progress/blocked/done, so
|
|
81
|
-
// narrowing read those packets as unfinished and the block survived the fix that was
|
|
82
|
-
// supposed to clear it. Measured on the reported shape before this: 18 candidates.
|
|
83
|
-
for (const status of ['done', 'completed', 'complete']) {
|
|
84
|
-
withProject((root) => {
|
|
85
|
-
for (let index = 1; index <= 3; index += 1) {
|
|
86
|
-
legacyPacket(root, `history-${index}`, { taskStatus: status });
|
|
87
|
-
}
|
|
88
|
-
legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
|
|
89
|
-
assert.equal(gateResolution(root).featureName, 'active-now', `task status ${status} must read as finished`);
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
83
|
test('a process-first packet keeps the strict task vocabulary', () => {
|
|
95
84
|
// The widened set is legacy-only. A process-first task saying `completed` is malformed,
|
|
96
85
|
// not finished, and must not be silently read as done.
|
|
@@ -102,20 +91,6 @@ test('a process-first packet keeps the strict task vocabulary', () => {
|
|
|
102
91
|
});
|
|
103
92
|
});
|
|
104
93
|
|
|
105
|
-
test('a legacy candidate never reaches the bulk branch', () => {
|
|
106
|
-
// The bulk branch exits before the semantic-digest, FLASH_UNVERIFIED, feature-receipt
|
|
107
|
-
// and completion-policy layers, so a legacy packet reaching it would turn a block into
|
|
108
|
-
// a silent pass. With every legacy packet finished there is nothing to narrow to, and
|
|
109
|
-
// the existing ambiguity must stand rather than the set being audited.
|
|
110
|
-
withProject((root) => {
|
|
111
|
-
legacyPacket(root, 'done-1', { status: 'done', phase: 'closeout' });
|
|
112
|
-
legacyPacket(root, 'done-2', { status: 'done', phase: 'closeout' });
|
|
113
|
-
const resolved = gateResolution(root);
|
|
114
|
-
assert.notEqual(resolved.layoutKind, 'process-v3-completed-set');
|
|
115
|
-
assert.ok(resolved.error, 'an all-legacy finished set must stay ambiguous');
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
94
|
test('a process-first set still reaches the bulk branch', () => {
|
|
120
95
|
withProject((root) => {
|
|
121
96
|
workflowPacket(root, 'done-1', 'done');
|
|
@@ -132,101 +107,21 @@ test('one unfinished process-first packet still narrows', () => {
|
|
|
132
107
|
});
|
|
133
108
|
});
|
|
134
109
|
|
|
135
|
-
// ── Closeout approval: which packet is claiming closeout ────────────────────────────
|
|
136
|
-
|
|
137
|
-
test('one packet claiming closeout among finished ones resolves', () => {
|
|
138
|
-
withProject((root) => {
|
|
139
|
-
legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
|
|
140
|
-
legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
|
|
141
|
-
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
142
|
-
assert.equal(resolved.error, undefined);
|
|
143
|
-
assert.equal(resolved.featureName, 'closing');
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('a lone finished packet still resolves', () => {
|
|
148
|
-
// Four existing cases depend on this: closeout approval is claimed against a finished
|
|
149
|
-
// spec, so a single packet must resolve whatever its status.
|
|
150
|
-
for (const status of ['done', 'completed']) {
|
|
151
|
-
withProject((root) => {
|
|
152
|
-
legacyPacket(root, 'only', { status });
|
|
153
|
-
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
154
|
-
assert.equal(resolved.featureName, 'only', `status ${status} must still resolve`);
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('a lone packet mid-execution still resolves', () => {
|
|
160
|
-
// The closeout filter would drop this one, so the single-candidate return above it is
|
|
161
|
-
// what keeps a repository holding exactly one in-flight packet resolvable. Without it
|
|
162
|
-
// the hook goes silent for that repository instead of checking the packet.
|
|
163
|
-
withProject((root) => {
|
|
164
|
-
legacyPacket(root, 'solo', { status: 'in_progress', phase: 'implementation', taskStatus: 'pending' });
|
|
165
|
-
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
166
|
-
assert.equal(resolved.featureName, 'solo');
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test('no packet claiming closeout resolves to null', () => {
|
|
171
|
-
// null means nothing is in flight and the hook stays silent; an error would keep
|
|
172
|
-
// blocking a repository where no closeout is being claimed at all.
|
|
173
|
-
withProject((root) => {
|
|
174
|
-
legacyPacket(root, 'a', { status: 'in_progress', phase: 'execution' });
|
|
175
|
-
legacyPacket(root, 'b', { status: 'in_progress', phase: 'execution' });
|
|
176
|
-
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }), null);
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test('several packets claiming closeout stay ambiguous and name only those', () => {
|
|
181
|
-
withProject((root) => {
|
|
182
|
-
legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
|
|
183
|
-
legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
|
|
184
|
-
legacyPacket(root, 'working', { status: 'in_progress', phase: 'execution' });
|
|
185
|
-
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
186
|
-
assert.equal(resolved.error, 'multiple_persisted');
|
|
187
|
-
assert.deepEqual(resolved.candidates, ['closing-a', 'closing-b']);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
110
|
// ── The recorded active feature: the escape the block told users to take ────────────
|
|
192
111
|
|
|
193
|
-
test('a recorded active feature resolves two packets claiming closeout', () => {
|
|
194
|
-
withProject((root) => {
|
|
195
|
-
legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
|
|
196
|
-
legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
|
|
197
|
-
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).error, 'multiple_persisted');
|
|
198
|
-
recordActiveFeature(root, '{"featureName": "closing-b"}\n');
|
|
199
|
-
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
200
|
-
assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
|
|
201
|
-
assert.equal(resolved.featureName, 'closing-b');
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test('a payload target beats the recorded file', () => {
|
|
206
|
-
withProject((root) => {
|
|
207
|
-
legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
|
|
208
|
-
legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
|
|
209
|
-
recordActiveFeature(root, '{"featureName": "alpha"}\n');
|
|
210
|
-
const resolved = RESOLVER.resolvePersistedSpec({
|
|
211
|
-
projectRoot: root, runtime: {}, target: { featureName: 'beta' },
|
|
212
|
-
});
|
|
213
|
-
assert.equal(resolved.featureName, 'beta', 'the caller must win');
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
112
|
test('a blank recorded feature is ignored rather than malformed', () => {
|
|
218
113
|
// explicitTargetValue turns a blank into null, and a null target becomes an
|
|
219
114
|
// explicit_malformed error that blocks. The reader must return null instead, or every
|
|
220
115
|
// project holding a stray file would be blocked by the very escape hatch.
|
|
221
116
|
for (const contents of ['{"featureName": " "}', '{"featureName": 42}', '{}', 'not json', '[]']) {
|
|
222
117
|
withProject((root) => {
|
|
223
|
-
|
|
118
|
+
workflowPacket(root, 'only', 'pending');
|
|
224
119
|
recordActiveFeature(root, contents);
|
|
225
120
|
assert.equal(
|
|
226
121
|
RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null,
|
|
227
122
|
`${contents} must be invisible`
|
|
228
123
|
);
|
|
229
|
-
assert.equal(RESOLVER.
|
|
124
|
+
assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
|
|
230
125
|
});
|
|
231
126
|
}
|
|
232
127
|
});
|
|
@@ -234,11 +129,12 @@ test('a blank recorded feature is ignored rather than malformed', () => {
|
|
|
234
129
|
test('a recorded feature cannot escape the specs root', () => {
|
|
235
130
|
// The reader adds no trust: the value goes through the resolver's existing checks.
|
|
236
131
|
withProject((root) => {
|
|
237
|
-
|
|
238
|
-
|
|
132
|
+
workflowPacket(root, 'a', 'pending');
|
|
133
|
+
workflowPacket(root, 'b', 'pending');
|
|
239
134
|
recordActiveFeature(root, '{"featureName": "../../etc"}');
|
|
240
|
-
const
|
|
241
|
-
|
|
135
|
+
const target = RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} });
|
|
136
|
+
const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, target });
|
|
137
|
+
assert.equal(resolved.error, 'explicit_malformed', 'an escaping name must produce the existing error, not a resolution');
|
|
242
138
|
assert.notEqual(resolved.featureName, '../../etc');
|
|
243
139
|
});
|
|
244
140
|
});
|
|
@@ -248,7 +144,7 @@ test('a symlinked recorded feature is refused', () => {
|
|
|
248
144
|
// project does not contain. The repository already refuses symlinked runtime
|
|
249
145
|
// dependencies elsewhere; this reader follows the same rule.
|
|
250
146
|
withProject((root) => {
|
|
251
|
-
|
|
147
|
+
workflowPacket(root, 'only', 'pending');
|
|
252
148
|
const outside = path.join(root, 'outside.json');
|
|
253
149
|
fs.writeFileSync(outside, '{"featureName": "only"}');
|
|
254
150
|
const dir = path.join(root, 'specs', '_shared');
|
|
@@ -260,9 +156,9 @@ test('a symlinked recorded feature is refused', () => {
|
|
|
260
156
|
|
|
261
157
|
test('an absent file leaves every project exactly as it was', () => {
|
|
262
158
|
withProject((root) => {
|
|
263
|
-
|
|
159
|
+
workflowPacket(root, 'only', 'pending');
|
|
264
160
|
assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
|
|
265
|
-
assert.equal(RESOLVER.
|
|
161
|
+
assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
|
|
266
162
|
});
|
|
267
163
|
});
|
|
268
164
|
|
|
@@ -272,8 +168,8 @@ test('the shared workflow resolver ignores the recorded file', () => {
|
|
|
272
168
|
// so the two gates apply it at their own call sites instead. Neither of those suites
|
|
273
169
|
// writes the file, so nothing else pins this containment.
|
|
274
170
|
withProject((root) => {
|
|
275
|
-
|
|
276
|
-
|
|
171
|
+
workflowPacket(root, 'alpha', 'done');
|
|
172
|
+
workflowPacket(root, 'beta', 'done');
|
|
277
173
|
recordActiveFeature(root, '{"featureName": "beta"}');
|
|
278
174
|
const resolved = RESOLVER.resolveWorkflowCandidate({
|
|
279
175
|
projectRoot: root, runtime: {}, target: null, includeCompleted: true,
|
|
@@ -288,7 +184,7 @@ test('the codex gate consults the file only after its payload', () => {
|
|
|
288
184
|
assert.equal(typeof utils.readActiveFeatureTarget, 'function', 'the wrapper must be exported');
|
|
289
185
|
assert.equal(typeof utils.extractExplicitTarget, 'function', 'the gate needs the payload extractor');
|
|
290
186
|
withProject((root) => {
|
|
291
|
-
|
|
187
|
+
workflowPacket(root, 'alpha', 'done');
|
|
292
188
|
recordActiveFeature(root, '{"featureName": "alpha"}');
|
|
293
189
|
assert.deepEqual(
|
|
294
190
|
utils.extractExplicitTarget({ featureName: 'beta' }) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
|
|
@@ -338,8 +234,8 @@ for (const [runtimeName, hook] of [['claude', 'src/claude/hooks/spec-gate.cjs']]
|
|
|
338
234
|
// identity complaint whose stated remedy nothing could supply; naming the feature
|
|
339
235
|
// makes the gate resolve it and report the violation that was there all along.
|
|
340
236
|
gitProject((dir) => {
|
|
341
|
-
|
|
342
|
-
|
|
237
|
+
workflowPacket(dir, 'alpha', 'done', 'pending');
|
|
238
|
+
workflowPacket(dir, 'beta', 'done', 'pending');
|
|
343
239
|
|
|
344
240
|
const blocked = runGate(hook, dir);
|
|
345
241
|
assert.match(blocked, /multiple active specs detected/, 'the reported block must reproduce');
|