#!/usr/bin/env node // msd-hook-version: {{MSD_VERSION}} // MSD Workflow Guard — PreToolUse hook // Detects when Claude attempts file edits outside a MSD workflow context // (no active /msd- skill or Task subagent) and injects an advisory warning. // // This is a SOFT guard for edits — it advises, not blocks. The edit still // proceeds. The warning nudges Claude to use /msd:quick or /msd:fast instead // of making direct edits that bypass state tracking. // // ONE hard block lives here: `git add -f` on an agent/worktree-agent branch // (WORKTREE_AGENT_FORCE_ADD_FORBIDDEN) — and that block leg fails CLOSED on // internal error (#3504): when the guard is enabled and the blocking context // holds, a thrown error exits 2 (block), not 0. The advisory legs keep the // fail-open posture — a broken advisory must never wedge every tool call. // // Enable via config: hooks.workflow_guard: true (default: false) // Only triggers on Write/Edit tool calls to non-.planning/ files. const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { tokenize, skipToSubcommand } = require('./lib/git-cmd.js'); const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js'); const { reportIfUndetermined } = require('./lib/git-probe.js'); // This guard is almost entirely advisory (fail open — a broken advisory must // never wedge every tool call), with ONE hard block (#3504 force-add-on- // agent-branch) that fails CLOSED on internal error instead. That split is // re-derived dynamically inside the outer catch (failClosedBlockContext), not // a fixed per-hook policy, so ON_CRASH names only the FINAL, unconditional // fallback reached when the fail-closed context does not apply — i.e. the // historical exit(0). Declared ONCE here so that fallback states its policy // explicitly rather than inheriting a default (#3911). const ON_CRASH = HOOK_ON_CRASH.ALLOW; function forceGitAddCwds(command, defaultCwd) { const tokens = tokenize(command || ''); const separators = new Set(['&&', '||', ';', '|']); const cwdList = []; // #3504: per-segment walk driven by git-cmd.js's canonical skipToSubcommand. // The previous inline walk knew six global flags and silently missed the // rest — `git -c core.hooksPath=/tmp/x add -f x`, `git --no-optional-locks // add -f x`, `--literal-pathspecs`, `--namespace=…` and friends all fell // through to a silent exit 0, a no-crash bypass the fail-closed catch is // structurally blind to (it only fires on throws). Sharing the classifier // makes the block's flag knowledge exactly the classifier's. const segments = []; let start = 0; for (let i = 0; i <= tokens.length; i++) { if (i === tokens.length || separators.has(tokens[i])) { if (i > start) segments.push(tokens.slice(start, i)); start = i + 1; } } for (const seg of segments) { const subIdx = skipToSubcommand(seg); if (subIdx === -1 || subIdx >= seg.length || seg[subIdx] !== 'add') continue; // Resolve a `-C ` (separate-arg form) preceding the subcommand so // the branch is probed at the repository the add targets. let gitCwd = defaultCwd; for (let k = 0; k < subIdx; ) { if (seg[k] === '-C' && k + 1 < subIdx) { gitCwd = path.resolve(gitCwd, seg[k + 1]); k += 2; continue; } k++; } for (let k = subIdx + 1; k < seg.length; k++) { if (seg[k] === '--') break; if (seg[k] === '--force' || seg[k] === '-f' || /^-[A-Za-z]*f[A-Za-z]*$/.test(seg[k])) { cwdList.push(gitCwd); break; } } } return cwdList; } function currentBranch(cwd) { const result = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, // #3504: bounded — this ran unbounded before, an indefinite hang under a // wedged git would hang every PreToolUse call. Host wiring allows a 5s // budget for the whole hook, so the probe gets 2s of it; a timeout // returns '' (branch unknown), which both the block decision and the // fail-closed re-check treat as "cannot establish agent branch". timeout: 2000, killSignal: 'SIGTERM', }); // #3911: a timeout/spawn-failure here previously degraded to '' exactly // like a clean "not on a branch" answer — indistinguishable from the // caller's point of view. reportIfUndetermined is a no-op on a genuine // negative and only emits a diagnostic when the probe itself could not // run; the '' fallback (and therefore this hook's exit code) is unchanged. reportIfUndetermined('msd-workflow-guard', 'git branch --show-current', result); if (result.status !== 0) return ''; return result.stdout.trim(); } // Agent-branch predicate, shared by the happy-path detector and the fail-closed // re-check so the block's scope cannot drift between the two paths (#3504). function isAgentBranch(branch) { return /^(worktree-)?agent-/.test(branch); } // Typed cwd read, shared by both paths for the same reason: a non-string cwd // degrades to process.cwd() instead of throwing inside path.join(). function payloadCwd(data) { return typeof data?.cwd === 'string' && data.cwd ? data.cwd : process.cwd(); } // The force-add block payload, emitted by the happy-path detector and the // fail-closed catch — one source so the two exits cannot drift. `origin` // distinguishes them structurally ('force-add-detected' vs 'fail-closed') so a // consumer — or the model reading stderr feedback — is never told a // force-add was detected when the call was in fact blocked unanalyzed. function emitForceAddBlock(origin) { const failClosed = origin === 'fail-closed'; const reason = failClosed ? 'workflow guard internal error on an agent branch - failing closed. The command was NOT analyzed and was NOT confirmed to be a force-add. Retry the call or inspect the guard.' : 'agent/worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.'; const output = { decision: 'block', code: 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN', origin: failClosed ? 'fail-closed' : 'force-add-detected', reason, }; // A native exit-2 hook bus may feed stderr back to the model (#2304) — deny's // stderrPayload keeps fd 2 to the plain reason string, matching the // pre-migration two-write byte-for-byte. deny(output, output.reason); } // #3504 fail-closed context re-derivation. Runs INSIDE the outer catch, after // an internal error, and answers exactly one question: does the blocking // context of the force-add guard hold for this payload? Each stage is guarded — // a re-derivation that itself throws must degrade to "cannot establish" (false), // never take down the catch. Deliberately does NOT re-detect the force-add: the // error may live in the detector itself, and on an agent branch with the guard // enabled, a Bash call under an internal error is conservative-correct to block. // Anything it cannot establish (unparseable payload, non-Bash tool, guard // disabled, branch not determinably agent-*) fails open, preserving the // advisory legs' fail-open posture. function failClosedBlockContext(rawInput) { let data; try { data = JSON.parse(rawInput); } catch { return false; } if (data === null || typeof data !== 'object' || data.tool_name !== 'Bash') return false; const cwd = payloadCwd(data); let enabled; try { enabled = workflowGuardEnabled(cwd); } catch { return false; } if (!enabled) return false; let branch; try { branch = currentBranch(cwd); } catch { return false; } return isAgentBranch(branch); } function workflowGuardEnabled(cwd) { const configPath = path.join(cwd, '.planning', 'config.json'); if (!fs.existsSync(configPath)) return false; try { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); return Boolean(config.hooks?.workflow_guard); } catch (e) { return false; } } let input = ''; const stdinTimeout = setTimeout(() => allow(undefined), 3000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { const data = JSON.parse(input); // #3504 test-only fault seam: throws right after parse so the fail-closed // posture of the outer catch is exercisable — no JSON-expressible input // throws in this handler today (#2547/#2595 hardened every read). Gated on // MSD_TEST_MODE as well so a leaked MSD_TEST_WORKFLOW_GUARD_FAULT in a real // shell cannot wedge a production session. The fault's failure direction // is CLOSED: the catch below may block, never bypass a block. if (process.env.MSD_TEST_MODE === '1' && process.env.MSD_TEST_WORKFLOW_GUARD_FAULT === '1') { throw new Error('MSD_TEST_WORKFLOW_GUARD_FAULT: injected fault'); } const toolName = data.tool_name; const cwd = data.cwd || process.cwd(); const isWorkflowGuardEnabled = workflowGuardEnabled(cwd); if (toolName === 'Bash') { if (!isWorkflowGuardEnabled) { allow(undefined); } const command = data.tool_input?.command || ''; for (const gitCwd of forceGitAddCwds(command, cwd)) { const branch = currentBranch(gitCwd); if (/^(worktree-)?agent-/.test(branch)) { emitForceAddBlock(); } } allow(undefined); } // Only guard Write, Edit, and MultiEdit tool calls if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) { allow(undefined); } // Check if we're inside a MSD workflow (Task subagent or /msd- skill) // Subagents have a session_id that differs from the parent // and typically have a description field set by the orchestrator if (data.tool_input?.is_subagent || data.session_type === 'task') { allow(undefined); } // Check the file being edited // #2595 (review Major 3, sibling sweep): typed read on BOTH fields. The // `&& value` keeps the original truthiness fallback intact — an empty // file_path must still fall through to `path`, which a bare typeof test // would have broken. const filePath = (typeof data.tool_input?.file_path === 'string' && data.tool_input.file_path) || (typeof data.tool_input?.path === 'string' && data.tool_input.path) || ''; // Allow edits to .planning/ files (MSD state management) if (filePath.includes('.planning/') || filePath.includes('.planning\\')) { allow(undefined); } // Allow edits to common config/docs files that don't need MSD tracking const allowedPatterns = [ /\.gitignore$/, /\.env/, /CLAUDE\.md$/, /AGENTS\.md$/, /GEMINI\.md$/, /settings\.json$/, ]; if (allowedPatterns.some(p => p.test(filePath))) { allow(undefined); } if (!isWorkflowGuardEnabled) { allow(undefined); // Guard disabled (default) or no MSD project } // If we get here: MSD project, guard enabled, file edit outside .planning/, // not in a subagent context. Inject advisory warning. const output = { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: `⚠️ WORKFLOW ADVISORY: You're editing ${path.basename(filePath)} directly without a MSD command. ` + 'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' + 'Consider using /msd:fast for trivial fixes or /msd:quick for larger changes ' + 'to maintain project state tracking. ' + 'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.', code: 'WORKFLOW_ADVISORY' } }; process.stdout.write(JSON.stringify(output)); } catch { // #3504: split posture on internal error. The ONE hard block in this hook // (force-add on agent branches) fails CLOSED — if the blocking context can // be re-derived from the payload (Bash tool + guard enabled + determinably // an agent branch), deny rather than silently allowing. Everything else // keeps the historical fail-open posture: a broken advisory guard must // never wedge the session's tool calls. ON_CRASH is declared ALLOW at // module top for exactly that unconditional fallback (#3911). if (failClosedBlockContext(input)) { emitForceAddBlock('fail-closed'); } crash(ON_CRASH, undefined); } });