@haposoft/cafekit 0.16.0 → 0.16.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 +5 -3
- package/bin/__tests__/codex-hooks.test.js +125 -0
- package/bin/__tests__/codex-native.test.js +95 -0
- package/bin/__tests__/develop-contract.test.js +43 -1
- package/bin/__tests__/grok-envelope.test.js +271 -0
- package/bin/__tests__/grok-platform.test.js +133 -0
- package/bin/__tests__/hook-payload.test.js +194 -0
- package/bin/__tests__/omp-bridge.test.js +168 -0
- package/bin/__tests__/omp-hooks.test.js +178 -0
- package/bin/__tests__/omp-runtime.test.js +150 -0
- package/bin/__tests__/orca-session.test.js +225 -0
- package/bin/__tests__/orca-skill.test.js +210 -0
- package/bin/__tests__/package-inventory.test.js +1 -0
- package/bin/__tests__/runtime-dir.test.js +158 -0
- package/bin/__tests__/runtime-schema.test.js +5 -3
- package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
- package/bin/install.js +5 -5
- package/bin/lib/context.js +50 -3
- package/bin/lib/i18n.js +3 -30
- package/bin/phases/codex-hooks.js +90 -17
- package/bin/phases/copy-payload.js +7 -0
- package/bin/phases/omp-runtime.js +140 -0
- package/bin/phases/root-config.js +3 -0
- package/bin/phases/select-platform.js +22 -1
- package/bin/phases/summary.js +0 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
- package/src/claude/hooks/__tests__/state.test.js +2 -0
- package/src/claude/hooks/agent.cjs +12 -7
- package/src/claude/hooks/completion-authority.cjs +10 -4
- package/src/claude/hooks/docs-sync.cjs +2 -1
- package/src/claude/hooks/inspect-block.cjs +27 -7
- package/src/claude/hooks/lib/config.cjs +5 -2
- package/src/claude/hooks/lib/counter.cjs +7 -5
- package/src/claude/hooks/lib/hook-payload.cjs +160 -0
- package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
- package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
- package/src/claude/hooks/precompact.cjs +2 -1
- package/src/claude/hooks/privacy-block.cjs +4 -2
- package/src/claude/hooks/rules.cjs +4 -2
- package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
- package/src/claude/hooks/semantic-review-authority.cjs +6 -4
- package/src/claude/hooks/session.cjs +16 -8
- package/src/claude/hooks/spec-gate.cjs +18 -8
- package/src/claude/hooks/spec-state.cjs +9 -5
- package/src/claude/hooks/state.cjs +6 -4
- package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
- package/src/claude/hooks/usage.cjs +5 -4
- package/src/claude/migration-manifest.json +3 -0
- package/src/claude/rules/skill-domain-routing.md +1 -0
- package/src/claude/rules/state-sync.md +7 -1
- package/src/claude/rules/workflow.md +2 -0
- package/src/claude/scripts/provenance.cjs +11 -4
- package/src/claude/scripts/spec-receipt.cjs +65 -4
- package/src/claude/skills/orca/SKILL.md +51 -0
- package/src/claude/skills/specs/SKILL.md +10 -11
- package/src/codex/hooks/session.cjs +6 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/hooks.json +18 -18
- package/src/codex/rules/state-sync.md +7 -1
- package/src/omp/extensions/cafekit-bridge.mjs +233 -0
- package/src/omp/hooks/privacy-block.cjs +250 -0
- package/src/omp/runtime.json +33 -0
- package/bin/lib/rtk-run.js +0 -28
- package/bin/phases/setup-rtk.js +0 -159
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -126,8 +126,9 @@ AGENTS.md
|
|
|
126
126
|
CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
|
|
127
127
|
and custom agents natively after the repository is trusted; no project
|
|
128
128
|
`config.toml` is generated. Review and trust project hooks with `/hooks`.
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
Installed hook commands are bound to the canonical project path on every platform,
|
|
130
|
+
so they need no Git repository, stay correct for a project nested inside a larger
|
|
131
|
+
repository, and remain stable when a session starts in a subdirectory.
|
|
131
132
|
CafeKit uses Codex's native status and usage UI instead of installing the
|
|
132
133
|
Claude statusline.
|
|
133
134
|
|
|
@@ -165,6 +166,7 @@ CafeKit ships many skills, but the main release surface is:
|
|
|
165
166
|
- `/cf:research <decision>`: choose proportional depth and return traceable evidence for an uncertain technical decision
|
|
166
167
|
- `/cf:route <material-request>`: classify ambiguous, multi-step, multi-domain, or elevated-risk work and compose the shortest valid installed chain
|
|
167
168
|
- `/cf:loop <bounded-experiment>`: run explicit-only numeric optimization in an isolated worktree and return a base-bound patch handoff
|
|
169
|
+
- `/cf:orca <orca-request>`: inside an Orca (onorca.dev) pane, route a request to read, wait on, or send to another agent pane, or spawn one into a worktree
|
|
168
170
|
- `/cf:specs <feature-description>`: create or resume a structured spec workflow
|
|
169
171
|
- `/cf:develop <feature-name>`: implement from approved spec artifacts
|
|
170
172
|
- `/cf:debug <issue>`: run adaptive-depth, diagnostic-only root-cause analysis with elimination and prevention-aware handoff
|
|
@@ -1746,3 +1746,128 @@ test('Claude and Codex fail closed consistently for malformed cwd payloads', ()
|
|
|
1746
1746
|
assert.equal(JSON.parse(codex.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1747
1747
|
});
|
|
1748
1748
|
});
|
|
1749
|
+
|
|
1750
|
+
test('Codex emits the required decision for all six receipt states', () => {
|
|
1751
|
+
// Codex owns no checker of its own: both wrappers in .codex/hooks/lib forward to
|
|
1752
|
+
// the shared module, so the two receipt modes arrive by inheritance. This case is
|
|
1753
|
+
// the regression lock on that inheritance. Each state asserts the decision Codex
|
|
1754
|
+
// must emit on its own terms; parity with Claude is checked in addition, never
|
|
1755
|
+
// instead, because both runtimes execute the same module and a broken
|
|
1756
|
+
// implementation would agree with itself.
|
|
1757
|
+
//
|
|
1758
|
+
// The dirty-tree state runs the hook from a directory below the project root with
|
|
1759
|
+
// the uncommitted change outside that directory's subtree. Without that, an
|
|
1760
|
+
// implementation resolving the repository root from the working directory returns
|
|
1761
|
+
// the same decision as a correct one and the case cannot fail.
|
|
1762
|
+
inHookFixture((root, hooks) => {
|
|
1763
|
+
const nested = path.join(root, 'packages', 'app');
|
|
1764
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
1765
|
+
const taskFile = path.join(feature, 'task-01-auth.md');
|
|
1766
|
+
const outsideDirt = path.join(root, 'src', 'outside.txt');
|
|
1767
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
1768
|
+
fs.mkdirSync(feature, { recursive: true });
|
|
1769
|
+
fs.mkdirSync(path.dirname(outsideDirt), { recursive: true });
|
|
1770
|
+
fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\n');
|
|
1771
|
+
fs.writeFileSync(outsideDirt, 'committed\n');
|
|
1772
|
+
|
|
1773
|
+
const staleBase = 'f'.repeat(40);
|
|
1774
|
+
const staleHead = 'e'.repeat(40);
|
|
1775
|
+
const taskBody = [
|
|
1776
|
+
'# Task 01: auth', '', 'Status: done', '',
|
|
1777
|
+
'## Dependencies', '', '- none', '',
|
|
1778
|
+
'## Verification Plan', '', '- Command: node --test', '',
|
|
1779
|
+
'## Receipt', '',
|
|
1780
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
1781
|
+
`Base: ${staleBase}`, `Head: ${staleHead}`,
|
|
1782
|
+
'```text', '$ node --test', 'pass: 1', '```', '',
|
|
1783
|
+
].join('\n');
|
|
1784
|
+
fs.writeFileSync(taskFile, taskBody);
|
|
1785
|
+
|
|
1786
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
1787
|
+
const payload = {
|
|
1788
|
+
cwd: nested,
|
|
1789
|
+
featureName: 'auth',
|
|
1790
|
+
session_id: 'session-a',
|
|
1791
|
+
hook_event_name: 'Stop',
|
|
1792
|
+
stop_hook_active: false,
|
|
1793
|
+
};
|
|
1794
|
+
const git = (...args) => {
|
|
1795
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
1796
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1797
|
+
return result;
|
|
1798
|
+
};
|
|
1799
|
+
const decide = () => {
|
|
1800
|
+
const result = runHook(gate, nested, payload);
|
|
1801
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1802
|
+
return result.stdout.trim() === '' ? 'accept' : 'block';
|
|
1803
|
+
};
|
|
1804
|
+
// Use the same module instance that minted the context: a trusted runtime
|
|
1805
|
+
// context is tracked per module, so mixing the package copy with the fixture
|
|
1806
|
+
// copy would report a spurious binding failure.
|
|
1807
|
+
const RECEIPT = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-receipt.cjs'));
|
|
1808
|
+
const sharedDecides = () => {
|
|
1809
|
+
const failures = RECEIPT.checkWorkflowTaskReceipt(
|
|
1810
|
+
feature, 'task-01-auth.md', workflowRuntimeContext(root), POLICY,
|
|
1811
|
+
).failures;
|
|
1812
|
+
return failures.length === 0 ? 'accept' : 'block';
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
// 1. untracked task file — never committed, so the binding stays.
|
|
1816
|
+
assert.equal(decide(), 'block', 'an untracked task file must keep full binding');
|
|
1817
|
+
|
|
1818
|
+
// 2. staged but not committed — `git add` alone must not read as committed.
|
|
1819
|
+
git('add', '-A');
|
|
1820
|
+
assert.equal(decide(), 'block', 'a staged-only task file must keep full binding');
|
|
1821
|
+
|
|
1822
|
+
// 3. committed on a clean tree — the one accepting state.
|
|
1823
|
+
git('commit', '-qm', 'receipt and source');
|
|
1824
|
+
// Generated runtime state is excluded by design, so assert cleanliness the way
|
|
1825
|
+
// the selector sees it: nothing dirty outside the specs root and the runtime
|
|
1826
|
+
// state roots. The gate's own cache write must not read as a dirty worktree.
|
|
1827
|
+
const cleanStatus = spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all',
|
|
1828
|
+
'--', '.', ':(exclude,literal)specs', ':(exclude,literal).codex/hooks/.logs',
|
|
1829
|
+
':(exclude,literal).claude/hooks/.logs', ':(exclude,literal).codex/runtime.json'], { encoding: 'utf8' });
|
|
1830
|
+
assert.equal(cleanStatus.stdout.trim(), '', 'nothing outside specs and runtime state may be dirty for this state to mean anything');
|
|
1831
|
+
assert.equal(decide(), 'accept', 'a committed, unchanged receipt on a clean tree must be accepted');
|
|
1832
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1833
|
+
|
|
1834
|
+
// 4. dirty tree outside the specs root, hook run from a nested directory with
|
|
1835
|
+
// the change outside that directory's subtree.
|
|
1836
|
+
fs.writeFileSync(outsideDirt, 'uncommitted\n');
|
|
1837
|
+
assert.ok(!path.relative(nested, outsideDirt).startsWith('..') === false,
|
|
1838
|
+
'the dirt must lie outside the working directory used for the run');
|
|
1839
|
+
assert.equal(decide(), 'block', 'an uncommitted change outside the specs root must rebind');
|
|
1840
|
+
assert.equal(sharedDecides(), 'block', 'the shared checker must agree');
|
|
1841
|
+
git('checkout', '--', 'src/outside.txt');
|
|
1842
|
+
|
|
1843
|
+
// 5. work confined to the specs root does not reopen a committed receipt.
|
|
1844
|
+
fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\n\nEdited after the receipt landed.\n');
|
|
1845
|
+
assert.equal(decide(), 'accept', 'edits inside the specs root must not rebind');
|
|
1846
|
+
git('checkout', '--', 'specs/auth/plan.md');
|
|
1847
|
+
|
|
1848
|
+
// 6. committed then modified — the receipt itself changed after the commit.
|
|
1849
|
+
fs.writeFileSync(taskFile, taskBody.replace(`Head: ${staleHead}`, `Head: ${'d'.repeat(40)}`));
|
|
1850
|
+
assert.equal(decide(), 'block', 'a receipt edited after commit must be rebound');
|
|
1851
|
+
assert.equal(sharedDecides(), 'block', 'the shared checker must agree');
|
|
1852
|
+
});
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
test('Codex wrappers stay pass-through and carry no receipt-mode logic', () => {
|
|
1856
|
+
// The mode selector must exist in exactly one place. If it is ever duplicated
|
|
1857
|
+
// into the Codex wrapper, the two runtimes can drift apart silently.
|
|
1858
|
+
const wrapper = fs.readFileSync(
|
|
1859
|
+
path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'),
|
|
1860
|
+
'utf8',
|
|
1861
|
+
);
|
|
1862
|
+
for (const marker of ['receiptBindingMode', 'skip-worktree', 'ls-files', 'cat-file', 'structure']) {
|
|
1863
|
+
assert.ok(
|
|
1864
|
+
!wrapper.includes(marker),
|
|
1865
|
+
`the Codex wrapper must not reimplement receipt-mode logic; found ${marker}`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
assert.match(wrapper, /checkWorkflowTaskReceipt\(featureDir, taskPath, runtimeContext, policy\)/);
|
|
1869
|
+
assert.match(
|
|
1870
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/spec-state.cjs'), 'utf8'),
|
|
1871
|
+
/Stop re-checks every done receipt/,
|
|
1872
|
+
);
|
|
1873
|
+
});
|
|
@@ -2035,6 +2035,101 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
|
|
|
2035
2035
|
});
|
|
2036
2036
|
});
|
|
2037
2037
|
|
|
2038
|
+
// Run an installed POSIX launcher with no PATH at all, so neither `git` nor a `node` on
|
|
2039
|
+
// PATH can rescue it. The launcher must already carry everything it needs.
|
|
2040
|
+
function runPosixLauncher(command, cwd) {
|
|
2041
|
+
return spawnSync(command.replace(/^node /, `"${process.execPath}" `), {
|
|
2042
|
+
cwd,
|
|
2043
|
+
encoding: 'utf8',
|
|
2044
|
+
input: JSON.stringify({
|
|
2045
|
+
session_id: 'posix-launcher-test',
|
|
2046
|
+
cwd,
|
|
2047
|
+
hook_event_name: 'SessionStart',
|
|
2048
|
+
source: 'startup'
|
|
2049
|
+
}),
|
|
2050
|
+
env: { ...process.env, PATH: '' },
|
|
2051
|
+
shell: true
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function sessionLauncher(projectRoot) {
|
|
2056
|
+
const config = JSON.parse(
|
|
2057
|
+
fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
|
|
2058
|
+
);
|
|
2059
|
+
return { config, command: config.hooks.SessionStart[0].hooks[0].command };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
test('Codex POSIX hook launchers run without Git and outside the repository root', () => {
|
|
2063
|
+
inTempProject((root) => {
|
|
2064
|
+
// A launcher that asks Git for its root gets nothing here and the outer root one
|
|
2065
|
+
// directory down, and in both cases points at a path that does not exist.
|
|
2066
|
+
const plain = path.join(root, 'no-git-project');
|
|
2067
|
+
fs.mkdirSync(plain, { recursive: true });
|
|
2068
|
+
assert.equal(install(plain).status, 0);
|
|
2069
|
+
|
|
2070
|
+
const repoRoot = path.join(root, 'repo');
|
|
2071
|
+
const nested = path.join(repoRoot, 'packages', 'inner');
|
|
2072
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
2073
|
+
spawnSync('git', ['init', '-q'], { cwd: repoRoot });
|
|
2074
|
+
assert.equal(install(nested).status, 0);
|
|
2075
|
+
|
|
2076
|
+
for (const projectRoot of [plain, nested]) {
|
|
2077
|
+
const { config, command } = sessionLauncher(projectRoot);
|
|
2078
|
+
for (const { handler } of allHookLaunchers(config)) {
|
|
2079
|
+
// `node '<single-quoted absolute path>'` and nothing else. Inside single quotes
|
|
2080
|
+
// the shell expands nothing, so a project path may safely contain $, a backtick,
|
|
2081
|
+
// or the word git — this fixture's own path contains it.
|
|
2082
|
+
assert.match(
|
|
2083
|
+
handler.command,
|
|
2084
|
+
/^node '(?:[^']|'\\'')+'$/,
|
|
2085
|
+
`launcher must be a plain quoted path: ${handler.command}`
|
|
2086
|
+
);
|
|
2087
|
+
assert.doesNotMatch(handler.command, /rev-parse/, `launcher must not consult Git: ${handler.command}`);
|
|
2088
|
+
assert.ok(
|
|
2089
|
+
handler.command.includes(path.join(projectRoot, '.codex', 'hooks')),
|
|
2090
|
+
`launcher must name this project's hooks directory: ${handler.command}`
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
const launched = runPosixLauncher(command, projectRoot);
|
|
2094
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
2095
|
+
assert.match(launched.stdout, /Session startup\./);
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
test('a reinstall repairs Codex launchers that resolved their path through Git', () => {
|
|
2101
|
+
inTempProject((root) => {
|
|
2102
|
+
assert.equal(install(root).status, 0);
|
|
2103
|
+
const configPath = path.join(root, '.codex', 'hooks.json');
|
|
2104
|
+
|
|
2105
|
+
// Exactly what an installer before 0.16.2 wrote.
|
|
2106
|
+
const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
2107
|
+
for (const { handler } of allHookLaunchers(downgraded)) {
|
|
2108
|
+
const script = path.basename(handler.command.replace(/'$/, ''));
|
|
2109
|
+
handler.command = `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`;
|
|
2110
|
+
}
|
|
2111
|
+
const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
|
|
2112
|
+
downgraded.hooks.SessionStart[0].hooks.push(foreign);
|
|
2113
|
+
fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
|
|
2114
|
+
|
|
2115
|
+
const again = install(root);
|
|
2116
|
+
assert.equal(again.status, 0, again.stderr);
|
|
2117
|
+
assert.match(again.stdout, /repaired \d+ launcher/, 'the repair must be reported, not silent');
|
|
2118
|
+
|
|
2119
|
+
const { config, command } = sessionLauncher(root);
|
|
2120
|
+
const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
|
|
2121
|
+
assert.equal(
|
|
2122
|
+
commands.filter((entry) => /rev-parse/.test(entry)).length,
|
|
2123
|
+
1,
|
|
2124
|
+
'only the hook CafeKit did not author may keep its own command'
|
|
2125
|
+
);
|
|
2126
|
+
assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
|
|
2127
|
+
const launched = runPosixLauncher(command, root);
|
|
2128
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
2129
|
+
assert.match(launched.stdout, /Session startup\./);
|
|
2130
|
+
});
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2038
2133
|
test('Codex installed Specs and spec-maker reject adaptive coverage mutations', () => {
|
|
2039
2134
|
inTempProject((root) => {
|
|
2040
2135
|
const userInstructions = '# User rules\n\nKeep this exact.\n';
|
|
@@ -89,7 +89,7 @@ function installClaudeRuntimeClosure(fixtureRoot, entry = 'hooks/spec-gate.cjs')
|
|
|
89
89
|
fs.copyFileSync(path.join(hooksRoot, hook), target);
|
|
90
90
|
}
|
|
91
91
|
// Hook helpers the raw-copied hooks require at runtime.
|
|
92
|
-
for (const helper of ['hook-state-dir.cjs']) {
|
|
92
|
+
for (const helper of ['hook-state-dir.cjs', 'runtime-dir.cjs', 'hook-payload.cjs']) {
|
|
93
93
|
const target = path.join(destinationRoot, 'hooks', 'lib', helper);
|
|
94
94
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
95
95
|
fs.copyFileSync(path.join(hooksRoot, 'lib', helper), target);
|
|
@@ -3338,3 +3338,45 @@ test('P0 canonical phantom vectors - zero, cancellation, suite, FAIL, error, col
|
|
|
3338
3338
|
assert.equal(POLICY.isTapMetadataHeading('# Notes'), false);
|
|
3339
3339
|
assert.equal(POLICY.isTapMetadataHeading('# pass 1 explanation'), false);
|
|
3340
3340
|
});
|
|
3341
|
+
|
|
3342
|
+
test('generated runtime state under every platform folder stays out of the worktree digest', () => {
|
|
3343
|
+
// The gate writes its cache and the installer writes runtime.json under the platform
|
|
3344
|
+
// folder on every run. If those writes moved Head, a project tracking its runtime
|
|
3345
|
+
// directory could never hold a stable receipt. The exclusion list must name each
|
|
3346
|
+
// shipped platform, and a real source change must still move Head.
|
|
3347
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-state-roots-'));
|
|
3348
|
+
try {
|
|
3349
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
3350
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
3351
|
+
assert.equal(result.status, 0, result.stderr);
|
|
3352
|
+
}
|
|
3353
|
+
fs.mkdirSync(path.join(root, 'specs', 'demo'), { recursive: true });
|
|
3354
|
+
fs.writeFileSync(path.join(root, 'specs', 'demo', 'plan.md'), '# Demo plan\n');
|
|
3355
|
+
const derive = () => PROVENANCE_HELPER.deriveRuntimeContext({
|
|
3356
|
+
projectRoot: root,
|
|
3357
|
+
specsRoot: path.join(root, 'specs'),
|
|
3358
|
+
specFile: path.join(root, 'specs', 'demo', 'plan.md'),
|
|
3359
|
+
featureName: 'demo',
|
|
3360
|
+
runtimeSession: 'state-roots',
|
|
3361
|
+
});
|
|
3362
|
+
const before = derive();
|
|
3363
|
+
|
|
3364
|
+
for (const folder of ['.claude', '.codex', '.omp']) {
|
|
3365
|
+
fs.mkdirSync(path.join(root, folder, 'hooks', '.logs'), { recursive: true });
|
|
3366
|
+
fs.mkdirSync(path.join(root, folder, '.logs'), { recursive: true });
|
|
3367
|
+
fs.writeFileSync(path.join(root, folder, 'hooks', '.logs', 'spec-gate-last.json'), '{"demo":{}}\n');
|
|
3368
|
+
fs.writeFileSync(path.join(root, folder, '.logs', 'hook-log.jsonl'), '{}\n');
|
|
3369
|
+
fs.writeFileSync(path.join(root, folder, 'runtime.json'), '{"spec":{"completion_gate":true}}\n');
|
|
3370
|
+
}
|
|
3371
|
+
const afterState = derive();
|
|
3372
|
+
assert.equal(afterState.head, before.head, 'runtime state under .claude, .codex, and .omp must not move Head');
|
|
3373
|
+
assert.equal(afterState.base, before.base);
|
|
3374
|
+
|
|
3375
|
+
// Anything else under the platform folder is source: a hook edit must move Head.
|
|
3376
|
+
fs.mkdirSync(path.join(root, '.omp', 'hooks'), { recursive: true });
|
|
3377
|
+
fs.writeFileSync(path.join(root, '.omp', 'hooks', 'rules.cjs'), '// edited\n');
|
|
3378
|
+
assert.notEqual(derive().head, before.head, 'a real file under the platform folder must still move Head');
|
|
3379
|
+
} finally {
|
|
3380
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Grok CLI runs these hooks through its Claude-compatibility layer, so the scripts must
|
|
4
|
+
// answer the same way whether the envelope came from Claude Code or from grok. Each case
|
|
5
|
+
// runs a real hook as a child process from a tree shaped like an install, with grok's
|
|
6
|
+
// camelCase envelope on stdin and CLAUDE_PROJECT_DIR set the way grok sets it.
|
|
7
|
+
//
|
|
8
|
+
// Every case names the gate it protects. Before the payload reader existed, each of them
|
|
9
|
+
// answered nothing: the hook read an undefined key, found no tool and no path, and let
|
|
10
|
+
// the call through while looking installed.
|
|
11
|
+
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
const { test } = require('node:test');
|
|
18
|
+
|
|
19
|
+
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
20
|
+
const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A `.claude` tree holding exactly what an install ships, inside a git repository.
|
|
24
|
+
* The repository matters because the Stop gate derives provenance with git before it
|
|
25
|
+
* reads anything else.
|
|
26
|
+
*/
|
|
27
|
+
function withInstall(run, { git = false } = {}) {
|
|
28
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grok-envelope-'));
|
|
29
|
+
try {
|
|
30
|
+
for (const rel of MANIFEST.runtime.files.filter((f) => f.startsWith('hooks/'))) {
|
|
31
|
+
const target = path.join(root, '.claude', rel);
|
|
32
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
33
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude', rel), target);
|
|
34
|
+
}
|
|
35
|
+
fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), path.join(root, '.claude', 'scripts'),
|
|
36
|
+
{ recursive: true });
|
|
37
|
+
if (git) {
|
|
38
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'],
|
|
39
|
+
['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
40
|
+
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
41
|
+
assert.equal(r.status, 0, r.stderr);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return run(root);
|
|
45
|
+
} finally {
|
|
46
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run one installed hook the way grok runs it. */
|
|
51
|
+
function runHook(root, name, payload, args = []) {
|
|
52
|
+
const r = spawnSync(process.execPath, [path.join(root, '.claude', 'hooks', name), ...args], {
|
|
53
|
+
cwd: root,
|
|
54
|
+
input: JSON.stringify(payload),
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
// grok sets CLAUDE_PROJECT_DIR as an alias of GROK_WORKSPACE_ROOT on every hook.
|
|
57
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: root, PROJECT_ROOT: '', GROK_SESSION_ID: 'grok-1' },
|
|
58
|
+
});
|
|
59
|
+
return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decisionOf(stdout) {
|
|
63
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(line);
|
|
66
|
+
const d = parsed?.hookSpecificOutput?.permissionDecision ?? parsed?.decision;
|
|
67
|
+
if (d) return { decision: d, reason: parsed?.hookSpecificOutput?.permissionDecisionReason ?? parsed?.reason };
|
|
68
|
+
} catch { /* not JSON, keep looking */ }
|
|
69
|
+
}
|
|
70
|
+
return { decision: null, reason: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('the privacy gate asks on a grok run_terminal_command', () => {
|
|
74
|
+
withInstall((root) => {
|
|
75
|
+
fs.writeFileSync(path.join(root, '.env'), 'TOKEN=redacted\n');
|
|
76
|
+
// grok's shell tool under grok's key names. Reading `data.tool_name` here yields
|
|
77
|
+
// undefined, so the command is never scanned and the secret is handed over.
|
|
78
|
+
const r = runHook(root, 'privacy-block.cjs', {
|
|
79
|
+
hookEventName: 'pre_tool_use',
|
|
80
|
+
sessionId: 'grok-1',
|
|
81
|
+
cwd: root,
|
|
82
|
+
toolName: 'run_terminal_command',
|
|
83
|
+
toolInput: { command: `cat ${path.join(root, '.env')}` },
|
|
84
|
+
});
|
|
85
|
+
const { decision, reason } = decisionOf(r.stdout);
|
|
86
|
+
assert.equal(decision, 'ask', 'a grok shell call touching .env must reach the approval prompt');
|
|
87
|
+
assert.match(reason, /\.env/, 'the prompt must name what it is protecting');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the privacy gate asks on a grok read_file path', () => {
|
|
92
|
+
withInstall((root) => {
|
|
93
|
+
fs.writeFileSync(path.join(root, '.env'), 'TOKEN=redacted\n');
|
|
94
|
+
// grok's read tool carries its argument as `path`, not `file_path`.
|
|
95
|
+
const r = runHook(root, 'privacy-block.cjs', {
|
|
96
|
+
hookEventName: 'pre_tool_use',
|
|
97
|
+
cwd: root,
|
|
98
|
+
toolName: 'read_file',
|
|
99
|
+
toolInput: { path: path.join(root, '.env') },
|
|
100
|
+
});
|
|
101
|
+
assert.equal(decisionOf(r.stdout).decision, 'ask');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('an ordinary grok read is not disturbed', () => {
|
|
106
|
+
withInstall((root) => {
|
|
107
|
+
fs.writeFileSync(path.join(root, 'README.md'), '# hello\n');
|
|
108
|
+
const r = runHook(root, 'privacy-block.cjs', {
|
|
109
|
+
hookEventName: 'pre_tool_use',
|
|
110
|
+
cwd: root,
|
|
111
|
+
toolName: 'read_file',
|
|
112
|
+
toolInput: { path: path.join(root, 'README.md') },
|
|
113
|
+
});
|
|
114
|
+
assert.equal(decisionOf(r.stdout).decision, null, 'the gate must not ask about an ordinary file');
|
|
115
|
+
assert.equal(r.code, 0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a broad glob is denied with its full reason in JSON', () => {
|
|
120
|
+
withInstall((root) => {
|
|
121
|
+
const r = runHook(root, 'inspect-block.cjs', {
|
|
122
|
+
hookEventName: 'pre_tool_use',
|
|
123
|
+
cwd: root,
|
|
124
|
+
toolName: 'Glob',
|
|
125
|
+
toolInput: { pattern: '**/*.ts' },
|
|
126
|
+
});
|
|
127
|
+
const { decision, reason } = decisionOf(r.stdout);
|
|
128
|
+
assert.equal(decision, 'deny');
|
|
129
|
+
// grok reads only the first stderr line, so the actionable half of the message has to
|
|
130
|
+
// travel in the JSON reason or the model is blocked without being told what to do.
|
|
131
|
+
assert.match(reason, /SCOPE LIMIT EXCEEDED/);
|
|
132
|
+
assert.match(reason, /Please narrow your scope/, 'the whole message must survive, not just its first line');
|
|
133
|
+
assert.equal(r.code, 2, 'exit 2 stays for a host that reads neither channel');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a nested task write is denied under the grok write tool', () => {
|
|
138
|
+
withInstall((root) => {
|
|
139
|
+
const target = path.join(root, 'specs', 'demo', 'tasks', 'task-R0-01-x.md');
|
|
140
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
141
|
+
// grok creates files with `write`; `search_replace` edits an existing one. The guard
|
|
142
|
+
// fires on Write only, so the alias has to keep those two apart.
|
|
143
|
+
const r = runHook(root, 'task-scaffold-guard.cjs', {
|
|
144
|
+
hookEventName: 'pre_tool_use',
|
|
145
|
+
cwd: root,
|
|
146
|
+
toolName: 'write',
|
|
147
|
+
toolInput: { path: target, content: '# x' },
|
|
148
|
+
});
|
|
149
|
+
const { decision, reason } = decisionOf(r.stdout);
|
|
150
|
+
assert.equal(decision, 'deny');
|
|
151
|
+
assert.match(reason, /TASK SCAFFOLD REQUIRED/);
|
|
152
|
+
assert.match(reason, /spec-scaffold\.cjs/, 'the command the model must run has to reach it');
|
|
153
|
+
assert.equal(r.code, 2);
|
|
154
|
+
|
|
155
|
+
// The escape route the message names must stay open: an edit is not a create.
|
|
156
|
+
const edit = runHook(root, 'task-scaffold-guard.cjs', {
|
|
157
|
+
hookEventName: 'pre_tool_use',
|
|
158
|
+
cwd: root,
|
|
159
|
+
toolName: 'search_replace',
|
|
160
|
+
toolInput: { path: target, old_string: 'a', new_string: 'b' },
|
|
161
|
+
});
|
|
162
|
+
assert.equal(decisionOf(edit.stdout).decision, null, 'search_replace must not be treated as a create');
|
|
163
|
+
assert.equal(edit.code, 0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/** A packet the Stop gate will block: one done task with no receipt. */
|
|
168
|
+
function unprovenPacket(root) {
|
|
169
|
+
const feature = path.join(root, 'specs', 'demo');
|
|
170
|
+
fs.mkdirSync(feature, { recursive: true });
|
|
171
|
+
fs.writeFileSync(path.join(feature, 'plan.md'), '# Demo plan\n');
|
|
172
|
+
fs.writeFileSync(path.join(feature, 'task-01-demo.md'), [
|
|
173
|
+
'# Task 01: demo', '', 'Status: done', '', '## Dependencies', '', '- none', '',
|
|
174
|
+
'## Verification Plan', '', '- Command: node --test', '',
|
|
175
|
+
].join('\n'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('the Stop gate honours stopHookActive', () => {
|
|
179
|
+
withInstall((root) => {
|
|
180
|
+
// The fixture must be one the gate would otherwise block, or silence proves nothing:
|
|
181
|
+
// an empty specs directory makes the gate exit quietly for a different reason.
|
|
182
|
+
unprovenPacket(root);
|
|
183
|
+
// Without the guard the gate re-blocks its own continuation until grok's cap ends
|
|
184
|
+
// the turn eight rounds later.
|
|
185
|
+
const r = runHook(root, 'spec-gate.cjs', {
|
|
186
|
+
hookEventName: 'stop',
|
|
187
|
+
sessionId: 'grok-1',
|
|
188
|
+
cwd: root,
|
|
189
|
+
stopHookActive: true,
|
|
190
|
+
});
|
|
191
|
+
assert.equal(r.stdout.trim(), '', 'a continuation round must pass silently');
|
|
192
|
+
assert.equal(r.code, 0);
|
|
193
|
+
}, { git: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the Stop gate still blocks an unproven done task', () => {
|
|
197
|
+
withInstall((root) => {
|
|
198
|
+
unprovenPacket(root);
|
|
199
|
+
// The guard must not become a way through: a Stop with no continuation flag still
|
|
200
|
+
// faces the receipt check.
|
|
201
|
+
const r = runHook(root, 'spec-gate.cjs', {
|
|
202
|
+
hookEventName: 'stop', sessionId: 'grok-1', cwd: root, stopHookActive: false,
|
|
203
|
+
});
|
|
204
|
+
const body = decisionOf(r.stdout);
|
|
205
|
+
assert.equal(body.decision, 'block', 'a done task without a receipt must still be blocked');
|
|
206
|
+
}, { git: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('the approval hook takes its approve path on a grok prompt', () => {
|
|
210
|
+
withInstall((root) => {
|
|
211
|
+
// Registered without an argv mode, this hook picks its mode from the event name, and
|
|
212
|
+
// approve() returns immediately unless that name is exactly 'UserPromptSubmit'. An
|
|
213
|
+
// approval phrase with no pending request is therefore the oracle: only the approve
|
|
214
|
+
// path can print a rejection line, so the line proves the event name was translated.
|
|
215
|
+
// Under an unreadable envelope the mode fell back to the stop path on every prompt.
|
|
216
|
+
const nonce = 'a'.repeat(24);
|
|
217
|
+
const r = runHook(root, 'completion-authority.cjs', {
|
|
218
|
+
hookEventName: 'user_prompt_submit',
|
|
219
|
+
sessionId: 'grok-1',
|
|
220
|
+
cwd: root,
|
|
221
|
+
prompt: `APPROVE CAFEKIT COMPLETION ${nonce}`,
|
|
222
|
+
});
|
|
223
|
+
assert.match(r.stdout, /CafeKit completion approval rejected/,
|
|
224
|
+
'the approve path must run and refuse an approval that matches no pending request');
|
|
225
|
+
assert.equal(decisionOf(r.stdout).decision, null, 'and it must not block the prompt');
|
|
226
|
+
|
|
227
|
+
// An ordinary prompt stays silent on both counts.
|
|
228
|
+
const plain = runHook(root, 'completion-authority.cjs', {
|
|
229
|
+
hookEventName: 'user_prompt_submit', sessionId: 'grok-1', cwd: root, prompt: 'please continue',
|
|
230
|
+
});
|
|
231
|
+
assert.equal(plain.stdout.trim(), '', 'an ordinary prompt produces nothing');
|
|
232
|
+
}, { git: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('the secret guardrail sees a grok prompt', () => {
|
|
236
|
+
withInstall((root) => {
|
|
237
|
+
// grok's spelling for the prompt field is unverified, so the reader accepts the Claude
|
|
238
|
+
// key and its camelCase twin. This case sends the twin: on the Claude key alone it
|
|
239
|
+
// would pass without proving any translation happened.
|
|
240
|
+
const r = runHook(root, 'secret-output-guardrail.cjs', {
|
|
241
|
+
hookEventName: 'user_prompt_submit',
|
|
242
|
+
sessionId: 'grok-1',
|
|
243
|
+
cwd: root,
|
|
244
|
+
userPrompt: 'show me the API key in the config',
|
|
245
|
+
});
|
|
246
|
+
assert.match(r.stdout, /## Secret handling/, 'the reminder must fire on a grok prompt');
|
|
247
|
+
const quiet = runHook(root, 'secret-output-guardrail.cjs', {
|
|
248
|
+
hookEventName: 'user_prompt_submit', cwd: root, userPrompt: 'rename the button',
|
|
249
|
+
});
|
|
250
|
+
assert.equal(quiet.stdout.trim(), '', 'an unrelated prompt stays quiet');
|
|
251
|
+
const claudeKey = runHook(root, 'secret-output-guardrail.cjs', {
|
|
252
|
+
hook_event_name: 'UserPromptSubmit', cwd: root, prompt: 'print the access token',
|
|
253
|
+
});
|
|
254
|
+
assert.match(claudeKey.stdout, /## Secret handling/, 'the Claude key keeps working');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a Claude envelope still works unchanged', () => {
|
|
259
|
+
withInstall((root) => {
|
|
260
|
+
fs.writeFileSync(path.join(root, '.env'), 'TOKEN=redacted\n');
|
|
261
|
+
// The regression guard for the host that was already working.
|
|
262
|
+
const r = runHook(root, 'privacy-block.cjs', {
|
|
263
|
+
hook_event_name: 'PreToolUse',
|
|
264
|
+
session_id: 'claude-1',
|
|
265
|
+
cwd: root,
|
|
266
|
+
tool_name: 'Read',
|
|
267
|
+
tool_input: { file_path: path.join(root, '.env') },
|
|
268
|
+
});
|
|
269
|
+
assert.equal(decisionOf(r.stdout).decision, 'ask');
|
|
270
|
+
});
|
|
271
|
+
});
|