Files
msd-core/hooks/msd-workflow-guard.js
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

295 lines
12 KiB
JavaScript

#!/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 <dir>` (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);
}
});