merge: wave 1 order 4 (guards) into main
CLAUDE.md: the review paragraph (order 5) and the guards paragraph (order 4) are both kept (planned union line). The launch gate went red on the merged tree: post-approval-log reads the session's permission_mode, and its regression test feeds that value. Both were untracked when the branch ran its suite, so the gate never saw them there. Operator decision 2026-10-06: a counted exemption in a new class (mode-read: 3 + 1 hits), and the binding-rule sentence in CLAUDE.md names it. An extra hit in either file turns the gate red again (checked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
47864f91b4
32 changed files with 3284 additions and 110 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -21,8 +21,9 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
> `tests/lib/no-claude-code-launch.test.mjs`; its exemption list holds historical
|
||||
> mentions plus one operator instrument (operator decision, 2026-10-02:
|
||||
> `docs/proevesett.md` tells a person how to run Anthropic's plugin eval tool on
|
||||
> the fixed test set; Voyage itself starts nothing), each with a reason and an
|
||||
> exact count.
|
||||
> the fixed test set; Voyage itself starts nothing) plus two files that only read
|
||||
> the session's permission mode from hook input (operator decision, 2026-10-06),
|
||||
> each with a reason and an exact count.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -61,6 +62,18 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
> repo's `.claude/rules/voyage-review-lessons.md`, each one operator-approved.
|
||||
> Contract → `docs/HANDOVER-CONTRACTS.md` §Handover 6.
|
||||
|
||||
> **Guards (P3/P7/P9/P12).** Command hooks only, wired for every session.
|
||||
> `pre-bash-executor` answers `ask` (holds in auto mode) before merge into
|
||||
> main, push, tag/publish and destructive git; `post-approval-log` writes
|
||||
> approvals to the K2 log. `pre-plan-gate` runs the K3 plan gate on a Voyage
|
||||
> plan leaving plan mode. `pre-write-executor` + `stop-test-lock` enforce the
|
||||
> K8 test lock (`lib/util/test-lock.mjs`). `post-edit-check` runs the target
|
||||
> repo's own format/lint 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. A missing contract CLI reads "not measured"/"not
|
||||
> logged", never green. Voyage's guards are deterministic only when no
|
||||
> installed mod handles `tool.check`.
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Model | Role |
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -372,6 +372,17 @@ The executor implements defense-in-depth security across three layers:
|
|||
2. **Prompt-level denylist** — Security rules embedded in the executor command and session spec template that hold even if a hook does not fire
|
||||
3. **Pre-execution plan scan** — Phase 2.4 scans all `Verify:` and `Checkpoint:` commands against the denylist before execution begins, catching dangerous commands before they reach the executor
|
||||
|
||||
**Guards during the build.** All are command hooks; none runs a model.
|
||||
|
||||
- **Approval guard (P12)** — `pre-bash-executor.mjs` answers `ask` before a merge into `main`, a push, a tag or package publish, and destructive git (hard reset, forced clean, force-deleted branch, dropped stash, discarded changes, history rewrite). Claude Code shows the prompt labelled `[plugin:voyage]`, also in auto mode, and the prompt names the way forward. `post-approval-log.mjs` writes an approved merge or push to `main` (`merge`) and a tag or publish (`release`) to the approval log with a one-hour expiry. It logs exactly what the person was asked, and nothing in a permission mode that shows no prompts, where no one is asked; other pushes and destructive git are asked about but not logged. Claude Code's own notification for a waiting permission prompt is the alert; Voyage adds none.
|
||||
- **Plan gate (P3)** — `pre-plan-gate.mjs` runs Voyage's plan gate on the plan Claude Code's plan mode presents and sends an invalid Voyage plan back to planning; when the gate cannot run, the prompt says "not measured". An approved Voyage plan is logged with the hash of its text. A plan without `plan_version` passes untouched.
|
||||
- **Test lock (P9)** — while a step's test is locked (`lib/util/test-lock.mjs`), `pre-write-executor.mjs` refuses Write, Edit and NotebookEdit on it, and `stop-test-lock.mjs` refuses "done" in the session that took the lock until the executor unlocks it.
|
||||
- **Post-edit check (P7)** — `post-edit-check.mjs` runs the target repo's own formatter and linter on each edited file (check-only; a tool is never downloaded) and logs the result. `lib/verification/step-check.mjs` re-runs them on the diff at step end, adds the type checker, and fails the step on findings. A repo without tools, or a configured tool that is missing or crashes, reads "not measured", never green. The check is on only while Voyage executes a plan in that repo, or when the repo switches it on with `.claude/voyage-guards.json` = `{ "post_edit_check": true }`.
|
||||
|
||||
**Precondition.** Voyage's guards are deterministic only when no installed mod handles `tool.check`. Claude Code runs such a mod after the hooks, and its answer can replace theirs: it can approve a call a Voyage guard stopped or asked about ([permissions](https://code.claude.com/docs/en/permissions)). A hook cannot tell whether such a mod is installed; `/plugin` lists the mods a session loaded.
|
||||
|
||||
**Credits.** The four approval classes come from Superpowers; a block that names the way forward from agent-skills; guard plus log from ai-native-sdlc; the alert from Cherny's practice. The post-edit tiers and "not measured" come from agent-skills' constraint-driven development, formatting per edit with the rest at step end from Cherny, the per-file / step-end split from Kiro, a check seen to pass and to fail on a planted problem from Pocock, and guard tests in both directions from ai-native-sdlc. The test lock, the plan-mode wrap and the post-edit hook are Claude Code's own documented hook patterns. Superpowers, Pocock, agent-skills and ai-native-sdlc are MIT-licensed; only ideas are borrowed, no code.
|
||||
|
||||
#### Recommended: disable Skill shell execution (CC v2.1.91+)
|
||||
|
||||
For fork-ers handling untrusted task briefs or plans from external
|
||||
|
|
@ -875,7 +886,7 @@ trekplan/
|
|||
│ └ 20 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
|
||||
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
|
||||
├── templates/ Frontmatter templates for brief, research, plan, session
|
||||
├── hooks/ 8 hooks (pre-bash, pre-write, pre-agent-cap, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
|
||||
├── hooks/ 12 hooks (pre-bash, pre-write, pre-agent-cap, pre-plan-gate, session-title, post-bash-stats, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, otel-export, stop-test-lock)
|
||||
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
|
||||
├── tests/ comprehensive node:test suite — `npm test` is the fork-readiness gate
|
||||
├── docs/ HANDOVER-CONTRACTS.md + architect-bridge-test.md
|
||||
|
|
|
|||
32
SECURITY.md
32
SECURITY.md
|
|
@ -31,15 +31,35 @@ The plugin's security posture covers:
|
|||
|
||||
| Hook | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `pre-bash-executor.mjs` | `PreToolUse` for Bash | BLOCKs known-dangerous shell patterns; WARNs on suspicious ones; fails open on parse errors |
|
||||
| `pre-write-executor.mjs` | `PreToolUse` for Write, Edit, NotebookEdit | BLOCKs writes to `.git/hooks/`, `~/.ssh/`, `.env`, and other sensitive paths; fails closed when it cannot read the target path |
|
||||
| `pre-bash-executor.mjs` | `PreToolUse` for Bash | BLOCKs known-dangerous shell patterns; WARNs on suspicious ones; answers `ask` before merge into `main`, push, tag/publish and destructive git (P12); fails open on parse errors |
|
||||
| `pre-write-executor.mjs` | `PreToolUse` for Write, Edit, NotebookEdit | BLOCKs writes to `.git/hooks/`, `~/.ssh/`, `.env`, and other sensitive paths, to Voyage's own state in the git directory (`voyage/`), and to test files a step has locked (P9); fails closed when it cannot read the target path or the lock, or cannot load the lock code |
|
||||
| `pre-plan-gate.mjs` | `PreToolUse` for ExitPlanMode | Runs Voyage's plan gate on a Voyage plan (it carries `plan_version`) and denies one the gate refuses; asks with "not measured" when the gate cannot run; other plans pass (P3) |
|
||||
| `post-approval-log.mjs` | `PostToolUse` for Bash, ExitPlanMode | Writes an approved merge/push to `main`, tag/publish, or Voyage plan to the approval log; says "not logged" when it cannot |
|
||||
| `post-edit-check.mjs` | `PostToolUse` for Edit, Write | Runs the target repo's own formatter and linter on the edited file, check-only, while Voyage executes a plan there or the repo has switched it on (P7) |
|
||||
| `stop-test-lock.mjs` | `Stop` | Refuses "done" while the session's step holds its test lock; lets the stop through when `stop_hook_active` is set (P9) |
|
||||
| `pre-compact-flush.mjs` | `PreCompact` | Flushes `progress.json` from git history before compaction (P0 drift fix); read-only beyond `progress.json` |
|
||||
| `session-title.mjs` *(planned, F9)* | `UserPromptSubmit` | Sets session title `voyage:<command>:<slug>` for telling parallel sessions apart |
|
||||
|
||||
All hooks are zero-dependency Node.js (`.mjs`) scripts and are designed
|
||||
to **fail open** — a hook crash never blocks the user's work. Hooks log
|
||||
to stderr only; they never write to user files outside their declared
|
||||
scope.
|
||||
All hooks are zero-dependency Node.js (`.mjs`) scripts. Most are designed
|
||||
to **fail open** — a hook crash never blocks the user's work. The
|
||||
exceptions are stated per hook above: the write guard fails closed when it
|
||||
cannot read a path or a test lock, and the plan gate asks rather than
|
||||
passes when it cannot run. No hook runs a model. Hooks never write to user
|
||||
files outside their declared scope; the test lock and the post-edit log
|
||||
live in the repository's git directory (`<git dir>/voyage/`), where they
|
||||
cannot be committed.
|
||||
|
||||
### Precondition: mods
|
||||
|
||||
Voyage's guards are deterministic only when no installed mod handles `tool.check`.
|
||||
A Claude Code mod that handles `tool.check` answers after the permission
|
||||
rules and the `PreToolUse` hooks have decided, and its answer can replace
|
||||
theirs: it can approve a call a plugin hook blocked or asked about, and
|
||||
outside managed settings and Team/Enterprise plans it can approve a call a
|
||||
deny rule refuses (code.claude.com/docs/en/permissions, § Extend
|
||||
permissions with hooks). Voyage's hooks cannot tell whether such a mod is installed:
|
||||
no hook input names the loaded mods. `/plugin` shows them to a person
|
||||
(`N mods active · …`). Voyage itself ships no mods.
|
||||
|
||||
### Prompt-level denylist (`commands/trekexecute.md`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
186
lib/util/test-lock.mjs
Normal file
186
lib/util/test-lock.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/util/test-lock.mjs
|
||||
// K8 -- the test lock. The executor (wave-1 order 1) locks a step's test files
|
||||
// once the test is written and seen failing, and unlocks them when the test
|
||||
// passes. Two guards read the lock:
|
||||
// - pre-write-executor.mjs refuses Write/Edit/NotebookEdit on a locked file;
|
||||
// - stop-test-lock.mjs refuses "done" while the session that took the lock
|
||||
// still holds it.
|
||||
// The lock lives in the repository's git directory (<git dir>/voyage/
|
||||
// test-lock.json), so it can never be committed, and each worktree has its own.
|
||||
//
|
||||
// Usage:
|
||||
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
|
||||
// Exit: 0 = done, 1 = refused (another step holds the lock), 2 = could not
|
||||
// run (not a repository, bad usage, unreadable lock). 2 is never "unlocked".
|
||||
// Paths in --files are relative to the --cwd (default: the current directory).
|
||||
// The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in
|
||||
// Bash tool subprocesses (code.claude.com/docs/en/env-vars).
|
||||
//
|
||||
// Credit: locking tests during a fix is Claude Code's own hook pattern
|
||||
// (hooks-guide "Block edits to protected files").
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, realpathSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
|
||||
|
||||
export const LOCK_VERSION = 1;
|
||||
|
||||
export function lockPathFor(gitDir) {
|
||||
return join(gitDir, 'voyage', 'test-lock.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the lock for the repo that `start` (a file or directory) belongs to.
|
||||
* @returns {{ state: 'no-repo'|'none'|'locked'|'corrupt', lock?: object, path?: string, error?: string }}
|
||||
*/
|
||||
export function readLock(start) {
|
||||
const repo = resolveRepo(start);
|
||||
if (!repo) return { state: 'no-repo' };
|
||||
const path = lockPathFor(repo.gitDir);
|
||||
if (!existsSync(path)) return { state: 'none', path, repo };
|
||||
try {
|
||||
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (!lock || !Array.isArray(lock.files) || !Number.isInteger(lock.step)) {
|
||||
return { state: 'corrupt', path, repo, error: 'lock has no files/step' };
|
||||
}
|
||||
return { state: 'locked', lock, path, repo };
|
||||
} catch (e) {
|
||||
return { state: 'corrupt', path, repo, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// A pointer from the session that took a lock to the lock file, so the stop
|
||||
// guard finds it even after the session has moved to another directory.
|
||||
const SESSION_DIR = join(tmpdir(), `voyage-test-locks-${process.getuid ? process.getuid() : 'u'}`);
|
||||
const sessionPointer = (sessionId) =>
|
||||
join(SESSION_DIR, `${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32)}.json`);
|
||||
|
||||
// Pointers whose lock is gone are dropped on the next lock.
|
||||
function sweepPointers() {
|
||||
try {
|
||||
for (const f of readdirSync(SESSION_DIR)) {
|
||||
const p = join(SESSION_DIR, f);
|
||||
try {
|
||||
if (!existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path)) rmSync(p, { force: true });
|
||||
} catch { rmSync(p, { force: true }); }
|
||||
}
|
||||
} catch { /* no dir yet */ }
|
||||
}
|
||||
|
||||
function writeSessionPointer(sessionId, lockPath) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
sweepPointers();
|
||||
writeFileSync(sessionPointer(sessionId), JSON.stringify({ lock_path: lockPath }));
|
||||
} catch { /* the cwd lookup still works */ }
|
||||
}
|
||||
|
||||
function removeSessionPointer(sessionId) {
|
||||
if (sessionId) rmSync(sessionPointer(sessionId), { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock this session holds, wherever it lives: { state: 'locked', lock, path }
|
||||
* or { state: 'none' }. A pointer to a lock that is gone, or that another
|
||||
* session now holds, is not this session's lock.
|
||||
*/
|
||||
export function lockForSession(sessionId) {
|
||||
if (!sessionId) return { state: 'none' };
|
||||
try {
|
||||
const { lock_path } = JSON.parse(readFileSync(sessionPointer(sessionId), 'utf8'));
|
||||
const lock = JSON.parse(readFileSync(lock_path, 'utf8'));
|
||||
if (lock?.session === sessionId && Array.isArray(lock.files) && Number.isInteger(lock.step)) {
|
||||
return { state: 'locked', lock, path: lock_path };
|
||||
}
|
||||
} catch { /* no pointer, or the lock is gone */ }
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Is `file` one of the locked files? Compared as canonical real paths.
|
||||
export function isLockedFile(lock, file) {
|
||||
const c = canonicalPath(file);
|
||||
return lock.files.includes(c);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--files' || a === '--step' || a === '--cwd') { out[a.slice(2)] = argv[i + 1]; i += 1; }
|
||||
else out._.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fail(code, msg) {
|
||||
process.stderr.write(`[voyage] test-lock: ${msg}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const args = parseArgs(argv);
|
||||
const cmd = args._[0];
|
||||
if (!['lock', 'unlock', 'status'].includes(cmd)) fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status [--cwd <dir>]');
|
||||
const cwd = resolve(args.cwd || process.cwd());
|
||||
const cur = readLock(cwd);
|
||||
if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`);
|
||||
|
||||
if (cmd === 'status') {
|
||||
if (cur.state === 'corrupt') {
|
||||
process.stdout.write(JSON.stringify({ locked: null, state: 'corrupt', lock_path: cur.path, error: cur.error }, null, 2) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const l = cur.lock || {};
|
||||
process.stdout.write(JSON.stringify({
|
||||
locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null,
|
||||
locked_at: l.locked_at ?? null, lock_path: cur.path,
|
||||
}, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'unlock') {
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`);
|
||||
removeSessionPointer(cur.lock?.session);
|
||||
rmSync(cur.path, { force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// lock
|
||||
if (!args.files) fail(2, 'lock needs --files <a,b>');
|
||||
if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step <N> (a whole number)');
|
||||
const step = Number(args.step);
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`);
|
||||
if (cur.state === 'locked' && cur.lock.step !== step) {
|
||||
fail(1, `step ${cur.lock.step} holds the lock; unlock it first`);
|
||||
}
|
||||
const files = String(args.files).split(',').map((f) => f.trim()).filter(Boolean).map((f) => canonicalPath(resolve(cwd, f)));
|
||||
if (files.length === 0) fail(2, 'lock needs at least one file');
|
||||
const lock = {
|
||||
version: LOCK_VERSION,
|
||||
step,
|
||||
files: [...new Set(files)],
|
||||
session: process.env.CLAUDE_CODE_SESSION_ID || null,
|
||||
locked_at: new Date().toISOString(),
|
||||
};
|
||||
mkdirSync(dirname(cur.path), { recursive: true });
|
||||
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
||||
writeSessionPointer(lock.session, cur.path);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) main(process.argv.slice(2));
|
||||
275
lib/verification/repo-tools.mjs
Normal file
275
lib/verification/repo-tools.mjs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/verification/repo-tools.mjs
|
||||
// Finds the target repository's OWN formatter, linter and type checker, and
|
||||
// runs them. Used by the post-edit guard (P7, hooks/scripts/post-edit-check.mjs,
|
||||
// the fast per-file tier) and by the step check (K9, step-check.mjs, the
|
||||
// step-end and full tiers).
|
||||
//
|
||||
// Rules:
|
||||
// - A tool counts only when the repo configures it AND it is installed:
|
||||
// node tools in the repo's node_modules/.bin, the rest on PATH. Nothing is
|
||||
// ever downloaded (no npx), so a check never reaches the network.
|
||||
// - Every run is check-only (prettier --check, ruff format --check, ...): the
|
||||
// guard reports, it never rewrites a file behind Claude's back.
|
||||
// - Each tool's exit code maps to clean / finding / error. An error is a
|
||||
// tool that could not give an answer; it is never read as clean.
|
||||
// - Spawned without a shell; file names are passed as arguments.
|
||||
// The exit-code meanings below are the tools' documented conventions as I
|
||||
// know them; they were not re-read per tool for this module (ikke verifisert).
|
||||
//
|
||||
// The tiers (fast per file / step end / full) and "not measured" are from
|
||||
// agent-skills' constraint-driven development (MIT, `9d0c60d`); the split
|
||||
// between per-file and step-end checks is Kiro's (docs only). Ideas only.
|
||||
//
|
||||
// CLI: node lib/verification/repo-tools.mjs --repo <dir>
|
||||
// prints the detected tools as JSON. Exit 0 = at least one available tool,
|
||||
// 2 = none available (not measured) or not a repository.
|
||||
|
||||
import { existsSync, readFileSync, statSync, realpathSync, accessSync, constants } from 'node:fs';
|
||||
import { dirname, join, resolve, isAbsolute, extname, delimiter } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Real path of `p`, or of its nearest existing ancestor joined with the rest:
|
||||
// macOS reaches /var through /private/var, and a path that does not exist yet
|
||||
// must still compare equal to the same path once it does. The native realpath
|
||||
// also returns the on-disk letter case, so `Tests/a.js` and `tests/a.js` on a
|
||||
// case-insensitive file system are one path (the JS realpath keeps the case
|
||||
// it was given).
|
||||
export function canonicalPath(p) {
|
||||
let cur = resolve(p);
|
||||
const rest = [];
|
||||
while (!existsSync(cur)) {
|
||||
const up = dirname(cur);
|
||||
if (up === cur) break;
|
||||
rest.unshift(cur.slice(up.length).replace(/^\/+/, ''));
|
||||
cur = up;
|
||||
}
|
||||
let real = cur;
|
||||
try { real = realpathSync.native(cur); } catch { /* keep resolved */ }
|
||||
return rest.length ? join(real, ...rest) : real;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository a path belongs to: { root, gitDir } (real paths), or null.
|
||||
* Reads the filesystem only; follows a worktree's `.git` file to its git dir.
|
||||
*/
|
||||
export function resolveRepo(start) {
|
||||
let dir = canonicalPath(start || process.cwd());
|
||||
try { if (!statSync(dir).isDirectory()) dir = dirname(dir); } catch { dir = dirname(dir); }
|
||||
while (!existsSync(dir)) dir = dirname(dir);
|
||||
for (;;) {
|
||||
const dotGit = join(dir, '.git');
|
||||
if (existsSync(dotGit)) {
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return { root: dir, gitDir: realpathSync.native(dotGit) };
|
||||
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
||||
if (!m) return null;
|
||||
const gd = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
|
||||
return { root: dir, gitDir: realpathSync.native(gd) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const up = dirname(dir);
|
||||
if (up === dir) return null;
|
||||
dir = up;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const JS_EXT = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.vue'];
|
||||
const PRETTIER_EXT = [...JS_EXT, '.json', '.css', '.scss', '.less', '.md', '.yaml', '.yml', '.html'];
|
||||
|
||||
function readText(p) {
|
||||
try { return readFileSync(p, 'utf8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function anyFile(root, names) {
|
||||
return names.some((n) => existsSync(join(root, n)));
|
||||
}
|
||||
|
||||
function pkgKey(root, key) {
|
||||
const txt = readText(join(root, 'package.json'));
|
||||
if (!txt) return false;
|
||||
try { return Object.prototype.hasOwnProperty.call(JSON.parse(txt), key); } catch { return false; }
|
||||
}
|
||||
|
||||
function tomlHas(root, file, section) {
|
||||
const txt = readText(join(root, file));
|
||||
return !!txt && txt.split('\n').some((l) => l.trim() === `[${section}]` || l.trim().startsWith(`[${section}.`));
|
||||
}
|
||||
|
||||
function isExecutable(p) {
|
||||
try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch { return false; }
|
||||
}
|
||||
|
||||
function localBin(root, name) {
|
||||
const p = join(root, 'node_modules', '.bin', name);
|
||||
return isExecutable(p) ? p : null;
|
||||
}
|
||||
|
||||
function onPath(name, env) {
|
||||
for (const dir of String(env.PATH || '').split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
const p = join(dir, name);
|
||||
if (isExecutable(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each spec: when the repo configures it, where its binary lives, how to call
|
||||
// it for a list of files (file tier) or for the whole repo (repo tier), and
|
||||
// which exit codes mean "found problems".
|
||||
const SPECS = [
|
||||
{
|
||||
name: 'prettier', kind: 'format', tier: 'file', ext: PRETTIER_EXT,
|
||||
configured: (r) => anyFile(r, ['.prettierrc', '.prettierrc.json', '.prettierrc.yaml', '.prettierrc.yml', '.prettierrc.json5',
|
||||
'.prettierrc.js', '.prettierrc.cjs', '.prettierrc.mjs', '.prettierrc.toml', 'prettier.config.js', 'prettier.config.cjs',
|
||||
'prettier.config.mjs']) || pkgKey(r, 'prettier'),
|
||||
bin: (r) => localBin(r, 'prettier'),
|
||||
argv: (files) => ['--check', ...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'biome', kind: 'lint', tier: 'file', ext: [...JS_EXT, '.json', '.jsonc', '.css'],
|
||||
configured: (r) => anyFile(r, ['biome.json', 'biome.jsonc']),
|
||||
bin: (r) => localBin(r, 'biome'),
|
||||
argv: (files) => ['check', ...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'eslint', kind: 'lint', tier: 'file', ext: JS_EXT,
|
||||
configured: (r) => anyFile(r, ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
|
||||
'.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml']) || pkgKey(r, 'eslintConfig'),
|
||||
bin: (r) => localBin(r, 'eslint'),
|
||||
argv: (files) => [...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'tsc', kind: 'typecheck', tier: 'repo',
|
||||
configured: (r) => existsSync(join(r, 'tsconfig.json')),
|
||||
bin: (r) => localBin(r, 'tsc'),
|
||||
argv: () => ['--noEmit', '-p', '.'], findingCodes: [1, 2],
|
||||
},
|
||||
{
|
||||
name: 'ruff-check', kind: 'lint', tier: 'file', ext: ['.py', '.pyi'],
|
||||
configured: (r) => anyFile(r, ['ruff.toml', '.ruff.toml']) || tomlHas(r, 'pyproject.toml', 'tool.ruff'),
|
||||
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'ruff')) ? join(r, '.venv', 'bin', 'ruff') : onPath('ruff', env)),
|
||||
argv: (files) => ['check', ...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'ruff-format', kind: 'format', tier: 'file', ext: ['.py', '.pyi'],
|
||||
configured: (r) => anyFile(r, ['ruff.toml', '.ruff.toml']) || tomlHas(r, 'pyproject.toml', 'tool.ruff'),
|
||||
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'ruff')) ? join(r, '.venv', 'bin', 'ruff') : onPath('ruff', env)),
|
||||
argv: (files) => ['format', '--check', ...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'mypy', kind: 'typecheck', tier: 'repo',
|
||||
configured: (r) => anyFile(r, ['mypy.ini', '.mypy.ini']) || tomlHas(r, 'pyproject.toml', 'tool.mypy') || tomlHas(r, 'setup.cfg', 'mypy'),
|
||||
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'mypy')) ? join(r, '.venv', 'bin', 'mypy') : onPath('mypy', env)),
|
||||
argv: () => ['.'], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
// gofmt -l lists unformatted files and exits 0: a listed file is a finding.
|
||||
name: 'gofmt', kind: 'format', tier: 'file', ext: ['.go'],
|
||||
configured: (r) => existsSync(join(r, 'go.mod')),
|
||||
bin: (_r, env) => onPath('gofmt', env),
|
||||
argv: (files) => ['-l', ...files], findingCodes: [], stdoutIsFinding: true,
|
||||
},
|
||||
{
|
||||
name: 'go-vet', kind: 'lint', tier: 'repo',
|
||||
configured: (r) => existsSync(join(r, 'go.mod')),
|
||||
bin: (_r, env) => onPath('go', env),
|
||||
argv: () => ['vet', './...'], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'rustfmt', kind: 'format', tier: 'file', ext: ['.rs'],
|
||||
configured: (r) => existsSync(join(r, 'Cargo.toml')),
|
||||
bin: (_r, env) => onPath('rustfmt', env),
|
||||
argv: (files) => ['--check', ...files], findingCodes: [1],
|
||||
},
|
||||
{
|
||||
name: 'swiftformat', kind: 'format', tier: 'file', ext: ['.swift'],
|
||||
configured: (r) => existsSync(join(r, '.swiftformat')),
|
||||
bin: (_r, env) => onPath('swiftformat', env),
|
||||
argv: (files) => ['--lint', ...files], findingCodes: [1],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The tools the repo at `root` configures, each marked available or not.
|
||||
* @returns {Array<{name, kind, tier, ext?, available: boolean, bin: string|null, reason: string}>}
|
||||
*/
|
||||
export function detectTools(root, { env = process.env } = {}) {
|
||||
const out = [];
|
||||
for (const s of SPECS) {
|
||||
if (!s.configured(root)) continue;
|
||||
const bin = s.bin(root, env);
|
||||
out.push({
|
||||
name: s.name, kind: s.kind, tier: s.tier, ext: s.ext, bin, available: !!bin,
|
||||
reason: bin ? '' : `${s.name} is configured in the repo but not installed`,
|
||||
spec: s,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Available file-tier tools that apply to this file.
|
||||
export function toolsForFile(tools, file) {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return tools.filter((t) => t.available && t.tier === 'file' && t.ext.includes(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one tool. `files` is ignored for repo-tier tools.
|
||||
* @returns {{ name, kind, status: 'clean'|'finding'|'error', exit: number|null, output: string }}
|
||||
*/
|
||||
export function runTool(tool, files, { cwd, timeout = 120_000, env = process.env } = {}) {
|
||||
const s = tool.spec;
|
||||
// A file named like `-x.js` or `--config=y` would reach the tool as an
|
||||
// option; `./` keeps it a file for every tool, with or without `--` support.
|
||||
const safe = files.map((f) => (f.startsWith('-') ? `./${f}` : f));
|
||||
const r = spawnSync(tool.bin, s.argv(safe), { cwd, encoding: 'utf8', timeout, env });
|
||||
const output = `${r.stdout || ''}${r.stderr || ''}`.trim().slice(0, 4000);
|
||||
let status;
|
||||
if (r.error || r.status === null) status = 'error';
|
||||
else if (s.stdoutIsFinding) status = r.status === 0 ? ((r.stdout || '').trim() ? 'finding' : 'clean') : 'error';
|
||||
else if (r.status === 0) status = 'clean';
|
||||
else status = s.findingCodes.includes(r.status) ? 'finding' : 'error';
|
||||
return { name: tool.name, kind: tool.kind, status, exit: r.status ?? null, output: r.error ? String(r.error.message) : output };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const i = process.argv.indexOf('--repo');
|
||||
const start = i !== -1 ? process.argv[i + 1] : null;
|
||||
if (!start) {
|
||||
process.stderr.write('usage: repo-tools.mjs --repo <dir>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const repo = resolveRepo(start);
|
||||
if (!repo) {
|
||||
process.stdout.write(JSON.stringify({ status: 'not_measured', reason: 'not a git repository', tools: [] }, null, 2) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const tools = detectTools(repo.root).map(({ spec, ...t }) => t);
|
||||
const any = tools.some((t) => t.available);
|
||||
process.stdout.write(JSON.stringify({ status: any ? 'tools' : 'not_measured', root: repo.root, tools }, null, 2) + '\n');
|
||||
process.exit(any ? 0 : 2);
|
||||
}
|
||||
127
lib/verification/step-check.mjs
Normal file
127
lib/verification/step-check.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/verification/step-check.mjs
|
||||
// K9 -- the step check. At step end (and on --full) it runs the target repo's
|
||||
// own tools and reads the post-edit guard's log (P7):
|
||||
// - file-tier tools (formatters, linters) over the changed files -- the diff
|
||||
// against HEAD plus untracked files -- or, with --full, over every tracked
|
||||
// file;
|
||||
// - repo-tier tools (type checkers, `go vet`) once;
|
||||
// - the post-edit log: a changed file whose latest record is a finding, and
|
||||
// that no tool re-checked now, still counts as a finding;
|
||||
// - optionally the architecture-rule runner (K5, lib/verification/
|
||||
// arch-rules.mjs, wave-1 order 3): missing or exit 2 = listed as not
|
||||
// measured, exit 1 = a finding.
|
||||
//
|
||||
// Usage: node lib/verification/step-check.mjs --cwd <dir> [--full]
|
||||
// Output: JSON on stdout { status, root, files, not_covered, checks: [...] }.
|
||||
// Exit: 0 = green (something was measured, every configured tool answered,
|
||||
// nothing found), 1 = findings, 2 = not measured (a configured tool
|
||||
// is missing or crashed, nothing could be checked, not a repository,
|
||||
// bad usage). 2 is never green. Changed files no tool covers are listed
|
||||
// in `not_covered`.
|
||||
//
|
||||
// Credit: the tiers and "not measured" are agent-skills' constraint-driven
|
||||
// development (MIT, `9d0c60d`); the stop at step end is Cherny's and Kiro's.
|
||||
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveRepo, detectTools, runTool } from './repo-tools.mjs';
|
||||
import { latestPostEditRecords } from '../../hooks/lib/post-edit.mjs';
|
||||
import { runContract } from '../../hooks/lib/contract-cli.mjs';
|
||||
|
||||
// NUL-separated and unquoted, so a name with spaces or non-ASCII letters
|
||||
// comes back as it is on disk (git quotes such names by default).
|
||||
function gitPaths(cwd, args) {
|
||||
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args, '-z'], { cwd, encoding: 'utf8', timeout: 60_000 });
|
||||
if (r.status !== 0) return null;
|
||||
return r.stdout.split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
function changedFiles(root) {
|
||||
const tracked = gitPaths(root, ['diff', '--name-only', 'HEAD']) ?? gitPaths(root, ['diff', '--name-only']) ?? [];
|
||||
const untracked = gitPaths(root, ['ls-files', '--others', '--exclude-standard']) ?? [];
|
||||
return [...new Set([...tracked, ...untracked])].filter((f) => existsSync(join(root, f)));
|
||||
}
|
||||
|
||||
function allFiles(root) {
|
||||
return (gitPaths(root, ['ls-files']) ?? []).filter((f) => existsSync(join(root, f)));
|
||||
}
|
||||
|
||||
export function stepCheck({ cwd, full = false }) {
|
||||
const repo = resolveRepo(cwd);
|
||||
if (!repo) return { status: 'not_measured', reason: `not a git repository: ${cwd}`, checks: [] };
|
||||
const files = full ? allFiles(repo.root) : changedFiles(repo.root);
|
||||
const tools = detectTools(repo.root);
|
||||
const checks = [];
|
||||
const covered = new Set();
|
||||
|
||||
for (const t of tools) {
|
||||
if (!t.available) {
|
||||
checks.push({ name: t.name, kind: t.kind, status: 'not_measured', reason: t.reason });
|
||||
continue;
|
||||
}
|
||||
if (t.tier === 'repo') {
|
||||
const r = runTool(t, [], { cwd: repo.root, timeout: 300_000 });
|
||||
checks.push({ ...r, status: r.status === 'error' ? 'not_measured' : r.status });
|
||||
continue;
|
||||
}
|
||||
const mine = files.filter((f) => t.ext.includes(extname(f).toLowerCase()));
|
||||
if (mine.length === 0) continue;
|
||||
const r = runTool(t, mine, { cwd: repo.root, timeout: 300_000 });
|
||||
checks.push({ ...r, files: mine, status: r.status === 'error' ? 'not_measured' : r.status });
|
||||
if (r.status !== 'error') mine.forEach((f) => covered.add(f));
|
||||
}
|
||||
|
||||
const log = latestPostEditRecords(repo.gitDir);
|
||||
const stale = files.filter((f) => !covered.has(f) && log.get(f)?.status === 'finding');
|
||||
if (stale.length) {
|
||||
checks.push({ name: 'post-edit-log', kind: 'log', status: 'finding', files: stale,
|
||||
output: 'the post-edit guard logged findings for these files and no tool re-checked them now' });
|
||||
}
|
||||
|
||||
const arch = runContract('K5', ['--repo', repo.root], { cwd: repo.root, timeout: 300_000 });
|
||||
if (!arch.ran) checks.push({ name: 'arch-rules', kind: 'architecture', status: 'not_measured', reason: arch.why });
|
||||
else {
|
||||
const status = arch.code === 0 ? 'clean' : arch.code === 1 ? 'finding' : 'not_measured';
|
||||
checks.push({ name: 'arch-rules', kind: 'architecture', status, exit: arch.code, output: (arch.stdout + arch.stderr).trim().slice(0, 4000) });
|
||||
}
|
||||
|
||||
// Changed files no available tool covers: listed, never dropped. They do not
|
||||
// turn the step red or grey on their own (a README edit in a JS repo is
|
||||
// normal), but the report names them.
|
||||
const notCovered = files.filter((f) => !tools.some((t) => t.available && t.tier === 'file' && t.ext.includes(extname(f).toLowerCase())));
|
||||
|
||||
// A tool the repo configures that gave no answer (not installed, crashed)
|
||||
// means the step was not fully measured: never green. The arch-rule runner
|
||||
// is optional (K5) and is exempt.
|
||||
const unanswered = checks.filter((c) => c.status === 'not_measured' && c.name !== 'arch-rules');
|
||||
const measured = checks.filter((c) => c.status === 'clean' || c.status === 'finding');
|
||||
let status;
|
||||
if (checks.some((c) => c.status === 'finding')) status = 'findings';
|
||||
else if (unanswered.length || measured.length === 0) status = 'not_measured';
|
||||
else status = 'green';
|
||||
return { status, root: repo.root, full, files, not_covered: notCovered, checks };
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const argv = process.argv.slice(2);
|
||||
const i = argv.indexOf('--cwd');
|
||||
const cwd = i !== -1 ? argv[i + 1] : null;
|
||||
if (!cwd) {
|
||||
process.stdout.write(JSON.stringify({ status: 'not_measured', reason: 'usage: step-check.mjs --cwd <dir> [--full]', checks: [] }) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const report = stepCheck({ cwd, full: argv.includes('--full') });
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
process.exit(report.status === 'green' ? 0 : report.status === 'findings' ? 1 : 2);
|
||||
}
|
||||
126
tests/hooks/approval-ask.test.mjs
Normal file
126
tests/hooks/approval-ask.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// tests/hooks/approval-ask.test.mjs
|
||||
// P12 — approval as a guard. The Bash guard answers `permissionDecision: "ask"`
|
||||
// for merge into main, push, tag/release and destructive git, so a person sees
|
||||
// the call before it runs — also in auto mode (code.claude.com/docs/en/hooks
|
||||
// § PreToolUse decision control: «A hook's "ask" also forces a permission
|
||||
// prompt in auto mode»). Tested both ways per event: every "must ask" has a
|
||||
// "must pass" neighbour, so a guard that asks about everything fails here too.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { join } from 'node:path';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, scriptsFor, makeRepo, gitIn, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs')).catch(() => ({}));
|
||||
|
||||
const MAIN_REPO = makeRepo('main');
|
||||
const FEATURE_REPO = makeRepo('main');
|
||||
gitIn(FEATURE_REPO, 'checkout', '-q', '-b', 'feat');
|
||||
|
||||
async function decide(command, cwd = FEATURE_REPO) {
|
||||
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return { ...r, out, decision: out?.hookSpecificOutput?.permissionDecision ?? null };
|
||||
}
|
||||
|
||||
const MUST_ASK = [
|
||||
// [command, cwd, why]
|
||||
['git push origin main', FEATURE_REPO, 'push to main'],
|
||||
['git push', FEATURE_REPO, 'push (any branch)'],
|
||||
['git push origin feat', FEATURE_REPO, 'push of a feature branch'],
|
||||
['git push --force-with-lease origin feat', FEATURE_REPO, 'force push'],
|
||||
['git push --tags', FEATURE_REPO, 'push of tags'],
|
||||
['git -C /tmp/x push origin main', FEATURE_REPO, 'git with -C before the subcommand'],
|
||||
['FOO=1 git push origin main', FEATURE_REPO, 'env-prefixed push'],
|
||||
['npm test && git push origin main', FEATURE_REPO, 'push after a test'],
|
||||
['sh -c "git push origin main"', FEATURE_REPO, 'push inside a shell wrapper'],
|
||||
['git merge --no-ff worktree-x', MAIN_REPO, 'merge while on main'],
|
||||
['git merge --ff-only feat', MAIN_REPO, 'fast-forward merge while on main'],
|
||||
['git checkout main && git merge feat', FEATURE_REPO, 'switch to main, then merge'],
|
||||
['git switch master; git merge feat', FEATURE_REPO, 'switch to master, then merge'],
|
||||
['git tag v1.2.3', FEATURE_REPO, 'create a tag'],
|
||||
['git tag -a v1 -m "release"', FEATURE_REPO, 'annotated tag'],
|
||||
['git tag -d v1', FEATURE_REPO, 'delete a tag'],
|
||||
['npm publish', FEATURE_REPO, 'publish a package'],
|
||||
['git reset --hard HEAD~1', FEATURE_REPO, 'hard reset'],
|
||||
['git clean -fd', FEATURE_REPO, 'clean with force'],
|
||||
['git clean -xdf', FEATURE_REPO, 'clean with combined flags'],
|
||||
['git branch -D feat', FEATURE_REPO, 'force-delete a branch'],
|
||||
['git branch --delete --force feat', FEATURE_REPO, 'force-delete, long flags'],
|
||||
['git stash drop', FEATURE_REPO, 'drop a stash'],
|
||||
['git stash clear', FEATURE_REPO, 'clear all stashes'],
|
||||
['git checkout -- src/a.js', FEATURE_REPO, 'discard changes to a file'],
|
||||
['git checkout .', FEATURE_REPO, 'discard all changes'],
|
||||
['git restore src/a.js', FEATURE_REPO, 'restore the working tree'],
|
||||
['git filter-branch --tree-filter x HEAD', FEATURE_REPO, 'history rewrite'],
|
||||
];
|
||||
|
||||
const MUST_PASS = [
|
||||
['git status', FEATURE_REPO, 'read-only'],
|
||||
['git log --oneline -5', FEATURE_REPO, 'read-only'],
|
||||
['git merge-base --is-ancestor main HEAD', MAIN_REPO, 'merge-base is not a merge'],
|
||||
['git merge feat', FEATURE_REPO, 'merge into a feature branch'],
|
||||
['git merge --abort', MAIN_REPO, 'aborting a merge'],
|
||||
['git tag', FEATURE_REPO, 'list tags'],
|
||||
['git tag -l "v*"', FEATURE_REPO, 'list tags with a pattern'],
|
||||
['git tag --list', FEATURE_REPO, 'list tags, long flag'],
|
||||
['echo "git push origin main"', FEATURE_REPO, 'quoted data, not a command'],
|
||||
['git commit -m "never git push to main"', FEATURE_REPO, 'commit message naming push'],
|
||||
['git restore --staged src/a.js', FEATURE_REPO, 'unstage only'],
|
||||
['git branch -d feat', FEATURE_REPO, 'safe branch delete'],
|
||||
['git stash list', FEATURE_REPO, 'list stashes'],
|
||||
['git checkout -b new-branch', FEATURE_REPO, 'create a branch'],
|
||||
['git checkout main', FEATURE_REPO, 'switch branch'],
|
||||
['git fetch origin', FEATURE_REPO, 'fetch'],
|
||||
['npm test', FEATURE_REPO, 'tests'],
|
||||
['git worktree remove /tmp/wt --force', FEATURE_REPO, 'worktree cleanup (Hard Rule 12)'],
|
||||
['git clean -n', FEATURE_REPO, 'clean dry run'],
|
||||
];
|
||||
|
||||
for (const [cmd, cwd, why] of MUST_ASK) {
|
||||
test(`P12 must ask: ${cmd} (${why})`, async () => {
|
||||
const r = await decide(cmd, cwd);
|
||||
assert.equal(r.code, 0, `ask is a JSON answer on exit 0, not a block; stderr: ${r.stderr}`);
|
||||
assert.equal(r.decision, 'ask', `expected permissionDecision "ask" for \`${cmd}\`; stdout: ${r.stdout}`);
|
||||
assert.equal(r.out.hookSpecificOutput.hookEventName, 'PreToolUse');
|
||||
const reason = r.out.hookSpecificOutput.permissionDecisionReason;
|
||||
assert.match(reason, /Voyage/, 'the prompt names Voyage as the asker');
|
||||
assert.match(reason, /Way forward:/, 'the prompt explains the way forward');
|
||||
});
|
||||
}
|
||||
|
||||
for (const [cmd, cwd, why] of MUST_PASS) {
|
||||
test(`P12 must pass: ${cmd} (${why})`, async () => {
|
||||
const r = await decide(cmd, cwd);
|
||||
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
||||
assert.equal(r.decision, null, `no ask expected for \`${cmd}\`; stdout: ${r.stdout}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('P12: a BLOCK rule still wins over ask (exit 2, no JSON)', async () => {
|
||||
const r = await decide('git push origin main; rm -rf /');
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('P12: the Bash guard that asks is the one hooks.json runs for Bash', () => {
|
||||
const scripts = scriptsFor('PreToolUse', 'Bash');
|
||||
assert.ok(scripts.some((s) => s.endsWith('pre-bash-executor.mjs')), JSON.stringify(scripts));
|
||||
});
|
||||
|
||||
test('P12: approval kinds follow the operator decision (06.10): merge/push to main = merge, tag/publish = release, the rest unlogged', () => {
|
||||
assert.equal(typeof classifyGitApproval, 'function', 'hooks/lib/git-approval.mjs must export classifyGitApproval');
|
||||
const kinds = (cmd, branch) => classifyGitApproval(cmd, { branch }).map((m) => m.kind);
|
||||
assert.deepEqual(kinds('git merge feat', 'main'), ['merge']);
|
||||
assert.deepEqual(kinds('git push origin main', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git push origin HEAD:refs/heads/main', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git push', 'main'), ['merge'], 'a bare push from main pushes main');
|
||||
assert.deepEqual(kinds('git push origin feat', 'feat'), [null]);
|
||||
assert.deepEqual(kinds('git tag v1', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('git push --tags', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('npm publish', 'feat'), ['release']);
|
||||
assert.deepEqual(kinds('git reset --hard', 'feat'), [null]);
|
||||
assert.deepEqual(kinds('git status', 'main'), []);
|
||||
});
|
||||
135
tests/hooks/approval-log.test.mjs
Normal file
135
tests/hooks/approval-log.test.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// tests/hooks/approval-log.test.mjs
|
||||
// P12 + P3 — an approval a person gave through Claude Code's prompt is written
|
||||
// to the approval log (contract K2, lib/util/approval-log.mjs, owned by wave-1
|
||||
// order 2). The log CLI is a STUB here until order 2 is merged: the stub
|
||||
// records its argv. When the CLI is missing, the guard says "not logged" —
|
||||
// never green, never silent.
|
||||
//
|
||||
// Operator decision 06.10: merge/push to main is logged as `merge`, tag and
|
||||
// publish as `release`; push of other branches and destructive git are asked
|
||||
// about but not logged. P12 approvals carry an expiry (`--expires`).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { scriptsFor, pluginCopy, makeRepo, gitIn, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const K2 = 'lib/util/approval-log.mjs';
|
||||
const STUBBED = pluginCopy({ [K2]: 'stub' });
|
||||
const MISSING = pluginCopy({ [K2]: 'missing' });
|
||||
const hookIn = (root) => join(root, 'hooks', 'scripts', 'post-approval-log.mjs');
|
||||
|
||||
const REPO = makeRepo('main');
|
||||
gitIn(REPO, 'checkout', '-q', '-b', 'feat');
|
||||
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
let callNo = 0;
|
||||
|
||||
// A Bash call goes through the same two hooks as in a session: the guard asks
|
||||
// (and records what it asked), the call runs, then the approval is logged.
|
||||
async function post(root, input, env = {}) {
|
||||
const logFile = join(tmp(), 'stub.jsonl');
|
||||
const ids = { session_id: 'sess-log', tool_use_id: `toolu_log_${(callNo += 1)}` };
|
||||
if (input.tool_name === 'Bash') {
|
||||
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
|
||||
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);
|
||||
}
|
||||
const r = await runHookWithEnv(hookIn(root), { hook_event_name: 'PostToolUse', cwd: REPO, ...ids, ...input }, { STUB_LOG: logFile, ...env });
|
||||
return { ...r, calls: readStubLog(logFile), out: parseJsonOut(r.stdout) };
|
||||
}
|
||||
|
||||
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: '', stderr: '' } });
|
||||
|
||||
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
|
||||
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
|
||||
const exitPlan = (plan, filePath = '/tmp/plans/p.md') => ({
|
||||
tool_name: 'ExitPlanMode',
|
||||
tool_input: { plan, planFilePath: filePath },
|
||||
tool_response: { plan, filePath },
|
||||
});
|
||||
|
||||
function arg(call, flag) {
|
||||
const i = call.argv.indexOf(flag);
|
||||
return i === -1 ? undefined : call.argv[i + 1];
|
||||
}
|
||||
|
||||
test('K2: push to main is logged as merge, with subject, sha256 and an expiry', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 1, `one append expected; got ${JSON.stringify(r.calls)}`);
|
||||
const c = r.calls[0];
|
||||
assert.equal(c.argv[0], 'append');
|
||||
assert.equal(arg(c, '--kind'), 'merge');
|
||||
assert.ok(arg(c, '--subject'), 'subject (a path) is given');
|
||||
assert.match(arg(c, '--sha256'), /^[0-9a-f]{64}$/);
|
||||
const exp = Date.parse(arg(c, '--expires'));
|
||||
assert.ok(Number.isFinite(exp) && exp > Date.now(), 'expires is a future ISO time');
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /approval logged/i);
|
||||
});
|
||||
|
||||
test('K2: a tag is logged as release', async () => {
|
||||
const r = await post(STUBBED, bash('git tag v9.9.9'));
|
||||
assert.equal(r.calls.length, 1);
|
||||
assert.equal(arg(r.calls[0], '--kind'), 'release');
|
||||
});
|
||||
|
||||
test('K2: push of a feature branch is asked about but NOT logged', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin feat'));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('K2: destructive git is asked about but NOT logged', async () => {
|
||||
const r = await post(STUBBED, bash('git reset --hard HEAD~1'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('K2: an ordinary command logs nothing and says nothing', async () => {
|
||||
const r = await post(STUBBED, bash('npm test'));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('K2 missing: the approval is reported "not logged", never green', async () => {
|
||||
const r = await post(MISSING, bash('git push origin main'));
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('K2 refuses (exit 1): reported "not logged"', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '1' });
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('K2 could not run (exit 2): reported "not logged"', async () => {
|
||||
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '2' });
|
||||
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
test('P3: an approved Voyage plan is logged as plan, bound to the sha256 of the approved text', async () => {
|
||||
const r = await post(STUBBED, exitPlan(VOYAGE_PLAN, '/tmp/plans/voyage-plan.md'));
|
||||
assert.equal(r.calls.length, 1, JSON.stringify(r.calls));
|
||||
const c = r.calls[0];
|
||||
assert.equal(arg(c, '--kind'), 'plan');
|
||||
assert.equal(arg(c, '--subject'), '/tmp/plans/voyage-plan.md');
|
||||
assert.equal(arg(c, '--sha256'), sha(VOYAGE_PLAN));
|
||||
});
|
||||
|
||||
test('P3: an approved plan without plan_version is not Voyage\'s and is not logged', async () => {
|
||||
const r = await post(STUBBED, exitPlan(OTHER_PLAN));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 0);
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('wiring: hooks.json runs post-approval-log on PostToolUse for Bash and ExitPlanMode', () => {
|
||||
for (const tool of ['Bash', 'ExitPlanMode']) {
|
||||
const s = scriptsFor('PostToolUse', tool);
|
||||
assert.ok(s.some((p) => p.endsWith('post-approval-log.mjs')), `${tool}: ${JSON.stringify(s)}`);
|
||||
}
|
||||
});
|
||||
143
tests/hooks/guard-helpers.mjs
Normal file
143
tests/hooks/guard-helpers.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// tests/hooks/guard-helpers.mjs
|
||||
// Shared helpers for the wave-1 guard tests (P3, P7, P9 lock, P12). Not a test
|
||||
// file itself (no `.test.mjs`), so `npm test` does not run it.
|
||||
//
|
||||
// - Resolves hooks.json the way Claude Code does (matcher semantics:
|
||||
// code.claude.com/docs/en/hooks § Matcher patterns), so a wiring gap fails
|
||||
// even when a script is correct. Same rules as write-guard-coverage.test.mjs.
|
||||
// - Builds throwaway git repos and a throwaway copy of the plugin in which the
|
||||
// contract CLIs another wave-1 order owns (K2 approval log, K3 plan gate, K5
|
||||
// arch rules) are stubs that record their argv and exit with a chosen code.
|
||||
|
||||
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, readdirSync, chmodSync, existsSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export const ROOT = join(HERE, '..', '..');
|
||||
export const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
|
||||
|
||||
// "*", "" or omitted match all; a value of only letters, digits, _, -, spaces,
|
||||
// `,` and `|` is a list of exact names; anything else is an unanchored regex.
|
||||
export function matcherFires(matcher, name) {
|
||||
if (matcher === undefined || matcher === '' || matcher === '*') return true;
|
||||
if (/^[A-Za-z0-9_\- ,|]+$/.test(matcher)) {
|
||||
return matcher.split(/[|,]/).map((s) => s.trim()).includes(name);
|
||||
}
|
||||
return new RegExp(matcher).test(name);
|
||||
}
|
||||
|
||||
// Every script hooks.json would run for this event (and tool, when the event
|
||||
// has a matcher), with ${CLAUDE_PLUGIN_ROOT} resolved to `root`.
|
||||
export function scriptsFor(event, name, root = ROOT) {
|
||||
const cfg = JSON.parse(readFileSync(join(root, 'hooks', 'hooks.json'), 'utf8'));
|
||||
const out = [];
|
||||
for (const entry of cfg.hooks[event] || []) {
|
||||
if (name !== undefined && !matcherFires(entry.matcher, name)) continue;
|
||||
for (const h of entry.hooks || []) {
|
||||
const arg = (h.args || []).find((a) => a.endsWith('.mjs'));
|
||||
if (arg) out.push(arg.replace('${CLAUDE_PLUGIN_ROOT}', root));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tmp(prefix = 'voyage-guard-') {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
}
|
||||
|
||||
// A git repo with one commit on `branch`.
|
||||
export function makeRepo(branch = 'main') {
|
||||
const dir = tmp('voyage-repo-');
|
||||
git(dir, 'init', '-q', '-b', branch);
|
||||
git(dir, 'config', 'user.email', 't@example.invalid');
|
||||
git(dir, 'config', 'user.name', 'T');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
git(dir, 'config', 'tag.gpgsign', 'false');
|
||||
writeFileSync(join(dir, 'README.md'), '# t\n');
|
||||
git(dir, 'add', 'README.md');
|
||||
git(dir, 'commit', '-q', '-m', 'init');
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function gitIn(cwd, ...args) {
|
||||
return git(cwd, ...args);
|
||||
}
|
||||
|
||||
// A stub CLI: records its argv and cwd as one JSON line in $STUB_LOG, prints
|
||||
// $STUB_STDOUT, exits with $STUB_EXIT (default 0).
|
||||
const STUB = `#!/usr/bin/env node
|
||||
import { appendFileSync } from 'node:fs';
|
||||
const log = process.env.STUB_LOG;
|
||||
if (log) appendFileSync(log, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
|
||||
if (process.env.STUB_STDOUT) process.stdout.write(process.env.STUB_STDOUT);
|
||||
process.exit(Number(process.env.STUB_EXIT || 0));
|
||||
`;
|
||||
|
||||
function copyTree(src, dst) {
|
||||
mkdirSync(dst, { recursive: true });
|
||||
for (const e of readdirSync(src, { withFileTypes: true })) {
|
||||
const s = join(src, e.name);
|
||||
const d = join(dst, e.name);
|
||||
if (e.isDirectory()) copyTree(s, d);
|
||||
else copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
|
||||
// A copy of the plugin's hooks/ and the lib modules the guards import, with
|
||||
// the named contract CLIs replaced by the stub (or left out when `missing`).
|
||||
// stubs: { 'lib/util/approval-log.mjs': 'stub' | 'missing', ... }
|
||||
export function pluginCopy(stubs = {}) {
|
||||
const root = tmp('voyage-plugin-');
|
||||
copyTree(join(ROOT, 'hooks'), join(root, 'hooks'));
|
||||
copyTree(join(ROOT, 'lib'), join(root, 'lib'));
|
||||
for (const [rel, mode] of Object.entries(stubs)) {
|
||||
const p = join(root, rel);
|
||||
if (mode === 'missing') {
|
||||
rmSync(p, { force: true });
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, STUB);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function readStubLog(path) {
|
||||
if (!existsSync(path)) return [];
|
||||
return readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
// An executable shell stub at <dir>/<name>: exits 1 and prints a line when any
|
||||
// file argument contains the text BAD, else exits 0. Used as a fake formatter
|
||||
// or linter so the post-edit check can be shown to stop AND to pass.
|
||||
export function fakeTool(dir, name, { findingExit = 1 } = {}) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const p = join(dir, name);
|
||||
writeFileSync(
|
||||
p,
|
||||
`#!/bin/sh
|
||||
found=0
|
||||
for a in "$@"; do
|
||||
case "$a" in -*) continue ;; esac
|
||||
if [ -f "$a" ] && grep -q BAD "$a"; then echo "${name}: problem in $a"; found=1; fi
|
||||
done
|
||||
if [ "$found" = 1 ]; then exit ${findingExit}; fi
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
chmodSync(p, 0o755);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function parseJsonOut(stdout) {
|
||||
const s = String(stdout || '').trim();
|
||||
if (!s) return null;
|
||||
return JSON.parse(s);
|
||||
}
|
||||
301
tests/hooks/guards-review-regressions.test.mjs
Normal file
301
tests/hooks/guards-review-regressions.test.mjs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// tests/hooks/guards-review-regressions.test.mjs
|
||||
// Regressions from the independent review of the wave-1 guards (06.10). Each
|
||||
// test reproduces one finding the first test set did not cover; all failed
|
||||
// before the fix.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
||||
const LOCK = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
||||
const STEP = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs'));
|
||||
|
||||
const FEAT = makeRepo('main');
|
||||
gitIn(FEAT, 'checkout', '-q', '-b', 'feat');
|
||||
const MAIN = makeRepo('main');
|
||||
|
||||
async function decision(command, cwd = FEAT) {
|
||||
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return out?.hookSpecificOutput?.permissionDecision ?? (r.code === 2 ? 'block' : null);
|
||||
}
|
||||
|
||||
// 1. Quotes that are not quotes
|
||||
for (const cmd of [
|
||||
"# push it, it's ready\ngit push origin main",
|
||||
"echo don\\'t; git push origin main",
|
||||
"echo 'unterminated; git push origin main",
|
||||
]) {
|
||||
test(`review 1: an apostrophe that is not a closed quote does not hide a push: ${JSON.stringify(cmd)}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Quoted program, subcommand or ref
|
||||
for (const cmd of ['git "push" origin feat', '"git" push', "'git' push", "git 'push' origin feat"]) {
|
||||
test(`review 2: a quoted word is still the word: ${cmd}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
test('review 2: a quoted ref keeps its kind (push to "main" is a merge approval)', () => {
|
||||
assert.deepEqual(classifyGitApproval('git push origin "main"', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 2: quoted data with spaces stays data', async () => {
|
||||
assert.equal(await decision('echo "git push origin main"'), null);
|
||||
assert.equal(await decision('git commit -m "wip"'), null);
|
||||
});
|
||||
|
||||
// 3. Wrappers
|
||||
for (const cmd of ['timeout 60 git push', 'nice git push', 'nice -n 5 git push', 'env -i git push', 'env -u HOME git push',
|
||||
'sudo -u bob git push', 'command -p git push', 'time -p git push', 'eval git push', 'xargs -n 1 git push']) {
|
||||
test(`review 3: wrapper seen through: ${cmd}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
// 8. One branch model for the whole command
|
||||
test('review 8: switching to main then pushing is a push to main', () => {
|
||||
assert.deepEqual(classifyGitApproval('git checkout main && git push', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 8: switching away from main then merging is not a merge into main', () => {
|
||||
assert.deepEqual(classifyGitApproval('git checkout feat && git merge x', { branch: 'main' }), []);
|
||||
});
|
||||
test('review 8: git -C <dir> reads that repo\'s branch', () => {
|
||||
assert.deepEqual(classifyGitApproval(`git -C ${MAIN} merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
test('review 8: cd <dir> && ... reads that repo\'s branch', () => {
|
||||
assert.deepEqual(classifyGitApproval(`cd ${MAIN} && git merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
||||
});
|
||||
|
||||
test('review 8: the log records what the person was asked, not a re-reading after the command', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
gitIn(repo, 'checkout', '-q', '-b', 'feat');
|
||||
const cmd = 'git checkout main && git merge feat';
|
||||
const base = { session_id: 's-8', tool_use_id: 'toolu_8', cwd: repo, tool_name: 'Bash', tool_input: { command: cmd } };
|
||||
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
assert.match(pre.stdout, /merge into main/);
|
||||
gitIn(repo, 'checkout', '-q', 'main'); // the command ran
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
const calls = readStubLog(log);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].argv[calls[0].argv.indexOf('--kind') + 1], 'merge');
|
||||
});
|
||||
|
||||
test('review 8: a command nobody was asked about is not logged, even if it reads like one afterwards', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
|
||||
hook_event_name: 'PostToolUse', session_id: 's-9', tool_use_id: 'toolu_never_asked', cwd: repo,
|
||||
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
||||
}, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
test('uncertain (bypass mode): an approval that no person gave is not logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const base = { session_id: 's-b', tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
|
||||
await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
// 9. Classification
|
||||
const kinds = (cmd, branch = 'feat', cwd) => classifyGitApproval(cmd, { branch, cwd }).map((m) => m.kind);
|
||||
for (const cmd of ['git tag -v v1', 'git tag --verify v1', 'git push -n', 'git push --dry-run origin main']) {
|
||||
test(`review 9: not asked: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
||||
}
|
||||
for (const cmd of ['npm --tag beta publish', 'pnpm -r publish', 'yarn npm publish', 'npx npm publish', 'gh release create v1']) {
|
||||
test(`review 9: release: ${cmd}`, () => assert.deepEqual(kinds(cmd), ['release']));
|
||||
}
|
||||
test('review 9: pushing an existing tag by name is a release', () => {
|
||||
const repo = makeRepo('main');
|
||||
gitIn(repo, 'tag', 'v1.0');
|
||||
assert.deepEqual(kinds('git push origin v1.0', 'main', repo), ['release']);
|
||||
});
|
||||
for (const cmd of ['git checkout HEAD file.txt', 'git switch -f main', 'git switch --discard-changes main', 'git branch -M main']) {
|
||||
test(`review 9: destructive: ${cmd}`, () => assert.deepEqual(kinds(cmd, 'feat'), [null]));
|
||||
}
|
||||
test('review 9: deleting main on the remote is destructive, not a merge approval', () => {
|
||||
assert.deepEqual(kinds('git push origin :main'), [null]);
|
||||
assert.deepEqual(kinds('git push origin --delete main'), [null]);
|
||||
});
|
||||
|
||||
// 5 + 6 + 7. The write guard
|
||||
test('review 5: a lock module that cannot load blocks the write (fail closed), the path rules still run', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/test-lock.mjs': 'missing' });
|
||||
const hook = join(plugin, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const r = await runHook(hook, { tool_name: 'Edit', tool_input: { file_path: join(makeRepo('main'), 'a.js') } });
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.stderr, /could not load/i);
|
||||
});
|
||||
test('review 5: a guard module that cannot load makes the Bash guard ask, never pass silently', async () => {
|
||||
const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' });
|
||||
const r = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { tool_name: 'Bash', tool_input: { command: 'ls' } });
|
||||
assert.equal(parseJsonOut(r.stdout)?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
});
|
||||
|
||||
function lockIn(repo, rel, env = {}) {
|
||||
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
test('review 6: another letter case does not get past the lock (case-insensitive file systems)', async () => {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'));
|
||||
writeFileSync(join(repo, 'tests', 'a.test.mjs'), 'x');
|
||||
lockIn(repo, 'tests/a.test.mjs');
|
||||
const upper = join(repo, 'Tests', 'a.test.mjs');
|
||||
let caseInsensitive = false;
|
||||
try { realpathSync(upper); caseInsensitive = true; } catch { caseInsensitive = false; }
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Edit', tool_input: { file_path: upper } });
|
||||
if (caseInsensitive) assert.equal(r.code, 2, 'same file, other case');
|
||||
else assert.equal(r.code, 0, 'a different file on a case-sensitive file system');
|
||||
});
|
||||
|
||||
test('review 7: the lock file itself cannot be written with the file tools (repo and worktree git dirs)', async () => {
|
||||
for (const p of ['/r/.git/voyage/test-lock.json', '/r/.git/worktrees/wt/voyage/test-lock.json', '/r/.git/voyage/post-edit.jsonl']) {
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
||||
assert.equal(r.code, 2, p);
|
||||
}
|
||||
});
|
||||
|
||||
// 10. The stop guard finds the session's lock wherever the session now stands
|
||||
const SESS_MOVED = `sess-moved-${process.pid}-${Date.now()}`;
|
||||
test('review 10: the stop guard finds the lock taken by this session from another directory', async () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.test.js'), 'x');
|
||||
lockIn(repo, 'a.test.js', { CLAUDE_CODE_SESSION_ID: SESS_MOVED });
|
||||
const r = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
||||
assert.equal(parseJsonOut(r.stdout)?.decision, 'block');
|
||||
spawnSync(process.execPath, [LOCK, 'unlock'], { cwd: repo });
|
||||
const after = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
||||
assert.equal(parseJsonOut(after.stdout), null, 'unlock clears it');
|
||||
});
|
||||
|
||||
// 4, 11, 12. The step check
|
||||
function runStep(repo) {
|
||||
const r = spawnSync(process.execPath, [STEP, '--cwd', repo], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
||||
return { code: r.status, json: JSON.parse(r.stdout) };
|
||||
}
|
||||
function jsRepo() {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n');
|
||||
gitIn(repo, 'add', '.prettierrc', '.gitignore');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tools');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
return repo;
|
||||
}
|
||||
test('review 4: a configured tool that crashed is not green', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'eslint.config.js'), 'export default []');
|
||||
// eslint is configured and installed, but crashes (exit 2 = config error) on every call
|
||||
writeFileSync(join(repo, 'node_modules', '.bin', 'eslint'), '#!/bin/sh\nexit 2\n', { mode: 0o755 });
|
||||
writeFileSync(join(repo, 'a.js'), 'ok'); // prettier: clean
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 2, JSON.stringify(r.json));
|
||||
assert.equal(r.json.status, 'not_measured');
|
||||
});
|
||||
test('review 4: changed files no tool covers are listed, never silently dropped', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok');
|
||||
writeFileSync(join(repo, 'b.py'), 'x = 1');
|
||||
const r = runStep(repo);
|
||||
assert.deepEqual(r.json.not_covered, ['b.py']);
|
||||
});
|
||||
test('review 11: a changed file whose name starts with "-" reaches the tool as a file, not a flag', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, '-x.js'), 'BAD');
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 1, JSON.stringify(r.json));
|
||||
});
|
||||
test('review 12: a changed file with a non-ASCII name is checked', () => {
|
||||
const repo = jsRepo();
|
||||
writeFileSync(join(repo, 'blåbær.js'), 'BAD');
|
||||
const r = runStep(repo);
|
||||
assert.equal(r.code, 1, JSON.stringify(r.json));
|
||||
});
|
||||
|
||||
test('uncertain (relative plan path): the plan gate resolves planFilePath against the session cwd', async () => {
|
||||
const plugin = pluginCopy({ 'lib/validators/plan-gate.mjs': 'stub' });
|
||||
const dir = tmp();
|
||||
writeFileSync(join(dir, 'plan.md'), '---\nplan_version: 1.7\n---\n');
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-plan-gate.mjs'), {
|
||||
hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: dir,
|
||||
tool_input: { plan: '---\nplan_version: 1.7\n---\n', planFilePath: 'plan.md' },
|
||||
}, { STUB_LOG: log, STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 2, r.stdout);
|
||||
assert.equal(readStubLog(log)[0].argv[0], join(dir, 'plan.md'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Second review round (06.10): what the first fixes left open or introduced.
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const cmd of [
|
||||
'git commit -m "fix \\"x\\" bug" && git push origin main && echo "ok"',
|
||||
'echo "a\\"b" ; git push origin main ; echo "c"',
|
||||
'if git push origin main; then echo ok; fi',
|
||||
'while git push origin main; do sleep 1; done',
|
||||
"eval 'git push origin main'",
|
||||
'echo a\\ #b; git push origin main',
|
||||
]) {
|
||||
test(`review 2nd round: still asks: ${JSON.stringify(cmd)}`, async () => {
|
||||
assert.equal(await decision(cmd), 'ask');
|
||||
});
|
||||
}
|
||||
|
||||
test('review 2nd round (7): the .git/voyage block ignores letter case', async () => {
|
||||
for (const p of ['/r/.GIT/voyage/test-lock.json', '/r/.git/VOYAGE/test-lock.json', '/r/.Git/hooks/pre-commit']) {
|
||||
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
||||
assert.equal(r.code, 2, p);
|
||||
}
|
||||
});
|
||||
|
||||
test('review 2nd round (N1): redirections are not file arguments', () => {
|
||||
assert.deepEqual(kinds('git checkout main 2>/dev/null && git merge x', 'feat'), ['merge']);
|
||||
assert.deepEqual(kinds('git checkout feat2 2>&1', 'feat'), []);
|
||||
assert.deepEqual(kinds('git push origin 2>&1', 'main'), ['merge']);
|
||||
assert.deepEqual(kinds('git checkout main > /dev/null && git merge x', 'feat'), ['merge']);
|
||||
});
|
||||
|
||||
for (const cmd of ['npm publish --dry-run', 'cargo publish --dry-run', 'npm view x publish', 'yarn info publish']) {
|
||||
test(`review 2nd round (N2): not a release: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
||||
}
|
||||
|
||||
test('review 2nd round (N5): a detached switch leaves no branch to merge into', () => {
|
||||
assert.deepEqual(kinds('git switch --detach main && git merge x', 'feat'), []);
|
||||
assert.deepEqual(kinds('git checkout --detach main && git merge x', 'feat'), []);
|
||||
});
|
||||
|
||||
test('review 2nd round: a push option value is not the remote', () => {
|
||||
assert.deepEqual(kinds('git push -o ci.skip origin main', 'feat'), ['merge']);
|
||||
});
|
||||
|
||||
test('review 2nd round (N4): when the record cannot be written, the prompt says the approval will not be logged', async () => {
|
||||
const plugin = pluginCopy({});
|
||||
// TMPDIR points at a file, so the record directory cannot be created.
|
||||
const fileAsDir = join(tmp(), 'not-a-dir');
|
||||
writeFileSync(fileAsDir, 'x');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), {
|
||||
hook_event_name: 'PreToolUse', session_id: 's-n4', tool_use_id: 'toolu_n4', cwd: makeRepo('main'),
|
||||
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
||||
}, { TMPDIR: fileAsDir });
|
||||
const reason = parseJsonOut(r.stdout).hookSpecificOutput.permissionDecisionReason;
|
||||
assert.match(reason, /will not be logged/i);
|
||||
});
|
||||
95
tests/hooks/plan-gate-hook.test.mjs
Normal file
95
tests/hooks/plan-gate-hook.test.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// tests/hooks/plan-gate-hook.test.mjs
|
||||
// P3 — Claude Code's plan approval, wrapped. A PreToolUse hook on ExitPlanMode
|
||||
// receives the plan text and the plan file path (code.claude.com/docs/en/hooks
|
||||
// § ExitPlanMode: «Claude Code injects the plan content and file path before
|
||||
// passing the input to hooks»), runs Voyage's plan gate (contract K3,
|
||||
// lib/validators/plan-gate.mjs, owned by wave-1 order 3; a STUB here until it
|
||||
// is merged) and sends an invalid Voyage plan back. Only Voyage plans (they
|
||||
// carry `plan_version`) are gated: the hook runs in every session, so every
|
||||
// other plan passes untouched.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { scriptsFor, pluginCopy, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const K3 = 'lib/validators/plan-gate.mjs';
|
||||
const STUBBED = pluginCopy({ [K3]: 'stub' });
|
||||
const MISSING = pluginCopy({ [K3]: 'missing' });
|
||||
const hookIn = (root) => join(root, 'hooks', 'scripts', 'pre-plan-gate.mjs');
|
||||
|
||||
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
|
||||
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
|
||||
|
||||
function planFile(text) {
|
||||
const dir = join(tmp(), 'plans');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, text);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function exitPlanMode(root, plan, env = {}, { withPath = true } = {}) {
|
||||
const logFile = join(tmp(), 'stub.jsonl');
|
||||
const input = { plan };
|
||||
if (withPath) input.planFilePath = planFile(plan);
|
||||
const r = await runHookWithEnv(
|
||||
hookIn(root),
|
||||
{ hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: tmp(), tool_input: input },
|
||||
{ STUB_LOG: logFile, ...env },
|
||||
);
|
||||
let out = null;
|
||||
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
||||
return { ...r, out, calls: readStubLog(logFile), path: input.planFilePath };
|
||||
}
|
||||
|
||||
test('P3 must stop: a Voyage plan the gate refuses (K3 exit 1, open BLOCKER) is sent back', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '1', STUB_STDOUT: 'BLOCKER open: step 2 has no Verify' });
|
||||
assert.equal(r.code, 2, `deny = exit 2; stdout ${r.stdout}`);
|
||||
assert.match(r.stderr, /Voyage/);
|
||||
assert.match(r.stderr, /BLOCKER open/, 'the gate\'s own words reach Claude');
|
||||
assert.equal(r.calls.length, 1);
|
||||
assert.equal(r.calls[0].argv[0], r.path, 'the gate runs on the plan file Claude Code attached');
|
||||
});
|
||||
|
||||
test('P3 must pass: a Voyage plan the gate accepts (K3 exit 0) goes on', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '0' });
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.out, null, 'no decision: Claude Code\'s own approval prompt follows');
|
||||
assert.equal(r.calls.length, 1);
|
||||
});
|
||||
|
||||
test('P3 must pass: a plan without plan_version is not Voyage\'s; the gate is not even run', async () => {
|
||||
const r = await exitPlanMode(STUBBED, OTHER_PLAN, { STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out, null);
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('P3 not measured: K3 could not run (exit 2) -> ask, never a silent pass', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '2' });
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i);
|
||||
});
|
||||
|
||||
test('P3 not measured: K3 missing (order 3 not merged) -> ask "not measured"', async () => {
|
||||
const r = await exitPlanMode(MISSING, VOYAGE_PLAN);
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i);
|
||||
});
|
||||
|
||||
test('P3 not measured: a Voyage plan with no plan file path -> ask "not measured"', async () => {
|
||||
const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, {}, { withPath: false });
|
||||
assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask');
|
||||
assert.equal(r.calls.length, 0);
|
||||
});
|
||||
|
||||
test('wiring: hooks.json runs pre-plan-gate on PreToolUse for ExitPlanMode only', () => {
|
||||
assert.ok(scriptsFor('PreToolUse', 'ExitPlanMode').some((p) => p.endsWith('pre-plan-gate.mjs')));
|
||||
for (const tool of ['Bash', 'Write', 'Edit', 'Agent', 'EnterPlanMode']) {
|
||||
assert.ok(!scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-plan-gate.mjs')), tool);
|
||||
}
|
||||
});
|
||||
126
tests/hooks/post-edit-check.test.mjs
Normal file
126
tests/hooks/post-edit-check.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// tests/hooks/post-edit-check.test.mjs
|
||||
// P7 — the guard after edits (PostToolUse Edit|Write). It runs the target
|
||||
// repo's OWN formatter and linter on the edited file (the fast tier), logs the
|
||||
// result for the step check (K9), and says "not measured" when the repo has no
|
||||
// tool for that file — never green.
|
||||
//
|
||||
// Operator decision 06.10: it is on only while Voyage executes a plan in that
|
||||
// repo, or when the repo has switched it on (.claude/voyage-guards.json with
|
||||
// "post_edit_check": true). Every other session sees nothing.
|
||||
//
|
||||
// Tested both ways: a file the fake formatter rejects must be reported (the
|
||||
// "must stop" side — PostToolUse cannot undo the edit, so the stop happens at
|
||||
// step end, via K9), and a clean file must pass.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, scriptsFor, makeRepo, fakeTool, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const HOOK = join(ROOT, 'hooks', 'scripts', 'post-edit-check.mjs');
|
||||
|
||||
function optIn(repo) {
|
||||
mkdirSync(join(repo, '.claude'), { recursive: true });
|
||||
writeFileSync(join(repo, '.claude', 'voyage-guards.json'), JSON.stringify({ post_edit_check: true }));
|
||||
}
|
||||
|
||||
function executing(repo, status = 'in_progress') {
|
||||
const dir = join(repo, '.claude', 'projects', '2026-10-06-x');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'progress.json'), JSON.stringify({ status, steps: {} }));
|
||||
}
|
||||
|
||||
function prettierRepo() {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}\n');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function afterEdit(repo, rel, content) {
|
||||
const file = join(repo, rel);
|
||||
mkdirSync(join(file, '..'), { recursive: true });
|
||||
writeFileSync(file, content);
|
||||
const r = await runHook(HOOK, {
|
||||
hook_event_name: 'PostToolUse', tool_name: 'Edit', cwd: repo,
|
||||
tool_input: { file_path: file, old_string: 'a', new_string: 'b' },
|
||||
tool_response: { filePath: file },
|
||||
});
|
||||
return { ...r, out: parseJsonOut(r.stdout) };
|
||||
}
|
||||
|
||||
const logOf = (repo) => {
|
||||
const p = join(repo, '.git', 'voyage', 'post-edit.jsonl');
|
||||
return existsSync(p) ? readFileSync(p, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : [];
|
||||
};
|
||||
|
||||
test('P7 off: a repo that has not switched it on and runs no Voyage plan -> silent, no log', async () => {
|
||||
const repo = prettierRepo();
|
||||
const r = await afterEdit(repo, 'src/a.js', 'BAD\n');
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(r.out, null);
|
||||
assert.equal(logOf(repo).length, 0);
|
||||
});
|
||||
|
||||
test('P7 must report: switched on, the repo\'s formatter rejects the file', async () => {
|
||||
const repo = prettierRepo();
|
||||
optIn(repo);
|
||||
const r = await afterEdit(repo, 'src/a.js', 'BAD\n');
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.out?.decision, 'block', `findings go back to Claude next to the tool result: ${r.stdout}`);
|
||||
assert.match(r.out.reason, /prettier/);
|
||||
const log = logOf(repo);
|
||||
assert.equal(log.length, 1);
|
||||
assert.equal(log[0].status, 'finding');
|
||||
});
|
||||
|
||||
test('P7 must pass: switched on, the formatter accepts the file', async () => {
|
||||
const repo = prettierRepo();
|
||||
optIn(repo);
|
||||
const r = await afterEdit(repo, 'src/a.js', 'ok\n');
|
||||
assert.equal(r.code, 0);
|
||||
assert.notEqual(r.out?.decision, 'block');
|
||||
assert.equal(logOf(repo)[0].status, 'clean');
|
||||
});
|
||||
|
||||
test('P7 on during a Voyage plan run (progress.json in_progress), without the switch', async () => {
|
||||
const repo = prettierRepo();
|
||||
executing(repo);
|
||||
const r = await afterEdit(repo, 'src/a.js', 'BAD\n');
|
||||
assert.equal(r.out?.decision, 'block');
|
||||
});
|
||||
|
||||
test('P7 off once the plan run is completed', async () => {
|
||||
const repo = prettierRepo();
|
||||
executing(repo, 'completed');
|
||||
const r = await afterEdit(repo, 'src/a.js', 'BAD\n');
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('P7 not measured: switched on in a repo with no tools -> "not measured", never green', async () => {
|
||||
const repo = makeRepo('main');
|
||||
optIn(repo);
|
||||
const r = await afterEdit(repo, 'src/a.js', 'whatever\n');
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.out?.hookSpecificOutput?.additionalContext || '', /not measured/i);
|
||||
assert.doesNotMatch(r.out.hookSpecificOutput.additionalContext, /\bclean\b/i);
|
||||
assert.equal(logOf(repo)[0].status, 'not_measured');
|
||||
});
|
||||
|
||||
test('P7 not measured: a configured tool that is not installed is not a pass', async () => {
|
||||
const repo = makeRepo('main');
|
||||
optIn(repo);
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}\n'); // config, but no node_modules/.bin/prettier
|
||||
const r = await afterEdit(repo, 'src/a.js', 'BAD\n');
|
||||
assert.match(r.out?.hookSpecificOutput?.additionalContext || '', /not measured/i);
|
||||
});
|
||||
|
||||
test('wiring: hooks.json runs post-edit-check on PostToolUse for Edit and Write', () => {
|
||||
for (const tool of ['Edit', 'Write']) {
|
||||
assert.ok(scriptsFor('PostToolUse', tool).some((p) => p.endsWith('post-edit-check.mjs')), tool);
|
||||
}
|
||||
assert.ok(!scriptsFor('PostToolUse', 'Bash').some((p) => p.endsWith('post-edit-check.mjs')));
|
||||
});
|
||||
26
tests/hooks/storm-matcher.test.mjs
Normal file
26
tests/hooks/storm-matcher.test.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// tests/hooks/storm-matcher.test.mjs
|
||||
// The STORM cap (pre-agent-cap.mjs) must see subagent spawns. The tool was
|
||||
// renamed Task -> Agent in Claude Code 2.1.63; the docs state the old name
|
||||
// still works as an alias «in settings and agent definitions»
|
||||
// (code.claude.com/docs/en/sub-agents, the rename note) and say nothing about
|
||||
// hook matchers, which compare against the tool name the hook input carries.
|
||||
// So the matcher must name `Agent` itself. Resolved with Claude Code's matcher
|
||||
// rules (hooks § Matcher patterns), as write-guard-coverage.test.mjs does.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { scriptsFor } from './guard-helpers.mjs';
|
||||
|
||||
const fires = (tool) => scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-agent-cap.mjs'));
|
||||
|
||||
test('STORM cap fires on Agent (the tool name since 2.1.63)', () => {
|
||||
assert.ok(fires('Agent'), 'pre-agent-cap.mjs must fire for tool_name "Agent"');
|
||||
});
|
||||
|
||||
test('STORM cap still fires on WebSearch, WebFetch and the old Task name', () => {
|
||||
for (const t of ['WebSearch', 'WebFetch', 'Task']) assert.ok(fires(t), t);
|
||||
});
|
||||
|
||||
test('STORM cap does not fire on unrelated tools', () => {
|
||||
for (const t of ['Bash', 'Edit', 'Write', 'Read', 'AgentOutput', 'ExitPlanMode']) assert.ok(!fires(t), t);
|
||||
});
|
||||
131
tests/hooks/test-lock-guard.test.mjs
Normal file
131
tests/hooks/test-lock-guard.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// tests/hooks/test-lock-guard.test.mjs
|
||||
// P9 (3) — the lock on the tests. Two guards read the K8 lock:
|
||||
// - the write guard (pre-write-executor.mjs, PreToolUse Write|Edit|
|
||||
// NotebookEdit) refuses a change to a locked test file;
|
||||
// - the stop guard (stop-test-lock.mjs, Stop) refuses "done" while the step
|
||||
// that took the lock in THIS session still holds it, with the
|
||||
// stop_hook_active guard (code.claude.com/docs/en/hooks § Stop input).
|
||||
// Pattern: Claude Code's own hooks (hooks-guide «Block edits to protected
|
||||
// files»; hooks reference § TaskCompleted/Stop decision control).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { ROOT, scriptsFor, makeRepo, parseJsonOut } from './guard-helpers.mjs';
|
||||
|
||||
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
||||
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
||||
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
||||
|
||||
const RUN = `${process.pid}-${Date.now()}`;
|
||||
const SESS_A = `sess-A-${RUN}`;
|
||||
const SESS_B = `sess-B-${RUN}`;
|
||||
|
||||
function lockedRepo({ session = SESS_A, step = 2 } = {}) {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'), { recursive: true });
|
||||
writeFileSync(join(repo, 'tests', 'locked.test.js'), 'x\n');
|
||||
writeFileSync(join(repo, 'tests', 'free.test.js'), 'y\n');
|
||||
writeFileSync(join(repo, 'nb.ipynb'), '{}\n');
|
||||
const r = spawnSync(process.execPath, [CLI, 'lock', '--files', 'tests/locked.test.js,nb.ipynb', '--step', String(step)], {
|
||||
cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: session },
|
||||
});
|
||||
assert.equal(r.status, 0, `lock failed: ${r.stderr}`);
|
||||
return repo;
|
||||
}
|
||||
|
||||
const edit = (file_path) => ({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path, old_string: 'x', new_string: 'z' } });
|
||||
|
||||
test('P9 lock must stop: Edit on a locked test file -> exit 2, names the step', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 2, r.stderr);
|
||||
assert.match(r.stderr, /locked/i);
|
||||
assert.match(r.stderr, /step 2/);
|
||||
assert.match(r.stderr, /operator/, 'the way forward: a wrong test goes to the operator');
|
||||
});
|
||||
|
||||
test('P9 lock must stop: the same file reached through a symlinked path (/tmp vs /private/tmp)', async () => {
|
||||
const repo = lockedRepo();
|
||||
const real = realpathSync(repo);
|
||||
const alias = real.startsWith('/private/') ? real.slice('/private'.length) : real;
|
||||
const r = await runHook(PRE_WRITE, edit(join(alias, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('P9 lock must stop: Write and NotebookEdit too', async () => {
|
||||
const repo = lockedRepo();
|
||||
const w = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: join(repo, 'tests', 'locked.test.js'), content: '' } });
|
||||
assert.equal(w.code, 2);
|
||||
const n = await runHook(PRE_WRITE, { tool_name: 'NotebookEdit', tool_input: { notebook_path: join(repo, 'nb.ipynb'), new_source: '' } });
|
||||
assert.equal(n.code, 2);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: Edit on an unlocked file in the same repo', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'free.test.js')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: a repo with no lock at all', async () => {
|
||||
const repo = makeRepo('main');
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock must pass: after unlock', async () => {
|
||||
const repo = lockedRepo();
|
||||
spawnSync(process.execPath, [CLI, 'unlock'], { cwd: repo });
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
});
|
||||
|
||||
test('P9 lock fails closed: an unreadable lock blocks writes in that repo', async () => {
|
||||
const repo = lockedRepo();
|
||||
const st = JSON.parse(spawnSync(process.execPath, [CLI, 'status'], { cwd: repo, encoding: 'utf8' }).stdout);
|
||||
writeFileSync(st.lock_path, '{not json');
|
||||
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
||||
assert.equal(r.code, 2);
|
||||
assert.ok(r.stderr.includes(st.lock_path), 'the message names the lock file to inspect');
|
||||
});
|
||||
|
||||
const stop = (repo, extra = {}) => ({ hook_event_name: 'Stop', session_id: SESS_A, cwd: repo, stop_hook_active: false, ...extra });
|
||||
|
||||
test('P9 stop must stop: "done" refused while this session\'s step holds the lock', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(STOP, stop(repo));
|
||||
assert.equal(r.code, 0);
|
||||
const out = parseJsonOut(r.stdout);
|
||||
assert.equal(out?.decision, 'block', r.stdout);
|
||||
assert.match(out.reason, /step 2/);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: stop_hook_active is true (no endless loop)', async () => {
|
||||
const repo = lockedRepo();
|
||||
const r = await runHook(STOP, stop(repo, { stop_hook_active: true }));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: the lock belongs to another session', async () => {
|
||||
const repo = lockedRepo({ session: SESS_B });
|
||||
// a session that holds no lock anywhere stops in a repo another session locked
|
||||
const r = await runHook(STOP, stop(repo, { session_id: `sess-free-${RUN}` }));
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('P9 stop must pass: no lock', async () => {
|
||||
const r = await runHook(STOP, stop(makeRepo('main'), { session_id: `sess-none-${RUN}` }));
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(parseJsonOut(r.stdout), null);
|
||||
});
|
||||
|
||||
test('wiring: the write guard covers the lock for Write, Edit, NotebookEdit; the stop guard runs on Stop', () => {
|
||||
for (const tool of ['Write', 'Edit', 'NotebookEdit']) {
|
||||
assert.ok(scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-write-executor.mjs')), tool);
|
||||
}
|
||||
assert.ok(scriptsFor('Stop').some((p) => p.endsWith('stop-test-lock.mjs')));
|
||||
});
|
||||
71
tests/lib/guards-prose-pins.test.mjs
Normal file
71
tests/lib/guards-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// tests/lib/guards-prose-pins.test.mjs
|
||||
// Prose pins owned by the guards process (wave-1 order 4). Counted as doc pins
|
||||
// by lib/util/test-census.mjs (PIN_FILE_RE matches `prose-pins.test.mjs`).
|
||||
//
|
||||
// The mod precondition: a Claude Code mod that handles `tool.check` answers
|
||||
// AFTER the rules and the PreToolUse hooks, and «its answer can replace
|
||||
// theirs» — it can approve a call a plugin PreToolUse hook blocked
|
||||
// (code.claude.com/docs/en/permissions § Extend permissions with hooks,
|
||||
// verified verbatim 06.10). So Voyage's stop guards are deterministic only
|
||||
// under a stated condition, and the condition is written where the guards are
|
||||
// described: README § Security hardening, SECURITY.md, and the head of every
|
||||
// PreToolUse stop guard.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
export const MOD_PRECONDITION = "Voyage's guards are deterministic only when no installed mod handles `tool.check`.";
|
||||
|
||||
const STOP_GUARDS = [
|
||||
'hooks/scripts/pre-bash-executor.mjs',
|
||||
'hooks/scripts/pre-write-executor.mjs',
|
||||
'hooks/scripts/pre-agent-cap.mjs',
|
||||
'hooks/scripts/pre-plan-gate.mjs',
|
||||
];
|
||||
|
||||
test('mod precondition: README § Security hardening states it', () => {
|
||||
const md = read('README.md');
|
||||
const start = md.indexOf('### Security hardening');
|
||||
assert.ok(start !== -1);
|
||||
const end = md.indexOf('\n### ', start + 1);
|
||||
assert.ok(md.slice(start, end).includes(MOD_PRECONDITION), 'README § Security hardening must state the mod precondition');
|
||||
});
|
||||
|
||||
test('mod precondition: SECURITY.md states it', () => {
|
||||
assert.ok(read('SECURITY.md').includes(MOD_PRECONDITION));
|
||||
});
|
||||
|
||||
test('mod precondition: the head of every PreToolUse stop guard states it', () => {
|
||||
for (const rel of STOP_GUARDS) {
|
||||
const head = read(rel).split('\n').slice(0, 40).join('\n').replace(/^\/\/ ?/gm, '').replace(/\n/g, ' ');
|
||||
assert.ok(head.replace(/\s+/g, ' ').includes(MOD_PRECONDITION), `${rel}: the first 40 lines must state the mod precondition`);
|
||||
}
|
||||
});
|
||||
|
||||
test('mod precondition: the guards cannot see a mod; the docs say so where it is stated', () => {
|
||||
// Looked up 06.10: no hook input field names installed mods; the documented
|
||||
// ways to see them are `/plugin` and a CLI subcommand, both for a person.
|
||||
assert.match(read('SECURITY.md'), /cannot tell whether such a mod is installed/);
|
||||
});
|
||||
|
||||
test('P12 guard: README names the classes it asks about and that it holds in auto mode', () => {
|
||||
const md = read('README.md');
|
||||
const sec = md.slice(md.indexOf('### Security hardening'), md.indexOf('#### Recommended: disable Skill shell execution'));
|
||||
for (const w of ['merge into `main`', 'push', 'tag', 'destructive git', 'auto mode']) {
|
||||
assert.ok(sec.includes(w), `README § Security hardening must mention ${w}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('borrowings are credited where the guards are described (PLAN fixed limit 3)', () => {
|
||||
const md = read('README.md');
|
||||
const sec = md.slice(md.indexOf('### Security hardening'), md.indexOf('#### Recommended: disable Skill shell execution'));
|
||||
for (const src of ['Superpowers', 'agent-skills', 'ai-native-sdlc', 'Pocock', 'Kiro', 'Cherny', 'Claude Code']) {
|
||||
assert.ok(sec.includes(src), `credit missing for ${src}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -65,6 +65,8 @@ const EXEMPT = new Map([
|
|||
['tests/fixtures/synthesis/exploration/dependency-tracer.md', ['c', 2, 'recorded exploration fixture']],
|
||||
['docs/proevesett.md', ['operator-instrument', 3, "the operator's own eval run through Anthropic's `claude plugin eval`; Voyage starts nothing - kept by operator decision 2026-10-02"]],
|
||||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
|
||||
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
|
||||
]);
|
||||
|
||||
function trackedFiles(root) {
|
||||
|
|
@ -137,7 +139,7 @@ test('every EXEMPT entry is tracked, has a reason, and hits exactly its recorded
|
|||
const files = new Set(trackedFiles(ROOT));
|
||||
for (const [rel, [cls, count, reason]] of EXEMPT) {
|
||||
assert.ok(files.has(rel), `EXEMPT names untracked ${rel}`);
|
||||
assert.ok(['c', 'self', 'operator-instrument'].includes(cls), `${rel}: unknown class ${cls}`);
|
||||
assert.ok(['c', 'self', 'operator-instrument', 'mode-read'].includes(cls), `${rel}: unknown class ${cls}`);
|
||||
assert.ok(reason && reason.length > 10, `${rel}: an exemption needs a reason`);
|
||||
const n = scan(ROOT, [rel]).length;
|
||||
assert.equal(n, count, `${rel}: ${n} hit(s), EXEMPT records ${count} - a new hit is a launch, a lost one is a stale entry`);
|
||||
|
|
|
|||
96
tests/lib/repo-tools.test.mjs
Normal file
96
tests/lib/repo-tools.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// tests/lib/repo-tools.test.mjs
|
||||
// lib/verification/repo-tools.mjs finds the target repo's OWN formatter,
|
||||
// linter and type checker. A tool counts only when the repo configures it AND
|
||||
// it is installed locally (node_modules/.bin) or on PATH; nothing is ever
|
||||
// downloaded. Exit codes are mapped per tool to clean / finding / error, and an
|
||||
// error is never read as clean.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRepo, fakeTool, tmp } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const mod = await import(join(HERE, '..', '..', 'lib', 'verification', 'repo-tools.mjs'));
|
||||
const { resolveRepo, detectTools, toolsForFile, runTool } = mod;
|
||||
|
||||
test('resolveRepo: finds the root and git dir from a file path that does not exist yet', () => {
|
||||
const repo = makeRepo('main');
|
||||
const r = resolveRepo(join(repo, 'src', 'new', 'file.js'));
|
||||
assert.equal(r.root, realpathSync(repo));
|
||||
assert.equal(r.gitDir, realpathSync(join(repo, '.git')));
|
||||
});
|
||||
|
||||
test('resolveRepo: null outside a repository', () => {
|
||||
assert.equal(resolveRepo(tmp()), null);
|
||||
});
|
||||
|
||||
test('detectTools: prettier counts only with config AND a local binary', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
let tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
const p = tools.find((t) => t.name === 'prettier');
|
||||
assert.ok(p && !p.available, 'configured but not installed is listed as unavailable');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
assert.ok(tools.find((t) => t.name === 'prettier').available);
|
||||
});
|
||||
|
||||
test('detectTools: a repo with nothing configured has no tools', () => {
|
||||
assert.deepEqual(detectTools(makeRepo('main'), { env: { PATH: '/nonexistent' } }), []);
|
||||
});
|
||||
|
||||
test('detectTools: eslint and tsc (repo tier) from config + local binaries', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'eslint.config.js'), 'export default [];');
|
||||
writeFileSync(join(repo, 'tsconfig.json'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'eslint');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'tsc');
|
||||
const tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
const eslint = tools.find((t) => t.name === 'eslint');
|
||||
const tsc = tools.find((t) => t.name === 'tsc');
|
||||
assert.equal(eslint.kind, 'lint');
|
||||
assert.equal(eslint.tier, 'file');
|
||||
assert.equal(tsc.kind, 'typecheck');
|
||||
assert.equal(tsc.tier, 'repo');
|
||||
});
|
||||
|
||||
test('detectTools: ruff from pyproject [tool.ruff] and a binary on PATH', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'pyproject.toml'), '[tool.ruff]\nline-length = 100\n');
|
||||
const bin = join(tmp(), 'bin');
|
||||
fakeTool(bin, 'ruff');
|
||||
const tools = detectTools(repo, { env: { PATH: bin } });
|
||||
assert.ok(tools.some((t) => t.name === 'ruff-check' && t.available));
|
||||
assert.ok(tools.some((t) => t.name === 'ruff-format' && t.available));
|
||||
});
|
||||
|
||||
test('toolsForFile: by extension, file tier only', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, 'tsconfig.json'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'tsc');
|
||||
const tools = detectTools(repo, { env: { PATH: '/nonexistent' } });
|
||||
assert.deepEqual(toolsForFile(tools, 'src/a.ts').map((t) => t.name), ['prettier']);
|
||||
assert.deepEqual(toolsForFile(tools, 'image.png').map((t) => t.name), []);
|
||||
});
|
||||
|
||||
test('runTool: clean, finding and error are three different answers', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
mkdirSync(join(repo, 'src'));
|
||||
writeFileSync(join(repo, 'src', 'good.js'), 'ok');
|
||||
writeFileSync(join(repo, 'src', 'bad.js'), 'BAD');
|
||||
const tool = detectTools(repo, { env: { PATH: '/nonexistent' } }).find((t) => t.name === 'prettier');
|
||||
assert.equal(runTool(tool, ['src/good.js'], { cwd: repo }).status, 'clean');
|
||||
const f = runTool(tool, ['src/bad.js'], { cwd: repo });
|
||||
assert.equal(f.status, 'finding');
|
||||
assert.match(f.output, /problem/);
|
||||
// exit 7 is neither clean nor a finding for prettier
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier', { findingExit: 7 });
|
||||
assert.equal(runTool(tool, ['src/bad.js'], { cwd: repo }).status, 'error');
|
||||
});
|
||||
106
tests/lib/step-check.test.mjs
Normal file
106
tests/lib/step-check.test.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// tests/lib/step-check.test.mjs
|
||||
// K9 — the step check (lib/verification/step-check.mjs --cwd <dir>). At step
|
||||
// end it runs the repo's own tools over the changed files (and the repo-wide
|
||||
// ones, such as the type checker), reads the post-edit guard's log, and
|
||||
// answers 0 = green, 1 = findings, 2 = "not measured" (never green). JSON on
|
||||
// stdout. The architecture-rule runner (K5, wave-1 order 3) is optional: when
|
||||
// it is missing, it is listed as not measured.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const CLI = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
|
||||
function run(cli, args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin', ...env } });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch { json = null; }
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr, json };
|
||||
}
|
||||
|
||||
function toolRepo() {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, '.prettierrc'), '{}');
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n');
|
||||
gitIn(repo, 'add', '.prettierrc', '.gitignore');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tools');
|
||||
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('K9 green (0): the changed files pass the repo\'s own tools', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 0, r.stdout + r.stderr);
|
||||
assert.equal(r.json.status, 'green');
|
||||
assert.ok(r.json.checks.some((c) => c.name === 'prettier' && c.status === 'clean'));
|
||||
});
|
||||
|
||||
test('K9 findings (1): a changed file the formatter rejects', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'BAD\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.status, 'findings');
|
||||
});
|
||||
|
||||
test('K9 findings (1): the post-edit log holds a finding for a changed file no tool can re-check', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.js'), 'x\n');
|
||||
mkdirSync(join(repo, '.git', 'voyage'), { recursive: true });
|
||||
appendFileSync(join(repo, '.git', 'voyage', 'post-edit.jsonl'),
|
||||
JSON.stringify({ ts: new Date().toISOString(), file: 'a.js', status: 'finding', results: [{ name: 'prettier', status: 'finding' }] }) + '\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.status, 'findings', 'exit 1 must be a finding, not a crash');
|
||||
});
|
||||
|
||||
test('K9 not measured (2): a repo with no tools at all -> never green', () => {
|
||||
const repo = makeRepo('main');
|
||||
writeFileSync(join(repo, 'a.js'), 'BAD\n');
|
||||
const r = run(CLI, ['--cwd', repo]);
|
||||
assert.equal(r.code, 2, r.stdout);
|
||||
assert.equal(r.json.status, 'not_measured');
|
||||
});
|
||||
|
||||
test('K9 not measured (2): not a git repository', () => {
|
||||
const r = run(CLI, ['--cwd', tmp()]);
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('K9 usage error (2): no --cwd', () => {
|
||||
assert.equal(run(CLI, []).code, 2);
|
||||
});
|
||||
|
||||
test('K9 --full: checks tracked files, not only the diff', () => {
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'old.js'), 'BAD\n');
|
||||
gitIn(repo, 'add', 'old.js');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'old');
|
||||
assert.notEqual(run(CLI, ['--cwd', repo]).json.status, 'findings', 'old.js is not in the diff');
|
||||
assert.equal(run(CLI, ['--cwd', repo, '--full']).code, 1);
|
||||
});
|
||||
|
||||
test('K5 optional: arch-rules missing is listed as not measured and does not turn green into red', () => {
|
||||
const plugin = pluginCopy({ 'lib/verification/arch-rules.mjs': 'missing' });
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(join(plugin, 'lib', 'verification', 'step-check.mjs'), ['--cwd', repo]);
|
||||
assert.equal(r.code, 0, r.stdout);
|
||||
const arch = r.json.checks.find((c) => c.name === 'arch-rules');
|
||||
assert.equal(arch.status, 'not_measured');
|
||||
});
|
||||
|
||||
test('K5 optional: arch-rules stub reporting a breach (exit 1) makes the step red', () => {
|
||||
const plugin = pluginCopy({ 'lib/verification/arch-rules.mjs': 'stub' });
|
||||
const repo = toolRepo();
|
||||
writeFileSync(join(repo, 'a.js'), 'ok\n');
|
||||
const r = run(join(plugin, 'lib', 'verification', 'step-check.mjs'), ['--cwd', repo], { STUB_EXIT: '1' });
|
||||
assert.equal(r.code, 1, r.stdout);
|
||||
assert.equal(r.json?.checks.find((c) => c.name === 'arch-rules')?.status, 'finding');
|
||||
});
|
||||
104
tests/lib/test-lock.test.mjs
Normal file
104
tests/lib/test-lock.test.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// tests/lib/test-lock.test.mjs
|
||||
// K8 — the test lock CLI (lib/util/test-lock.mjs). The executor (wave-1 order
|
||||
// 1) locks a step's test files once the test is written and seen failing, and
|
||||
// unlocks them when the test passes. The lock lives inside the repository's
|
||||
// git directory, so it can never be committed.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRepo, gitIn, tmp } from '../hooks/guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(HERE, '..', '..', 'lib', 'util', 'test-lock.mjs');
|
||||
|
||||
function run(cwd, args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
function repoWithTests() {
|
||||
const repo = makeRepo('main');
|
||||
mkdirSync(join(repo, 'tests'), { recursive: true });
|
||||
writeFileSync(join(repo, 'tests', 'a.test.js'), 'a\n');
|
||||
writeFileSync(join(repo, 'tests', 'b.test.js'), 'b\n');
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('K8 lock: exit 0, status names the step and the files', () => {
|
||||
const repo = repoWithTests();
|
||||
const r = run(repo, ['lock', '--files', 'tests/a.test.js,tests/b.test.js', '--step', '3']);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
const s = run(repo, ['status']);
|
||||
assert.equal(s.code, 0);
|
||||
const st = JSON.parse(s.stdout);
|
||||
assert.equal(st.locked, true);
|
||||
assert.equal(st.step, 3);
|
||||
assert.equal(st.files.length, 2);
|
||||
assert.ok(st.files.every((f) => f.endsWith('.test.js')));
|
||||
});
|
||||
|
||||
test('K8 lock: stored outside what can be committed (git status stays clean)', () => {
|
||||
const repo = repoWithTests();
|
||||
gitIn(repo, 'add', '-A');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'tests');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']).code, 0);
|
||||
assert.equal(gitIn(repo, 'status', '--porcelain'), '', 'the lock must not appear as a change in the repo');
|
||||
const st = JSON.parse(run(repo, ['status']).stdout);
|
||||
assert.ok(st.lock_path.includes(`${'.git'}`), `lock path should be inside the git dir: ${st.lock_path}`);
|
||||
});
|
||||
|
||||
test('K8 lock records the Claude Code session it was taken in', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '2'], { CLAUDE_CODE_SESSION_ID: 'sess-123' });
|
||||
assert.equal(JSON.parse(run(repo, ['status']).stdout).session, 'sess-123');
|
||||
});
|
||||
|
||||
test('K8 unlock: exit 0, status unlocked', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
assert.equal(run(repo, ['unlock']).code, 0);
|
||||
assert.equal(JSON.parse(run(repo, ['status']).stdout).locked, false);
|
||||
});
|
||||
|
||||
test('K8 lock refuses (exit 1) while another step holds the lock', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
const r = run(repo, ['lock', '--files', 'tests/b.test.js', '--step', '2']);
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.stderr, /step 1/);
|
||||
});
|
||||
|
||||
test('K8: --cwd points at another directory', () => {
|
||||
const repo = repoWithTests();
|
||||
assert.equal(run(tmp(), ['lock', '--cwd', repo, '--files', 'tests/a.test.js', '--step', '4']).code, 0);
|
||||
assert.equal(JSON.parse(run(tmp(), ['status', '--cwd', repo]).stdout).step, 4);
|
||||
});
|
||||
|
||||
test('K8 could not run (exit 2): not a git repository', () => {
|
||||
const r = run(tmp(), ['lock', '--files', 'x.test.js', '--step', '1']);
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
|
||||
test('K8 could not run (exit 2): bad usage', () => {
|
||||
const repo = repoWithTests();
|
||||
assert.equal(run(repo, ['lock', '--step', '1']).code, 2, 'no --files');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js']).code, 2, 'no --step');
|
||||
assert.equal(run(repo, ['lock', '--files', 'tests/a.test.js', '--step', 'x']).code, 2, 'step not a number');
|
||||
assert.equal(run(repo, ['frobnicate']).code, 2);
|
||||
});
|
||||
|
||||
test('K8 status on a corrupt lock: exit 2, never "unlocked"', () => {
|
||||
const repo = repoWithTests();
|
||||
run(repo, ['lock', '--files', 'tests/a.test.js', '--step', '1']);
|
||||
const path = JSON.parse(run(repo, ['status']).stdout).lock_path;
|
||||
writeFileSync(path, '{not json');
|
||||
const s = run(repo, ['status']);
|
||||
assert.equal(s.code, 2);
|
||||
assert.ok(existsSync(path));
|
||||
assert.ok(!/"locked":\s*false/.test(s.stdout));
|
||||
assert.ok(readFileSync(path, 'utf8').startsWith('{not'), 'status never rewrites the lock');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue