* 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>
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
// allow-test-rule: source-text-is-the-product #2304 — this test's whole job is
|
|
// scanning hooks/*.js source text for the inlined KIMI_TOOL_NAMES copies; the
|
|
// text IS the artifact under test (the copies have no runtime binding).
|
|
/**
|
|
* Kimi guard-normalization parity test (#2304 / PR #2326 review Major 1;
|
|
* extended by PR #2301 review Major 2 for hooks/gsd-write-guard.js).
|
|
*
|
|
* The KIMI_TOOL_NAMES map + normalizeKimiPayload helper is deliberately
|
|
* inlined per hook script (a sibling require is a staging dependency that
|
|
* can fail silently — see the rationale comment in each guard), which
|
|
* leaves five hand-maintained copies plus their inverse in bin/install.js
|
|
* (claudeToKimiTools / convertKimiToolName). Nothing at runtime binds them.
|
|
*
|
|
* This test is that binding, with zero runtime coupling:
|
|
* 1. the five inlined copies are byte-identical;
|
|
* 2. every entry in each guard map is the value-inverse of what the
|
|
* installer's matcher vocabulary emits for that Claude tool;
|
|
* 3. every guard-relevant Claude tool the installer translates has a
|
|
* reverse entry — so a vocabulary extension or rename that updates
|
|
* convertKimiToolName without updating the guards fails HERE instead
|
|
* of leaving a guard silently dormant (the #2304 failure mode).
|
|
*
|
|
* gsd-write-guard.js is bound SEMANTICALLY, not byte-wise: its copy
|
|
* intentionally omits the Edit-class mapping (the guard exits 0 for any
|
|
* tool but Write, so StrReplaceFile/old_string handling there is dead code
|
|
* — #2301 review Major 1), so its map is checked against the installer
|
|
* inverse and for Write-dormancy, the only tool it inspects. It is still
|
|
* required to CARRY a block, so the copy cannot silently disappear.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { convertKimiToolName } = require('../bin/install.js');
|
|
|
|
// Enumerated by scanning, never hardcoded: a sixth guard added later with its
|
|
// own copy of the block must be swept in automatically, or the copies diverge
|
|
// exactly the way this test exists to prevent (PR #2326 review M2). The
|
|
// dynamic scan is also what makes the no-shared-module decision safe.
|
|
const KIMI_MARKER = 'const KIMI_TOOL_NAMES';
|
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
const ALL_BLOCK_FILES = fs
|
|
.readdirSync(HOOKS_DIR)
|
|
.filter((f) => f.endsWith('.js'))
|
|
.filter((f) => fs.readFileSync(path.join(HOOKS_DIR, f), 'utf8').includes(KIMI_MARKER))
|
|
.map((f) => `hooks/${f}`)
|
|
.sort();
|
|
|
|
// Bound semantically (map-inverse + Write-dormancy), never byte-wise — see header.
|
|
const WRITE_GUARD_FILE = 'hooks/gsd-write-guard.js';
|
|
|
|
// The byte-identity cohort: every scanned guard except the deliberate subset.
|
|
const HOOK_FILES = ALL_BLOCK_FILES.filter((f) => f !== WRITE_GUARD_FILE);
|
|
|
|
// The five guards normalized for #2304. A scan that misses one of these is a
|
|
// broken scan, not a passing test — without this floor, an over-narrow filter
|
|
// would "pass" by finding nothing to check.
|
|
const KNOWN_NORMALIZED_GUARDS = [
|
|
'hooks/gsd-prompt-guard.js',
|
|
'hooks/gsd-read-guard.js',
|
|
'hooks/gsd-read-injection-scanner.js',
|
|
'hooks/gsd-secret-read-guard.js',
|
|
'hooks/gsd-workflow-guard.js',
|
|
'hooks/gsd-worktree-path-guard.js',
|
|
];
|
|
|
|
// Claude tool names whose PreToolUse/PostToolUse guards are registered with a
|
|
// translated matcher on Kimi (runtime-hooks-surface.cts buildKimiHooksTomlBlock):
|
|
// the write guards match WriteFile|StrReplaceFile, the injection scanner
|
|
// matches ReadFile, and gsd-workflow-guard.js matches Shell|WriteFile|StrReplaceFile.
|
|
const GUARD_RELEVANT_CLAUDE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'Read', 'Bash'];
|
|
|
|
function extractBlock(file) {
|
|
const src = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
const start = src.indexOf('const KIMI_TOOL_NAMES');
|
|
assert.notEqual(start, -1, `${file}: KIMI_TOOL_NAMES block not found`);
|
|
const endMarker = ' return data;\n}';
|
|
const end = src.indexOf(endMarker, start);
|
|
assert.notEqual(end, -1, `${file}: normalizeKimiPayload end not found`);
|
|
return src.slice(start, end + endMarker.length);
|
|
}
|
|
|
|
function parseMap(block) {
|
|
// The guards declare `new Map([['KimiName', 'ClaudeName'], …])` (a Map so
|
|
// prototype keys resolve to undefined — review M1); parse the pair list.
|
|
const m = block.match(/const KIMI_TOOL_NAMES = new Map\(\[([\s\S]*?)\]\);/);
|
|
assert.ok(m, 'KIMI_TOOL_NAMES Map literal not parseable');
|
|
const entries = {};
|
|
for (const kv of m[1].matchAll(/\['(\w+)', '(\w+)'\]/g)) {
|
|
entries[kv[1]] = kv[2];
|
|
}
|
|
assert.ok(Object.keys(entries).length > 0, 'KIMI_TOOL_NAMES parsed empty');
|
|
return entries;
|
|
}
|
|
|
|
function assertMapIsInstallerInverse(map, file) {
|
|
for (const [kimiName, claudeName] of Object.entries(map)) {
|
|
const modulePath = convertKimiToolName(claudeName);
|
|
assert.ok(
|
|
typeof modulePath === 'string' && modulePath.endsWith(`:${kimiName}`),
|
|
`${file}: KIMI_TOOL_NAMES.${kimiName} -> '${claudeName}' is not the inverse of ` +
|
|
`convertKimiToolName('${claudeName}') = ${modulePath}`
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('Kimi guard normalization parity', () => {
|
|
test('the scan finds every known normalized guard (floor — a scan that finds nothing must fail)', () => {
|
|
for (const known of KNOWN_NORMALIZED_GUARDS) {
|
|
assert.ok(
|
|
HOOK_FILES.includes(known),
|
|
`${known} carries no '${KIMI_MARKER}' block — either its normalization ` +
|
|
'was removed or the scan filter broke; both mean lost coverage'
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the write guard carries a normalization block (semantically bound, but never absent)', () => {
|
|
assert.ok(
|
|
ALL_BLOCK_FILES.includes(WRITE_GUARD_FILE),
|
|
`${WRITE_GUARD_FILE} carries no '${KIMI_MARKER}' block — the shrink guard ` +
|
|
'is silently dormant on Kimi (#2304)'
|
|
);
|
|
});
|
|
|
|
test('all inlined copies of the normalization block are byte-identical', () => {
|
|
const blocks = HOOK_FILES.map(extractBlock);
|
|
for (let i = 1; i < blocks.length; i++) {
|
|
assert.equal(
|
|
blocks[i],
|
|
blocks[0],
|
|
`${HOOK_FILES[i]} normalization block diverges from ${HOOK_FILES[0]}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every guard map is the value-inverse of the installer matcher vocabulary', () => {
|
|
assertMapIsInstallerInverse(parseMap(extractBlock(HOOK_FILES[0])), HOOK_FILES[0]);
|
|
assertMapIsInstallerInverse(parseMap(extractBlock(WRITE_GUARD_FILE)), WRITE_GUARD_FILE);
|
|
});
|
|
|
|
test('every guard-relevant Claude tool has a reverse entry (dormancy alarm)', () => {
|
|
const map = parseMap(extractBlock(HOOK_FILES[0]));
|
|
for (const claudeName of GUARD_RELEVANT_CLAUDE_TOOLS) {
|
|
const modulePath = convertKimiToolName(claudeName);
|
|
assert.ok(modulePath, `installer no longer maps ${claudeName} — update this test`);
|
|
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
|
|
assert.ok(
|
|
map[kimiName] !== undefined,
|
|
`Kimi name '${kimiName}' (from ${claudeName}) has no KIMI_TOOL_NAMES ` +
|
|
`reverse entry — the matching guard would be silently dormant on Kimi (#2304)`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('gsd-write-guard.js maps the Kimi name for Write (its only inspected tool)', () => {
|
|
const map = parseMap(extractBlock(WRITE_GUARD_FILE));
|
|
const modulePath = convertKimiToolName('Write');
|
|
assert.ok(modulePath, "installer no longer maps 'Write' — update this test");
|
|
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
|
|
assert.equal(
|
|
map[kimiName],
|
|
'Write',
|
|
`${WRITE_GUARD_FILE}: Kimi name '${kimiName}' must map to 'Write' or the ` +
|
|
'shrink guard is silently dormant on Kimi (#2304)'
|
|
);
|
|
// The copy must also carry the payload-field half of the normalization —
|
|
// WriteFile delivers `path`, the guard reads `file_path`.
|
|
assert.ok(
|
|
extractBlock(WRITE_GUARD_FILE).includes('input.file_path'),
|
|
`${WRITE_GUARD_FILE}: normalizeKimiPayload no longer maps path -> file_path`
|
|
);
|
|
});
|
|
});
|
|
|
|
// The two shell guards (gsd-graphify-update.sh, gsd-phase-boundary.sh) carry
|
|
// the same #2304 normalization reimplemented in shell — a byte-identity
|
|
// assertion cannot span the JS↔shell boundary, so instead of faking one this
|
|
// block pins the two vocabulary facts each script depends on to the
|
|
// installer's live mapping. Behavior is covered by negative-controlled tests
|
|
// beside each hook's existing suite (graphify-auto-update.slow.test.cjs,
|
|
// hooks-opt-in.test.cjs); this block is only the vocabulary-drift alarm
|
|
// (a convertKimiToolName rename fails HERE).
|
|
describe('Kimi shell-guard vocabulary parity (#2304)', () => {
|
|
const readHook = (file) =>
|
|
fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
test('gsd-graphify-update.sh maps the installer\'s Bash vocabulary back to Bash', () => {
|
|
const modulePath = convertKimiToolName('Bash');
|
|
assert.ok(modulePath, 'installer no longer maps Bash — update this test');
|
|
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
|
|
const src = readHook('hooks/gsd-graphify-update.sh');
|
|
assert.ok(
|
|
src.includes('TOOL_NAME="${TOOL_NAME##*:}"'),
|
|
'gsd-graphify-update.sh no longer strips the Kimi module-path prefix'
|
|
);
|
|
assert.ok(
|
|
src.includes(`[ "$TOOL_NAME" = "${kimiName}" ]`) && src.includes('TOOL_NAME="Bash"'),
|
|
`gsd-graphify-update.sh no longer maps Kimi '${kimiName}' to Bash — ` +
|
|
'the hook is silently dormant on Kimi (#2304)'
|
|
);
|
|
});
|
|
|
|
test('gsd-phase-boundary.sh prefers Kimi\'s authoritative tool_input.path, falls back to file_path (#2752)', () => {
|
|
const src = readHook('hooks/gsd-phase-boundary.sh');
|
|
assert.ok(
|
|
src.includes('(typeof i.path===\'string\'&&i.path)||(typeof i.file_path===\'string\'&&i.file_path)||\'\''),
|
|
'gsd-phase-boundary.sh no longer prefers tool_input.path over file_path — ' +
|
|
'path is the authoritative field (kimi-cli executes on it); a model-supplied ' +
|
|
'decoy file_path must not suppress or fabricate a reminder (#2752, mirrors #2595)'
|
|
);
|
|
});
|
|
});
|