feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
|
|
@ -22,7 +22,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"matcher": "WebSearch|WebFetch|Task",
|
||||
"matcher": "WebSearch|WebFetch|Task|Agent",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
|
@ -30,6 +30,16 @@
|
|||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-agent-cap.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-plan-gate.mjs"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
|
|
@ -53,6 +63,26 @@
|
|||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-bash-stats.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash|ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-approval-log.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-edit-check.mjs"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
|
|
@ -86,6 +116,15 @@
|
|||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/otel-export.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-test-lock.mjs"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
60
hooks/lib/approval-record.mjs
Normal file
60
hooks/lib/approval-record.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// hooks/lib/approval-record.mjs
|
||||
// What the person was asked. pre-bash-executor.mjs writes one record per
|
||||
// asked call, keyed by session_id + tool_use_id (both in PreToolUse and
|
||||
// PostToolUse input); post-approval-log.mjs takes it after the call ran and
|
||||
// logs exactly that -- never a re-reading of the command afterwards, which
|
||||
// can see another branch than the one the question named. No record = the
|
||||
// person was not asked = nothing is logged.
|
||||
//
|
||||
// Records live in the OS temp dir (voyage-approvals-<uid>/), are removed when taken,
|
||||
// and anything older than a day is swept on the next write.
|
||||
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// One directory per user: another user's directory in a shared /tmp is never ours.
|
||||
const DIR = join(tmpdir(), `voyage-approvals-${process.getuid ? process.getuid() : 'u'}`);
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function pathFor(sessionId, toolUseId) {
|
||||
const key = createHash('sha256').update(`${sessionId || ''}\n${toolUseId}`).digest('hex').slice(0, 32);
|
||||
return join(DIR, `${key}.json`);
|
||||
}
|
||||
|
||||
function sweep() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
for (const f of readdirSync(DIR)) {
|
||||
const p = join(DIR, f);
|
||||
try { if (now - statSync(p).mtimeMs > MAX_AGE_MS) rmSync(p, { force: true }); } catch { /* raced */ }
|
||||
}
|
||||
} catch { /* no dir yet */ }
|
||||
}
|
||||
|
||||
/** @returns {boolean} written */
|
||||
export function writeApprovalRecord(sessionId, toolUseId, record) {
|
||||
if (!toolUseId) return false;
|
||||
try {
|
||||
mkdirSync(DIR, { recursive: true });
|
||||
sweep();
|
||||
writeFileSync(pathFor(sessionId, toolUseId), JSON.stringify(record));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and remove the record; null when there is none. */
|
||||
export function takeApprovalRecord(sessionId, toolUseId) {
|
||||
if (!toolUseId) return null;
|
||||
const p = pathFor(sessionId, toolUseId);
|
||||
try {
|
||||
const rec = JSON.parse(readFileSync(p, 'utf8'));
|
||||
rmSync(p, { force: true });
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
130
hooks/lib/bash-view.mjs
Normal file
130
hooks/lib/bash-view.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// hooks/lib/bash-view.mjs
|
||||
// How the Bash guards read a command. Moved out of pre-bash-executor.mjs so
|
||||
// the guard that asks before a call (pre-bash-executor.mjs) and the hook that
|
||||
// logs the approval after it (post-approval-log.mjs) read a command the same
|
||||
// way. Behavior unchanged from the inline copy.
|
||||
//
|
||||
// Bash normalization from llm-security/scanners/lib/bash-normalize.mjs (MIT,
|
||||
// same author); lib/parsers/bash-normalize.mjs keeps the test-suite copy.
|
||||
|
||||
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
export function normalizeBashExpansion(cmd) {
|
||||
if (!cmd || typeof cmd !== 'string') return cmd || '';
|
||||
|
||||
let result = cmd
|
||||
// Strip empty single quotes: w''get -> wget
|
||||
.replace(/''/g, '')
|
||||
// Strip empty double quotes: r""m -> rm
|
||||
.replace(/""/g, '')
|
||||
// Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x)
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
// Multi-char ${ANYTHING} -> '' (unknown value, strip entirely)
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
// Strip backtick subshell with empty/whitespace content
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
// Iteratively strip backslash between word chars (c\u\r\l needs 2 passes)
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Strip ANSI, collapse whitespace.
|
||||
export function normalizeCommand(cmd) {
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command-position view -- for rules that must distinguish a command from data
|
||||
// that merely names one. Whitespace is NOT collapsed, so newline stays a
|
||||
// separator. Quoted spans and heredoc bodies become data; the argument of a
|
||||
// shell wrapper stays a command; backslash-escaped names are seen through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `out` ends with the `-c` of a shell invocation -- the next quoted span is a
|
||||
// command string, not data. Optional leading `sudo` and absolute path.
|
||||
const SHELL_C_TAIL =
|
||||
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
|
||||
|
||||
// Drop heredoc bodies, keeping the operator line. Their newlines are not
|
||||
// command separators, and without this every heredoc line that happens to
|
||||
// start with a matched word reads as command position. Runs before the quote
|
||||
// scan, since a body may contain quotes that would desync it.
|
||||
export function stripHeredocBodies(cmd) {
|
||||
return cmd.replace(
|
||||
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
|
||||
(_match, head) => head,
|
||||
);
|
||||
}
|
||||
|
||||
// `out` ends with `eval` -- like `sh -c`, its quoted argument is run as a command.
|
||||
const EVAL_TAIL = /(?:^|[\s;|&])eval\s*$/;
|
||||
|
||||
// A quoted span that is one plain word ("push", 'git', "main").
|
||||
const PLAIN_WORD = /^[^\s'"`$;|&()<>{}\\]+$/;
|
||||
|
||||
// opts.keepWords: a quoted plain word stays that word (`git "push"` reads as
|
||||
// `git push`); without it every quoted span is data, as the BLOCK rules have
|
||||
// always read it. Quoted text with spaces is data either way.
|
||||
export function commandPositionView(cmd, opts = {}) {
|
||||
const src = stripHeredocBodies(cmd);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === '\\' && i + 1 < src.length) {
|
||||
// An escaped character is literal: `don\'t` opens no quote, and an
|
||||
// escaped space stays inside its word (so `a\ #b` starts no comment).
|
||||
// A backslash-newline continues the line.
|
||||
const next = src[i + 1];
|
||||
out += next === '\n' ? ' ' : /\s/.test(next) ? '_' : next;
|
||||
i += 2;
|
||||
} else if (ch === '#' && (out === '' || /[\s;|&(]$/.test(out))) {
|
||||
// A comment runs to the end of the line; its apostrophes are not quotes.
|
||||
const nl = src.indexOf('\n', i);
|
||||
i = nl === -1 ? src.length : nl;
|
||||
} else if (ch === "'" || ch === '"') {
|
||||
// Inside double quotes a backslash escapes the next character (`"a \" b"`
|
||||
// is one span); inside single quotes nothing is escaped.
|
||||
let close = i + 1;
|
||||
while (close < src.length && src[close] !== ch) close += ch === '"' && src[close] === '\\' ? 2 : 1;
|
||||
if (close >= src.length) close = -1;
|
||||
if (close === -1) {
|
||||
// Unterminated quote: the shell would not run this as written, so
|
||||
// read the rest as code rather than hide it as data.
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const inner = src.slice(i + 1, close);
|
||||
if (SHELL_C_TAIL.test(out) || EVAL_TAIL.test(out)) out += `;${inner};`;
|
||||
else out += opts.keepWords && PLAIN_WORD.test(inner) ? inner : ' ';
|
||||
i = close + 1;
|
||||
} else {
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return (
|
||||
out
|
||||
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
|
||||
// in front of it. Flags taking a separate argument (`-I {}`) are not
|
||||
// parsed -- the separator lands before the argument, not the command.
|
||||
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
|
||||
// `\name` runs name -- the backslash only suppresses alias expansion.
|
||||
// normalizeBashExpansion covers the between-word-chars case; this covers
|
||||
// a backslash at command position.
|
||||
.replace(/\\(\w)/g, '$1')
|
||||
);
|
||||
}
|
||||
|
||||
// The command-position view of a raw command string, as the guards build it.
|
||||
export function viewOf(command, opts = {}) {
|
||||
return commandPositionView(normalizeBashExpansion(command).replace(/\x1B\[[0-9;]*m/g, ''), opts);
|
||||
}
|
||||
39
hooks/lib/contract-cli.mjs
Normal file
39
hooks/lib/contract-cli.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// hooks/lib/contract-cli.mjs
|
||||
// Runs a contract CLI that another wave-1 order owns (K2 approval log, K3 plan
|
||||
// gate, K5 arch rules) and maps its answer. A CLI that is not there yet, or
|
||||
// that cannot run, is "not measured" / "not logged" -- never green.
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
export const CONTRACTS = {
|
||||
K2: join(PLUGIN_ROOT, 'lib', 'util', 'approval-log.mjs'),
|
||||
K3: join(PLUGIN_ROOT, 'lib', 'validators', 'plan-gate.mjs'),
|
||||
K5: join(PLUGIN_ROOT, 'lib', 'verification', 'arch-rules.mjs'),
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {{ ran: boolean, code: number|null, stdout: string, stderr: string, why?: string }}
|
||||
* ran=false when the CLI file is missing or could not be started.
|
||||
*/
|
||||
export function runContract(id, args, { cwd, timeout = 60_000 } = {}) {
|
||||
const cli = CONTRACTS[id];
|
||||
if (!existsSync(cli)) return { ran: false, code: null, stdout: '', stderr: '', why: `${id} CLI not present (${cli})` };
|
||||
// No shell: every argument is passed as-is, so no text is ever spliced into a command line.
|
||||
const r = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', timeout });
|
||||
if (r.error || r.status === null) {
|
||||
return { ran: false, code: null, stdout: r.stdout || '', stderr: r.stderr || '', why: `${id} could not run (${r.error?.message || 'signal'})` };
|
||||
}
|
||||
return { ran: true, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
// A Voyage plan carries `plan_version` in its leading frontmatter block.
|
||||
export function isVoyagePlan(text) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
return !!m && /^plan_version\s*:/m.test(m[1]);
|
||||
}
|
||||
382
hooks/lib/git-approval.mjs
Normal file
382
hooks/lib/git-approval.mjs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
// hooks/lib/git-approval.mjs
|
||||
// P12 -- which shell commands need a person's approval before they run, and
|
||||
// which of those approvals go into the approval log (contract K2).
|
||||
//
|
||||
// The four classes (merge into main, push, tag/release, destructive git) are
|
||||
// Superpowers' (MIT, `8ca22db`); "a block names the way forward" is
|
||||
// agent-skills' (MIT, `9d0c60d`); guard + log is ai-native-sdlc's (MIT,
|
||||
// `11fb8d7`). Ideas only; no code is copied.
|
||||
//
|
||||
// Log kinds (operator decision 06.10): merge into main and push to main are
|
||||
// logged as `merge`; a tag, a pushed tag and a package publish as `release`.
|
||||
// A push of another branch and destructive git are asked about but not logged
|
||||
// (kind null): K2 has no kind for them, and the contract stays as it is.
|
||||
//
|
||||
// Read on the command-position view (bash-view.mjs), so a quoted commit
|
||||
// message or an `echo` that names `git push` is data, not a command. The
|
||||
// command is followed segment by segment: `cd <dir>`, `git -C <dir>` and
|
||||
// `git checkout|switch <branch>` change which repo and branch the next git
|
||||
// call acts on. Not seen: git aliases (`git p`), commands assembled in
|
||||
// variables, and `git worktree remove --force` (routine executor cleanup,
|
||||
// Hard Rule 12).
|
||||
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { viewOf } from './bash-view.mjs';
|
||||
|
||||
// How long a logged P12 approval stays valid (K2 `--expires`).
|
||||
export const APPROVAL_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const MAIN = /^(?:main|master)$/;
|
||||
|
||||
// Wrapper programs that run the command after them, with the options of each
|
||||
// that take a separate value.
|
||||
const WRAPPERS = {
|
||||
sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T'],
|
||||
env: ['-u', '-S', '-C', '--unset', '--chdir', '--split-string'],
|
||||
timeout: ['-s', '-k', '--signal', '--kill-after'],
|
||||
nice: ['-n', '--adjustment'],
|
||||
time: ['-f', '-o', '--format', '--output'],
|
||||
exec: ['-a'],
|
||||
xargs: ['-n', '-I', '-L', '-P', '-d', '-E', '-s', '-a', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace'],
|
||||
stdbuf: ['-i', '-o', '-e'],
|
||||
command: [], builtin: [], nohup: [], eval: [], then: [], do: [], else: [], '!': [],
|
||||
if: [], elif: [], while: [], until: [],
|
||||
};
|
||||
|
||||
// git options that take a separate value before the subcommand.
|
||||
const GIT_OPTS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
|
||||
|
||||
const base = (p) => String(p || '').split('/').pop();
|
||||
|
||||
// Redirections are not arguments: `git checkout main 2>/dev/null` names one
|
||||
// branch, not a branch and a path. An operator standing alone takes the next
|
||||
// token as its target.
|
||||
function dropRedirects(tokens) {
|
||||
const out = [];
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const t = tokens[i];
|
||||
if (/^\d*(?:>>?|<<?|>\|)$/.test(t)) { i += 1; continue; }
|
||||
if (/^\d*(?:>>?|<<?)\S/.test(t)) continue;
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function segments(view) {
|
||||
return view
|
||||
// `2>&1`, `>&2`, `&>file` would otherwise split at their `&`.
|
||||
.replace(/\d*>&\d*-?/g, ' ')
|
||||
.replace(/&>>?/g, ' > ')
|
||||
.split(/\$\(|[\n;|&()`{}]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => dropRedirects(s.split(/\s+/)))
|
||||
.filter((t) => t.length);
|
||||
}
|
||||
|
||||
// Drop env assignments and wrapper words (with their options); return the
|
||||
// tokens from the real program on.
|
||||
function programTokens(tokens) {
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || /^\d+$/.test(t)) { i += 1; continue; }
|
||||
const w = WRAPPERS[base(t)];
|
||||
if (!w) break;
|
||||
const name = base(t);
|
||||
i += 1;
|
||||
while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') {
|
||||
i += w.includes(tokens[i]) ? 2 : 1;
|
||||
}
|
||||
if (name === 'timeout' && i < tokens.length) i += 1; // the duration
|
||||
}
|
||||
return tokens.slice(i);
|
||||
}
|
||||
|
||||
// For `git [global opts] <sub> <args...>`, return { sub, args, dir }.
|
||||
function gitCall(tokens) {
|
||||
if (base(tokens[0]) !== 'git') return null;
|
||||
let i = 1;
|
||||
let dir = null;
|
||||
while (i < tokens.length && tokens[i].startsWith('-')) {
|
||||
const t = tokens[i];
|
||||
if (t === '-C' && tokens[i + 1]) dir = dir ? join(dir, tokens[i + 1]) : tokens[i + 1];
|
||||
i += GIT_OPTS_WITH_VALUE.has(t) ? 2 : 1;
|
||||
}
|
||||
if (i >= tokens.length) return null;
|
||||
return { sub: tokens[i], args: tokens.slice(i + 1), dir };
|
||||
}
|
||||
|
||||
const hasFlag = (args, ...flags) => args.some((a) => flags.includes(a));
|
||||
// A short-flag cluster such as -fdx contains `ch`.
|
||||
const shortHas = (args, ch) => args.some((a) => /^-[A-Za-z]+$/.test(a) && a.includes(ch));
|
||||
const positional = (args) => args.filter((a) => !a.startsWith('-'));
|
||||
// Positionals, skipping the values of the options that take one.
|
||||
function positionalSkipping(args, valued) {
|
||||
const out = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (valued.includes(args[i])) { i += 1; continue; }
|
||||
if (!args[i].startsWith('-')) out.push(args[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const PUSH_VALUED = ['-o', '--push-option', '--repo', '--receive-pack', '--exec'];
|
||||
|
||||
const WAY = {
|
||||
merge: 'approve to merge now; deny and Claude stops here. A merge into main is recorded in the approval log.',
|
||||
push: 'approve to push now; deny and Claude stops here. A push to main is recorded in the approval log.',
|
||||
release: 'approve to tag or publish now; deny and Claude stops here. The release is recorded in the approval log.',
|
||||
destructive: 'approve only if losing that work is intended; deny and Claude stops here. Nothing is logged.',
|
||||
};
|
||||
|
||||
const destructive = (name) => ({ cls: 'destructive', name, kind: null });
|
||||
|
||||
function classifyPush(args, ctx) {
|
||||
if (hasFlag(args, '-n', '--dry-run') || shortHas(args, 'n')) return [];
|
||||
const tags = hasFlag(args, '--tags', '--follow-tags');
|
||||
const del = hasFlag(args, '--delete', '-d');
|
||||
const force = hasFlag(args, '--force', '--force-with-lease', '--mirror') ||
|
||||
args.some((a) => a.startsWith('--force-with-lease=') || a.startsWith('+')) || shortHas(args, 'f');
|
||||
const refspecs = positionalSkipping(args, PUSH_VALUED).slice(1); // the first positional is the remote
|
||||
const out = [];
|
||||
let toMain = false;
|
||||
let other = false;
|
||||
let tagPush = tags;
|
||||
for (const r of refspecs) {
|
||||
if (del || r.startsWith(':')) { out.push(destructive('delete a remote branch or tag')); continue; }
|
||||
const dst = (r.includes(':') ? r.split(':').pop() : r).replace(/^\+/, '');
|
||||
const name = dst.replace(/^refs\/heads\//, '');
|
||||
if (dst.startsWith('refs/tags/') || (!dst.startsWith('refs/') && tagExists(ctx.cwd, name) && !MAIN.test(name))) tagPush = true;
|
||||
else if (MAIN.test(name) || (name === 'HEAD' && MAIN.test(ctx.branch || ''))) toMain = true;
|
||||
else other = true;
|
||||
}
|
||||
if (refspecs.length === 0 && !tags) {
|
||||
if (MAIN.test(ctx.branch || '')) toMain = true;
|
||||
else other = true;
|
||||
}
|
||||
if (toMain) out.push({ cls: 'push', name: force ? 'force push to main' : 'push to main', kind: 'merge' });
|
||||
if (other) out.push({ cls: 'push', name: force ? 'force push' : 'push', kind: null });
|
||||
if (tagPush) out.push({ cls: 'release', name: 'push of tags', kind: 'release' });
|
||||
return out;
|
||||
}
|
||||
|
||||
function classifyGit(call, ctx) {
|
||||
const { sub, args } = call;
|
||||
switch (sub) {
|
||||
case 'push':
|
||||
return classifyPush(args, ctx);
|
||||
case 'merge':
|
||||
if (hasFlag(args, '--abort', '--continue', '--quit')) return [];
|
||||
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge' }] : [];
|
||||
case 'tag': {
|
||||
if (hasFlag(args, '-l', '--list', '-v', '--verify') || shortHas(args, 'l')) return [];
|
||||
if (hasFlag(args, '-d', '--delete')) return [{ cls: 'release', name: 'delete a tag', kind: null }];
|
||||
// Options that take a value: their value is not a tag name.
|
||||
const valued = new Set(['-m', '-F', '-u', '--message', '--file', '--local-user', '--sort', '--contains', '--no-contains', '--points-at', '--merged', '--no-merged', '--format', '--cleanup']);
|
||||
const names = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (valued.has(args[i])) { i += 1; continue; }
|
||||
if (!args[i].startsWith('-')) names.push(args[i]);
|
||||
}
|
||||
return names.length > 0 ? [{ cls: 'release', name: 'create a tag', kind: 'release' }] : [];
|
||||
}
|
||||
case 'reset':
|
||||
return hasFlag(args, '--hard') ? [destructive('hard reset')] : [];
|
||||
case 'clean':
|
||||
if (hasFlag(args, '-n', '--dry-run') || shortHas(args, 'n')) return [];
|
||||
return hasFlag(args, '--force') || shortHas(args, 'f') ? [destructive('clean with force')] : [];
|
||||
case 'branch': {
|
||||
const del = hasFlag(args, '-d', '--delete') || shortHas(args, 'd');
|
||||
const force = hasFlag(args, '-f', '--force') || shortHas(args, 'f');
|
||||
if (hasFlag(args, '-D') || shortHas(args, 'D') || (del && force)) return [destructive('force-delete a branch')];
|
||||
if (hasFlag(args, '-M') || shortHas(args, 'M')) return [destructive('force-rename a branch')];
|
||||
return [];
|
||||
}
|
||||
case 'stash':
|
||||
return args[0] === 'drop' || args[0] === 'clear' ? [destructive(`stash ${args[0]}`)] : [];
|
||||
case 'checkout': {
|
||||
if (hasFlag(args, '-b', '-B', '--orphan')) return [];
|
||||
const pos = positional(args);
|
||||
const discard = hasFlag(args, '--', '-f', '--force') || pos.includes('.') || pos.length >= 2;
|
||||
return discard ? [destructive('discard working-tree changes')] : [];
|
||||
}
|
||||
case 'switch':
|
||||
return hasFlag(args, '-f', '--force', '--discard-changes') ? [destructive('discard working-tree changes')] : [];
|
||||
case 'restore': {
|
||||
const staged = hasFlag(args, '--staged', '-S');
|
||||
const worktree = hasFlag(args, '--worktree', '-W');
|
||||
return !staged || worktree ? [destructive('discard working-tree changes')] : [];
|
||||
}
|
||||
case 'filter-branch':
|
||||
case 'filter-repo':
|
||||
return [destructive('history rewrite')];
|
||||
case 'update-ref':
|
||||
return hasFlag(args, '-d') ? [destructive('delete a ref')] : [];
|
||||
case 'reflog':
|
||||
return args[0] === 'expire' || args[0] === 'delete' ? [destructive('reflog expiry')] : [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// The branch a checkout/switch leaves the repo on, or undefined if it does not move.
|
||||
function branchAfter(call) {
|
||||
const { sub, args } = call;
|
||||
if (sub !== 'checkout' && sub !== 'switch') return undefined;
|
||||
if (hasFlag(args, '--detach') || (sub === 'switch' && hasFlag(args, '-d'))) return null; // detached: no branch
|
||||
const create = sub === 'checkout' ? ['-b', '-B', '--orphan'] : ['-c', '-C', '--create', '--force-create', '--orphan'];
|
||||
for (let i = 0; i < args.length; i += 1) if (create.includes(args[i]) && args[i + 1]) return args[i + 1];
|
||||
if (hasFlag(args, '--')) return undefined;
|
||||
const pos = positional(args);
|
||||
return pos.length === 1 && pos[0] !== '.' ? pos[0] : undefined;
|
||||
}
|
||||
|
||||
function classifyPublish(tokens) {
|
||||
let t = tokens;
|
||||
if (['npx', 'pnpx', 'bunx'].includes(base(t[0]))) t = t.slice(1);
|
||||
const prog = base(t[0]);
|
||||
if (['npm', 'pnpm', 'yarn', 'bun', 'cargo'].includes(prog)) {
|
||||
const rest = t.slice(1);
|
||||
// The subcommand is the first positional (option values skipped);
|
||||
// `yarn npm publish` is Yarn Berry's form.
|
||||
const pos = positionalSkipping(rest, ['--tag', '--registry', '--access', '--otp', '-w', '--workspace',
|
||||
'--filter', '--cwd', '-C', '--prefix', '--manifest-path', '--package', '-p', '--token']);
|
||||
const sub = prog === 'yarn' && pos[0] === 'npm' ? pos[1] : pos[0];
|
||||
if (sub === 'publish' && !hasFlag(rest, '--dry-run', '-n')) {
|
||||
return [{ cls: 'release', name: `${prog} publish`, kind: 'release' }];
|
||||
}
|
||||
}
|
||||
if (prog === 'gh' && t[1] === 'release' && t[2] === 'create') {
|
||||
return [{ cls: 'release', name: 'gh release create', kind: 'release' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const expandHome = (p) => (p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p);
|
||||
|
||||
/**
|
||||
* Which approvals a command needs.
|
||||
* @param {string} command raw Bash command
|
||||
* @param {{ cwd?: string, branch?: string|null }} ctx where the command runs; `branch`
|
||||
* overrides the branch read from `cwd` for the first segment (tests).
|
||||
* @returns {Array<{cls, name, kind: 'merge'|'release'|null, way, subject}>}
|
||||
* `subject` is the top of the repo the matched call acts on.
|
||||
*/
|
||||
export function classifyGitApproval(command, ctx = {}) {
|
||||
if (!command || typeof command !== 'string') return [];
|
||||
let cwd = resolve(ctx.cwd || process.cwd());
|
||||
let branch = ctx.branch !== undefined ? ctx.branch : currentBranch(cwd);
|
||||
const out = [];
|
||||
for (const seg of segments(viewOf(command, { keepWords: true }))) {
|
||||
const tokens = programTokens(seg);
|
||||
if (base(tokens[0]) === 'cd') {
|
||||
const to = tokens[1] && tokens[1] !== '-' ? tokens[1] : null;
|
||||
if (to) { cwd = resolve(cwd, expandHome(to)); branch = currentBranch(cwd); }
|
||||
continue;
|
||||
}
|
||||
const call = gitCall(tokens);
|
||||
if (call) {
|
||||
const callCwd = call.dir ? resolve(cwd, expandHome(call.dir)) : cwd;
|
||||
const callBranch = call.dir ? currentBranch(callCwd) : branch;
|
||||
const subject = repoRootOf(callCwd);
|
||||
for (const m of classifyGit(call, { branch: callBranch, cwd: callCwd })) out.push({ ...m, subject });
|
||||
const next = branchAfter(call);
|
||||
if (next !== undefined && !call.dir) branch = next;
|
||||
continue;
|
||||
}
|
||||
for (const m of classifyPublish(tokens)) out.push({ ...m, subject: repoRootOf(cwd) });
|
||||
}
|
||||
return out.map((m) => ({ ...m, way: WAY[m.cls] }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The repository a command runs in -- read from the filesystem, no git spawn.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findDotGit(start) {
|
||||
let dir = resolve(start);
|
||||
while (!existsSync(dir)) {
|
||||
const up = dirname(dir);
|
||||
if (up === dir) return null;
|
||||
dir = up;
|
||||
}
|
||||
for (;;) {
|
||||
const p = join(dir, '.git');
|
||||
if (existsSync(p)) return { root: dir, dotGit: p };
|
||||
const up = dirname(dir);
|
||||
if (up === dir) return null;
|
||||
dir = up;
|
||||
}
|
||||
}
|
||||
|
||||
function gitDirOf(found) {
|
||||
try {
|
||||
if (statSync(found.dotGit).isDirectory()) return found.dotGit;
|
||||
const m = readFileSync(found.dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
||||
if (!m) return null;
|
||||
return isAbsolute(m[1]) ? m[1] : resolve(found.root, m[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A worktree's git dir names the shared one in `commondir`; refs live there.
|
||||
function commonDirOf(gitDir) {
|
||||
try {
|
||||
const c = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
||||
return isAbsolute(c) ? c : resolve(gitDir, c);
|
||||
} catch {
|
||||
return gitDir;
|
||||
}
|
||||
}
|
||||
|
||||
// The checked-out branch of the repo at `cwd`, or null (detached, no repo).
|
||||
export function currentBranch(cwd) {
|
||||
try {
|
||||
const found = findDotGit(cwd || process.cwd());
|
||||
if (!found) return null;
|
||||
const gitDir = gitDirOf(found);
|
||||
if (!gitDir) return null;
|
||||
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
||||
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Does tag `name` exist in the repo at `cwd` (loose or packed)?
|
||||
export function tagExists(cwd, name) {
|
||||
if (!name || name.includes('..')) return false;
|
||||
try {
|
||||
const found = findDotGit(cwd || process.cwd());
|
||||
if (!found) return false;
|
||||
const gitDir = gitDirOf(found);
|
||||
if (!gitDir) return false;
|
||||
const common = commonDirOf(gitDir);
|
||||
if (existsSync(join(common, 'refs', 'tags', name))) return true;
|
||||
const packed = join(common, 'packed-refs');
|
||||
return existsSync(packed) && readFileSync(packed, 'utf8').split('\n').some((l) => l.endsWith(` refs/tags/${name}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The top of the repo at `cwd` (the approval's subject), or cwd itself.
|
||||
export function repoRootOf(cwd) {
|
||||
const found = findDotGit(cwd || process.cwd());
|
||||
return found ? found.root : resolve(cwd || process.cwd());
|
||||
}
|
||||
|
||||
// The prompt text for an ask. Names Voyage, the classes, and the way forward.
|
||||
export function askReason(matches) {
|
||||
const names = [...new Set(matches.map((m) => m.name))].join('; ');
|
||||
const ways = [...new Set(matches.map((m) => m.way))].join(' ');
|
||||
return (
|
||||
`Voyage asks before this command (P12 approval guard): ${names}. ` +
|
||||
'It changes something outside this working copy or throws work away, so a person decides. ' +
|
||||
`Way forward: ${ways}`
|
||||
);
|
||||
}
|
||||
76
hooks/lib/post-edit.mjs
Normal file
76
hooks/lib/post-edit.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// hooks/lib/post-edit.mjs
|
||||
// Shared by the post-edit guard (P7, hooks/scripts/post-edit-check.mjs) and
|
||||
// the step check (K9, lib/verification/step-check.mjs): when the guard is on,
|
||||
// and where its log lives.
|
||||
//
|
||||
// On (operator decision 06.10): while Voyage executes a plan in the repo -- a
|
||||
// `.claude/projects/*/progress.json` (or legacy `.trekexecute-progress-*.json`)
|
||||
// with status "in_progress" -- or when the repo has switched it on with
|
||||
// `.claude/voyage-guards.json` = { "post_edit_check": true }. Off everywhere
|
||||
// else, because the guard is wired for every session in every repo.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const OPT_IN_FILE = join('.claude', 'voyage-guards.json');
|
||||
|
||||
function readJson(p) {
|
||||
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function inProgress(p) {
|
||||
return readJson(p)?.status === 'in_progress';
|
||||
}
|
||||
|
||||
export function executingPlan(root) {
|
||||
const projects = join(root, '.claude', 'projects');
|
||||
try {
|
||||
for (const d of readdirSync(projects, { withFileTypes: true })) {
|
||||
if (d.isDirectory() && inProgress(join(projects, d.name, 'progress.json'))) return true;
|
||||
}
|
||||
} catch { /* no projects dir */ }
|
||||
try {
|
||||
for (const f of readdirSync(root)) {
|
||||
if (/^\.trekexecute-progress-.+\.json$/.test(f) && inProgress(join(root, f))) return true;
|
||||
}
|
||||
} catch { /* unreadable root */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
export function optedIn(root) {
|
||||
const p = join(root, OPT_IN_FILE);
|
||||
return existsSync(p) && readJson(p)?.post_edit_check === true;
|
||||
}
|
||||
|
||||
/** @returns {'plan'|'opt-in'|null} why the guard is on, or null when it is off */
|
||||
export function postEditActive(root) {
|
||||
if (optedIn(root)) return 'opt-in';
|
||||
if (executingPlan(root)) return 'plan';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function postEditLogPath(gitDir) {
|
||||
return join(gitDir, 'voyage', 'post-edit.jsonl');
|
||||
}
|
||||
|
||||
export function appendPostEditLog(gitDir, record) {
|
||||
const p = postEditLogPath(gitDir);
|
||||
mkdirSync(join(gitDir, 'voyage'), { recursive: true });
|
||||
appendFileSync(p, JSON.stringify(record) + '\n');
|
||||
return p;
|
||||
}
|
||||
|
||||
// The latest record per file (repo-relative path).
|
||||
export function latestPostEditRecords(gitDir) {
|
||||
const p = postEditLogPath(gitDir);
|
||||
const out = new Map();
|
||||
if (!existsSync(p)) return out;
|
||||
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const r = JSON.parse(line);
|
||||
if (r && typeof r.file === 'string') out.set(r.file, r);
|
||||
} catch { /* a torn line is skipped, never read as clean */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
89
hooks/scripts/post-approval-log.mjs
Normal file
89
hooks/scripts/post-approval-log.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: post-approval-log.mjs
|
||||
// Event: PostToolUse (Bash, ExitPlanMode)
|
||||
// Purpose: write an approval a person gave through Claude Code's prompt into
|
||||
// the approval log (contract K2, lib/util/approval-log.mjs).
|
||||
// - Bash: the call ran, so the ask from pre-bash-executor.mjs was approved.
|
||||
// What is logged is the record that hook wrote of what the person was
|
||||
// asked (hooks/lib/approval-record.mjs); no record, nothing logged.
|
||||
// Merge/push to main is logged as `merge`, tag/publish as `release`, each
|
||||
// with `--expires` (APPROVAL_TTL_MS). Push of other branches and
|
||||
// destructive git are not logged (operator decision 06.10). In
|
||||
// bypassPermissions mode nothing is logged: no person was asked.
|
||||
// sha256 binds the approval to the command and the commit it left HEAD on.
|
||||
// - ExitPlanMode: the person approved a plan. A Voyage plan (it carries
|
||||
// `plan_version`) is logged as `plan`, bound to the sha256 of the approved
|
||||
// text (`tool_response.plan`, hooks reference section ExitPlanMode).
|
||||
// The guard never blocks (PostToolUse cannot), and it is never silent about a
|
||||
// lost approval: when K2 is missing, refuses (1) or cannot run (2), Claude is
|
||||
// told "not logged".
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { APPROVAL_TTL_MS } from '../lib/git-approval.mjs';
|
||||
import { takeApprovalRecord } from '../lib/approval-record.mjs';
|
||||
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
|
||||
|
||||
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
function say(text) {
|
||||
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }));
|
||||
}
|
||||
|
||||
function headOf(cwd) {
|
||||
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 10_000 });
|
||||
return r.status === 0 ? r.stdout.trim() : '';
|
||||
}
|
||||
|
||||
function append(kind, subject, hash, cwd, expires) {
|
||||
const args = ['append', '--kind', kind, '--subject', subject, '--sha256', hash];
|
||||
if (expires) args.push('--expires', expires);
|
||||
const r = runContract('K2', args, { cwd });
|
||||
if (!r.ran) return `${kind} approval not logged: ${r.why}`;
|
||||
if (r.code === 0) return `${kind} approval logged${expires ? ` (expires ${expires})` : ''}`;
|
||||
const why = (r.stderr || r.stdout).trim().split('\n')[0] || `exit ${r.code}`;
|
||||
return `${kind} approval not logged: the approval log ${r.code === 1 ? 'refused it' : 'could not run'} (${why})`;
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cwd = input?.cwd || process.cwd();
|
||||
|
||||
if (input?.tool_name === 'ExitPlanMode') {
|
||||
const plan = input?.tool_response?.plan ?? input?.tool_input?.plan;
|
||||
if (!isVoyagePlan(plan)) process.exit(0);
|
||||
const subject = input?.tool_response?.filePath || input?.tool_input?.planFilePath || '(plan without a file path)';
|
||||
say(`[voyage] ${append('plan', subject, sha256(plan), cwd)}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (input?.tool_name === 'Bash') {
|
||||
// Log what the person was asked (pre-bash-executor.mjs wrote it), nothing else.
|
||||
const rec = takeApprovalRecord(input?.session_id, input?.tool_use_id);
|
||||
const matches = (rec?.matches || []).filter((m) => m.kind);
|
||||
if (matches.length === 0) process.exit(0);
|
||||
if (rec.permission_mode === 'bypassPermissions') {
|
||||
say('[voyage] approval not logged: the session runs in bypassPermissions mode, so no person was asked.');
|
||||
process.exit(0);
|
||||
}
|
||||
const expires = new Date(Date.now() + APPROVAL_TTL_MS).toISOString();
|
||||
const seen = new Set();
|
||||
const notes = [];
|
||||
for (const m of matches) {
|
||||
const key = `${m.kind}\n${m.subject}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const subject = m.subject || cwd;
|
||||
const hash = sha256(`${rec.command}\n${headOf(subject)}`);
|
||||
notes.push(append(m.kind, subject, hash, cwd, expires));
|
||||
}
|
||||
say(`[voyage] ${notes.join('; ')}.`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
90
hooks/scripts/post-edit-check.mjs
Normal file
90
hooks/scripts/post-edit-check.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: post-edit-check.mjs
|
||||
// Event: PostToolUse (Edit, Write)
|
||||
// Purpose: P7 -- after each edit, run the target repo's OWN formatter and
|
||||
// linter on the edited file (the fast tier of three: per file here / step
|
||||
// end and full in lib/verification/step-check.mjs, contract K9). Findings go
|
||||
// back to Claude next to the tool result (`decision: "block"`, hooks
|
||||
// reference section PostToolUse decision control); the edit itself already
|
||||
// happened, so the stop is at step end, where K9 exits 1 on them.
|
||||
// A file no configured, installed tool covers is "not measured" -- never
|
||||
// clean. Every result is logged in <git dir>/voyage/post-edit.jsonl for K9.
|
||||
//
|
||||
// On only while Voyage executes a plan in the repo, or when the repo has
|
||||
// switched it on (hooks/lib/post-edit.mjs; operator decision 06.10). In every
|
||||
// other session it exits at once and says nothing.
|
||||
//
|
||||
// Credit: running the formatter after Edit|Write is Anthropic's own documented
|
||||
// hook pattern (hooks-guide); formatting per edit and the rest at step end is
|
||||
// Cherny's; the cost tiers and "not measured" are agent-skills' (MIT); the
|
||||
// per-file / step-end split is Kiro's. Ideas only.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { relative } from 'node:path';
|
||||
import { resolveRepo, detectTools, toolsForFile, runTool, canonicalPath } from '../../lib/verification/repo-tools.mjs';
|
||||
import { postEditActive, appendPostEditLog } from '../lib/post-edit.mjs';
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const file = input?.tool_input?.file_path;
|
||||
if (!file || typeof file !== 'string') process.exit(0);
|
||||
|
||||
const repo = resolveRepo(file);
|
||||
if (!repo) process.exit(0);
|
||||
const why = postEditActive(repo.root);
|
||||
if (!why) process.exit(0);
|
||||
|
||||
const rel = relative(repo.root, canonicalPath(file));
|
||||
const all = detectTools(repo.root);
|
||||
const tools = toolsForFile(all, file);
|
||||
const record = { ts: new Date().toISOString(), file: rel, on: why, session: input?.session_id ?? null };
|
||||
|
||||
function out(obj) {
|
||||
process.stdout.write(JSON.stringify(obj));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (tools.length === 0) {
|
||||
const missing = all.filter((t) => !t.available).map((t) => t.reason);
|
||||
appendPostEditLog(repo.gitDir, { ...record, status: 'not_measured', results: [], reason: missing.join('; ') || 'no tool for this file type' });
|
||||
out({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostToolUse',
|
||||
additionalContext:
|
||||
`[voyage] post-edit check: not measured for ${rel} -- no formatter or linter for this file type is ` +
|
||||
`both configured and installed in this repository${missing.length ? ` (${missing.join('; ')})` : ''}.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const results = tools.map((t) => runTool(t, [rel], { cwd: repo.root, timeout: 30_000 }));
|
||||
const findings = results.filter((r) => r.status === 'finding');
|
||||
const errors = results.filter((r) => r.status === 'error');
|
||||
const status = findings.length ? 'finding' : errors.length ? 'not_measured' : 'clean';
|
||||
appendPostEditLog(repo.gitDir, { ...record, status, results: results.map(({ output, ...r }) => r) });
|
||||
|
||||
const errText = errors.length ? ` Not measured: ${errors.map((e) => `${e.name} could not run (exit ${e.exit})`).join('; ')}.` : '';
|
||||
|
||||
if (findings.length) {
|
||||
out({
|
||||
decision: 'block',
|
||||
reason:
|
||||
`[voyage] post-edit check found problems in ${rel}: ` +
|
||||
findings.map((f) => `${f.name} (${f.kind}): ${f.output.split('\n').slice(0, 15).join('\n')}`).join('\n') +
|
||||
`\nFix them before the step ends; the step check fails on them.${errText}`,
|
||||
});
|
||||
}
|
||||
|
||||
out({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostToolUse',
|
||||
additionalContext: status === 'clean'
|
||||
? `[voyage] post-edit check clean for ${rel}: ${results.map((r) => r.name).join(', ')}.`
|
||||
: `[voyage] post-edit check for ${rel}:${errText}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-agent-cap.mjs
|
||||
// Event: PreToolUse (WebSearch | WebFetch | Task)
|
||||
// Event: PreToolUse (WebSearch | WebFetch | Task | Agent)
|
||||
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
|
||||
// so the cap is a reader that fells rather than prose the model obeys.
|
||||
// The subagent tool is `Agent` since Claude Code 2.1.63; the docs keep `Task`
|
||||
// as an alias only "in settings and agent definitions", so the matcher names
|
||||
// both (tests/hooks/storm-matcher.test.mjs).
|
||||
//
|
||||
// Precondition: Voyage's guards are deterministic only when no installed mod
|
||||
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
||||
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
||||
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
||||
//
|
||||
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
|
||||
// invoked BY the loop. A gate the caller chooses to consult is advice. The
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-bash-executor.mjs
|
||||
// Event: PreToolUse (Bash)
|
||||
// Purpose: Block or warn about destructive shell commands. Wired universally
|
||||
// (every Voyage session, not just execution) by deliberate decision — these
|
||||
// are session-agnostic safety rails (rm -rf /, fork bombs, …). The CC if:
|
||||
// path-scoping mechanism now works, but narrowing to execute-only would only
|
||||
// weaken protection with no offsetting benefit. See cc-upgrade matrix CC-15/F2.
|
||||
// Purpose: Block or warn about destructive shell commands, and ask a person
|
||||
// before merge into main, push, tag/release and destructive git (P12).
|
||||
// Wired universally (every Voyage session, not just execution) by deliberate
|
||||
// decision -- these are session-agnostic safety rails (rm -rf /, fork bombs,
|
||||
// ...). The CC if: path-scoping mechanism now works, but narrowing to
|
||||
// execute-only would only weaken protection with no offsetting benefit. See
|
||||
// cc-upgrade matrix CC-15/F2.
|
||||
//
|
||||
// Precondition: Voyage's guards are deterministic only when no installed mod
|
||||
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
||||
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
||||
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
// - Read JSON from stdin: { tool_name, tool_input, cwd }
|
||||
// - tool_input.command — the shell command string
|
||||
// - BLOCK (exit 2): catastrophic/irreversible operations
|
||||
// - ASK (exit 0, JSON permissionDecision "ask"): merge into main, push,
|
||||
// tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is
|
||||
// labelled [plugin:voyage] and also appears in auto mode (hooks reference
|
||||
// section PreToolUse decision control). Advisories are added to the prompt.
|
||||
// - WARN (exit 0): risky but recoverable operations — advisory to stderr
|
||||
// - Allow (exit 0): everything else
|
||||
//
|
||||
// Based on llm-security's pre-bash-destructive.mjs with executor-specific additions.
|
||||
// bash-normalize logic copied inline (MIT) — cannot import from separate plugin.
|
||||
// Command reading lives in hooks/lib/bash-view.mjs (shared with the hook that
|
||||
// logs approvals).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bash normalization (from llm-security/scanners/lib/bash-normalize.mjs)
|
||||
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeBashExpansion(cmd) {
|
||||
if (!cmd || typeof cmd !== 'string') return cmd || '';
|
||||
|
||||
let result = cmd
|
||||
// Strip empty single quotes: w''get -> wget
|
||||
.replace(/''/g, '')
|
||||
// Strip empty double quotes: r""m -> rm
|
||||
.replace(/""/g, '')
|
||||
// Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x)
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
// Multi-char ${ANYTHING} -> '' (unknown value, strip entirely)
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
// Strip backtick subshell with empty/whitespace content
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
// Iteratively strip backslash between word chars (c\u\r\l needs 2 passes)
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
// The guard's own modules are loaded so that a broken install cannot crash
|
||||
// the hook: a crash (exit 1) is a non-blocking error to Claude Code, which
|
||||
// would let every command through. A module that will not load makes the
|
||||
// guard ask about every command instead.
|
||||
let lib;
|
||||
let loadError = null;
|
||||
try {
|
||||
const [view, approval, record] = await Promise.all([
|
||||
import('../lib/bash-view.mjs'),
|
||||
import('../lib/git-approval.mjs'),
|
||||
import('../lib/approval-record.mjs'),
|
||||
]);
|
||||
lib = { ...view, ...approval, ...record };
|
||||
} catch (e) {
|
||||
loadError = e;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -197,69 +197,6 @@ const WARN_RULES = [
|
|||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize: strip ANSI, collapse whitespace
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeCommand(cmd) {
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command-position view — for rules that must distinguish a command from data
|
||||
// that merely names one. Whitespace is NOT collapsed, so newline stays a
|
||||
// separator. Quoted spans and heredoc bodies become data; the argument of a
|
||||
// shell wrapper stays a command; backslash-escaped names are seen through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `out` ends with the `-c` of a shell invocation — the next quoted span is a
|
||||
// command string, not data. Optional leading `sudo` and absolute path.
|
||||
const SHELL_C_TAIL =
|
||||
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
|
||||
|
||||
// Drop heredoc bodies, keeping the operator line. Their newlines are not
|
||||
// command separators, and without this every heredoc line that happens to
|
||||
// start with a matched word reads as command position. Runs before the quote
|
||||
// scan, since a body may contain quotes that would desync it.
|
||||
function stripHeredocBodies(cmd) {
|
||||
return cmd.replace(
|
||||
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
|
||||
(_match, head) => head,
|
||||
);
|
||||
}
|
||||
|
||||
function commandPositionView(cmd) {
|
||||
const src = stripHeredocBodies(cmd);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "'" || ch === '"') {
|
||||
const close = src.indexOf(ch, i + 1);
|
||||
const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close);
|
||||
// Unterminated quote — treat the remainder as one span and stop.
|
||||
out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' ';
|
||||
i = close === -1 ? src.length : close + 1;
|
||||
} else {
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return (
|
||||
out
|
||||
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
|
||||
// in front of it. Flags taking a separate argument (`-I {}`) are not
|
||||
// parsed — the separator lands before the argument, not the command.
|
||||
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
|
||||
// `\name` runs name — the backslash only suppresses alias expansion.
|
||||
// normalizeBashExpansion covers the between-word-chars case; this covers
|
||||
// a backslash at command position.
|
||||
.replace(/\\(\w)/g, '$1')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -278,6 +215,20 @@ if (!command || typeof command !== 'string') {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason:
|
||||
`Voyage's Bash guard could not load its rules (${loadError.message}), so this command was not checked. ` +
|
||||
'Way forward: approve only a command you have read yourself; reinstall Voyage to restore the guard.',
|
||||
},
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
const { normalizeBashExpansion, normalizeCommand, commandPositionView, classifyGitApproval, askReason, writeApprovalRecord } = lib;
|
||||
|
||||
// Strip bash evasion, then normalize whitespace
|
||||
const deobfuscated = normalizeBashExpansion(command);
|
||||
const normalized = normalizeCommand(deobfuscated);
|
||||
|
|
@ -311,4 +262,35 @@ if (warnings.length > 0) {
|
|||
);
|
||||
}
|
||||
|
||||
// P12: ask a person before merge into main, push, tag/release, destructive git.
|
||||
// A classifier error must not turn into a silent allow of a push: ask instead.
|
||||
let approvals;
|
||||
try {
|
||||
approvals = classifyGitApproval(command, { cwd: input.cwd });
|
||||
} catch (e) {
|
||||
approvals = [{ name: `a command the approval guard could not read (${e.message})`, kind: null, way: 'approve only if you have read the command yourself.' }];
|
||||
}
|
||||
|
||||
if (approvals.length > 0) {
|
||||
// What the person is asked is what post-approval-log.mjs logs once the call ran.
|
||||
const loggable = approvals.filter((m) => m.kind).map(({ kind, name, subject }) => ({ kind, name, subject }));
|
||||
const recorded = writeApprovalRecord(input.session_id, input.tool_use_id, {
|
||||
command,
|
||||
permission_mode: input.permission_mode ?? null,
|
||||
matches: loggable,
|
||||
asked_at: new Date().toISOString(),
|
||||
});
|
||||
// Say it now if this approval cannot reach the log afterwards.
|
||||
const unlogged = loggable.length > 0 && !recorded
|
||||
? ' Note: this approval will not be logged (Voyage could not record the question).' : '';
|
||||
const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged;
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: askReason(approvals) + advisory,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
|
|
|||
71
hooks/scripts/pre-plan-gate.mjs
Normal file
71
hooks/scripts/pre-plan-gate.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-plan-gate.mjs
|
||||
// Event: PreToolUse (ExitPlanMode)
|
||||
// Purpose: P3 -- wrap Claude Code's plan approval. Claude Code hands this hook
|
||||
// the plan text and the plan file path (hooks reference section ExitPlanMode:
|
||||
// "Claude Code injects the plan content and file path before passing the
|
||||
// input to hooks"). For a Voyage plan (it carries `plan_version`) the hook
|
||||
// runs Voyage's plan gate (contract K3, lib/validators/plan-gate.mjs) on that
|
||||
// file and sends a plan the gate refuses back to planning. Every other plan
|
||||
// passes untouched: the hook runs in every session.
|
||||
//
|
||||
// Precondition: Voyage's guards are deterministic only when no installed mod
|
||||
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
||||
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
||||
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
||||
//
|
||||
// Answers:
|
||||
// - K3 exit 0 -> exit 0, no output (Claude Code's own approval follows)
|
||||
// - K3 exit 1 -> exit 2 (deny): the gate's words go to Claude
|
||||
// - K3 exit 2 / missing / no plan file -> "ask" with "not measured": never a
|
||||
// silent pass, and not a refusal of a plan nobody could check
|
||||
// Credit: wrapping plan mode is Claude Code's own pattern (P3 analysis).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
|
||||
|
||||
function ask(reason) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Voyage plan gate: not measured (${reason}). Approve only if you have checked the plan yourself.`,
|
||||
},
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
// Unreadable input: nothing to judge, and no plan to call Voyage's.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const plan = input?.tool_input?.plan;
|
||||
if (!isVoyagePlan(plan)) process.exit(0);
|
||||
|
||||
const cwd = input?.cwd || process.cwd();
|
||||
const given = input?.tool_input?.planFilePath;
|
||||
// Plan paths are absolute in practice; a relative one is read from the
|
||||
// session's directory, the same one the gate runs in.
|
||||
const planPath = typeof given === 'string' && given ? resolve(cwd, given) : null;
|
||||
if (!planPath || !existsSync(planPath)) {
|
||||
ask('Claude Code attached no readable plan file');
|
||||
}
|
||||
|
||||
const r = runContract('K3', [planPath], { cwd });
|
||||
if (!r.ran) ask(r.why);
|
||||
if (r.code === 0) process.exit(0);
|
||||
if (r.code === 1) {
|
||||
const words = (r.stdout + r.stderr).trim().slice(0, 4000) || 'the plan gate refused the plan';
|
||||
process.stderr.write(
|
||||
'[voyage] Plan sent back: Voyage\'s plan gate refused this plan.\n' +
|
||||
`${words}\n` +
|
||||
'Way forward: fix what the gate names, or plan again; then ask for approval again.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
ask(`the plan gate exited ${r.code}`);
|
||||
|
|
@ -6,6 +6,12 @@
|
|||
// ~/.ssh, ~/.aws, .git/hooks, .env, shell configs benefits every session.
|
||||
// The CC if: path-scoping mechanism now works, but narrowing to execute-only
|
||||
// would only weaken protection with no benefit. See cc-upgrade matrix CC-15/F2.
|
||||
// Also enforces the test lock (P9, contract K8, lib/util/test-lock.mjs).
|
||||
//
|
||||
// Precondition: Voyage's guards are deterministic only when no installed mod
|
||||
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
||||
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
||||
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
|
|
@ -13,11 +19,26 @@
|
|||
// tool_input.notebook_path (NotebookEdit)
|
||||
// - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets
|
||||
// - BLOCK (exit 2): no readable target path (fail closed)
|
||||
// - BLOCK (exit 2): a test file the current step has locked (K8), and any
|
||||
// write in a repo whose lock cannot be read (fail closed)
|
||||
// - Allow (exit 0): everything else
|
||||
// Bash can still rewrite a locked file (sed -i, >); this guard covers the
|
||||
// file tools only.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// The lock code is loaded so that a broken install cannot crash this hook (a
|
||||
// crash is a non-blocking error: every write would pass, the path rules
|
||||
// included). A lock module that will not load blocks the write instead.
|
||||
let lockLib = null;
|
||||
let lockLoadError = null;
|
||||
try {
|
||||
lockLib = await import('../../lib/util/test-lock.mjs');
|
||||
} catch (e) {
|
||||
lockLoadError = e;
|
||||
}
|
||||
|
||||
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -26,11 +47,20 @@ const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|||
const BLOCK_RULES = [
|
||||
{
|
||||
name: 'Git hook injection (.git/hooks/)',
|
||||
test: (p) => /\/\.git\/hooks\//.test(p),
|
||||
test: (p) => /\/\.git\/hooks\//i.test(p),
|
||||
description:
|
||||
'Writing to .git/hooks/ could inject malicious git hooks that execute ' +
|
||||
'on every commit, push, or checkout. Blocked.',
|
||||
},
|
||||
{
|
||||
name: "Voyage's own guard state (.git/voyage/)",
|
||||
// The test lock and the post-edit log. A worktree's git dir is
|
||||
// .git/worktrees/<name>/, so that form is covered too.
|
||||
test: (p) => /\/\.git\/(?:worktrees\/[^/]+\/)?voyage(?:\/|$)/i.test(p),
|
||||
description:
|
||||
'Writing to the git directory\'s voyage/ folder would unlock a locked test or ' +
|
||||
'rewrite the post-edit log. Blocked; the executor and the operator manage it.',
|
||||
},
|
||||
{
|
||||
name: 'Claude settings self-modification',
|
||||
test: (p) => /\/\.claude\/settings[^/]*\.json$/.test(p),
|
||||
|
|
@ -149,5 +179,39 @@ for (const rule of BLOCK_RULES) {
|
|||
}
|
||||
}
|
||||
|
||||
// P9 test lock (K8).
|
||||
if (lockLoadError) {
|
||||
process.stderr.write(
|
||||
'[voyage] BLOCKED: the test-lock guard could not load\n' +
|
||||
` ${lockLoadError.message}\n` +
|
||||
' Without it no write can be checked against a locked test. Blocked (fail ' +
|
||||
'closed). Reinstall Voyage to restore the guard.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const { readLock, isLockedFile } = lockLib;
|
||||
const lock = readLock(resolved);
|
||||
if (lock.state === 'corrupt') {
|
||||
process.stderr.write(
|
||||
'[voyage] BLOCKED: unreadable test lock\n' +
|
||||
` Lock: ${lock.path} (${lock.error})\n` +
|
||||
' A step locked its tests, but the lock cannot be read, so no write in this ' +
|
||||
'repository can be checked against it. Blocked (fail closed). Ask the operator ' +
|
||||
'to inspect the lock file.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (lock.state === 'locked' && isLockedFile(lock.lock, resolved)) {
|
||||
process.stderr.write(
|
||||
`[voyage] BLOCKED: locked test file (step ${lock.lock.step})\n` +
|
||||
` Path: ${resolved}\n` +
|
||||
` Step ${lock.lock.step} locked this test after it was seen failing; it must pass ` +
|
||||
'unchanged. Way forward: change the code under test, not the test. If the test ' +
|
||||
'itself is wrong, stop and tell the operator; only the operator or the executor ' +
|
||||
'unlocks it.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Allow
|
||||
process.exit(0);
|
||||
|
|
|
|||
50
hooks/scripts/stop-test-lock.mjs
Normal file
50
hooks/scripts/stop-test-lock.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: stop-test-lock.mjs
|
||||
// Event: Stop
|
||||
// Purpose: P9 (3) -- "done" is refused while a step's tests are locked (K8,
|
||||
// lib/util/test-lock.mjs). The executor unlocks a step only after its test
|
||||
// passes, so a held lock means the step's test has not been seen passing.
|
||||
//
|
||||
// Scope: only the session that took the lock is held (the lock records
|
||||
// CLAUDE_CODE_SESSION_ID, which the docs say matches the hook input's
|
||||
// session_id: code.claude.com/docs/en/env-vars). Any other session
|
||||
// in the same repository stops freely, so a lock left behind by a crash
|
||||
// cannot trap unrelated work.
|
||||
// Loop guard: when stop_hook_active is true, Claude Code is already continuing
|
||||
// because of a stop hook; this hook then lets the stop through
|
||||
// (code.claude.com/docs/en/hooks section Stop input; hooks-guide "Stop hook hits
|
||||
// the block cap").
|
||||
// TaskCompleted is not used: it fires on the TaskUpdate tool, which current
|
||||
// models do not get by default (tools-reference section Task tool availability).
|
||||
//
|
||||
// Credit: refusing "done" until the check passes is Claude Code's own
|
||||
// documented pattern (hooks reference section TaskCompleted / Stop decision control).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLock, lockForSession } from '../../lib/util/test-lock.mjs';
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (input?.stop_hook_active === true) process.exit(0);
|
||||
|
||||
// The lock in the session's current repo, else the one this session took
|
||||
// elsewhere (the session may have moved to another directory since).
|
||||
let lock = readLock(input?.cwd || process.cwd());
|
||||
if (lock.state !== 'locked' || lock.lock.session !== input?.session_id) lock = lockForSession(input?.session_id);
|
||||
if (lock.state !== 'locked') process.exit(0);
|
||||
if (!lock.lock.session || lock.lock.session !== input?.session_id) process.exit(0);
|
||||
|
||||
const files = lock.lock.files.join(', ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
decision: 'block',
|
||||
reason:
|
||||
`[voyage] Not done: step ${lock.lock.step} still holds its test lock (${files}). ` +
|
||||
"The step is done when its test passes and the executor unlocks it. Run the step's " +
|
||||
'test; if it passes, finish the step. If it cannot pass, say so plainly and stop.',
|
||||
}));
|
||||
process.exit(0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue