* 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>
This commit is contained in:
@@ -183,10 +183,11 @@ function isCodexHooksFeatureKey(key) {
|
||||
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
||||
}
|
||||
|
||||
// #768 \u2014 Claude Code permissions.allow / permissions.deny entries.
|
||||
// #768 \u2014 Claude Code permissions.allow entries.
|
||||
// Pre-populated during Claude installs to eliminate first-run approval friction
|
||||
// for gsd-core's own known-safe tool calls, and to add defense-in-depth deny
|
||||
// entries for common credential files.
|
||||
// for gsd-core's own known-safe tool calls. (The defense-in-depth deny entries
|
||||
// for credential files that #768 also wrote are retired \u2014 see
|
||||
// GSD_CLAUDE_LEGACY_DENY_PERMISSIONS below.)
|
||||
//
|
||||
// Format: each string uses Claude Code's documented permission rule syntax \u2014
|
||||
// "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
|
||||
@@ -204,7 +205,20 @@ const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
|
||||
'Read(STATE.md)',
|
||||
'Edit(STATE.md)',
|
||||
]);
|
||||
const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
|
||||
// #4221 \u2014 Retired deny rules. #768 wrote these three `Read()` deny rules
|
||||
// into settings.json; Claude Code 2.1.259 hardened the Bash-side enforcement
|
||||
// of Read() deny rules so that ANY such rule makes every
|
||||
// `cd DIR && grep/cat relative-path` compound prompt for approval, even in
|
||||
// `auto` permission mode \u2014 and GSD subagents emit hundreds of those per
|
||||
// session. The same protection now ships as the managed PreToolUse hook
|
||||
// hooks/gsd-secret-read-guard.js (a hook denial is not a permission rule and
|
||||
// never arms that check). Unlike the #2278 allow-side migration, there is no
|
||||
// surviving "current" deny list: the constant is RENAMED to its legacy role
|
||||
// and only ever filtered, never added. Byte-equal strings only \u2014 a user's
|
||||
// own hand-written identical rule is indistinguishable and is removed too
|
||||
// (the install manifest never recorded permission strings, so a
|
||||
// manifest-gated cleanup is not possible).
|
||||
const GSD_CLAUDE_LEGACY_DENY_PERMISSIONS = Object.freeze([
|
||||
'Read(.env)',
|
||||
'Read(.env.*)',
|
||||
'Read(.secrets)',
|
||||
@@ -236,6 +250,13 @@ const GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
|
||||
* so existing installs end up with the working `Edit(...)` forms instead of
|
||||
* both the dead legacy entry and its replacement sitting side by side.
|
||||
*
|
||||
* Migration (#4221): the retired GSD_CLAUDE_LEGACY_DENY_PERMISSIONS entries
|
||||
* are removed from permissions.deny (byte-equal only). Nothing is added to
|
||||
* deny any more: an absent `deny` key is left absent (never created as an
|
||||
* empty array), and a `deny` array emptied BY THIS FILTER is deleted so the
|
||||
* retirement leaves no `"deny": []` residue; a user's pre-existing empty
|
||||
* `deny: []` is untouched.
|
||||
*
|
||||
* Defensive: if settings is not a plain object, returns immediately without
|
||||
* throwing. If permissions.allow / permissions.deny exist but are not arrays
|
||||
* (malformed settings), they are replaced with valid arrays.
|
||||
@@ -252,7 +273,7 @@ function mergeClaudePermissions(settings) {
|
||||
if (!Array.isArray(settings.permissions.allow)) {
|
||||
settings.permissions.allow = [];
|
||||
}
|
||||
if (!Array.isArray(settings.permissions.deny)) {
|
||||
if (settings.permissions.deny !== undefined && !Array.isArray(settings.permissions.deny)) {
|
||||
settings.permissions.deny = [];
|
||||
}
|
||||
|
||||
@@ -265,9 +286,13 @@ function mergeClaudePermissions(settings) {
|
||||
settings.permissions.allow.push(entry);
|
||||
}
|
||||
}
|
||||
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
||||
if (!settings.permissions.deny.includes(entry)) {
|
||||
settings.permissions.deny.push(entry);
|
||||
if (Array.isArray(settings.permissions.deny)) {
|
||||
const before = settings.permissions.deny.length;
|
||||
settings.permissions.deny = settings.permissions.deny.filter(
|
||||
(e) => !GSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
|
||||
);
|
||||
if (settings.permissions.deny.length === 0 && before > 0) {
|
||||
delete settings.permissions.deny;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9039,17 +9064,29 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
);
|
||||
if (settings.permissions.allow.length !== before) {
|
||||
permissionsModified = true;
|
||||
// #4221: an array this filter emptied was GSD-only \u2014 remove the
|
||||
// key rather than leave an empty array behind (Antigravity symmetry).
|
||||
if (settings.permissions.allow.length === 0) {
|
||||
delete settings.permissions.allow;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(settings.permissions.deny)) {
|
||||
const before = settings.permissions.deny.length;
|
||||
// #4221: the deny rules are retired, so this is a legacy-only filter.
|
||||
settings.permissions.deny = settings.permissions.deny.filter(
|
||||
(e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e)
|
||||
(e) => !GSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
|
||||
);
|
||||
if (settings.permissions.deny.length !== before) {
|
||||
permissionsModified = true;
|
||||
if (settings.permissions.deny.length === 0) {
|
||||
delete settings.permissions.deny;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (permissionsModified && Object.keys(settings.permissions).length === 0) {
|
||||
delete settings.permissions;
|
||||
}
|
||||
if (permissionsModified) {
|
||||
settingsModified = true;
|
||||
console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
|
||||
@@ -13932,7 +13969,7 @@ module.exports = {
|
||||
mergeClaudePermissions,
|
||||
GSD_CLAUDE_ALLOW_PERMISSIONS,
|
||||
GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
|
||||
GSD_CLAUDE_DENY_PERMISSIONS,
|
||||
GSD_CLAUDE_LEGACY_DENY_PERMISSIONS,
|
||||
GSD_CODEX_MARKER,
|
||||
// #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
|
||||
CODEX_SANDBOX_HOLDS,
|
||||
|
||||
Reference in New Issue
Block a user