Files
msd-core/tests/docs-hooks-table-parity.test.cjs
Cody Anderson 77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on
Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets
(the .env.example/.sample/.template/.dist templates stay readable).
Read checks file_path; Grep checks an explicit path and judges the glob
per brace alternative; Bash runs a two-pass token scan (quotes, comments,
redirects with fd digits, separators, $( )/backtick/<( ) recursion,
heredoc bodies never scanned as commands, nested bash -c/eval rescans,
git <ref>:<path> shapes) with a closed non-reading exemption set for
existence checks. Fail-open crash policy; 1 MiB commands are denied as
command-too-large; more than 64 glob alternatives as glob-too-complex.

Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt
for approval whenever any Read() deny rule exists, even in auto mode. A
hook denial is not a permission rule and never arms that check. The
installer-written deny rules are retired in the follow-up commit.

Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks
HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking
guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell),
shell-command-projection managed sets, installer-migration-report,
OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch),
docs tables in five locales, ADR-766 always-on list, regen:derived
fixtures, and a new table-driven unit suite.

* test(#4221): pin the secret-read guard in existing hook gates

Register gsd-secret-read-guard.js in every existing hook gate: the
hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest
REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity
EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal-
hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS,
kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and
typed-payload floors, the OpenCode adapter (grep mapping, include ->
glob, three dispatch tests) and a Kimi TOML matcher assertion.

* fix(#4221): retire installer Read() deny rules (legacy filter)

Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS
and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets)
strings. mergeClaudePermissions now only filters them out of an existing
permissions.deny: an absent deny key stays absent, a malformed one is
still repaired to [], and an array emptied by the filter is deleted so
no `"deny": []` residue is left. Uninstall filters the same legacy list
and, symmetric with the Antigravity branch, drops an emptied allow or
deny key and an emptied permissions object.

Unlike the #2278 allow-side migration there is no surviving current
deny list, so the constant is renamed rather than mirrored. Removal is
byte-exact: a hand-written identical rule is indistinguishable from the
installer's and is removed too (the manifest never recorded permission
strings). USER-GUIDE and CONTEXT.md updated.

* test(#4221): flip install-regressions deny-rule assertions to the retired shape

The fresh-merge, non-destructive merge, idempotency, end-to-end install,
reinstall and uninstall assertions now expect no Read(.env*) deny rules
and no permissions.deny key on a fresh install; the deny:null repair case
is kept. A new describe block covers the legacy filter: retired strings
removed with a user entry kept, partial sets, near-miss strings
untouched, idempotency, GSD-only deny array deleted, a pre-existing
empty deny preserved, and uninstall symmetry for allow/deny/permissions.

* chore(#4221): add changeset fragment for PR #4236

* fix(#4221): case-fold names; scan shell stdin and xargs pipes

Review round 1 (trek-e):

- Blocker: secret-name matching is now case-insensitive in the Read,
  Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a
  case-insensitive filesystem are recognized as the same secret file.
- Major: a shell interpreter's script is now scanned wherever it comes
  from. The tokenizer keeps heredoc bodies as per-segment tokens and
  records separator operators; pass 2 groups by segment id and resolves
  bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined
  `-lc`) scans the script operand, a file operand is checked as a file
  (a `<( )` operand's echo/printf output is reconstructed), otherwise
  stdin is the script and heredocs, here-strings and a piped echo/printf
  source are scanned. `eval` joins all its operands; `source`/`.` handle
  process substitution. Data heredocs (`cat <<EOF`, the commit-message
  shape) stay unscanned.
- Major: `… | xargs <cmd>` checks the upstream segment's operands as
  file names when the sub-command reads (`echo .env | xargs cat`,
  `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the
  inference; a shell sub-command's `-c` script is scanned.

Header, USER-GUIDE bullet and changeset updated; documented gaps now
include piped scripts from non-echo sources and `exec`/`timeout`
wrappers. 60 new suite cases pin the block and allow shapes.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00

175 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product (#3839)
// Reads the docs hook tables and the hook-surface source whose registrations
// ARE the deployed contract — asserting the docs rows match the surface.
/**
* Docs hook-table parity — docs-hooks-table-parity.test.cjs
*
* #3839: the shipped hook tables documented `gsd-validate-commit.sh` as
* PostToolUse when it is registered PreToolUse (a PreToolUse hook BLOCKS a
* commit via exit 2; a PostToolUse hook cannot — the documented event
* misdescribes the hook's entire contract). The same scan found
* `gsd-session-state.sh` documented PostToolUse while registered
* SessionStart. Nine files carried the wrong rows (ARCHITECTURE.md +
* INVENTORY.md × en, ja-JP, zh-CN, ko-KR, pt-BR).
*
* Truth source: the literal hook-spec array in `buildKimiHooksTomlBlock`
* (src/runtime-hooks-surface.cts) — its own comment pins the invariant
* "mirrors applySettingsJsonHooks' settings.json wiring 1:1" — unioned with
* literal-event probe lines (`settings.hooks.<Event>.some(…
* referencesHook(…, 'gsd-…'))`). Registrations written through the
* runtime-resolved variables (`preToolEvent`/`postToolEvent`) are NOT
* statically parseable and are covered only via the mirror invariant; hooks
* registered on other surfaces (statusline, plugin-surface) are out of
* scope. Docs rows are exempt when their Event cell is not exactly one
* registered event (multi-event `A` / `B` cells, `statusLine`, `(helper)`,
* host-native names) — note docs/how-to/install-on-your-runtime.md also
* documents these mappings in transposed event-first tables, which this
* hook-first row parser intentionally does not read.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SURFACE_PATH = path.join(ROOT, 'src', 'runtime-hooks-surface.cts');
const DOC_TABLES = [
'docs/ARCHITECTURE.md',
'docs/INVENTORY.md',
'docs/ja-JP/ARCHITECTURE.md',
'docs/ja-JP/INVENTORY.md',
'docs/zh-CN/ARCHITECTURE.md',
'docs/zh-CN/INVENTORY.md',
'docs/ko-KR/ARCHITECTURE.md',
'docs/ko-KR/INVENTORY.md',
'docs/pt-BR/ARCHITECTURE.md',
'docs/pt-BR/INVENTORY.md',
];
// The exact basename set the surface parser must resolve. If a registration
// disappears or the parser drifts, this pin fails instead of the parity
// checks silently narrowing to a subset.
const EXPECTED_SURFACE_HOOKS = [
'gsd-agent-isolation-guard.js',
'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-context-monitor.js',
'gsd-graphify-update.sh',
'gsd-phase-boundary.sh',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-secret-read-guard.js',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-workflow-guard.js',
'gsd-worktree-path-guard.js',
'gsd-write-guard.js',
];
/** hook basename → Set of events it is registered under on this surface. */
function registeredHookEvents() {
const src = fs.readFileSync(SURFACE_PATH, 'utf8');
const map = new Map();
// Probe lines name hooks WITHOUT file extensions (`'gsd-session-state'`).
// Resolve a bare name against the actual hooks/ directory (the file's
// extension is ground truth) so both spellings resolve to one entry.
const resolveBase = (name) => {
if (/\.(sh|js|cmd)$/.test(name)) return name;
for (const ext of ['.js', '.sh', '.cmd']) {
if (fs.existsSync(path.join(ROOT, 'hooks', `${name}${ext}`))) return `${name}${ext}`;
}
return name; // no file twin (e.g. a name never documented in docs tables)
};
const add = (base, event) => {
const key = resolveBase(base);
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(event);
};
let m;
// Literal hook-spec array (the Kimi mirror of the settings.json wiring).
const specRe = /event:\s*'([A-Za-z]+)',\s*command:\s*cmd\('([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = specRe.exec(src)) !== null) add(path.basename(m[2]), m[1]);
// Probe lines paired with a literal event:
// settings.hooks.<Event>.some(… referencesHook(…, '<name>'))
const probeRe = /settings\.hooks\.([A-Za-z]+)\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record<string, unknown>, '([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = probeRe.exec(src)) !== null) add(m[2], m[1]);
// Probe lines paired with the runtime-resolved variables — statically
// resolved to their non-Gemini canonical events (docs document the
// canonical Claude/GS wiring; BeforeTool/AfterTool are the Gemini twins):
// const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse'
const dynRe = /settings\.hooks\[(preToolEvent|postToolEvent)\]\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record<string, unknown>, '([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = dynRe.exec(src)) !== null) add(m[2], m[1] === 'preToolEvent' ? 'PreToolUse' : 'PostToolUse');
return map;
}
/** Hook-table rows: [basename, eventCell] for `gsd-*` hooks. */
function docHookRows(docPath) {
const lines = fs.readFileSync(path.join(ROOT, docPath), 'utf8').split(/\r?\n/);
const rows = [];
for (const line of lines) {
const m = line.match(/^\|\s*`?(gsd-[a-z0-9-]+\.(?:sh|js|cmd))`?\s*\|\s*`([^`]+)`\s*\|/);
if (m) rows.push([m[1], m[2]]);
}
return rows;
}
// Cells that are not a single surface event: multi-event (`A` / `B`),
// non-event identifiers (statusLine, host-native names), or placeholders.
const SINGLE_EVENT_CELL = /^[A-Z][A-Za-z]+$/;
describe('docs hook tables match runtime-hooks-surface registrations', () => {
const surface = registeredHookEvents();
test('surface parser resolves the exact expected hook set (no silent drift)', () => {
const withExt = [...surface.keys()].filter((k) => /\.(sh|js|cmd)$/.test(k)).sort();
assert.deepEqual(
withExt,
[...EXPECTED_SURFACE_HOOKS].sort(),
'parser must resolve exactly the expected surface registrations'
);
});
for (const doc of DOC_TABLES) {
test(`${doc}: every single-event hook row matches the surface`, () => {
const rows = docHookRows(doc);
assert.ok(rows.length >= 3, `${doc}: hook-table parser found rows (guard against silent table drift)`);
const mismatches = [];
for (const [base, cell] of rows) {
const events = surface.get(base);
if (!events) continue; // not registered on this surface (statusline, plugin-surface hooks…)
if (!SINGLE_EVENT_CELL.test(cell)) continue; // multi-event or non-event cell
if (!events.has(cell)) {
mismatches.push(`${base}: docs say \`${cell}\`, surface registers ${[...events].join(', ')}`);
}
}
assert.deepEqual(mismatches, [], `docs rows must match src/runtime-hooks-surface.cts (#3839)`);
});
}
test('#3839 regression pin: the two misdocumented hooks are asserted directly', () => {
assert.ok(surface.get('gsd-validate-commit.sh').has('PreToolUse'),
'gsd-validate-commit.sh blocks commits — must stay PreToolUse on the surface');
assert.ok(surface.get('gsd-session-state.sh').has('SessionStart'),
'gsd-session-state.sh orients the session — must stay SessionStart on the surface');
// ja-JP/ARCHITECTURE.md's hook table is a shorter translation that never
// listed these two hooks — it stays in the parity loop above but not here.
const PINNED = DOC_TABLES.filter((d) => d !== 'docs/ja-JP/ARCHITECTURE.md');
for (const doc of PINNED) {
const rows = docHookRows(doc);
const vc = rows.find(([b]) => b === 'gsd-validate-commit.sh');
assert.ok(vc, `${doc}: validate-commit row present`);
assert.equal(vc[1], 'PreToolUse', `${doc}: validate-commit documented as PreToolUse`);
const ss = rows.find(([b]) => b === 'gsd-session-state.sh');
assert.ok(ss, `${doc}: session-state row present`);
assert.equal(ss[1], 'SessionStart', `${doc}: session-state documented as SessionStart`);
}
});
});