Hooks
What gsd-core runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add open-gsd/gsd-core > /plugin install gsd-core@gsd-core
Ships with gsd-core. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js"
PreToolUse
- Matches
Write|Editnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-prompt-guard.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-guard.js" - Matches
Write|Edit|MultiEditnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js" - Matches
Writenode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js" - Matches
Read|Grep|Bashnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-secret-read-guard.js" - Matches
Agent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-agent-isolation-guard.js"
PostToolUse
- Matches
Bash|Edit|Write|MultiEdit|Agent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js" - Matches
Read|WebFetch|WebSearchnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js"
SubagentStop
node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"
Stop
node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"
PreCompact
node "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js"
FileChanged
- Matches
config.jsonnode "${CLAUDE_PLUGIN_ROOT}/hooks/gsd-config-reload.js"
Where it lives
- hooks/gsd-agent-isolation-guard.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // GSD Agent Isolation Dispatch Guard — PreToolUse hook (#3045) // // Problem: `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` // resolves the project's dispatch isolation correctly // (`gsd_run query dispatch-isolation --raw`), but DELIVERY of that value into // the model-authored `Agent(subagent_type="gsd-executor", ...)` call is a // prose instruction ("substitute $HARNESS_FLAG's value ... on Claude Code it // is literally isolation=\"worktree\""). Nothing verifies the model actually // copied it. When it is omitted, the executor runs and commits directly in // the user's PRIMARY checkout instead of an isolated worktree, with no // consent and no warning. // // A prose backstop cannot fix a prose defect — it is the same class of // artifact the model may equally skip. This hook enforces the invariant at // the tooling layer instead: HARD-BLOCKING. // // Applicability (must positively determine all three to act — otherwise // inert): // 1. this is a GSD project (`.planning/config.json` exists under cwd), // 2. the project's resolved dispatch isolation is `harness-worktree`, // 3. the dispatch target is an executor (`subagent_type === "gsd-executor"`; // no other executor-shaped subagent type exists in agents/ today). // // Fail-closed exception (#3050 lesson: a guard that cannot verify must not // answer "safe"): if the project IS a GSD project but the hook cannot read // or resolve its dispatch-isolation configuration, it DENIES rather than // defaulting to the "safe-looking" none/allow value that // `gsd-core/bin/gsd-tools.cjs`'s own `routeDispatchIsolation` degrades to on // error. That existing query is fail-OPEN by design (sequential execution // is always safe for the SCHEDULER); this guard's job is the opposite // invariant (never dispatch unisolated when isolation was promised), so it // cannot reuse that fail-open default and instead resolves isolation // itself, distinguishing "resolved cleanly" from "could not resolve". // // Isolation resolution (#3045 BLOCKER fix, see hooks/lib/isolation-sentinel.js): // prefers the workflow's own PERSISTED per-dispatch decision (a sentinel // `record-dispatch-isolation` writes after `executor-isolation-dispatch.md` // resolves ISOLATION in shell, applying workflow.use_worktrees, the #2474 // per-plan submodule degrade, and the #683/#3060 base-check auto-degrade) // over re-deriving a host CAPABILITY from the registry. A fresh sentinel is // authoritative — `none`/`orchestrator-worktree` ALLOW immediately // (sequential/orchestrator-managed dispatch is legitimate, not a bug); an // absent/stale sentinel falls back to a conservative registry+config check // (GSD_RUNTIME env > .planning/config.json `runtime` > the per-install // `.gsd-runtime` marker, #3566 — no confident signal degrades to inert rather // than guessing 'claude', see resolveRegistryIsolation) // gated additionally by `workflow.use_worktrees` — read directly, in-process, // no subprocess spawn. // // Triggers on: Agent/Task tool calls with subagent_type === "gsd-executor" // (both names accepted — #3045 MAJOR 1: only Agent was previously matched, // silently inert on any host/version whose subagent tool is named Task). // Action: BLOCK (exit 2) when isolation should be enforced and is not // No-op: any tool other than Agent/Task, non-executor targets, GSD projects // whose resolved isolation is not harness-worktree, non-GSD projects, // malformed payloads, or a dispatch that already carries the correct // isolation parameter. 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js'); const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js'); const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js'); // Required at module top, alongside the other ./lib requires — NOT behind // ensureRuntimeBuild() below. Terminating on a parse/timeout failure must // never depend on the gitignored build artifacts this hook self-heals for // its own registry lookups (#3911). // // This guard's outer catch (main(), below) has always exited 0 (fail open): // that outer catch only covers payload PARSING failing before applicability // could even be determined (malformed stdin JSON, etc.) — the guard's real // fail-closed logic (a GSD project whose dispatch-isolation configuration // cannot be verified) is handled separately, inside evaluateDispatch/ // resolveIsolationState, and already returns a 'block' decision through the // normal exit-2 path rather than through this catch. So an unparseable // payload has nothing to enforce; allowing it preserves today's behavior // exactly. const ON_CRASH = HOOK_ON_CRASH.ALLOW; // #3582: gsd-core/bin/lib/*.cjs (runtime-name-policy.cjs, capability-registry.cjs // below) are tsc build artifacts (ADR-457), gitignored and absent on a raw // plugin-marketplace / git-clone install that never ran `npm run build:lib`. // Self-heal before the first such require (resolveRegistryIsolation, below) — // see ensureRuntimeBuild's own header for the full rationale. This module // itself (gsd-core/bin/ensure-runtime-build.cjs) depends on nothing under // ./lib, so requiring it here is always safe. const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs'); // No other executor-shaped subagent_type exists in agents/ today // (verified: only agents/gsd-executor.md). A Set, not a bare string compare, // so a future sibling executor role can be added here without touching the // matching logic below. const EXECUTOR_SUBAGENT_TYPES = new Set(['gsd-executor']); /** * Parse a registry `harnessIsolationFlag` of the shape `key="value"` (the * only shape an `Agent()` tool_input k - hooks/gsd-check-update-worker.jsGitHub
- hooks/gsd-check-update.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // Check for GSD updates in background, write result to cache // Called by SessionStart hook - runs once per session const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawn } = require('child_process'); // #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact // (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone // install that never ran `npm run build:lib`. This SessionStart hook must // DEGRADE (fall back to a generic cache filename) rather than crash session // start. gsd-check-update-worker.js — the process this hook spawns — degrades // identically and independently, so the shared fallback literal keeps the // cache path consistent between writer and reader even in the (rare) // doubly-degraded case. This try/require/ensureRuntimeBuild/require/catch // shape is deliberately duplicated (not extracted to hooks/lib/) — see // gsd-check-update-worker.js's identical #3582 comment for why. let updateCacheFileName = 'gsd-update-check.json'; try { const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs'); ensureRuntimeBuild(); ({ updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs')); } catch (e) { // Runtime library missing/broken and could not self-build — degrade to the // fallback filename above rather than crash the SessionStart hook. } const homeDir = os.homedir(); const cwd = process.cwd(); // Detect runtime config directory (supports Claude, OpenCode, Kilo, Gemini) // Respects CLAUDE_CONFIG_DIR for custom config directory setups function detectConfigDir(baseDir) { // Check env override first (supports multi-account setups) const envDir = process.env.CLAUDE_CONFIG_DIR; if (envDir && fs.existsSync(path.join(envDir, 'gsd-core', 'VERSION'))) { return envDir; } for (const dir of ['.claude', '.gemini', '.config/kilo', '.kilo', '.config/opencode', '.opencode']) { if (fs.existsSync(path.join(baseDir, dir, 'gsd-core', 'VERSION'))) { return path.join(baseDir, dir); } } return envDir || path.join(baseDir, '.claude'); } const globalConfigDir = detectConfigDir(homeDir); const projectConfigDir = detectConfigDir(cwd); // Use a shared, tool-agnostic cache directory to avoid multi-runtime // resolution mismatches where check-update writes to one runtime's cache // but statusline reads from another (#1421). const cacheDir = path.join(homeDir, '.cache', 'gsd'); const cacheFile = path.join(cacheDir, updateCacheFileName); // VERSION file locations (check project first, then global) const projectVersionFile = path.join(projectConfigDir, 'gsd-core', 'VERSION'); const globalVersionFile = path.join(globalConfigDir, 'gsd-core', 'VERSION'); // Ensure cache directory exists if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } // Run check in background via a dedicated worker script. // Spawning a file (rather than node -e '<inline code>') keeps the worker logic // in plain JS with no template-literal regex-escaping concerns, and makes the // worker independently testable. const workerPath = path.join(__dirname, 'gsd-check-update-worker.js'); const child = spawn(process.execPath, [workerPath], { stdio: 'ignore', windowsHide: true, detached: true, // Required on Windows for proper process detachment env: { ...process.env, GSD_CACHE_FILE: cacheFile, GSD_PROJECT_VERSION_FILE: projectVersionFile, GSD_GLOBAL_VERSION_FILE: globalVersionFile, }, }); child.unref(); - hooks/gsd-config-reload.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // gsd-config-reload.js — FileChanged hook: hot-reload GSD config context // Fires when .planning/config.json is modified, created, or deleted. // // When the user edits .planning/config.json mid-session, this hook reads the // updated config and injects a summary as additionalContext so the agent knows // the new configuration without requiring a session restart. // // Input (from Claude Code): // { session_id, cwd, hook_event_name: "FileChanged", // file_path: "/abs/path/.planning/config.json", event: "change"|"add"|"unlink" } // // Output: // { hookSpecificOutput: { hookEventName: "FileChanged", additionalContext: "..." } } // or exits 0 silently (if config absent, unreadable, or event is "unlink"). // // Enabled for all Claude Code installs. This hook is always-on — it is a // no-op when .planning/config.json is absent (ENOENT → exit 0). const fs = require('fs'); const path = require('path'); const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js'); // This hook only injects an advisory config-reload summary; a crash mid-parse // must not block the session or the FileChanged event that triggered it — the // agent simply keeps using the config context it already had (#3911). const ON_CRASH = HOOK_ON_CRASH.ALLOW; let input = ''; // Timeout guard: if stdin does not close within 8s exit silently rather than // hanging until Claude Code kills the process and reports "hook error". const stdinTimeout = setTimeout(() => allow(undefined), 8000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => (input += chunk)); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { const data = JSON.parse(input); const event = data.event; // "change" | "add" | "unlink" const filePath = data.file_path || ''; const cwd = data.cwd || process.cwd(); // Only handle the GSD planning config — verify both basename and that the // resolved path is .planning/config.json relative to cwd. The hook // matcher ('config.json') fires on any watched config.json; this guard // ensures an unrelated config.json in node_modules/ or elsewhere does not // inject spurious additionalContext. const basename = path.basename(filePath); if (basename !== 'config.json') { allow(undefined); } const expectedPath = path.resolve(cwd, '.planning', 'config.json'); if (path.resolve(filePath) !== expectedPath) { allow(undefined); } // On unlink (deletion) emit a brief notice and exit if (event === 'unlink') { allow({ hookSpecificOutput: { hookEventName: 'FileChanged', additionalContext: 'GSD config (.planning/config.json) was deleted. ' + 'Falling back to built-in defaults for this session.', }, }); } // Read the updated config file let config; try { const raw = fs.readFileSync(filePath, 'utf8'); config = JSON.parse(raw); } catch (e) { if (e && e.code === 'ENOENT') allow(undefined); // Malformed JSON — inform the agent without crashing allow({ hookSpecificOutput: { hookEventName: 'FileChanged', additionalContext: 'GSD config (.planning/config.json) was modified but could not be parsed. ' + 'Check the file for JSON syntax errors.', }, }); } // Build a concise summary of key config fields the agent cares about const lines = ['GSD config reloaded (.planning/config.json updated):']; if (config.runtime) lines.push(` runtime: ${config.runtime}`); if (config.mode) lines.push(` mode: ${config.mode}`); // hooks section (opt-in toggles agents act on) if (config.hooks && typeof config.hooks === 'object') { const hookKeys = Object.entries(config.hooks) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}=${v}`) .join(', '); if (hookKeys) lines.push(` hooks: { ${hookKeys} }`); } // workflow section (key toggles) if (config.workflow && typeof config.workflow === 'object') { const wfKeys = Object.entries(config.workflow) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}=${v}`) .join(', '); if (wfKeys) lines.push(` workflow: { ${wfKeys} }`); } // model overrides (agents use these) if (config.models && typeof config.models === 'object') { const modelKeys = Object.entries(config.models) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}=${v}`) .join(', '); if (modelKeys) lines.push(` models: { ${modelKeys} }`); } if (lines.length === 1) { // No notable fields — still confirm the reload happened lines.push(' (no notable keys changed)'); } const additionalContext = lines.join('\n'); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'FileChanged', additionalContext, }, })); } catch (e) { // Silent fail — never block the session on a config reload error. // ON_CRASH is declared ALLOW at module top: this preserves today's // exit(0) fail-open behavior exactly (#3911). crash(ON_CRASH, undefined); } }); - hooks/gsd-context-monitor.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // Context Monitor - PostToolUse/AfterTool hook (Gemini uses AfterTool) // Reads context metrics from the statusline bridge file and injects // warnings when context usage is high. This makes the AGENT aware of // context limits (the statusline only shows the user). // // How it works: // 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json // 2. This hook reads those metrics after each tool use // 3. When remaining context drops below thresholds, it injects a warning // as additionalContext, which the agent sees in its conversation // // Thresholds: // WARNING (remaining <= 35%): Agent should wrap up current task // CRITICAL (remaining <= 25%): Agent should stop immediately and save state // Both fire-points are overridable per project via .planning/config.json // (hooks.context_warning_threshold / hooks.context_critical_threshold, #4285); // the values above are the defaults used when the keys are absent or unusable. // // Debounce: 5 tool uses between warnings to avoid spam // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately) const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js'); // This hook only injects an advisory context-usage warning; it never blocks // the tool call it rides in on. A crash here (e.g. a malformed bridge file) // must not turn a PostToolUse advisory into a blocked tool call — losing a // context warning is far cheaper than stalling the agent's work (#3911). const ON_CRASH = HOOK_ON_CRASH.ALLOW; const WARNING_THRESHOLD = 35; // remaining_percentage <= 35% (default, see resolveThresholds) const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25% (default, see resolveThresholds) const STALE_SECONDS = 60; // ignore metrics older than 60s const DEBOUNCE_CALLS = 5; // min tool uses between warnings // How long after a PreCompact readings stay suspect. The watermark records the // compaction's START; the compaction keeps running after it, and a statusline // render during it stamps the PRE-compaction reading with a CURRENT timestamp // (Codex review of #3808, round 3) — so "newer than the watermark" alone still // admits it. Everything inside this window is dropped instead. The cost is // bounded: a healthy reading dropped here behaves identically to an accepted // one (it would exit above-threshold anyway). A genuine exhaustion reading // inside the window is SKIPPED, not queued — its warning and its #1974 // breadcrumb both fire on the next reading after the window, so they are // delayed by at most this window plus the accepted skew below when a later // reading comes, and lost when // none does, i.e. when the session ends inside the window (review of #3808, // round 9). That loss is accepted over the alternative, which is trusting a // reading that may be the pre-compaction value under a fresh timestamp. const COMPACT_GRACE_SECONDS = 60; // How far AHEAD of this process's clock a watermark may be and still be // honored. PreCompact stamps it from the same clock as the reader, so the // legitimate skew is 0; this tolerance only absorbs a clock step. It is a // THRESHOLD, so it is named rather than inlined and carries its own boundary // trio (Codex review of #3808, round 4). Note it also extends the mute: a // watermark this far ahead pushes first recovery from +61 to +66 (measured). const WATERMARK_SKEW_SECONDS = 5; // Resolve the two fire-points from the project's `.planning/config.json` // (#4285). The constants above are the DEFAULTS; a project overrides either one // through `hooks.context_warning_threshold` / `hooks.context_critical_threshold`, // which is what keeps a tuned fire-point alive across updates — this file is in // the MANAGED registry, so an edit to the constants is re-staged away by the // next install. // // TOTAL and never-throwing: this hook must not block the tool call it rides in // on, so every unusable input degrades to the default instead of raising. // Unusable is decided by Number.isFinite, which is type-strict (the string // "30" and true are both rejected, unlike the global isFinite), plus the 0-100 // domain of the remaining_percentage these are compared against. // // The PAIR is validated too, and falls back TOGETHER. `critical >= warning` has // no coherent reading — critical fires deeper into the window than warning — // and honouring one side of an inconsistent pair silently picks which of the // operator's two numbers to discard. This also rejects a single override that // contradicts the OTHER key's default (warning 20 with critical absent, i.e. // 25); the resulting pair is the same nonsense either way. Set-time validation // cannot stand in for this check: `config-set` writes one key per call, so // tuning both (warning first, then critical) is transiently inconsistent on // disk, and refusing it there would block a legitimate configuration. function resolveThresholds(hooks) { const defaults = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD }; if (!hooks || typeof hooks !== 'object') return defaults; const usable = (value, fallback) => (Number.isFinite(value) && value >= 0 && value <= 100) ? value : fallback; const warning = usable(hooks.context_warning_threshold, WARNING_THRESHOLD); const critical = usable(hooks.context_critical_threshold, CRITICAL_THRESHOLD); return critical < warning ? { warning, critical } : defaults; } // One DEFINITION of what counts as a lifecycle event name, shared by the #3709 // PreCompact reset and the #2289 output-envelope allowlist. Two call sites, one // rule — so the two cannot drift into disagreeing about what "no event name" is. // TOTAL, and STRICT about type: only an actual string is an event name. The old // inline expression threw on a truthy non-string, and hoisting it ahead of the // pi - hooks/gsd-cursor-post-tool.jsGitHub
- hooks/gsd-cursor-pre-tool.jsGitHub
- hooks/gsd-cursor-session-start.jsGitHub
- hooks/gsd-cursor-stop.jsGitHub
- hooks/gsd-cursor-subagent-start.jsGitHub
- hooks/gsd-cursor-subagent-stop.jsGitHub
- hooks/gsd-ensure-canonical-path.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // // gsd-ensure-canonical-path — SessionStart hook (#997) // // PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that // hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows, // templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand // environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them. // In a classic `bin/install.js` install the canonical path is a real directory // holding the bundled tree, so the includes resolve. In a Claude Code // *marketplace plugin* install the plugin manager only unpacks the package // into the version-pinned plugin cache and never runs `bin/install.js`, so // `~/.claude/gsd-core/` is never created and every @-include resolves to // nothing — every agent that depends on one fails (e.g. the executor). // // FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT // set and a bundled `gsd-core/` tree found beneath it), ensure // `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts, // references, templates, workflows) are symlinked to the plugin's bundled tree. // This changes ZERO @-references, is a no-op in classic installs (where each // subdir is already a real directory), preserves user-generated files // (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals // after `claude plugin update` rotates the version dir, and uses Windows // junctions for symlinks on win32. // // SECURITY: the resolved bundled-tree path and every per-subdir link target are // kept strictly inside the resolved plugin root (realpath-normalised, prefix- // checked). A real (non-symlink) file or directory already sitting at a managed // link target is NEVER clobbered. 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { allow } = require('./lib/hook-exit.js'); // Immutable, bundled subdirectories that the canonical path must expose. These // are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into. // User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are // NOT in this list and are never created, moved, or deleted by this hook. const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows']; /** * Resolve the canonical runtime config dir for the active runtime. * * Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors * gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The * canonical GSD tree always lives at `<configDir>/gsd-core`. */ function resolveConfigDir(homeDir, env) { const envDir = env.CLAUDE_CONFIG_DIR; if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) { return envDir; } return path.join(homeDir, '.claude'); } /** * Locate the bundled `gsd-core/` tree beneath a plugin root. * * Claude Code unpacks the package so the bundled tree sits at * `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to * that directory, or null if it is absent / not a directory. Resolving with * realpath collapses symlinks/.. so the subsequent containment check is sound. */ function resolveBundledTree(pluginRoot) { if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) { return null; } let root; try { root = fs.realpathSync(pluginRoot); } catch (_) { return null; // plugin root does not exist } const bundled = path.join(root, 'gsd-core'); let bundledReal; try { // The bundled tree must be a real directory (or a symlink to one) that // resolves to a path inside the plugin root. realpathSync throws ENOENT/ // ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence // check is needed. Reject anything that does not resolve to a directory. bundledReal = fs.realpathSync(bundled); if (!fs.statSync(bundledReal).isDirectory()) return null; } catch (_) { return null; } // SECURITY: the resolved bundled tree must stay inside the resolved plugin // root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root // is rejected — we never link the canonical path at content we do not own. const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) { return null; } return bundledReal; } /** * The fs.symlinkSync `type` to use for a directory link on a given platform. * * On Windows, unprivileged users cannot create symlinks but CAN create * junctions; 'junction' requires an absolute target (we always pass one). On * POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable * without a Windows host. */ function dirLinkType(platform) { return platform === 'win32' ? 'junction' : 'dir'; } /** * Create a directory symlink (junction on win32) from linkPath -> target. * Throws on real failure so the caller records it. */ function createDirLink(target, linkPath, platform) { fs.symlinkSync(target, linkPath, dirLinkType(platform)); } /** * Does `linkPath` already correctly point at `expectedTarget`? * Used to make the hook idempotent — a correct link is left untouched. */ function linkPointsAt(linkPath, expectedTarget) { try { if (!fs.lstatSync(linkPath).isSymbolicLink()) return false; const resolved = fs.realpathSync(linkPath); return resolved === fs.realpathSync(expectedTarget); } catch (_) { return false; } } /** * Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs. * * Pure, dependency-injected core so tests drive it with a fake home, fake * plugin root, and explicit platform. Returns a structured result describing * exactly what happened (never throws for ordinary conditions — only truly * unexpected I/O errors propagate, and the thin CLI wrapper swallows those so * a hook failure never blocks a session). * * - hooks/gsd-graphify-update.shGitHub
- hooks/gsd-node-runner.shGitHub
- hooks/gsd-phase-boundary.shGitHub
- hooks/gsd-prompt-guard.jsRunsGitHub
Read the script
#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // GSD Prompt Injection Guard — PreToolUse hook // Scans file content being written to .planning/ for prompt injection patterns. // Defense-in-depth: catches injected instructions before they enter agent context. // // Triggers on: Write and Edit tool calls targeting .planning/ files // Action: Advisory warning (does not block) — logs detection for awareness // // Why advisory-only: Blocking would prevent legitimate workflow operations. // The goal is to surface suspicious content so the orchestrator can inspect it, // not to create false-positive deadlocks. const path = require('path'); const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js'); // This guard is advisory-only by design (see header) — it never blocks the // Write/Edit it scans, only adds context about it. A crash here must not // start blocking now, which is strictly worse than the advisory it exists // to add on top of an already-permitted operation (#3911). const ON_CRASH = HOOK_ON_CRASH.ALLOW; // Prompt injection patterns — shared with gsd-read-injection-scanner.js via // hooks/lib/injection-patterns.js so the two surfaces cannot drift (#3504). // Deliberately a subset of security.cjs's set: hooks stay loadable without the // compiled lib tree. Staging of the lib helper is allowlisted in // GSD_HOOK_LIB_FILES (bin/install.js). const { INJECTION_PATTERNS, describePattern } = require('./lib/injection-patterns.js'); // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]] // matcher is registered pre-translated (runtime-hooks-surface.cts // buildKimiHooksTomlBlock) — so without normalizing the payload too, the // matcher fires but the tool_name check below exits 0 and the guard is dormant // on Kimi. The tool_input field names differ as well (kimi-cli // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`, // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` — // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile') // names; unknown names fall through untouched. Inlined per guard (not // hooks/lib/): hook scripts are staged as standalone files, and a sibling // require is a staging dependency that can fail silently. // A Map, not an object literal: bare bracket lookup resolves prototype keys // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the // !mapped fall-through never fires for them; Map.get returns undefined (same // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts). const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]); function normalizeKimiPayload(data) { // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive // payloads reached the `data.tool_name` read below and threw — falsifying // this function's own "total over the inputs JSON can express" claim, which // property (e) now tests directly. Harmless in practice (a null payload has // nothing to guard, and the throw landed in the same fail-open catch as the // exit-0 it now takes deliberately) but the claim should be true as stated. if (data === null || typeof data !== 'object') return data; const raw = data.tool_name; if (typeof raw !== 'string') return data; const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1)); if (!mapped) return data; data.tool_name = mapped; if (data.tool_response === undefined && data.tool_output !== undefined) { data.tool_response = data.tool_output; } const input = data.tool_input; if (input && typeof input === 'object') { // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright, // not merely fill in when `file_path` happens to be absent. kimi-cli's file // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py, // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the // model's raw json-parsed // arguments to PreToolUse verbatim, doing typed validation only later inside // tool.call() — after the hook has already decided. So a `file_path` in a // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined` // condition it SHADOWED the field kimi-cli actually executes on. A payload // pairing a cross-root `path` with a spurious `file_path: ""` left every // guard reading an empty string and exiting 0, while the identical write // without the extra key blocked — a bypass needing no crash at all. The same // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer // `catch { process.exit(0) }`: the same crash-to-allow this fix closes // elsewhere, reached through the guard's own read rather than through // normalization. Overwriting can only ever narrow what a guard inspects to // the path that will actually be written, so it cannot under-block. if (typeof input.path === 'string') { input.file_path = input.path; } const edits = Array.isArray(input.edit) ? input.edit : (input.edit && typeof input.edit === 'object') ? [input.edit] : []; if (edits.length) { // #2547: `e?.old`, not `e.old` — `??` guards the value, not the // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError // here. normalizeKimiPayload runs before any tool dispatch, so that throw // reached each guard's outer `catch { process.exit(0) }` and silently // downgraded a should-BLOCK call into an allow. (A string/number entry // never threw — `('x').old` is a legal read yielding undefined.) // // The String() coercion is guarded for the same reason: `{"toString - hooks/gsd-read-guard.jsRunsGitHub
- hooks/gsd-read-injection-scanner.jsRunsGitHub
- hooks/gsd-secret-read-guard.jsRunsGitHub
- hooks/gsd-session-state.shGitHub
- hooks/gsd-statusline.jsGitHub
- hooks/gsd-update-banner.jsGitHub
- hooks/gsd-validate-commit.shGitHub
- hooks/gsd-windsurf-pre-command.jsGitHub
- hooks/gsd-windsurf-pre-write.jsGitHub
- hooks/gsd-workflow-guard.jsGitHub
- hooks/gsd-worktree-path-guard.jsRunsGitHub
- hooks/gsd-write-guard.jsRunsGitHub
- hooks/managed-hooks-registry.cjsGitHub
All 29 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Git. Ship. Done. A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.
Repo: open-gsd/gsd-core

