Lab 5 solution
This is the finished state of Lab 5 — Hooks: invariants that cannot be talked past. Work through the lab before you read it. The point of Lab 5 is breaking your own hook, and a finished file skips that.
Everything here lives in the companion repo at harness-reference/.factory/. Copy the folder into your clone if you fell behind, then start a new droid session. Droid snapshots hooks at startup, so a session that was already running won't see them.
.factory/hooks.json# PreToolUse ×2, PostToolUse, Stophooks/lib/protected.mjs# shared helpers every hook importsprotect-paths.mjs# PreToolUse · Create|Edit|ApplyPatch · blocksprotect-shell.mjs# PreToolUse · Execute · blocks writesmoney-lint.mjs# PostToolUse · feedbackprotected-diff.mjs# Stop · checks the outcome, logs evidenceprotected-changes.log# written by the Stop hook, git-ignored
The scripts are Node ES modules with no dependencies. The companion repo is "type": "module" and needs Node 20.11 or later, so .mjs is not strictly required, but it makes the module type obvious to anyone reading the hook command.
hooks.json
The event map sits at the top level of the file. There is no hooks wrapper around it. That wrapper only appears when hooks are declared inside settings.json.
{
"PreToolUse": [
{
"matcher": "Create|Edit|ApplyPatch",
"hooks": [
{ "type": "command", "command": "node \"$FACTORY_PROJECT_DIR\"/.factory/hooks/protect-paths.mjs", "timeout": 10 }
]
},
{
"matcher": "Execute",
"commandRegex": "(src/money|migrations/|legacy/)",
"hooks": [
{ "type": "command", "command": "node \"$FACTORY_PROJECT_DIR\"/.factory/hooks/protect-shell.mjs", "timeout": 10 }
]
}
],
"PostToolUse": [
{
"matcher": "Create|Edit|ApplyPatch",
"hooks": [
{ "type": "command", "command": "node \"$FACTORY_PROJECT_DIR\"/.factory/hooks/money-lint.mjs", "timeout": 10 }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node \"$FACTORY_PROJECT_DIR\"/.factory/hooks/protected-diff.mjs", "timeout": 30 }
]
}
]
}
Every command uses "$FACTORY_PROJECT_DIR", quoted, so the hook runs no matter which directory the session is in, and survives a path with spaces in it.
lib/protected.mjs
Every hook imports this module: the protected-path table and its messages, stdin parsing, the project root (FACTORY_PROJECT_DIR first, then the event's cwd), path normalization, the block() helper that exits 2, and the ticket lookup Lab 8 uses. Copy it along with the hooks, or none of them will load.
// Shared by the Larkspur hooks. Plain Node, no dependencies.
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join, relative } from 'node:path';
export const PROTECTED = [
{
prefix: 'src/money/',
why: 'src/money/ is protected: Money, basis points and roundHalfEven carry the fee-disclosure invariants.',
instead: 'If the task seems to need a change here, stop and ask a human (AGENTS.md > Never).',
},
{
prefix: 'migrations/',
why: 'migrations/ is protected: schema changes need an approved change ticket (CHG-nnnnn) and a human.',
instead: 'Stop and ask for a change ticket. Do not invent one.',
},
{
prefix: 'legacy/',
why: 'legacy/ is vendored and frozen: any byte change re-opens the reconciliation certification.',
instead: 'Write an adapter outside legacy/ that wraps it.',
},
];
export async function readHookInput() {
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
try {
return JSON.parse(raw);
} catch {
process.stderr.write('larkspur hook: could not parse hook input JSON; not checking this call.\n');
process.exit(1); // non-blocking error: Droid continues
}
}
export function projectDir(input) {
return process.env.FACTORY_PROJECT_DIR || input?.cwd || process.cwd();
}
/** Project-relative POSIX path, or null when the path is outside the project. */
export function toRelative(root, p) {
const cleaned = String(p).trim().replace(/^["']|["']$/g, '').replace(/^[ab]\//, '');
const rel = relative(root, isAbsolute(cleaned) ? cleaned : join(root, cleaned)).split('\\').join('/');
return rel.startsWith('..') ? null : rel;
}
export function protectedArea(rel) {
if (rel === null) return null;
const withSlash = rel.endsWith('/') ? rel : `${rel}/`;
return PROTECTED.find((a) => rel.startsWith(a.prefix) || a.prefix === withSlash) ?? null;
}
/** Every file a Create/Edit/ApplyPatch call targets: file_path plus paths named in patch text. */
export function targetPaths(toolInput = {}) {
const found = new Set();
for (const key of ['file_path', 'path', 'filePath']) {
if (typeof toolInput[key] === 'string') found.add(toolInput[key]);
}
for (const value of Object.values(toolInput)) {
if (typeof value !== 'string') continue;
for (const m of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$|^(?:\+\+\+|---) (?!\/dev\/null)(\S+)/gm)) {
found.add(m[1] ?? m[2] ?? m[3]);
}
}
return [...found];
}
/** All string values of the tool input joined, e.g. to look for a CHG id in new content. */
export const inputText = (toolInput = {}) => Object.values(toolInput).filter((v) => typeof v === 'string').join('\n');
export function git(root, ...args) {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
return r.status === 0 ? r.stdout : null;
}
/**
* Approved, unexpired change tickets whose scope covers `rel`, read from the
* same tickets.json the change-control MCP server serves. A hook is a shell
* command, not an MCP client, so it reads the server's data file directly.
*/
export function approvedTicketsFor(root, rel, today = new Date().toISOString().slice(0, 10)) {
const file = join(root, 'tools', 'change-control-mcp', 'tickets.json');
if (!existsSync(file)) return [];
const { tickets } = JSON.parse(readFileSync(file, 'utf8'));
return tickets.filter(
(t) => t.status === 'approved' && (!t.expires || t.expires >= today) && t.scope.some((s) => rel.startsWith(s)),
);
}
export function block(lines) {
process.stderr.write(`${lines.join('\n')}\n`);
process.exit(2);
}
protect-paths.mjs
Blocks the file tools from touching the three protected trees. This is the Step 2 script, unchanged.
#!/usr/bin/env node
// PreToolUse hook for Create|Edit|ApplyPatch (Lab 5).
// Blocks any file write under src/money/, migrations/ or legacy/ with exit 2.
// Stderr goes back to Droid, so the message says what to do instead.
// Migrations are ALWAYS blocked here; protect-paths.lab8.mjs relaxes that when
// the new content cites an approved change ticket.
import { pathToFileURL } from 'node:url';
import { block, projectDir, protectedArea, readHookInput, targetPaths, toRelative } from './lib/protected.mjs';
/** `allow(rel, area, input, root)` may return a string (why it is allowed) to let one path through. */
export async function main(allow = () => null) {
const input = await readHookInput();
if (!['Create', 'Edit', 'ApplyPatch'].includes(input.tool_name)) process.exit(0);
const root = projectDir(input);
for (const p of targetPaths(input.tool_input)) {
const rel = toRelative(root, p);
const area = protectedArea(rel);
if (!area) continue;
const allowed = allow(rel, area, input, root);
if (allowed) {
process.stdout.write(`larkspur: ${rel} allowed: ${allowed}\n`);
continue;
}
block([`BLOCKED ${input.tool_name} on ${rel}.`, area.why, area.instead]);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) await main();
protect-shell.mjs
Closes the sed -i hole from Step 3. It judges each command in a chain separately, blocks the ones that write to a protected path, and lets reads and reverts through.
#!/usr/bin/env node
// PreToolUse hook for Execute (Lab 5). hooks.json only sends commands that
// mention a protected path (commandRegex), and this script decides whether the
// command WRITES there: sed -i / perl -i, > and >> redirection, tee, mv, cp
// (destination), rm, touch, truncate, git mv / git rm, and inline node/python
// that writes files. Reading is fine. Every `git checkout` and `git restore` is
// allowed so that reverting works (`git checkout -- path`). That also lets
// `git checkout <ref> -- path` restore a DIFFERENT version: a known hole, which
// Lab 5 names and the Stop hook catches.
//
// This is a HEURISTIC, not a sandbox. A determined command (a script file, an
// eval, a variable holding the path) gets past it. It exists to catch the
// ordinary shell edit, and the Stop hook (protected-diff.mjs) and the CI check
// are the backstops.
import { block, projectDir, protectedArea, readHookInput, toRelative } from './lib/protected.mjs';
const input = await readHookInput();
if (input.tool_name !== 'Execute') process.exit(0);
const root = projectDir(input);
const command = String(input.tool_input?.command ?? '');
const hit = (token) => protectedArea(toRelative(root, token.replace(/^\.\//, '')));
const words = (segment) => segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((w) => w.replace(/^["']|["']$/g, '')) ?? [];
function writesTo(segment) {
const w = words(segment);
const [cmd, sub] = w;
const args = w.slice(1).filter((a) => !a.startsWith('-'));
if (cmd === 'git' && (sub === 'checkout' || sub === 'restore')) return null; // reverting is allowed
for (const m of segment.matchAll(/(?:^|[^<0-9&])>>?\s*([^\s;|&]+)/g)) {
if (hit(m[1])) return { target: m[1], how: 'shell redirection' };
}
const first = (list, how) => {
const t = list.find(hit);
return t ? { target: t, how } : null;
};
if ((cmd === 'sed' || cmd === 'gsed' || cmd === 'perl') && w.some((a) => /^-[a-z]*i/.test(a))) {
return first(w.slice(1), `${cmd} in-place edit`);
}
if (cmd === 'tee') return first(args, 'tee');
if (['mv', 'rm', 'rmdir', 'unlink', 'touch', 'truncate', 'chmod', 'ln'].includes(cmd)) return first(args, cmd);
if (cmd === 'cp' || cmd === 'rsync' || cmd === 'install') return args.length && hit(args.at(-1)) ? { target: args.at(-1), how: cmd } : null;
if (cmd === 'git' && (sub === 'mv' || sub === 'rm')) return first(args.slice(1), `git ${sub}`);
if (/^(node|python3?|ruby|deno|bun)$/.test(cmd ?? '') && /write|open\(.*['"][wa]/i.test(segment)) {
return first(w.flatMap((a) => a.match(/[\w./-]*(?:src\/money|migrations|legacy)\/[\w./-]*/g) ?? []), `${cmd} script`);
}
return null;
}
for (const segment of command.split(/&&|\|\||[;|\n]/)) {
const found = writesTo(segment.trim());
if (found) {
const area = hit(found.target);
block([
`BLOCKED: this command writes to ${found.target} (${found.how}).`,
area.why,
area.instead,
'Reverting a protected file is allowed: git checkout -- <path>',
]);
}
}
process.exit(0);
It errs toward blocking where it can see a write. An inline node -e script that reads src/money/amount.ts and writes somewhere else is blocked, because the script contains both a write call and a protected path. That costs an occasional false positive on a harmless command, and Droid gets a message telling it why, so it can take another route. It also has holes, and the lab names them: it allows every git checkout and git restore, including git checkout main -- src/money/rounding.ts, which restores a different version; and it splits chains on ; and | without parsing quotes, so a sed expression containing ; isn't recognized. The Stop hook catches the result of both.
money-lint.mjs
The feedback hook from Step 6. It runs after the edit, so it can't prevent anything. Exit 2 sends its message back to Droid, which is expected to fix the line in the same session.
#!/usr/bin/env node
// PostToolUse hook for Create|Edit|ApplyPatch (Lab 5). Feedback, not prevention:
// PostToolUse runs AFTER the edit, so this cannot stop it; exit 2 feeds the
// message back to Droid so it fixes its own work.
//
// For files under src/ but outside src/money/, it reads the file as it is now
// and flags float arithmetic on money in the lines this change added (lines
// that differ from HEAD; the whole file when it is new or git is unavailable).
// Only added lines, so code already on main does not nag every edit.
//
// The regex is deliberately BLUNT. It will flag things that are not money
// (`/ 100` on a percentage, Math.floor on a day count, a comment). Those false
// positives are on purpose: Lab 5 has you count them and decide what a hook
// this noisy costs versus what it catches.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { git, projectDir, readHookInput, targetPaths, toRelative } from './lib/protected.mjs';
const FLOAT_MONEY = [/\*\s*0\.\d*/, /\*\s*1\.\d*/, /\/\s*100\b/, /\.toFixed\(/, /Math\.round\(/, /Math\.floor\(/, /parseFloat\(/];
const ADVICE = 'Use basisPoints()/multiplyBasisPoints() from src/money/amount.ts.';
const input = await readHookInput();
if (!['Create', 'Edit', 'ApplyPatch'].includes(input.tool_name)) process.exit(0);
const root = projectDir(input);
function addedLineNumbers(rel) {
if (git(root, 'ls-files', '--error-unmatch', rel) === null) return null; // new or no git: whole file
const diff = git(root, 'diff', '-U0', 'HEAD', '--', rel);
if (diff === null) return null;
const nums = new Set();
for (const m of diff.matchAll(/^@@ -\S+ \+(\d+)(?:,(\d+))? @@/gm)) {
const start = Number(m[1]);
const count = m[2] === undefined ? 1 : Number(m[2]);
for (let i = 0; i < count; i++) nums.add(start + i);
}
return nums;
}
const problems = [];
for (const p of targetPaths(input.tool_input)) {
const rel = toRelative(root, p);
if (!rel || !rel.startsWith('src/') || rel.startsWith('src/money/')) continue;
if (!/\.(ts|js|mjs|cjs)$/.test(rel) || !existsSync(join(root, rel))) continue;
const added = addedLineNumbers(rel);
readFileSync(join(root, rel), 'utf8')
.split('\n')
.forEach((line, i) => {
if (added && !added.has(i + 1)) return;
for (const re of FLOAT_MONEY) {
const m = re.exec(line);
if (m) problems.push(`${rel}:${i + 1} uses \`${m[0].trim()}\` on an amount. ${ADVICE}`);
}
});
}
if (problems.length) {
process.stderr.write(
`${problems.join('\n')}\n` +
'Money is integer cents and rates are basis points (they round half-even). ' +
'If a flagged line is not money, leave it and say so in your summary.\n',
);
process.exit(2);
}
process.exit(0);
Two design choices matter here. First, it checks only lines that differ from HEAD. The starter repo already has float debt outside src/money/: limit * 0.9 in isNearDailyLimit and spend * 0.015 in estimateCashback. A hook that flagged the whole file would fire on every edit to limits.ts, and Droid would learn to ignore it. That debt is Lab 10's Mission. Second, it's still blunt. It flags RETAIL_DAILY_LIMIT * 1.5, which is the T3 regression the lab asks you to explain. Lab 6 fixes that with a skill, not by weakening this file.
protected-diff.mjs
The Stop hook from Step 5. It doesn't care which tool changed a file. It asks git.
#!/usr/bin/env node
// Stop hook (Lab 5). Before Droid finishes, list every changed or untracked
// file under src/money/, migrations/ or legacy/. If there are any, exit 2: the
// message goes back to Droid, which must revert them or explain the human
// approval it has.
//
// Loop guard: when stop_hook_active is true, Droid is already continuing
// because of a Stop hook. Blocking again could loop forever, so instead we
// append the files to .factory/protected-changes.log and let it stop. The CI
// protected-path check (Lab 9) is the final backstop.
import { appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { git, projectDir, protectedArea, readHookInput } from './lib/protected.mjs';
const input = await readHookInput();
const root = projectDir(input);
const tracked = git(root, 'diff', '--name-only', 'HEAD');
const untracked = git(root, 'ls-files', '--others', '--exclude-standard');
if (tracked === null || untracked === null) {
process.stderr.write('protected-diff: not a git repository with a HEAD commit; skipped.\n');
process.exit(1); // non-blocking
}
const files = [...new Set(`${tracked}\n${untracked}`.split('\n').filter(Boolean))].filter((f) => protectedArea(f)).sort();
if (!files.length) process.exit(0);
if (input.stop_hook_active === true) {
mkdirSync(join(root, '.factory'), { recursive: true });
appendFileSync(
join(root, '.factory', 'protected-changes.log'),
`${new Date().toISOString()} session=${input.session_id ?? 'unknown'} stopped with protected changes: ${files.join(', ')}\n`,
);
process.exit(0);
}
process.stderr.write(
[
'Protected paths changed in this session:',
...files.map((f) => ` - ${f}`),
'Revert them (git checkout -- <path>, or delete new files) unless a human approved this',
'exact change in this session. If one did, say who and why in your summary.',
].join('\n') + '\n',
);
process.exit(2);
On the second attempt to stop (stop_hook_active is true), it lets Droid finish, because blocking again would loop. It appends a plain-text line to .factory/protected-changes.log (timestamp, session=<id>, and the files) for the Lab 9 CI check. The companion repo's .gitignore already ignores that file.
Test every script without Droid
Pipe each script the JSON it would receive, and check the exit code. Run these from the repo root.
# protect-paths: expect 2, then 0
echo '{"cwd":"'$PWD'","tool_name":"Edit","tool_input":{"file_path":"src/money/rounding.ts"}}' | node .factory/hooks/protect-paths.mjs; echo "exit $?"
echo '{"cwd":"'$PWD'","tool_name":"Edit","tool_input":{"file_path":"src/fees/calc.ts"}}' | node .factory/hooks/protect-paths.mjs; echo "exit $?"
# protect-shell: expect 2 (sed -i), 0 (read), 0 (revert)
for c in "sed -i '' 's/x/y/' src/money/rounding.ts" "cat src/money/rounding.ts" "git checkout -- src/money/rounding.ts"; do
node -e 'console.log(JSON.stringify({cwd:process.cwd(),tool_name:"Execute",tool_input:{command:process.argv[1]}}))' "$c" \
| node .factory/hooks/protect-shell.mjs; echo "exit $? <- $c"
done
# money-lint: make a float edit, expect 2; revert, expect 0
sed -i.bak 's|return null; // TODO(PAY-231): business accounts|return cents(RETAIL_DAILY_LIMIT * 1.5);|' src/transfers/limits.ts && rm src/transfers/limits.ts.bak
echo '{"cwd":"'$PWD'","tool_name":"Edit","tool_input":{"file_path":"src/transfers/limits.ts"}}' | node .factory/hooks/money-lint.mjs; echo "exit $?"
git checkout -- src/transfers/limits.ts
# protected-diff: touch a protected file, expect 2, then 0 with stop_hook_active, then revert
echo "// probe" >> src/money/rounding.ts
echo '{"cwd":"'$PWD'","session_id":"probe","stop_hook_active":false}' | node .factory/hooks/protected-diff.mjs; echo "exit $?"
echo '{"cwd":"'$PWD'","session_id":"probe","stop_hook_active":true}' | node .factory/hooks/protected-diff.mjs; echo "exit $?"
git checkout -- src/money/rounding.ts && tail -n 1 .factory/protected-changes.log
The hooks reference shows tool_input.file_path for Create, and its quickstart reads tool_input.command for Execute. It doesn't document ApplyPatch's input shape, so targetPaths() checks file_path, path and filePath and also scans string inputs for patch headers. Run one real session with droid --debug and confirm what your version sends.
What a good run looks like
In /hooks, the Project tab lists four matcher groups across PreToolUse, PostToolUse and Stop.
Repeating the lab's Step 3 prompt ("make it pass, and if a tool is blocked, find another way"), a good session shows the Edit blocked by protect-paths.mjs, the sed -i blocked by protect-shell.mjs, and then Droid working in test/fees/overdraft.test.ts with freezeClock. git status at the end shows nothing under src/money/.
Before the scoreboard, the lab's Step 7 has you confirm that hooks fire in a headless droid exec run, because the docs don't state it directly. If they fire, the scoreboard after Lab 5 is 22 of 24: T2 at 3 of 3, T4 at 2 of 3, and T3 down to 2 of 3 because of the money-lint false positive. If T3 didn't drop on your run, look at the T3 sessions anyway. The hook probably fired and Droid got lucky with the rewrite.
Three wrong turns, and how to spot them
Exit 1 instead of exit 2. The hook prints a clear message and the edit happens anyway. Any non-zero exit other than 2 is a non-blocking error. Spot it by piping the Step 2 test JSON into the script and reading echo $?. If you see 1, the hook has never blocked anything.
A hook that never runs. Three common causes, none of which fails loudly. A matcher with the wrong case (edit instead of Edit, or Bash instead of Execute: the shell tool is called Execute). A hooks wrapper around the events in hooks.json (the wrapper belongs only in settings.json). Or a session started before you saved the file: Droid snapshots hooks at startup and only warns that the file changed. Spot it in /hooks: if the Project tab doesn't show your groups, Droid isn't running them. droid --debug shows hook matching and execution.
A relative path in the command. node .factory/hooks/protect-paths.mjs works until a session changes directory, and then fails with a non-zero exit that isn't 2, which means "carry on". Always use "$FACTORY_PROJECT_DIR".
Known limits of this reference
protect-shell.mjsreads command text. A command that assembles a protected path at run time gets past it. That's by design: theStophook checks the outcome.protected-diff.mjscompares the working tree withHEAD. A change Droid committed during the session is part ofHEAD, so it doesn't show up. At Low autonomy a commit needs your approval, and Lab 9's CI check diffs the branch against its base, which catches committed changes too.- If git can't run,
protected-diff.mjsexits1and Droid carries on. That fails open. Decide deliberately whether you want that, and if not, change it to exit2with a message.