Files
msd-core/scripts/build-hooks.js
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

283 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Copy GSD hooks to dist for installation.
* Validates JavaScript syntax before copying to prevent shipping broken hooks.
* See #1107, #1109, #1125, #1161 — a duplicate const declaration shipped
* in dist and caused PostToolUse hook errors for all users.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
// Per-process staging directory for atomic writes. Using process.pid in the
// name eliminates all contention between concurrent builders: each process
// owns its own staging dir and never races with another builder's cleanup.
// Lives under hooks/ so it shares a filesystem with DIST_DIR (POSIX
// rename(2) is only atomic within the same filesystem) but is NOT inside
// DIST_DIR — so readers that readdirSync(DIST_DIR) (e.g. bin/install.js,
// install-hooks-copy tests) never observe a transient ".tmp" sibling.
// The parent pattern hooks/.dist-staging-*/ is gitignored.
const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
// Hooks to copy (pure Node.js, no bundling needed)
const HOOKS_TO_COPY = [
'gsd-check-update-worker.js',
'gsd-check-update.js',
// SessionStart canonical-path bootstrap (#997). In a Claude Code marketplace
// plugin install, ~/.claude/gsd-core is never created, so every
// `@~/.claude/gsd-core/...` include in agents/commands/templates resolves to
// nothing. This hook symlinks the canonical path's immutable subdirs to the
// plugin's bundled gsd-core/ tree; no-op in classic installs. Must ship to
// dist so the installer copies it into the target hooks/ dir.
'gsd-ensure-canonical-path.js',
// Required by gsd-check-update-worker.js at runtime — must ship alongside it
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
'managed-hooks-registry.cjs',
'gsd-context-monitor.js',
// Cursor lifecycle hooks (#777 + ADR-1239/#2089): 6 managed events
'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.js',
'gsd-cursor-pre-tool.js',
'gsd-cursor-stop.js',
'gsd-cursor-subagent-start.js',
'gsd-cursor-subagent-stop.js',
// Windsurf/Cascade lifecycle hooks (ADR-1239/#2100 Stage 2): 2 blocking events
'gsd-windsurf-pre-write.js',
'gsd-windsurf-pre-command.js',
// Claude Code FileChanged hook (#770) — hot-reloads gsd config when
// .planning/config.json changes mid-session. Must ship to dist so the
// installer can copy it to the target hooks/ dir and register FileChanged.
'gsd-config-reload.js',
// Agent-dispatch isolation guard (#3045): blocks an executor Agent()
// dispatch missing its harness isolation parameter when the project
// resolves to harness-worktree. Requires the sibling
// gsd-core/bin/lib/{runtime-name-policy,capability-registry}.cjs modules
// at runtime — those ship as part of the full gsd-core/ tree, not via this
// list.
'gsd-agent-isolation-guard.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
// Secret-file read guard (#4221) — replaces the installer's Read(.env*) deny rules
'gsd-secret-read-guard.js',
'gsd-statusline.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
'gsd-worktree-path-guard.js',
// Catastrophic-shrink guard for curated .planning/ artifacts (#2255, fix 3 of #973)
'gsd-write-guard.js',
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
// Portable node resolver (#3662). Managed JS hook commands under
// --portable-hooks route through it (bash <resolver> <baked-node>
// <script>) so node resolves at hook-fire time in every environment
// sharing the config root. It IS registered in MANAGED_HOOKS
// (managed-hooks-registry.cjs) for staleness tracking like every other
// shipped .sh hook, and install.js stamps its {{GSD_VERSION}} header the
// same way (#4076 — the prior comment here claimed the opposite on both
// counts, which is why the header was missing and staleness detection was
// permanently broken for this file).
'gsd-node-runner.sh',
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
// .planning/config.json graphify.auto_update; off by default.
'gsd-graphify-update.sh'
];
// Subdirectories under hooks/ whose contents must also ship to dist. Each
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
// installed runtime path. See #3579.
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
// Used between Windows rename retries; this script is sync end-to-end so
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
// is bounded (~400ms) — acceptable for a one-shot build script.
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/**
* Atomic-replace via fs.renameSync, with Windows-only retry and fallback.
*
* POSIX rename(2) atomically replaces dest even when readers hold open
* handles on it. Windows MoveFileEx (which fs.renameSync uses with
* MOVEFILE_REPLACE_EXISTING) cannot — it throws EPERM/EBUSY when another
* process has the destination open. Concurrent install.js readers and
* antivirus scanners are the realistic triggers; both release handles
* within milliseconds, so a short backoff resolves the race. After
* retries are exhausted, fall back to copy-then-unlink (re-introduces
* the truncate-then-write race for this single file but keeps the build
* moving rather than crashing). If even copy fails because dest is hard-
* locked, log a non-fatal warning and leave the prior dest in place — a
* subsequent build invocation will retry from a fresh state.
*/
function renameAtomicWithRetry(stagedDest, dest, hook) {
if (process.platform !== 'win32') {
fs.renameSync(stagedDest, dest);
return;
}
const BACKOFFS_MS = [10, 30, 90, 270];
for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
try {
fs.renameSync(stagedDest, dest);
return;
} catch (e) {
const transient = e && (e.code === 'EPERM' || e.code === 'EBUSY');
if (!transient) throw e;
if (attempt < BACKOFFS_MS.length) {
sleepSync(BACKOFFS_MS[attempt]);
continue;
}
// Retries exhausted; fall back to copy-then-unlink.
try {
fs.copyFileSync(stagedDest, dest);
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
console.warn(`\x1b[33m! ${hook}: rename failed (${e.code}) after ${BACKOFFS_MS.length} retries; used copy-fallback\x1b[0m`);
return;
} catch (fallbackErr) {
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
console.warn(`\x1b[33m! ${hook}: rename + copy fallback both failed (${e.code} → ${fallbackErr.code || fallbackErr.message}); leaving prior dest in place\x1b[0m`);
return;
}
}
}
}
/**
* Validate JavaScript syntax without executing the file.
* Catches SyntaxError (duplicate const, missing brackets, etc.)
* before the hook gets shipped to users.
*/
function validateSyntax(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
try {
// Use vm.compileFunction to check syntax without executing
new vm.Script(content, { filename: path.basename(filePath) });
return null; // No error
} catch (e) {
if (e instanceof SyntaxError) {
return e.message;
}
throw e;
}
}
function build() {
// Ensure dist and staging directories exist (staging is a sibling of dist
// used to make writes atomic — see STAGE_DIR comment above).
if (!fs.existsSync(DIST_DIR)) {
fs.mkdirSync(DIST_DIR, { recursive: true });
}
if (!fs.existsSync(STAGE_DIR)) {
fs.mkdirSync(STAGE_DIR, { recursive: true });
}
let hasErrors = false;
// Copy hooks to dist with syntax validation
for (const hook of HOOKS_TO_COPY) {
const src = path.join(HOOKS_DIR, hook);
const dest = path.join(DIST_DIR, hook);
if (!fs.existsSync(src)) {
console.warn(`Warning: ${hook} not found, skipping`);
continue;
}
// Validate JS syntax before copying (.sh files skip — not Node.js)
if (hook.endsWith('.js')) {
const syntaxError = validateSyntax(src);
if (syntaxError) {
console.error(`\x1b[31m✗ ${hook}: SyntaxError — ${syntaxError}\x1b[0m`);
hasErrors = true;
continue;
}
}
console.log(`\x1b[32m✓\x1b[0m Copying ${hook}...`);
// Atomic write: copy to a per-process staging file in the per-PID sibling
// STAGE_DIR (same filesystem as DIST_DIR so rename(2) is atomic), then
// rename into place. Multiple test files invoke this script concurrently
// from their before() hooks; fs.copyFileSync truncates then writes the
// destination — readers (install.js subprocesses spawned by parallel
// install tests) can observe the dest empty or partial mid-write,
// producing flaky failures such as bug-2136 part 4 where installed .sh
// hooks lacked their "# gsd-hook-version:" header. POSIX rename(2)
// makes the swap atomic so readers see either the old file or the new
// file. The staging file lives outside DIST_DIR so readdirSync(DIST_DIR)
// (in install.js and tests) never observes a transient ".tmp" sibling.
// Each process uses its own STAGE_DIR (keyed by PID) so concurrent
// builders never race on staging-dir creation or cleanup.
const stagedDest = path.join(STAGE_DIR, `${hook}.${Date.now()}`);
fs.copyFileSync(src, stagedDest);
// Preserve executable bit for shell scripts before rename so the
// installed file is executable from the very first observation.
if (hook.endsWith('.sh')) {
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
}
renameAtomicWithRetry(stagedDest, dest, hook);
}
// Copy whitelisted hook subdirectories (e.g. hooks/lib/) into dist so the
// installer's readdir-and-isFile loop in bin/install.js sees them and
// detached hook helpers resolve from the installed runtime path (#3579).
for (const subdir of HOOKS_SUBDIRS_TO_COPY) {
const srcDir = path.join(HOOKS_DIR, subdir);
if (!fs.existsSync(srcDir)) continue;
const destDir = path.join(DIST_DIR, subdir);
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
if (!ent.isFile()) continue;
const srcFile = path.join(srcDir, ent.name);
const destFile = path.join(destDir, ent.name);
if (ent.name.endsWith('.js')) {
const syntaxError = validateSyntax(srcFile);
if (syntaxError) {
console.error(`\x1b[31m✗ ${subdir}/${ent.name}: SyntaxError — ${syntaxError}\x1b[0m`);
hasErrors = true;
continue;
}
}
console.log(`\x1b[32m✓\x1b[0m Copying ${subdir}/${ent.name}...`);
const stagedDest = path.join(STAGE_DIR, `${subdir}__${ent.name}.${Date.now()}`);
fs.copyFileSync(srcFile, stagedDest);
if (ent.name.endsWith('.sh')) {
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
}
renameAtomicWithRetry(stagedDest, destFile, `${subdir}/${ent.name}`);
}
}
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
// rmSync with recursive:true is safe and leaves no race window.
try {
fs.rmSync(STAGE_DIR, { recursive: true, force: true });
} catch (e) { /* tolerate ENOENT if the dir was never created (e.g. all hooks skipped) */ }
if (hasErrors) {
console.error('\n\x1b[31mBuild failed: fix syntax errors above before publishing.\x1b[0m');
process.exit(1);
}
console.log('\nBuild complete.');
}
// Export HOOKS_TO_COPY so tests can require() this file and assert against
// the typed value instead of regex-parsing the source text (retires
// pending-migration-to-typed-ir for orphaned-hooks.test.cjs, per #455).
// Guard the build() call so requiring this file as a module does not trigger
// a full build run (which copies files and writes to disk).
if (require.main === module) {
build();
}
module.exports = { HOOKS_TO_COPY, HOOKS_SUBDIRS_TO_COPY };