Autonomous development workflow: brainstorm, auto, iterate, audit, review, ship, plus the prd.json sprint system.

Autonomous development workflow for Claude Code. Say what you want to build — Claude handles the rest.
Distributed as a plugin marketplace. Claude Code installs, updates, and removes it; there is no install script and nothing is copied into ~/.claude.
In Claude Code — the desktop app, the CLI, or an IDE session:
/plugin marketplace add djnsty23/claude-auto-dev@stable
/plugin install autodev-core@autodev
That is the whole install. Then say brainstorm.
@stable pins the install to the last release. Without it the marketplace follows main, which carries unreleased commits under the last version number, so two installs of "the same version" can run different code. Already installed without it? In ~/.claude/settings.json, add "ref": "stable" to the source of extraKnownMarketplaces.autodev, then run the marketplace add line above. Your plugins stay installed. Running the add line alone is refused while settings declare a different source.
Two optional add-ons:
/plugin install autodev-memory@autodev
/plugin install autodev-stack@autodev
If the install summary says Run /reload-plugins to activate., run that.
Upgrading from 7.x? The old installer copied files into ~/.claude and appended a function to your shell profile. Read MIGRATION.md before installing — it clears those out.
| Plugin | Contains | Install it if |
|---|---|---|
| autodev-core | The workflow — brainstorm, auto, iterate, audit, review, ship, scan, plus the prd.json sprint system, 4 subagents, and the sprint/typecheck/safety hooks | Always. This is the tool. |
| autodev-memory | Cross-session project memory + nightly memory-maintenance — automatic observation capture, semantic search, domain-knowledge briefs, repo-backed backup | You want Claude to remember earlier sessions in a project |
| autodev-stack | Supabase, Doppler, Stripe, and Remotion integrations | You use that stack |
autodev-core stands alone. The other two are additive and can be removed without touching it.
| Say | Does |
|---|---|
autodev-init | Read this codebase and write its real conventions to .claude/project-rules.md |
learn-from-fixes | Rank what this project keeps shipping broken, from its own fix history |
preflight | Scaffold the executable gate file that fails the build on those classes |
| — | scripts/find-orphan-checks.js finds verification code nothing runs |
brainstorm | Scan codebase + live site, propose improvements |
brainstorm apply | Create stories from the last brainstorm |
framework radar | Research the agent-development ecosystem and execute measured experiments |
auto | Work through all pending stories autonomously |
iterate | Convergence loop: brainstorm → fix → re-scan until clean |
audit | 7-agent parallel quality audit |
review | Runs /code-review, then this project's own checks |
ship | Build, test, review, deploy, verify |
scan / qa | Live site QA (visual + a11y + console) |
fix | Runs /debug, then verifies the fix the way this project requires |
commit | Conventional commit + push + PR |
test | Unit + browser tests |
security | Runs /security-review, then Supabase/RLS and cloud-key checks |
perf | Core Web Vitals audit |
a11y | WCAG 2.1 AA audit |
design / ui | UI design with anti-slop checklist |
progress | Show sprint progress |
sprint | Create/advance sprint |
Plugin skills are namespaced, so /autodev-core:audit always works even if you have another audit skill installed. See docs/commands.md for the full list.
Start with /autodev-init. It measures how your codebase is actually written — component style, data fetching, where auth is enforced, tokens vs raw colors — and writes .claude/project-rules.md. review, audit, and standards all defer to that file, so the tool enforces your conventions rather than the defaults this plugin happens to ship. Every rule it writes cites a count from your code; anything genuinely split is recorded as undecided and never flagged.
These build on Claude Code, they do not replace it. review, security, and fix each run the matching built-in command first (/code-review, /security-review, /debug) and then add only what is specific to this project — its design tokens, its RLS rules, its definition of "verified".
Quick fixes — skip the ceremony. For small tasks, just describe what you want. No auto, no sprints, no prd.json:
fix the button overflow on mobile
add loading state to the dashboard
brainstorm → scans codebase + live site, proposes improvements
auto → implements all pending stories + visual verification
ship → review + security + deploy + post-deploy scan
iterate → brainstorm → fix → re-scan loop until clean
Framework Radar. framework radar collects official changes across Claude Code, Codex, Gemini CLI, coding agents, agent SDKs, orchestration frameworks, MCP, evaluation harnesses and adjacent automation tooling. It discovers relevant captioned videos, keeps raw transcripts outside the repository, and corroborates each lead against primary sources and the current code. Every selected hypothesis is then executed as an A/B/simpler experiment in an isolated worktree. A winning variant may become a reviewable PR, but scheduled runs never merge, deploy, tag or release. Reports and raw measurements live under .claude/reports/. The deterministic collector is also available as npm run radar in this repo.
Visual verification. Claude opens your app in the built-in Browser pane, reads the page, checks the console, and screenshots desktop and mobile after each UI change. There is no skill to invoke — the browser tools are used directly.
The separate
browserskill and theagent-browserCLI steps were dropped in 8.79/8.80. The binary itself is unrelated and may still be installed for other tools, which is whyagent-browser-cleanup.jsstill runs: it clears zombie Chromium processes and a stolen Win+Shift+S hotkey on Windows.
Phone screenshots and other out-of-band files. Save anything into ~/Library/Mobile Documents/com~apple~CloudDocs/claude-inbox — iCloud, so an iOS Shortcut can drop a screenshot there in one tap — and the next prompt announces it with filename, path, and arrival age. Measured at ~30ms per prompt and zero context when the inbox is empty, which is almost every turn; the cost is flat whether one file is waiting or twenty-five, because the hook stats the directory and never opens a file. Each arrival is announced exactly once. Claude reads the image only when the arrival time makes it plausibly relevant — auto-injecting every screenshot would cost roughly a thousand tokens each.
Set AUTODEV_INBOX to use a different folder, AUTODEV_INBOX_DISABLED=1 to turn it off, and /inbox to list what is waiting.
Image auto-scan. Attach a screenshot to any turn and Claude surfaces every distinct issue it sees, not only the one you asked about. Add [focus] in your message to opt out.
This repo ships checks and is therefore held to its own standard: coverage measures execution, mutation measures verification. A function can be entered on every run while nothing asserts anything about it.
npm test # every suite, then validate. The gate.
npm run check:hooks # wired hooks no suite drives — a hard gate in validate
npm run check:functions # functions never entered (~20s)
npm run check:vacuity <subject.js> <suite.js> # code no assertion depends on
npm run check:suites # suites that cannot fail
npm run check:superseded # guidance a later decision has overtaken
npm run check:versions # the six files that must agree on a version
npm run check:runtime # asserts the version EXECUTING is the one you edited
npm run check:agent-cost # what a subagent really costs, from real transcripts
npm run check:agent-budget --lenses 4 --verify adversarial # how many agents to spawn now
npm run actions:cost # CI spend, from GitHub's own usage CSV
Four of those answer different questions and none substitutes for another: scripts nobody runs, hooks no suite drives, functions never entered, and code no assertion depends on.
check:vacuity rewrites its subject with mutants. It refuses a dirty subject, and validate fails while a *.vacuity-backup exists. If you kill a run, pkill -9 then pgrep to confirm — a survivor rewrites the file underneath you.
When several sessions run at once, they cannot see each other. These read the same transcripts the app writes and answer the questions that causes.
| Script | Answers |
|---|---|
fleet-status.js | Which sessions are live, and which are blocked on an unanswered question |
fleet-overlap.js | Which two sessions are working the same ground, scored on three separate signals |
watch-panels.js | Emits one line per NEWLY blocked session, for the Monitor tool |
brain-brief.js | Regenerates the volatile half of a handoff: fleet, ownership, open PRs, uncommitted work |
steer-log.js | Whether cross-session advice arrived before or after the work it described |
quota-tripwire.js | One alert when weekly usage is 30-50 minutes from exhaustion |
They live in plugins/autodev-core/scripts/. Two design rules they all follow, learned by getting them wrong first:
Print the population, not a verdict. "212 of 212 files read, 9 names, clean" can be judged. "clean" cannot be told apart from a check that ran on nothing.
Distinguish "checked, none found" from "could not check". Silence must never read as clean. Each script reports a missing dependency or an unreadable input in place rather than returning an empty result.
fleet-overlap.js scores three signals separately rather than blending them, because they mean different things: two sessions on one branch will collide, two in one repo might, and two sharing a word in their titles probably will not.
watch-panels.js takes --self <sessionId> or AUTODEV_SELF_SESSION so it does not report your own questions back to you, and persists its dedup state to disk so restarting it does not re-raise what you already answered.
/plugin marketplace update autodev
/plugin update autodev-core
There is no update-dev command any more — Claude Code owns the update.
Plugins cannot change your permissions or your model, by design. If you want the permission set this workflow assumes, merge docs/recommended-settings.json into your own settings — see docs/settings.md, which also explains which rules from the old 7.x template were removed and why.
Per project (created as needed):
prd.json # Tasks and sprint history
.claude/archives/ # Archived prd snapshots
.claude/reports/ # Scan and audit reports
.claude/screenshots/ # Visual verification output
Tasks use passes: null (pending), true (done), or "deferred".
Global: ~/.claude/auto-dev-memory.db — the autodev-memory SQLite store. It deliberately lives outside the plugin so uninstalling does not delete your project memory.
/plugin uninstall autodev-core@autodev
Repeat for any add-ons. Claude Code removes exactly what it installed. To drop the marketplace too:
/plugin marketplace remove autodev
Your prd.json files, memory database, and settings are untouched.
.claude-plugin/marketplace.json # The catalog
plugins/autodev-core/ # Plugin: skills, agents, hooks, templates
plugins/autodev-memory/ # Plugin: skills, hooks, runtime scripts
plugins/autodev-stack/ # Plugin: skills
docs/ # Docs, settings + CLAUDE.md templates
tooling/ # Repo tooling — validate, tests, bump. Never shipped.
VERSION # Single source of truth; tooling/bump.js propagates it
Contributions: see CONTRIBUTING.md. Run npm test before opening a PR — it runs every suite plus tooling/validate.js.
Skills not showing up. Run /plugin and confirm autodev-core is listed and enabled. If the install said so, run /reload-plugins. Hook changes need a new session.
Hook errors. Hooks need Node 18+ (node -v). They fail quietly by design; node tooling/validate.js checks that every hook in every hooks.json points at a file that exists.
Memory commands do nothing. autodev-memory needs node:sqlite, which is built in on Node 22+. On older Node the memory skills no-op rather than error.
Still have update-dev in your shell. That is from the 7.x installer. MIGRATION.md removes it.
MIT
hooks/fn/autodev-fn.mjs 157 lines1// autodev-fn.mjs — autodev-core's hooks module (Claude Code "function hooks").
2//
3// EARLY ACCESS SURFACE. Loads only where the host has enabled hooks modules
4// (`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, or the rollout flag); everywhere
5// else the `modules` entry in hooks.json is skipped and every shell hook
6// beside it runs unchanged. The declarations this is written against are
7// produced by `/plugin-types` in a session, or by tooling/extract-plugin-types.js.
8//
9// Four things a shell hook structurally cannot do, in one module because the
10// loader takes one module per plugin:
11//
12// 1. prompt.submit a pasted credential becomes [REDACTED:kind#n]
13// before the model sees it: the user message row
14// and every tool row carry the placeholder. (The
15// harness's own queue-operation row is written
16// earlier and can still hold the typed text; the
17// SessionEnd scan covers that.) The value lives in
18// worker memory, never $.store, so a Bash command
19// that names the placeholder still runs with it.
20// 2. tool.call {Bash} the command is decided (bash-rules.mjs: three
21// denies scoped to this repo, two Windows-only denies,
22// one credential-in-argv deny and one flag-appending
23// rewrite anywhere) and its
24// stdout/stderr are scrubbed of known values and
25// credential-shaped text before the model or the
26// transcript sees them.
27// 3. attribution.text the commit trailer is empty text. The standing
28// rule here is no co-author trailer, and a
29// mid-session instruction keeps re-adding one.
30// 4. $.ui.status one pinned line under the prompt: what this
31// module did this session, and the sprint's five
32// state counts from prd.json, at no context cost.
33//
34// THE SCANNER'S RULE, which shapes every line below: `$` is only ever spelled
35// `$.noun.event(...)` at a call site. It is never passed to a helper, bound,
36// or read, so the helpers in this directory are pure and every `$` call is
37// inline. `claude plugin validate <plugin-dir>` lists what this file hooks and
38// calls; an op it did not list is refused at run time.
39//
40// A hook that throws is skipped and `next`'s result stands, so a defect here
41// degrades to "no redaction on this call", never to a broken tool. The cost
42// of that is silence, which is why the status line exists: its absence is the
43// tell that the module is not running.
44
45import { redactText, scrubText, describeKinds, Vault } from './redact.mjs';
46import { decideBash } from './bash-rules.mjs';
47import { storiesOf, summarise, formatStatus } from './sprint-status.mjs';
48
49const vault = new Vault();
50const tally = { redacted: 0, denied: 0, rewritten: 0 };
51
52/** Whether `text` mentions a placeholder at all: the cheap pre-check before
53 * a restore, so an ordinary command costs one indexOf. */
54const mentionsPlaceholder = (text) => String(text).includes('[REDACTED:');
55
56/** @type {import('claude-code').Register} */
57export function register(on) {
58 on('session.start', async ($, e, next) => {
59 let counts = null;
60 try {
61 if (await $.fs.exists('prd.json')) {
62 counts = summarise(storiesOf(JSON.parse(await $.fs.readFile('prd.json'))));
63 }
64 } catch {
65 counts = null;
66 }
67 $.ui.status(formatStatus({ counts, tally }));
68 return next(e);
69 });
70
71 on('prompt.submit', async ($, e, next) => {
72 const r = redactText(e.text, vault);
73 if (r.count === 0) return next(e);
74 tally.redacted += r.count;
75 $.ui.log(`autodev-fn: redacted ${r.count} pasted secret(s) (${describeKinds(r.kinds)}); the value is held in memory for this session and put back when a Bash command names the placeholder`);
76 return next({ ...e, text: r.text });
77 });
78
79 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
80 const typed = String(e.command ?? '');
81
82 // 1. A placeholder in the command is the model using a pasted secret.
83 let command = typed;
84 if (mentionsPlaceholder(typed)) {
85 const restored = vault.restore(typed);
86 command = restored.text;
87 }
88
89 // 2. The rules. The repo is read once per call: it is one host round
90 // trip, and the session can `cd` between calls.
91 let decision;
92 try {
93 decision = decideBash({ command, cwd: await $.session.cwd(), repo: await $.session.repo() });
94 } catch {
95 decision = { command, notes: [], rules: [] };
96 }
97 if (decision.deny) {
98 tally.denied += 1;
99 $.ui.log(`autodev-fn: denied a Bash call (${decision.rule})`);
100 return { deny: `autodev-fn (${decision.rule}): ${decision.deny}` };
101 }
102 if (decision.rules.length) {
103 tally.rewritten += decision.rules.length;
104 $.ui.log(`autodev-fn: rewrote a Bash call (${decision.rules.join(', ')})`);
105 }
106
107 const input = decision.command === typed ? e : { ...e, command: decision.command };
108 const out = await next(input);
109 if (!out || out.deny || out.result === null || typeof out.result !== 'object') return out;
110
111 // 3. Scrub the output: exact values the vault knows, then patterns.
112 const result = out.result;
113 let changed = false;
114 let count = 0;
115 const kinds = {};
116 const scrubbed = { ...result };
117 for (const key of ['stdout', 'stderr']) {
118 if (typeof result[key] !== 'string' || result[key].length === 0) continue;
119 const s = scrubText(result[key], vault);
120 if (s.count === 0) continue;
121 scrubbed[key] = s.text;
122 changed = true;
123 count += s.count;
124 for (const [k, n] of Object.entries(s.kinds)) kinds[k] = (kinds[k] || 0) + n;
125 }
126
127 const notes = decision.notes.map((n) => `autodev-fn ${n}`);
128 if (changed) {
129 tally.redacted += count;
130 $.ui.log(`autodev-fn: redacted ${count} secret(s) from Bash output${Object.keys(kinds).length ? ` (${describeKinds(kinds)})` : ''}`);
131 notes.push(`autodev-fn redacted ${count} credential-shaped value(s) from this output; a [REDACTED:kind#n] token can be passed back into a later Bash command verbatim.`);
132 }
133 if (!changed && notes.length === 0) return out;
134
135 const context = [...(out.context ?? []), ...notes];
136 // A rewritten result cannot carry core's `ref`: core renders it with the
137 // tool's own mapper from `result` alone. An untouched result keeps the
138 // object it got, so core uses its own messages verbatim.
139 return changed ? { result: scrubbed, context } : { ...out, context };
140 });
141
142 on('attribution.text', { kind: 'commit' }, () => ({ text: '' }));
143
144 on('turn.complete', async ($, e, next) => {
145 let counts = null;
146 try {
147 if (await $.fs.exists('prd.json')) {
148 counts = summarise(storiesOf(JSON.parse(await $.fs.readFile('prd.json'))));
149 }
150 } catch {
151 counts = null;
152 }
153 $.ui.status(formatStatus({ counts, tally }));
154 return next(e);
155 });
156}
157hooks/fn/redact.mjs 312 lines1// redact.mjs — credential-shaped text out, placeholders in. Pure: no `$`, no I/O.
2//
3// Two jobs share one pattern table.
4//
5// redactText(text, vault) pattern-based: every credential-shaped string
6// becomes [REDACTED:<kind>#<n>], n stable per
7// distinct value while the vault lives.
8// Vault worker-memory map of value <-> placeholder, so a
9// key the operator pasted can be put back into a
10// Bash command (restore) and taken back out of that
11// command's output (scrub) without ever entering
12// the transcript.
13//
14// The vault is NEVER persisted. `$.store` is a JSON file on disk, which is a
15// worse home for a secret than the transcript this file exists to protect. A
16// hot reload of the plugin empties it; the model then meets a placeholder it
17// cannot resolve, the Bash call runs with the placeholder literally, and the
18// error names it. That is the safe direction.
19//
20// The pattern list is the one the transcript scanner converged on after its
21// first version reported 1,163 secrets of which 11 were real: every pattern
22// has a left anchor, and the generic KEY=value shape is gated on the NAME, not
23// on entropy. Entropy is not used at all: a 40-hex git SHA has more of it than
24// most API keys, and redacting SHAs would break every git session.
25
26const PLACEHOLDER_RE = /\[REDACTED:([a-z0-9-]+)#(\d+)\]/g;
27
28// A value that is obviously not a live credential: an env reference, a
29// template slot, a mask, an example. The KEY=value pattern skips these so an
30// `.env.example` or a docs page does not light up.
31const NOT_A_SECRET_RE = /^(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|%[A-Za-z_][A-Za-z0-9_]*%|<[^>]*>|\[REDACTED|x{4,}$|X{4,}$|\*{3,}|\.{3,}|\(.*\)|your[-_]|example|changeme|placeholder|redacted|null$|undefined$|true$|false$)/;
32
33function decodeJwtPayload(token) {
34 try {
35 const mid = token.split('.')[1];
36 const b64 = mid.replace(/-/g, '+').replace(/_/g, '/');
37 const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
38 return JSON.parse(bin);
39 } catch {
40 return null;
41 }
42}
43
44const JWT_SHAPE_RE = /^eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{20,}$/;
45
46/** A JWT whose payload says `role: anon`: a Supabase publishable key, an
47 * identifier rather than a secret, and the noisiest false positive the
48 * transcript scanner ever produced. */
49function isAnonJwt(value) {
50 if (!JWT_SHAPE_RE.test(value)) return false;
51 const payload = decodeJwtPayload(value);
52 return !!payload && payload.role === 'anon';
53}
54
55/** A value the named patterns leave alone: an env reference, a template slot,
56 * a mask, an example, or a key that is public by design. */
57export function isPlaceholderValue(value) {
58 return NOT_A_SECRET_RE.test(value) || isAnonJwt(value);
59}
60
61/**
62 * Each entry: `name`, `re` (global), optional `group` (which capture is the
63 * secret; the rest of the match is kept), optional `keep(secret)` returning
64 * true to leave a match alone, and `sample()` producing a synthetic
65 * known-positive for the suite. Samples are built at run time from a prefix
66 * and a repeated letter so no realistic-looking credential ever sits in
67 * source, where a later scanner would report it.
68 */
69/**
70 * A CLI flag whose value is a credential. The name has to END at the secret
71 * word, so `--tokenizer`, `--token-budget` and `--password-stdin` are not it. Shared with
72 * bash-rules.mjs, whose argv-credential deny refuses the same flags before a
73 * command runs: one pattern, so the two can never disagree about what a
74 * credential flag is.
75 */
76export const CREDENTIAL_FLAG = String.raw`(?<![A-Za-z0-9_-])--?(?:token|access[-_]?token|auth[-_]?token|api[-_]?key|secret|password|passwd|pwd)`;
77
78export const PATTERNS = [
79 {
80 name: 'google-refresh-token',
81 re: /(?<![A-Za-z0-9+/=_-])1\/\/[A-Za-z0-9_-]{60,}/g,
82 sample: () => '1//' + 'A'.repeat(100),
83 },
84 {
85 name: 'google-api-key',
86 re: /(?<![A-Za-z0-9+/=_-])AIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g,
87 sample: () => 'AIza' + 'B'.repeat(35),
88 },
89 {
90 name: 'anthropic-key',
91 re: /sk-ant-[A-Za-z0-9_-]{30,}/g,
92 sample: () => 'sk-ant-' + 'C'.repeat(40),
93 },
94 {
95 name: 'openai-key',
96 re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}/g,
97 sample: () => 'sk-' + 'D'.repeat(40),
98 },
99 {
100 name: 'github-token',
101 re: /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}/g,
102 sample: () => 'ghp_' + 'E'.repeat(36),
103 },
104 {
105 name: 'github-fine-grained',
106 re: /github_pat_[A-Za-z0-9_]{50,}/g,
107 sample: () => 'github_pat_' + 'F'.repeat(60),
108 },
109 {
110 name: 'stripe-live-key',
111 re: /\b[rs]k_live_[A-Za-z0-9]{20,}/g,
112 sample: () => 'sk_live_' + 'G'.repeat(25),
113 },
114 {
115 name: 'slack-token',
116 re: /\bxox[baprs]-[A-Za-z0-9-]{20,}/g,
117 sample: () => 'xoxb-' + 'H'.repeat(25),
118 },
119 {
120 name: 'slack-app-token',
121 re: /\bxapp-[0-9]-[A-Za-z0-9-]{20,}/g,
122 sample: () => 'xapp-1-' + 'I'.repeat(25),
123 },
124 {
125 name: 'aws-access-key',
126 re: /\bAKIA[0-9A-Z]{16}\b/g,
127 sample: () => 'AKIA' + 'JKLMNOPQRSTUVWXY',
128 },
129 {
130 name: 'doppler-token',
131 re: /\bdp\.(?:st|pt|sa|ct|it|scim)\.[A-Za-z0-9]{40,}/g,
132 sample: () => 'dp.st.' + 'K'.repeat(43),
133 },
134 {
135 // A JWT whose payload says `role: anon` is a Supabase publishable key:
136 // an identifier, public by design, and the noisiest false positive the
137 // transcript scanner ever produced. Anything else (service_role, a
138 // session token, an undecodable middle segment) is treated as live.
139 name: 'jwt',
140 re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{20,}/g,
141 keep: isAnonJwt,
142 sample: () => {
143 const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
144 return b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ role: 'service_role', iss: 'supabase' }) + '.' + 'L'.repeat(43);
145 },
146 },
147 {
148 name: 'private-key-block',
149 re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----|$)/g,
150 sample: () => 'REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER',
151 },
152 {
153 // postgres://user:REDACTED@host — only the password is replaced, so
154 // the host and database name a session needs stay readable.
155 name: 'url-password',
156 re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]{4,})(@)/gi,
157 group: 2,
158 sample: () => 'postgresql://app:' + 'N'.repeat(24) + '@db.example.internal:5432/app',
159 },
160 {
161 name: 'bearer-token',
162 re: /\b(Authorization\s*[:=]\s*["']?Bearer\s+)([A-Za-z0-9._~+/=-]{20,})/gi,
163 group: 2,
164 sample: () => 'Authorization: Bearer ' + 'O'.repeat(32),
165 },
166 {
167 // NAME=value and NAME: value where NAME says it is a secret. This is
168 // the shape of every env file, and the shape a masking sed missed on
169 // 2026-08-31 because two files spelled the separator with spaces.
170 name: 'named-assignment',
171 re: /^(\s*(?:export\s+|set\s+|\$env:)?[A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Za-z0-9_]*\s*[=:]\s*["']?)([^\s"']{12,})/gim,
172 group: 2,
173 keep: (value) => isPlaceholderValue(value),
174 sample: () => 'SUPABASE_SERVICE_ROLE_KEY=' + 'P'.repeat(32),
175 },
176 {
177 // A CLI flag that carries the credential inline: `--token <value>`,
178 // `--password=<value>`, `--api-key <value>`. A process listing prints
179 // the whole command line of every process on the machine, so a deploy
180 // started with an inline token shows it to every session that lists
181 // processes. The flag name has to END at the secret word (`--tokenizer`
182 // and `--token-budget` are not flags that carry one), and an env
183 // reference or placeholder after it is left alone like everywhere else.
184 name: 'cli-flag-secret',
185 re: new RegExp('(' + CREDENTIAL_FLAG + String.raw`(?:=|\s+)["']?)([^\s"']{12,})`, 'gi'),
186 group: 2,
187 keep: (value) => isPlaceholderValue(value),
188 sample: () => 'vercel deploy --prod --yes --token ' + 'R'.repeat(24),
189 },
190 {
191 // A table row as `doppler secrets` and `doppler secrets delete` print
192 // one: `│ NAME │ value │`. The 2026-09-02 leak was exactly this shape.
193 name: 'named-table-row',
194 re: /^(\s*[│|]?\s*[A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Za-z0-9_]*\s*[│|]\s*)([^\s│|]{8,})/gim,
195 group: 2,
196 keep: (value) => isPlaceholderValue(value),
197 sample: () => '│ QA_PRO_PASSWORD │ ' + 'Q'.repeat(16) + ' │',
198 },
199];
200
201/**
202 * Worker-memory map between secret values and their placeholders.
203 * Bounded: past MAX_ENTRIES the oldest entry is dropped, so a session that
204 * pastes hundreds of distinct values cannot grow the worker without limit.
205 */
206export class Vault {
207 constructor(maxEntries = 256) {
208 this.maxEntries = maxEntries;
209 this.byValue = new Map(); // value -> placeholder
210 this.byPlaceholder = new Map(); // placeholder -> value
211 this.counter = 0;
212 }
213
214 get size() {
215 return this.byValue.size;
216 }
217
218 placeholderFor(kind, value) {
219 const known = this.byValue.get(value);
220 if (known) return known;
221 this.counter += 1;
222 const placeholder = `[REDACTED:${kind}#${this.counter}]`;
223 this.byValue.set(value, placeholder);
224 this.byPlaceholder.set(placeholder, value);
225 if (this.byValue.size > this.maxEntries) {
226 const oldestValue = this.byValue.keys().next().value;
227 const oldestPlaceholder = this.byValue.get(oldestValue);
228 this.byValue.delete(oldestValue);
229 this.byPlaceholder.delete(oldestPlaceholder);
230 }
231 return placeholder;
232 }
233
234 /** Placeholders in `text` become their values again. */
235 restore(text) {
236 let restored = 0;
237 const out = String(text).replace(PLACEHOLDER_RE, (whole) => {
238 const value = this.byPlaceholder.get(whole);
239 if (value === undefined) return whole;
240 restored += 1;
241 return value;
242 });
243 return { text: out, restored };
244 }
245
246 /** Known values in `text` become their placeholders. Longest value first,
247 * so a value that contains another is replaced whole. */
248 scrub(text) {
249 let scrubbed = 0;
250 let out = String(text);
251 if (this.byValue.size === 0 || out.length === 0) return { text: out, scrubbed };
252 const values = [...this.byValue.keys()].sort((a, b) => b.length - a.length);
253 for (const value of values) {
254 if (!out.includes(value)) continue;
255 const placeholder = this.byValue.get(value);
256 out = out.split(value).join(placeholder);
257 scrubbed += 1;
258 }
259 return { text: out, scrubbed };
260 }
261}
262
263/**
264 * Pattern-based redaction. Returns `{ text, count, kinds }`; `kinds` is a
265 * name -> count map so a log line can say WHAT was redacted without saying
266 * what it was. With a vault, placeholders are stable per value and the value
267 * is remembered for restore/scrub; without one, placeholders are numbered per
268 * call and nothing is remembered.
269 */
270export function redactText(text, vault) {
271 let out = String(text);
272 if (out.length === 0) return { text: out, count: 0, kinds: {} };
273 const kinds = {};
274 let count = 0;
275 let local = 0;
276 for (const pattern of PATTERNS) {
277 pattern.re.lastIndex = 0;
278 out = out.replace(pattern.re, (...args) => {
279 const whole = args[0];
280 const groups = args.slice(1, -2);
281 const secret = pattern.group ? groups[pattern.group - 1] : whole;
282 if (!secret) return whole;
283 if (pattern.keep && pattern.keep(secret)) return whole;
284 count += 1;
285 kinds[pattern.name] = (kinds[pattern.name] || 0) + 1;
286 const placeholder = vault
287 ? vault.placeholderFor(pattern.name, secret)
288 : `[REDACTED:${pattern.name}#${++local}]`;
289 if (!pattern.group) return placeholder;
290 // Rebuild the match with only the secret group replaced.
291 const start = whole.indexOf(secret);
292 return whole.slice(0, start) + placeholder + whole.slice(start + secret.length);
293 });
294 }
295 return { text: out, count, kinds };
296}
297
298/** Pattern redaction plus exact scrub of every value the vault knows. */
299export function scrubText(text, vault) {
300 const known = vault ? vault.scrub(text) : { text: String(text), scrubbed: 0 };
301 const patterned = redactText(known.text, vault);
302 return {
303 text: patterned.text,
304 count: known.scrubbed + patterned.count,
305 kinds: patterned.kinds,
306 };
307}
308
309export function describeKinds(kinds) {
310 return Object.entries(kinds).map(([k, n]) => (n > 1 ? `${k} x${n}` : k)).join(', ');
311}
312hooks/fn/bash-rules.mjs 482 lines1// bash-rules.mjs — the Bash rules a shell hook could only warn about, decided
2// before the command runs. Pure: takes strings, returns a decision.
3//
4// Two kinds of rule, and the difference is the whole design:
5//
6// rewrite changes a flag the command should have carried and tells the
7// model it did so. A rewrite cannot block work; the worst case is
8// a flag the command did not need.
9// deny refuses the call with the rule's reason. Three denies are scoped
10// to THIS repository (`scope: 'repo'`): the commands its CLAUDE.md
11// forbids by name. A text denylist over Bash was measured on
12// 2026-08-17 to have blocked 807 legitimate calls and zero
13// dangerous ones, so these are exact shapes, not a list, and a new
14// one needs its own measurement. The fourth, `msys-pathconv`, is
15// Windows-only and unscoped; its measurement is in its comment.
16// The fifth, `argv-credential`, is unscoped too, and so is its
17// measurement. The sixth, `worktree-placement`, is unscoped
18// and measured in its comment. The seventh, `heredoc-backslash`,
19// is Windows-only and the one rule that reads a heredoc body.
20// Its measurement is in its comment.
21//
22// A rewrite must not change a command's FIRST TOKEN: the permission layer
23// matches an allowlist on it, inside next(e), so a prefix that the model never
24// wrote turns an allowed command into a prompt. Appending a flag is safe;
25// prefixing an env var is not, which is why msys-pathconv is a deny.
26//
27// The command is split into pipeline segments so a rule reads the command
28// that RUNS, not text that mentions it: `grep "git commit -m" file` starts
29// with grep and matches nothing. Text after a heredoc opener is not examined,
30// since a commit body that quotes this file's own rule must not trip it.
31
32import { CREDENTIAL_FLAG, isPlaceholderValue } from './redact.mjs';
33
34const SEGMENT_SPLIT_RE = /(\s*(?:&&|\|\||;|\|)\s*|\r?\n)/;
35const ENV_PREFIX = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*`;
36const GIT_OPTS = String.raw`(?:(?:-C|--git-dir|--work-tree)\s+\S+\s+|--no-pager\s+|-c\s+\S+\s+)*`;
37const GIT_SUBCOMMAND = (name) => new RegExp(`^\\s*${ENV_PREFIX}git\\s+${GIT_OPTS}${name}\\b([\\s\\S]*)$`);
38
39const GIT_COMMIT_RE = GIT_SUBCOMMAND('commit');
40const GIT_ADD_RE = GIT_SUBCOMMAND('add');
41const GIT_REV_READ_RE = GIT_SUBCOMMAND('(?:cat-file|show)');
42const DOPPLER_WRITE_RE = /^(\s*doppler\s+secrets\s+(?:set|delete|del|upload|rm))\b/;
43const ARGV_CREDENTIAL_RE = new RegExp(CREDENTIAL_FLAG + String.raw`(?:=|\s+)["']?([^\s"']+)`, 'gi');
44// What the shell expands into argv: `$VAR`, `${VAR}`, `$(...)`, backticks, `$env:VAR`, `%VAR%`.
45const EXPANDS_INTO_ARGV_RE = /^(?:\$[({A-Za-z_]|\$env:|%[A-Za-z_]|`)/i;
46
47/** A credential flag whose value reaches argv: an expansion, or a literal of
48 * 12+ characters that is not a placeholder. A following flag is no value. */
49function argvCredential(segment) {
50 for (const m of segment.matchAll(ARGV_CREDENTIAL_RE)) {
51 const value = m[1];
52 if (value.startsWith('-')) continue;
53 if (EXPANDS_INTO_ARGV_RE.test(value)) return true;
54 if (value.length >= 12 && !isPlaceholderValue(value)) return true;
55 }
56 return false;
57}
58
59// A short-option cluster carrying the letter: `-m`, `-am`, `-sm "x"`.
60const SHORT_FLAG = (letter) => new RegExp(`(?:^|\\s)-[a-zA-Z]*${letter}[a-zA-Z]*(?=\\s|=|$)`);
61const COMMIT_MESSAGE_RE = new RegExp(`${SHORT_FLAG('m').source}|(?:^|\\s)--message(?:=|\\s|$)`);
62const ADD_ALL_RE = new RegExp(`${SHORT_FLAG('A').source}|(?:^|\\s)--all(?=\\s|$)`);
63const AMEND_RE = /(?:^|\s)--amend(?=\s|$)/;
64// `rev:.path` — a bare leading dot right after the colon is the shape MSYS
65// mangles; `rev:./path` and `rev:dir/.file` are fine.
66const DOT_LEADING_REV_PATH_RE = /\S+:\.[^\s\\/.]/;
67
68// --- worktree placement -----------------------------------------------------
69// Paths are handled as strings with forward slashes, never through node:path:
70// the helpers here stay pure, and the session's cwd is a Windows path while the
71// command is Git Bash text. `/c/x` is the same directory as `C:/x` there.
72
73/** Split one segment into shell words. Null on an unbalanced quote. A word
74 * that the shell would expand (`$`, backticks) is marked, and so is a leading `~/`. */
75function shellWords(segment) {
76 const words = [];
77 let cur = null;
78 const start = () => { if (!cur) cur = { text: '', expands: false, tilde: false }; };
79 let quote = null;
80 const src = String(segment);
81 for (let i = 0; i < src.length; i++) {
82 const c = src[i];
83 if (quote === "'") {
84 if (c === "'") quote = null; else cur.text += c;
85 continue;
86 }
87 if (quote === '"') {
88 if (c === '"') { quote = null; continue; }
89 if (c === '\\' && i + 1 < src.length && '$`"\\'.includes(src[i + 1])) { cur.text += src[++i]; continue; }
90 if (c === '$' || c === '`') cur.expands = true;
91 cur.text += c;
92 continue;
93 }
94 if (/\s/.test(c)) { if (cur) { words.push(cur); cur = null; } continue; }
95 start();
96 if (c === "'" || c === '"') { quote = c; continue; }
97 if (c === '\\') { if (i + 1 < src.length) cur.text += src[++i]; continue; }
98 if (c === '$' || c === '`') cur.expands = true;
99 if (c === '~' && cur.text === '') {
100 if (/^(?:\/|\s|$)/.test(src.slice(i + 1, i + 2))) cur.tilde = true; else cur.expands = true;
101 }
102 cur.text += c;
103 }
104 if (quote) return null;
105 if (cur) words.push(cur);
106 return words;
107}
108
109function slashPath(p, windows) {
110 let t = String(p).replace(/\\/g, '/');
111 if (windows) {
112 const m = t.match(/^\/([A-Za-z])(?=\/|$)/);
113 if (m) t = m[1] + ':' + t.slice(2);
114 }
115 return t;
116}
117
118function normalizePath(t) {
119 const drive = /^[A-Za-z]:/.test(t) ? t.slice(0, 2).toUpperCase() : '';
120 const out = [];
121 for (const part of (drive ? t.slice(2) : t).split('/')) {
122 if (!part || part === '.') continue;
123 if (part === '..') out.pop(); else out.push(part);
124 }
125 return drive + '/' + out.join('/');
126}
127
128/** An absolute, normalized path, or null when it cannot be known from text. */
129function resolvePath(base, p, windows) {
130 const t = slashPath(p, windows);
131 if (windows ? /^[A-Za-z]:\//.test(t) : t.startsWith('/')) return normalizePath(t);
132 // Git Bash maps `/tmp` and friends onto its own install root.
133 if (t.startsWith('/') || /^[A-Za-z]:/.test(t) || !base) return null;
134 return normalizePath(base + '/' + t);
135}
136
137// The home a `~/` names, read from where the session stands: `C:/Users/<me>`,
138// `/home/<me>` or `/Users/<me>`. Anywhere else a `~/` word stays unread.
139const HOME_RE = /^(?:[A-Z]:\/Users\/[^/]+|\/home\/[^/]+|\/Users\/[^/]+)(?=\/|$)/i;
140function resolveWord(word, base, ctx) {
141 if (!word || word.expands) return null;
142 if (!word.tilde) return resolvePath(base, word.text, ctx.windows);
143 const home = ((ctx.start || '').match(HOME_RE) || [])[0];
144 return home ? normalizePath(home + '/' + word.text.slice(1)) : null;
145}
146
147const pathKey = (p, windows) => (windows ? p.toLowerCase() : p).replace(/\/+$/, '');
148function pathInside(child, parent, windows) {
149 const c = pathKey(child, windows);
150 const q = pathKey(parent, windows);
151 return c === q || c.startsWith(q + '/');
152}
153
154// Where gate sweeps and exports make throwaway worktrees and remove them:
155// worktree-placement.js calls these `transient`, not misplaced.
156const TRANSIENT_RE = /(?:^|\/)(?:tmp|temp)(?:\/|$)|^\/(?:private\/)?var\/folders\//i;
157
158// `git [globals] worktree add [options] <path> [<commit-ish>]`
159const WORKTREE_VALUE_OPTS = new Set(['-b', '-B', '--reason']);
160const REDIRECT_RE = /^\d*(?:>>?|<|>&|&>)/;
161// A redirection written alone, whose target is the next word: `2> err.txt`.
162const REDIRECT_BARE_RE = /^\d*(?:>>?|<|>&|&>)$/;
163
164/**
165 * When this segment is a `git worktree add` whose destination is known from
166 * the text and is not under its repository's `.claude/worktrees/` (or a temp
167 * dir), what to say. Null for everything else, and for anything it cannot read.
168 */
169function misplacedWorktree(segment, ctx) {
170 if (!/\bworktree\b/.test(segment) || !/\badd\b/.test(segment)) return null;
171 try {
172 const words = shellWords(segment);
173 if (!words) return null;
174 let i = 0;
175 while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i].text)) i++;
176 if (!words[i] || !/^git(?:\.exe)?$/.test(words[i].text)) return null;
177 i++;
178 let base = ctx.dir;
179 let viaC = false;
180 for (; i < words.length && words[i].text.startsWith('-'); i++) {
181 const w = words[i].text;
182 if (w === '-C') {
183 base = resolveWord(words[++i], base, ctx);
184 if (!base) return null;
185 viaC = true;
186 } else if (w === '-c') i++;
187 else if (/^--(?:git-dir|work-tree)/.test(w)) return null;
188 }
189 if (!words[i] || words[i].text !== 'worktree' || !words[i + 1] || words[i + 1].text !== 'add') return null;
190 const args = words.slice(i + 2);
191 let at = -1;
192 for (let k = 0; k < args.length; k++) {
193 const w = args[k].text;
194 if (w === '--') { at = k + 1 < args.length ? k + 1 : -1; break; }
195 if (REDIRECT_RE.test(w)) { if (REDIRECT_BARE_RE.test(w)) k++; continue; }
196 if (WORKTREE_VALUE_OPTS.has(w)) { k++; continue; }
197 if (w.startsWith('-')) continue;
198 at = k;
199 break;
200 }
201 if (at === -1 || !base) return null;
202 // Outside any repository, with nothing naming one, git refuses the add itself.
203 if (!ctx.repoRoot && !viaC && !ctx.moved) return null;
204 const dest = resolveWord(args[at], base, ctx);
205 if (!dest || TRANSIENT_RE.test(dest)) return null;
206
207 // The repository the command acts on: the session's own when it stands
208 // inside it, else the one a worktree path names, else the directory itself.
209 let root;
210 if (ctx.repoRoot && pathInside(base, ctx.repoRoot, ctx.windows)) root = ctx.repoRoot;
211 else {
212 const cut = pathKey(base, ctx.windows).indexOf('/.claude/worktrees/');
213 root = cut === -1 ? base : base.slice(0, cut);
214 }
215 const home = root.replace(/\/+$/, '') + '/.claude/worktrees';
216 if (pathInside(dest, home, ctx.windows) && pathKey(dest, ctx.windows) !== pathKey(home, ctx.windows)) return null;
217
218 const name = dest.replace(/\/+$/, '').split('/').pop() || 'wt';
219 const correct = home + '/' + name;
220 const quote = (t) => (/^[\w@%+=:,./-]+$/.test(t) ? t : '"' + t.replace(/(["\\$`])/g, '\\$1') + '"');
221 // The suggestion is the command alone: redirections are left for the caller to put back.
222 const shown = [];
223 for (let k = 0; k < args.length; k++) {
224 if (k !== at && REDIRECT_RE.test(args[k].text)) { if (REDIRECT_BARE_RE.test(args[k].text)) k++; continue; }
225 shown.push(k === at ? quote(correct) : quote(args[k].text));
226 }
227 const rest = shown.join(' ');
228 return { dest, root, correct, command: `git -C ${quote(root)} worktree add ${rest}`, viaC };
229 } catch {
230 return null;
231 }
232}
233
234// A heredoc whose delimiter is quoted (`<<'EOF'`, `<<"EOF"`): bash passes its
235// body through byte for byte, so any change to those bytes is the harness's.
236const QUOTED_HEREDOC_RE = /<<-?[ \t]*(['"])([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)(?:\n[ \t]*\2[ \t]*(?=\n|$)|$)/g;
237
238/** The first line of a quoted heredoc body that holds `\\`, or null. */
239function halvedHeredocLine(command) {
240 for (const m of String(command).matchAll(QUOTED_HEREDOC_RE)) {
241 const line = m[3].split('\n').find((l) => l.includes('\\\\'));
242 if (line !== undefined) return { delimiter: m[2], line: line.trim().slice(0, 120) };
243 }
244 return null;
245}
246
247/** The directory a `cd` segment moves to; undefined when the segment is no cd, null when unknown. */
248function cdTarget(segment, dir, ctx) {
249 if (!/^\s*(?:cd|pushd)\b/.test(segment)) return undefined;
250 const words = shellWords(segment);
251 if (!words || !/^(?:cd|pushd)$/.test(words[0].text)) return undefined;
252 const args = words.slice(1).filter((w) => !REDIRECT_RE.test(w.text));
253 if (args.length !== 1 || args[0].text === '-') return null;
254 return resolveWord(args[0], dir, ctx);
255}
256
257export const RULES = [
258 {
259 id: 'git-commit-m',
260 kind: 'deny',
261 scope: 'repo',
262 test: (segment) => {
263 const m = segment.match(GIT_COMMIT_RE);
264 return !!m && COMMIT_MESSAGE_RE.test(m[1]);
265 },
266 reason: 'CLAUDE.md: `git commit -F <file>`, never `-m`. The shell eats backticks in an inline message and force-push is blocked, so a mangled message cannot be amended.',
267 },
268 {
269 id: 'git-commit-amend',
270 kind: 'deny',
271 scope: 'repo',
272 test: (segment) => {
273 const m = segment.match(GIT_COMMIT_RE);
274 return !!m && AMEND_RE.test(m[1]);
275 },
276 reason: 'CLAUDE.md: never `git commit --amend` here. Several sessions commit to this clone at once and HEAD moves in seconds; commit small and forward.',
277 },
278 {
279 id: 'git-add-all',
280 kind: 'deny',
281 scope: 'repo',
282 test: (segment) => {
283 const m = segment.match(GIT_ADD_RE);
284 return !!m && ADD_ALL_RE.test(m[1]);
285 },
286 reason: 'CLAUDE.md: stage explicit paths, never `git add -A`. The same concurrency sweeps another session\'s in-flight work into your commit.',
287 },
288 {
289 id: 'doppler-silent',
290 kind: 'rewrite',
291 scope: 'all',
292 test: (segment) => DOPPLER_WRITE_RE.test(segment) && !/(?:^|\s)--silent(?=\s|$)/.test(segment),
293 apply: (segment) => segment.replace(DOPPLER_WRITE_RE, '$1 --silent'),
294 note: 'added `--silent` to a doppler write: without it the CLI prints the whole remaining secret store, values included, on every outcome.',
295 },
296 {
297 // A DENY, not a rewrite, and the only deny that is not repo-scoped.
298 // It began as a rewrite that prefixed `MSYS_NO_PATHCONV=1`, and
299 // [measured 2026-09-04] that turned an allowlisted `git show` into a
300 // command needing approval: the permission layer runs inside next(e)
301 // and matches on the command's first token, which the prefix had
302 // changed. A prompt for a command the model never wrote reads as the
303 // plugin breaking permissions. Refusing with the exact command to run
304 // keeps the decision deterministic and makes the prefixed command the
305 // model's own, so any prompt it draws is for what the model chose.
306 // The unrefused read fails 2 of 2 times on a dot-leading path
307 // (rules/verification-traps.md, the `rev:path` table) with "not a
308 // valid object name", and a `|| echo` fallback then reports a present
309 // file as missing, which is the outcome this rule exists to prevent.
310 id: 'msys-pathconv',
311 kind: 'deny',
312 scope: 'all',
313 test: (segment, ctx) => ctx.windows
314 && GIT_REV_READ_RE.test(segment)
315 && DOT_LEADING_REV_PATH_RE.test(segment)
316 && !/(?:^|\s)MSYS_NO_PATHCONV=1\s/.test(segment),
317 reason: (segment) => 'Git Bash on Windows rewrites a `rev:.path` argument as a Windows path list, so this read fails as "not a valid object name" and a `|| echo` fallback then reports a present file as missing. Run it with the conversion off, as your own command: `MSYS_NO_PATHCONV=1 '
318 + segment.trim() + '` (or through PowerShell, which has no MSYS layer).',
319 },
320 {
321 // Unscoped: a token in argv is readable in the process list by anything
322 // on the machine for as long as the process runs, which is how a deploy
323 // token leaked on 2026-09-16. `[measured 2026-09-24]` 30 days of
324 // transcripts, 137,892 Bash calls: 250 segments passed `--token` a value,
325 // and 243 were `$VAR` or `$(...)` handed to vercel or doppler, the leak
326 // itself. Of the other 7, five short literals and one placeholder were
327 // text inside scripts and stay allowed. One 12+ literal was a JS array
328 // inside a `node -e` script, and this rule refuses it: 1 false
329 // refusal in 244. The flag is redact.mjs's CREDENTIAL_FLAG.
330 id: 'argv-credential',
331 kind: 'deny',
332 scope: 'all',
333 test: (segment) => argvCredential(segment),
334 reason: 'A credential on the command line is readable in the process list by anything on the machine while the process runs, '
335 + 'and `--token $X` puts the expanded value there. Pass it through the environment variable the CLI reads, in the same command: '
336 + '`VERCEL_TOKEN="$(doppler secrets get VERCEL_TOKEN --plain)" vercel deploy --prod`. '
337 + 'vercel reads VERCEL_TOKEN, gh reads GH_TOKEN, doppler reads DOPPLER_TOKEN, supabase reads SUPABASE_ACCESS_TOKEN.',
338 },
339 {
340 // Unscoped: every repository keeps its worktrees in <root>/.claude/worktrees/.
341 // `[measured 2026-09-22]` twelve worktrees had landed beside their repos
342 // in the code root, and on 2026-09-24 three more were still appearing.
343 // A sibling worktree is invisible from inside its repo and reads as one
344 // more project in the code root. worktree-placement.js finds them after
345 // the fact. This refuses the hand-typed `git worktree add` that makes one.
346 // EnterWorktree and `isolation: "worktree"` already use the right place.
347 //
348 // It reads only what the text states. A destination or a `-C` built from
349 // `$VAR`, `$(...)`, backticks or `~user` is allowed unread, and so is a
350 // temp dir, where gate sweeps make worktrees they remove. A leading `~/`
351 // resolves against the home the cwd shows (`C:/Users/<me>`, `/home/<me>`,
352 // `/Users/<me>`), and stays unread when the cwd shows none. A literal `cd`
353 // earlier in the command moves the base, and an unreadable one stops it.
354 //
355 // `[measured 2026-09-24]` over 30 days of one operator's transcripts
356 // (1,703 files, 144,166 Bash calls, 794 commands naming `worktree add`)
357 // it refuses 37 and allows 757. 35 refusals were worktrees beside a repo
358 // or loose in the code root. One passed a ref where the path goes, so git
359 // would have made a worktree named after the branch, and the refusal
360 // names that path. One was a deliberate worktree on another drive: that
361 // is the cost, one in 37, and a path held in a variable still passes.
362 // Before `~/` was resolved it missed one leak, a `cd ~/...` followed by
363 // an absolute sibling path, which it now refuses.
364 id: 'worktree-placement',
365 kind: 'deny',
366 scope: 'all',
367 test: (segment, ctx) => !!misplacedWorktree(segment, ctx),
368 reason: (segment, ctx) => {
369 const m = misplacedWorktree(segment, ctx);
370 return `This puts a worktree at ${m.dest}, outside ${m.root}/.claude/worktrees/. A worktree beside its repo is invisible from inside the repo `
371 + 'and reads as one more project in the code root. Put it where every other worktree of this repo lives: '
372 + `\`${m.command}\``;
373 },
374 },
375 {
376 // WHOLE: tested once against the full command, heredoc body included,
377 // where every other rule reads only the segments before the body.
378 //
379 // On Windows the Bash tool delivers every `\\` in a command as `\`
380 // before bash parses it, so a quoted delimiter does not protect the
381 // body. `[measured 2026-09-29]` with `od -c`: a quoted-heredoc `\\b`,
382 // a single-quoted `'x\\y'` and a single-quoted `'x\\\\y'` arrived as
383 // `\b`, `x\y` and `x\\y`. A regex or a JSON escape written through
384 // one then means something else: `'\\b'` in a JS catalog became a
385 // backspace and the pattern matched nothing, with no error.
386 //
387 // `[measured 2026-09-29]` 30 days of one operator's transcripts (2,054
388 // files, 187,729 Bash calls). 1,799 calls had a quoted heredoc body
389 // holding `\\`, and this rule refuses them. 21.4% of the 1,751 feeding
390 // an interpreter or a code file failed to parse (SyntaxError,
391 // unexpected EOF, invalid regex, bad control character), against 2.3%
392 // of the 16,605 such heredocs without `\\`. Another 32.4% ran with a
393 // Python "invalid escape sequence" warning naming the halved escape,
394 // 4.6% failed some other way, and 41.6% showed nothing, which
395 // includes the silent case above. Replayed through this module with
396 // each call's recorded cwd, the rule denied 1,803 calls and no other
397 // rule's count moved. The prose rule (write the script with the
398 // Write tool) was in force the whole window. Not refused:
399 // single-quoted arguments,
400 // 1,082 calls with `\\` and 10.6% with a symptom, too low for a deny.
401 id: 'heredoc-backslash',
402 kind: 'deny',
403 scope: 'all',
404 whole: true,
405 test: (command, ctx) => ctx.windows && !!halvedHeredocLine(command),
406 reason: (command) => {
407 const h = halvedHeredocLine(command);
408 return `On Windows the Bash tool delivers every \`\\\\\` as \`\\\`, even inside a quoted heredoc, so this <<'${h.delimiter}' body `
409 + `reaches bash with one backslash where it has two (first at: ${h.line}). A regex, a JSON escape or a path in it changes meaning, often with no error. `
410 + 'Write the script with the Write tool and run it by path (`node <file>`, `py -3 <file>`).';
411 },
412 },
413];
414
415/**
416 * Whether `repo` (as `$.session.repo()` returns it) is this plugin's own
417 * repository, where the deny rules apply. Matched on the remote's path or the
418 * working tree's directory name; a fork under another name is a different
419 * repository with its own CLAUDE.md.
420 */
421export { misplacedWorktree, shellWords, resolvePath, halvedHeredocLine };
422
423export function isAutodevRepo(repo) {
424 if (!repo || typeof repo !== 'object') return false;
425 const remote = typeof repo.remote === 'string' ? repo.remote : '';
426 const root = typeof repo.root === 'string' ? repo.root : '';
427 if (/[/:]claude-auto-dev(?:\.git)?\/?$/.test(remote)) return true;
428 const base = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop();
429 return base === 'claude-auto-dev';
430}
431
432export function isWindowsPath(p) {
433 return /^[A-Za-z]:[\\/]/.test(String(p || ''));
434}
435
436/**
437 * Decide a Bash command.
438 *
439 * @param {{ command: string, cwd?: string, repo?: object|null }} input
440 * @returns {{ deny: string, rule: string } | { command: string, notes: string[], rules: string[] }}
441 */
442export function decideBash({ command, cwd, repo }) {
443 const original = String(command ?? '');
444 const windows = isWindowsPath(cwd);
445 const start = typeof cwd === 'string' && cwd ? resolvePath(null, cwd, windows) : null;
446 const root = repo && typeof repo.root === 'string' && repo.root ? resolvePath(null, repo.root, windows) : null;
447 const ctx = { windows, inRepo: isAutodevRepo(repo), start, dir: start, repoRoot: root, moved: false };
448
449 for (const rule of RULES) {
450 if (!rule.whole || (rule.scope === 'repo' && !ctx.inRepo) || !rule.test(original, ctx)) continue;
451 return { deny: typeof rule.reason === 'function' ? rule.reason(original, ctx) : rule.reason, rule: rule.id };
452 }
453
454 // Everything from the first heredoc opener on is body text, not commands.
455 const heredocAt = original.search(/<<-?\s*['"]?[A-Za-z_]/);
456 const head = heredocAt === -1 ? original : original.slice(0, heredocAt);
457 const tail = heredocAt === -1 ? '' : original.slice(heredocAt);
458
459 const parts = head.split(SEGMENT_SPLIT_RE);
460 const notes = [];
461 const rules = [];
462 for (let i = 0; i < parts.length; i += 2) {
463 let segment = parts[i];
464 if (!segment || !segment.trim()) continue;
465 const moved = cdTarget(segment, ctx.dir, ctx);
466 if (moved !== undefined) { ctx.dir = moved; ctx.moved = true; continue; }
467 for (const rule of RULES) {
468 if (rule.whole || (rule.scope === 'repo' && !ctx.inRepo)) continue;
469 if (!rule.test(segment, ctx)) continue;
470 if (rule.kind === 'deny') {
471 const reason = typeof rule.reason === 'function' ? rule.reason(segment, ctx) : rule.reason;
472 return { deny: reason, rule: rule.id };
473 }
474 segment = rule.apply(segment, ctx);
475 notes.push(rule.note);
476 rules.push(rule.id);
477 }
478 parts[i] = segment;
479 }
480 return { command: parts.join('') + tail, notes, rules };
481}
482hooks/fn/sprint-status.mjs 84 lines1// sprint-status.mjs — the pinned status line's text. Pure.
2//
3// `storiesOf` and `summarise` are an ES-module copy of
4// ../../scripts/prd-states.js, which a hooks module cannot import: the hooks
5// worker links ES modules only, and that file is CommonJS. This copy is a
6// DELIBERATE duplicate, held to the original by tooling/test-hooks-module.js,
7// which runs both over the same fixtures and fails on any difference. Change
8// the original, run the suite, then change this; never only this.
9
10const DONE = true;
11const PENDING = null;
12const FAILED = false;
13const DEFERRED = 'deferred';
14const NEEDS_SETUP = 'needs-setup';
15
16function isActionable(story) {
17 if (!story) return false;
18 const p = story.passes;
19 return p === PENDING || p === undefined || p === FAILED;
20}
21
22function isOutstanding(story) {
23 if (!story) return false;
24 const p = story.passes;
25 return p === PENDING || p === undefined || p === FAILED || p === NEEDS_SETUP;
26}
27
28export function storiesOf(prd) {
29 if (!prd || typeof prd !== 'object') return {};
30 const sprints = Array.isArray(prd.sprints) ? prd.sprints : [];
31 const merged = {};
32 let sawNested = false;
33 for (const sprint of sprints) {
34 const stories = sprint && sprint.stories;
35 if (!stories || typeof stories !== 'object') continue;
36 sawNested = true;
37 for (const [id, story] of Object.entries(stories)) merged[id] = story;
38 }
39 if (sawNested) return merged;
40 return (prd.stories && typeof prd.stories === 'object') ? prd.stories : {};
41}
42
43export function summarise(stories) {
44 const all = Array.isArray(stories) ? stories : Object.values(stories || {});
45 const counts = {
46 done: 0, pending: 0, failed: 0, deferred: 0, needsSetup: 0, unrecognised: 0,
47 };
48 for (const s of all) {
49 const p = s && s.passes;
50 if (p === DONE) counts.done++;
51 else if (p === PENDING || p === undefined) counts.pending++;
52 else if (p === FAILED) counts.failed++;
53 else if (p === DEFERRED) counts.deferred++;
54 else if (p === NEEDS_SETUP) counts.needsSetup++;
55 else counts.unrecognised++;
56 }
57 counts.total = all.length;
58 counts.actionable = all.filter(isActionable).length;
59 counts.outstanding = all.filter(isOutstanding).length;
60 return counts;
61}
62
63/**
64 * One line, under the prompt, that costs no context tokens. Every non-zero
65 * bucket is named so a state cannot go missing the way needs-setup once did;
66 * `unrecognised` is named loudest because it means the schema moved.
67 *
68 * @param {{ counts?: object|null, prdText?: string|null, tally: { redacted: number, denied: number, rewritten: number } }} input
69 */
70export function formatStatus({ counts, prdText, tally }) {
71 const fn = `fn: redacted ${tally.redacted} · denied ${tally.denied} · rewrote ${tally.rewritten}`;
72 if (prdText) return `${fn} │ ${prdText}`;
73 if (!counts) return `${fn} │ no prd.json`;
74 if (counts.total === 0) return `${fn} │ prd: no stories found`;
75 const parts = [];
76 if (counts.pending) parts.push(`${counts.pending} pending`);
77 if (counts.failed) parts.push(`${counts.failed} failed`);
78 if (counts.needsSetup) parts.push(`${counts.needsSetup} needs-setup`);
79 if (counts.deferred) parts.push(`${counts.deferred} deferred`);
80 if (counts.unrecognised) parts.push(`${counts.unrecognised} UNRECOGNISED`);
81 parts.push(`${counts.done}/${counts.total} done`);
82 return `${fn} │ prd: ${parts.join(' · ')}`;
83}
84