Holds risky Bash, PowerShell and file-edit calls with the Flywheel pre-action monitor, writes a hash-chained receipt for every turn, and shows held and passed…

This mod puts the Flywheel pre-action monitor in front of Claude Code's shell and file-edit tools. A call the monitor holds never runs, and Claude reads the reason and the hold id. Every turn leaves a hash-chained receipt you can check against the session transcript later.
It needs a Claude Code build with mods (function hooks). It was run on Claude Code 2.1.286.
In Claude Code:
/plugin marketplace add HarperZ9/flywheel
/plugin install flywheel-mod@flywheel-skills
/plugin configure flywheel-mod@flywheel-skills
From a shell, the same install with the one required setting:
claude plugin marketplace add HarperZ9/flywheel
claude plugin install flywheel-mod@flywheel-skills --config monitor_source=/path/to/flywheel
monitor_source is the folder that holds Flywheel's harness/ package. Use a clone of https://github.com/HarperZ9/flywheel. Released flywheel-verify packages up to 1.2.1 do not include the pre-action monitor. The mod also needs a Python 3 interpreter on the machine; set python if python is not the right command.
{ deny } with the monitor's reason and hold id, and the tool never runs. A pass hands the call on, so Claude Code's own permission check still runs..flywheel/mod-receipts/<session>.jsonl. The line records each screened call's tool_use_id, input digest, pre-filter hits, monitor verdict, hold id and outcome, plus file hashes before and after edits.tool.check hook and never answers with a decision, a result or consent. Every answer is what Claude Code's core returned or exactly { deny }.Two scripts come with it:
node scripts/audit-mods.mjs reads your installed mods and reports approval patterns. A HIGH finding is a hook that answers allow or sets consent. Read-only. Exits 1 on any HIGH finding.node scripts/rederive.mjs <receipts.jsonl> <transcript.jsonl> re-derives the receipts from a Claude Code transcript and prints MATCH or DRIFT.monitor_source is required. When it is empty, every screened call is denied as unchecked. An unpinned import harness could load an unrelated package with the same name, so the mod never guesses.
If the monitor cannot answer, the call is denied with a visible reason. on_unavailable: pass makes that one case fail open and logs it. The other settings (python, monitor_home, monitor_owner_config, monitor_timeout_ms, monitor_deadline_seconds, screen, status_site, receipts_dir) are described in .claude-plugin/plugin.json.
A held call is decided from your own terminal with flywheel monitor approve <hold_id>.
Validation. claude plugin validate passes with no notes. It reports the hooks session.start, tool.call for the six guarded tools, turn.complete and ui.render for AbovePrompt.
Engine tests. claude plugin test integrations/claude-code-mod runs 11 tests against the engine itself, and all 11 pass. The tests in tests/engine.test.ts cover these paths:
| Test | What it shows |
|---|---|
| held call | curl -sI https://example.com comes back denied with the hold id, and the tool never runs |
| benign call | ls -la runs without a monitor run |
screen: all | a benign call goes to the monitor, which passes it, and it runs |
no monitor_source, on_unavailable: deny | denied as unchecked; the tool never runs |
no monitor_source, on_unavailable: pass | the call goes through |
| receipt | two turns write two lines; each hash recomputes and links to the line before |
| band | the status line draws on the terminal and desktop surfaces |
A test has no process, so the test answers the monitor run. For the held call it answers with the exact bytes the real monitor printed for that command against the shipped rule pack, rule egress/002.
Paired mutations. Each assertion was checked against a deliberately broken copy of the mod:
| Break | Tests that failed |
|---|---|
tool.call hook removed | held call, screen: all, unchecked deny, receipt, band, and 2 older tests |
| hold turned into a pass | held call, receipt, band, and 1 older test |
on_unavailable inverted | both unchecked tests, and 1 older test |
| benign call denied | benign call |
| receipt writer removed | receipt |
| hash chain link dropped | receipt |
| band hook removed | band |
Live session. A headless session (claude -p --permission-mode default) ran with the mod installed from this repository's marketplace and monitor_source set with --config. A scratch driver plugin issued two Bash calls through the hook chain at turn start. The debug log shows:
session.start, tool.call, turn.complete, ui.render;curl -sI https://example.com, the monitor exited 2 in 438 ms, and the mod answered tool.call with the hold text and hold id, so nothing beneath it ran;git --version, the mod passed the call on unscreened, and Claude Code's own permission check refused it;turn.complete wrote one receipt line. Its hash chain verifies. Its input digest for the held call equals the args.sha256 in the monitor's own hold record.Limits of that run: the calls came from a driver plugin, so no model turn chose them. Plugin calls are not transcript rows, so rederive.mjs reports the held call as missing from the transcript. A held call issued by the model in a live session has not been observed yet.
The Node suite (npm test, 45 tests) still runs the mod in a stand-in runtime and runs the real monitor as a subprocess. CI runs it on every pull request.
The mod makes no network call and reads no environment variable. It sends nothing off the machine. On disk, in the project folder by default:
.flywheel/mod-receipts/: digests, rule ids, verdicts, hold ids, the monitor's reason text and the paths of edited files. No command text and no file content..flywheel/monitor/: the monitor's own records. For a held call this includes the call's arguments, so the owner can review the hold.It starts one subprocess per screened call: the configured Python running the monitor from monitor_source. The first receipt in a new folder may start one more Python process to create the folder.
The four attestations the Claude plugin directory asks a submitter to make, and where this mod stands on each:
monitor_source, outside the plugin folder. Until the monitor ships inside the plugin, this attestation is not met.https://github.com/HarperZ9/flywheel/issues.error.flywheel monitor approve, then the call again) is untested.Functional Source License 1.1 with an MIT future license (FSL-1.1-MIT), the same license as Flywheel. The full text is in LICENSE. Each version becomes MIT two years after its release.
hooks/module.mjs 244 lines1// Flywheel mod: holds risky tool calls with the Flywheel pre-action monitor,
2// writes a hash-chained receipt for every turn, and shows one status line.
3//
4// tool.call (Bash, PowerShell, Edit, Write, MultiEdit, NotebookEdit):
5// a call the risk pre-filter flags goes to the monitor. A hold
6// becomes { deny }. A pass calls next(e), so Claude Code's own
7// permission check still runs. If the monitor cannot answer, the
8// call is denied with a visible reason unless on_unavailable=pass.
9// turn.complete: appends one sealed JSON line per turn under .flywheel/.
10// ui.render (AbovePrompt): one line, drawn above whatever later mods draw.
11//
12// This mod only adds holds. It registers no tool.check hook, never returns
13// { decision }, never returns { result } for a tool call, and never sets
14// `consent`. Its only answers to tool.call are next(e)'s own result or { deny }.
15//
16// The host reads on(...) and $.noun.method(...) from source, so they are
17// spelled literally, and helpers that take $ are top-level functions here.
18
19import { classify } from "./lib/rules.mjs";
20import { monitorArgv, monitorEvent, readMonitorRun, holdText, unavailableText } from "./lib/monitor.mjs";
21import { toolArgs, inputDigest, turnRecord, seal, lastHash, sha256Hex } from "./lib/receipt.mjs";
22import { normalizeConfig, statusLine, resolveIn, safeName } from "./lib/config.mjs";
23
24const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
25const MAX_RECEIPT_BYTES = 3_500_000; // $.fs.write takes at most 4 MiB per file
26
27let cfg = normalizeConfig({});
28const st = freshState();
29
30function freshState() {
31 return { sessionId: "", cwd: "", counts: { held: 0, passed: 0, unavailable: 0 },
32 lastHash: null, part: 0, buckets: new Map(), busy: false };
33}
34
35export function register(on, options) {
36 cfg = normalizeConfig(options);
37
38 on("session.start", async ($, e, next) => {
39 const result = await next(e);
40 await startSession($, e);
41 return result;
42 });
43
44 // Fail closed: if the guard throws or times out, the .catch handler denies.
45 on("tool.call", { tool: ["Bash", "PowerShell", "Edit", "Write", "MultiEdit", "NotebookEdit"] }, guard).catch(failed);
46
47 on("turn.complete", async ($, e, next) => {
48 const result = await next(e);
49 try {
50 await writeReceipt($, e);
51 } catch (error) {
52 $.ui.log(`Flywheel: receipt not written for turn ${e.turnId}: ${String(error?.message ?? error).slice(0, 200)}`);
53 }
54 return result;
55 });
56
57 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => drawBand($, e, next));
58}
59
60// ---- session ---------------------------------------------------------------
61
62async function startSession($, e) {
63 Object.assign(st, freshState());
64 st.cwd = String((await $.session.cwd()) ?? e.cwd ?? "");
65 st.sessionId = String((await $.session.id()) ?? "");
66 try {
67 // A resumed session continues its chain from the newest part file.
68 while (await $.fs.exists(receiptPath(st.part + 1))) st.part += 1;
69 const path = receiptPath(st.part);
70 if (await $.fs.exists(path)) st.lastHash = lastHash(await $.fs.read(path));
71 } catch {
72 st.lastHash = null; // an unreadable earlier file starts a new chain, which verify shows
73 }
74 showStatus($);
75}
76
77function receiptPath(part) {
78 const name = safeName(st.sessionId) + (part > 0 ? `.${part}` : "") + ".jsonl";
79 return resolveIn(st.cwd, cfg.receiptsDir) + "/" + name;
80}
81
82// ---- tool.call -------------------------------------------------------------
83
84async function guard($, e, next) {
85 if (!st.cwd) st.cwd = String((await $.session.cwd()) ?? "");
86 const args = toolArgs(e);
87 const risk = classify(e.tool, args, st.cwd);
88 const call = {
89 tool_use_id: e.tool_use_id ?? "", tool: e.tool, input_sha256: await inputDigest(args),
90 screened: risk.screen || cfg.screen === "all", rule_hits: risk.hits.map((h) => h.id),
91 decision: "pass", monitor: null, outcome: "pending",
92 };
93 bucketFor(e.agentId).push(call);
94
95 if (!call.screened) {
96 call.decision = "pass-unscreened";
97 st.counts.passed += 1;
98 return runAndObserve($, e, next, call);
99 }
100 const verdict = await askMonitor($, e, args);
101 call.monitor = { verdict: verdict.verdict, hold_id: verdict.holdId ?? null, reason: verdict.reason ?? null };
102
103 if (verdict.verdict === "held") {
104 call.decision = "held";
105 call.outcome = "denied-by-mod";
106 st.counts.held += 1;
107 showStatus($);
108 return { deny: holdText(verdict, { ...cfg, monitorHome: resolveIn(st.cwd, cfg.monitorHome) }) };
109 }
110 if (verdict.verdict === "unavailable") {
111 st.counts.unavailable += 1;
112 if (cfg.onUnavailable !== "pass") {
113 call.decision = "unavailable-deny";
114 call.outcome = "denied-by-mod";
115 showStatus($);
116 return { deny: unavailableText(verdict) };
117 }
118 call.decision = "unavailable-pass";
119 $.ui.log(`Flywheel monitor unavailable, passing the call on (on_unavailable=pass): ${verdict.reason}`);
120 } else {
121 call.decision = "pass";
122 }
123 st.counts.passed += 1;
124 return runAndObserve($, e, next, call);
125}
126
127async function askMonitor($, e, args) {
128 const event = monitorEvent({ tool: e.tool, args, toolUseId: e.tool_use_id, sessionId: st.sessionId, cwd: st.cwd });
129 const argv = monitorArgv({ ...cfg, monitorHome: resolveIn(st.cwd, cfg.monitorHome) });
130 if (!argv) return { verdict: "unavailable", reason: "monitor_source is not set; set it to the folder that holds Flywheel's harness/ package" };
131 let run;
132 try {
133 run = await $.process.run(argv, { cwd: st.cwd || undefined, stdin: JSON.stringify(event), timeoutMs: cfg.monitorTimeoutMs });
134 } catch (error) {
135 return { verdict: "unavailable", reason: `could not run ${argv[0]}: ${String(error?.message ?? error).slice(0, 200)}` };
136 }
137 return readMonitorRun(run);
138}
139
140// Passes the call on and records what came back. Returns next's result as is.
141async function runAndObserve($, e, next, call) {
142 const isEdit = EDIT_TOOLS.has(e.tool);
143 const file = isEdit ? String(e.file_path ?? e.notebook_path ?? "") : "";
144 if (isEdit && file) call.file = { path: file, before_sha256: await fileDigest($, file), after_sha256: null };
145 const result = await next(e);
146 call.outcome = result?.deny !== undefined ? "denied-downstream" : result?.isError ? "error" : "ran";
147 if (call.file) call.file.after_sha256 = await fileDigest($, file);
148 showStatus($);
149 return result;
150}
151
152async function fileDigest($, path) {
153 try {
154 if (!(await $.fs.exists(path))) return null;
155 return await sha256Hex(await $.fs.read(path));
156 } catch {
157 return "unreadable";
158 }
159}
160
161async function failed($, e, next) {
162 st.counts.unavailable += 1;
163 const reason = `the Flywheel guard hook failed (${next.error?.kind ?? "error"})`;
164 return { deny: unavailableText({ reason }) };
165}
166
167function bucketFor(agentId) {
168 const key = agentId ?? "main";
169 if (!st.buckets.has(key)) st.buckets.set(key, []);
170 return st.buckets.get(key);
171}
172
173// ---- turn.complete ---------------------------------------------------------
174
175async function writeReceipt($, e) {
176 // A main turn and a subagent turn can end together; one writer at a time
177 // keeps the chain linear. The check and the claim have no await between them.
178 while (st.busy) await $.clock.sleep(20);
179 st.busy = true;
180 try {
181 await sealAndAppend($, e);
182 } finally {
183 st.busy = false;
184 }
185}
186
187async function sealAndAppend($, e) {
188 const key = e.agentId ?? "main";
189 const calls = st.buckets.get(key) ?? [];
190 st.buckets.delete(key);
191 const record = turnRecord({ sessionId: st.sessionId, turnId: e.turnId, agentId: e.agentId,
192 reason: e.reason, isAborted: e.isAborted, endedAt: await $.clock.now(), calls });
193 const sealed = await seal(record, st.lastHash);
194 await appendLine($, JSON.stringify(sealed));
195 st.lastHash = sealed.record_hash;
196 showStatus($);
197}
198
199// $.fs.write replaces a file's content, so "append" is read, add one line,
200// write. Each session writes its own file, and writeReceipt serializes writes.
201async function appendLine($, line) {
202 let path = receiptPath(st.part);
203 let text = "";
204 if (await $.fs.exists(path)) text = await $.fs.read(path);
205 if (utf8Length(text) + utf8Length(line) + 1 > MAX_RECEIPT_BYTES) {
206 st.part += 1;
207 path = receiptPath(st.part);
208 text = "";
209 }
210 try {
211 await $.fs.write(path, text + line + "\n");
212 } catch (error) {
213 // The folder may not exist yet; create it with the configured Python, then retry once.
214 const dir = path.slice(0, path.lastIndexOf("/"));
215 await $.process.run([cfg.python, "-P", "-E", "-c", "import os,sys; os.makedirs(sys.argv[1], exist_ok=True)", dir], { timeoutMs: 10000 });
216 await $.fs.write(path, text + line + "\n");
217 }
218}
219
220function utf8Length(s) {
221 return new TextEncoder().encode(s).length;
222}
223
224// ---- status line -----------------------------------------------------------
225
226function showStatus($) {
227 if (cfg.statusSite === "status") {
228 $.ui.status(statusLine(st.counts, st.lastHash, cfg));
229 } else if (cfg.statusSite === "band") {
230 $.ui.invalidate("ui.render");
231 }
232}
233
234async function drawBand($, e, next) {
235 const seen = st.counts.held + st.counts.passed + st.counts.unavailable;
236 if (cfg.statusSite !== "band" || (seen === 0 && !st.lastHash)) return next(e);
237 const { Box, Text } = $.ui.resolve(e);
238 const theirs = await next(e);
239 const alarm = st.counts.held > 0 || st.counts.unavailable > 0;
240 const line = Text({ color: alarm ? "yellow" : undefined, dimColor: !alarm,
241 wrap: "truncate-end", children: statusLine(st.counts, st.lastHash, cfg) });
242 return Box({ key: "flywheel-band", flexDirection: "column", children: [line, theirs] });
243}
244hooks/lib/rules.mjs 83 lines1// Risk pre-filter: which Bash, PowerShell, Edit, Write, MultiEdit and
2// NotebookEdit calls are worth sending to the Flywheel pre-action monitor.
3//
4// This list never denies anything. A hit only routes the call to the monitor,
5// which makes the hold-or-pass decision. A false positive costs one monitor
6// run. A false negative skips the monitor, so the patterns err broad. Set the
7// mod's `screen` option to `all` to send every matched tool call instead.
8//
9// Pure: no `$`, no I/O. Imported by the hooks module and by the tests.
10
11export const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
12export const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
13
14const SHELL_RULES = [
15 { id: "shell/destructive", reason: "deletes files recursively or by force",
16 re: /\brm\s+(-[a-zA-Z]*[rRf]|--recursive|--force)|\bRemove-Item\b[^|;&]*-(Recurse|Force)|\b(rmdir|rd)\s+\/s|\bdel\s+\/[sfq]|\bfind\b[^|;&]*-delete\b|\bshred\b|\bmkfs\b|\bdd\s+if=/i },
17 { id: "shell/git-history", reason: "rewrites or discards git history or work",
18 re: /\bgit\b[^|;&]*\b(reset\s+--hard|clean\s+-[a-zA-Z]*f|push\b[^|;&]*(--force|\s-f\b|\s\+\S)|checkout\s+(--\s+)?\.(\s|$)|restore\s+\.|branch\s+-D|filter-branch|filter-repo|update-ref\s+-d|reflog\s+expire|stash\s+(drop|clear))/i },
19 { id: "shell/publish", reason: "publishes, pushes or merges outside the machine",
20 re: /\b(npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b|\bgh\s+(release\s+create|pr\s+merge|repo\s+(create|delete|edit))\b|\bdocker\s+push\b|\bgit\b[^|;&]*\bpush\b|\bcargo\s+publish\b/i },
21 { id: "shell/network", reason: "reaches the network from the shell",
22 re: /\b(curl|wget|Invoke-WebRequest|Invoke-RestMethod|iwr|irm|scp|rsync|sftp|ftp|nc|ncat|ssh)\b/i },
23 { id: "shell/opaque-exec", reason: "runs code the pre-filter cannot read",
24 re: /\b(eval|iex|Invoke-Expression)\b|\b(bash|sh|zsh|cmd|pwsh|powershell)(\.exe)?\s+(-c|\/c|-Command|-EncodedCommand|-enc)\b|\b(python3?|py|node|ruby|perl)\s+-[ce]\b|base64\s+(-d|--decode)|\|\s*(ba)?sh\b/i },
25 { id: "shell/privilege", reason: "changes privileges, permissions or system state",
26 re: /\bsudo\b|\bdoas\b|\bchmod\s+(-R\s+)?[0-7]*7[0-7]*\b|\bchown\b|\bicacls\b|\breg(\.exe)?\s+(add|delete)\b|\bSet-ExecutionPolicy\b|\bschtasks\b|\bcrontab\b|\bsystemctl\b/i },
27 { id: "shell/monitor-tamper", reason: "touches hook configuration or the monitor itself",
28 re: /\.claude[\\/](settings|managed)|managed-settings\.json|[\\/.]flywheel[\\/]|\.flywheel\b|hook_cli|harness\.preaction|disableAllHooks|\b(kill|pkill|taskkill|Stop-Process)\b[^|;&]*(python|flywheel|claude)/i },
29 { id: "shell/credential", reason: "touches a credential file or secret store",
30 re: /[\\/]\.ssh[\\/]|\.aws[\\/]|\.netrc\b|\.git-credentials|\.npmrc\b|\.pypirc\b|\bid_(rsa|ed25519|ecdsa)\b|(^|[\s\\/'"])\.env(\.(?!example)[\w.-]+)?(\s|$|['"])|\bprintenv\b|\bGet-ChildItem\s+env:/i },
31 { id: "shell/install", reason: "installs packages or runs install scripts",
32 re: /\b(npm|pnpm|yarn)\s+(i|install|add)\b|\bnpx\s|\bpip3?\s+install\b|\buv\s+(pip\s+install|add)\b|\bgem\s+install\b|\bwinget\s+install\b|\bchoco\s+install\b/i },
33 { id: "shell/database", reason: "runs a migration or a destructive database statement",
34 re: /\bmigrate\b|\balembic\s+upgrade\b|\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b/i },
35];
36
37const PATH_RULES = [
38 { id: "file/config-tamper", reason: "writes hook, agent or CI configuration",
39 re: /(^|[\\/])\.claude[\\/]|(^|[\\/])\.codex[\\/]|managed-settings\.json$|(^|[\\/])\.flywheel[\\/]|(^|[\\/])\.git[\\/]|(^|[\\/])\.github[\\/]workflows[\\/]|(^|[\\/])\.husky[\\/]|(^|[\\/])\.mcp\.json$|(^|[\\/])CLAUDE\.md$|(^|[\\/])AGENTS\.md$/i },
40 { id: "file/credential", reason: "writes a credential or secret file",
41 re: /(^|[\\/])\.env(\.(?!example$)[\w.-]+)?$|(^|[\\/])\.ssh[\\/]|(^|[\\/])\.aws[\\/]|\.netrc$|\.git-credentials$|\.npmrc$|\.pypirc$|\.(pem|key|p12|pfx)$|(^|[\\/])id_(rsa|ed25519|ecdsa)$/i },
42 { id: "file/executable", reason: "writes a script, build or package manifest that later runs",
43 re: /\.(sh|bash|ps1|psm1|bat|cmd)$|(^|[\\/])(package\.json|pyproject\.toml|setup\.py|Makefile|Dockerfile|\.npmrc)$/i },
44];
45
46/** Slash-normalized, lower-cased path for prefix checks. */
47export function normPath(p) {
48 return String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
49}
50
51/** True when `file` is absolute and outside `cwd`. A relative path is inside. */
52export function outsideProject(file, cwd) {
53 const f = normPath(file);
54 const isAbs = f.startsWith("/") || /^[a-z]:\//.test(f);
55 if (!isAbs || !cwd) return false;
56 const root = normPath(cwd);
57 return !(f === root || f.startsWith(root + "/"));
58}
59
60/** The path a file tool writes, from its arguments. */
61export function targetPath(args) {
62 return args?.file_path ?? args?.notebook_path ?? args?.path ?? "";
63}
64
65/**
66 * Classifies one call. Returns `{ screen, hits }`: `screen` says whether the
67 * monitor should see it, `hits` lists the rule ids and reasons that matched.
68 */
69export function classify(tool, args, cwd) {
70 const hits = [];
71 if (SHELL_TOOLS.has(tool)) {
72 const command = String(args?.command ?? "");
73 for (const r of SHELL_RULES) if (r.re.test(command)) hits.push({ id: r.id, reason: r.reason });
74 } else if (FILE_TOOLS.has(tool)) {
75 const file = targetPath(args);
76 for (const r of PATH_RULES) if (r.re.test(file)) hits.push({ id: r.id, reason: r.reason });
77 if (outsideProject(file, cwd)) hits.push({ id: "file/outside-project", reason: "writes outside the project folder" });
78 }
79 return { screen: hits.length > 0, hits };
80}
81
82export const RULE_IDS = [...SHELL_RULES, ...PATH_RULES].map((r) => r.id).concat("file/outside-project");
83hooks/lib/monitor.mjs 105 lines1// Talking to the Flywheel pre-action monitor (harness/preaction/hook_cli.py).
2//
3// The mod runs the monitor's own Claude Code hook adapter as a subprocess and
4// hands it the same PreToolUse JSON a settings hook would receive on stdin.
5// The adapter's contract (hook_cli.py on Flywheel main, read 2026-10-02):
6// exit 0, empty stdout -> no decision; the call goes on to Claude Code
7// exit 2, permissionDecision -> held or blocked, with a reason on stdout/stderr
8// any other exit -> not a monitor answer; this mod treats it as
9// "monitor unavailable"
10// The mod sends no permission_mode, so the adapter treats the call as one with
11// nobody to ask and answers a hold with deny (it never answers "ask").
12//
13// Pure: builds argv and stdin, reads a finished run. No `$`, no I/O.
14
15/** One-line Python bootstrap that imports the monitor from a source folder
16 * given as argv[1], with -P -E so the working folder and PYTHON* variables
17 * cannot shadow the import. The folder arrives as an argument, never as code. */
18export const BOOT =
19 "import sys,runpy; sys.path.insert(0, sys.argv.pop(1)); " +
20 "runpy.run_module('harness.preaction.hook_cli', run_name='__main__')";
21
22/** The monitor's argv for this config, or null when monitor_source is empty.
23 * Importing `harness.preaction` from whatever Python finds first could load an
24 * unrelated package named `harness`, so the source folder is required. */
25export function monitorArgv(cfg) {
26 if (!cfg.monitorSource) return null;
27 const tail = ["claude-code", "--home", cfg.monitorHome, "--hold-mode", "deny",
28 "--deadline", String(cfg.monitorDeadlineSeconds)];
29 if (cfg.monitorOwnerConfig) tail.push("--owner-config", cfg.monitorOwnerConfig);
30 return [cfg.python, "-P", "-E", "-c", BOOT, cfg.monitorSource, ...tail];
31}
32
33/** The PreToolUse event the adapter reads on stdin. */
34export function monitorEvent({ tool, args, toolUseId, sessionId, cwd }) {
35 return {
36 hook_event_name: "PreToolUse",
37 tool_name: tool,
38 tool_input: args,
39 tool_use_id: toolUseId ?? "",
40 session_id: sessionId ?? "",
41 cwd: cwd ?? "",
42 };
43}
44
45function parseDecision(stdout) {
46 const text = String(stdout ?? "").trim();
47 if (!text) return null;
48 try {
49 const out = JSON.parse(text.split("\n")[0]);
50 const h = out?.hookSpecificOutput;
51 if (h && typeof h.permissionDecision === "string") {
52 return { decision: h.permissionDecision, reason: String(h.permissionDecisionReason ?? "") };
53 }
54 } catch {
55 // The adapter printed something other than its JSON: not a decision.
56 }
57 return { decision: "unparsed", reason: text.slice(0, 300) };
58}
59
60/**
61 * Reads a finished run. Returns one of:
62 * { verdict: "pass" }
63 * { verdict: "held", reason, holdId }
64 * { verdict: "unavailable", reason }
65 * A monitor "allow" is read as "pass": the mod then calls next(e), and Claude
66 * Code's own permission check still runs. The mod never turns it into an
67 * approval.
68 */
69export function readMonitorRun(run) {
70 if (!run || typeof run.exitCode !== "number") {
71 return { verdict: "unavailable", reason: "the monitor returned no exit code" };
72 }
73 const parsed = parseDecision(run.stdout);
74 const stderr = String(run.stderr ?? "").trim();
75 if (run.exitCode === 0 && parsed === null) return { verdict: "pass" };
76 if (run.exitCode === 0 && parsed.decision === "allow") return { verdict: "pass" };
77 if ((run.exitCode === 0 || run.exitCode === 2) && parsed && (parsed.decision === "deny" || parsed.decision === "ask")) {
78 return held(parsed.reason || stderr);
79 }
80 if (run.exitCode === 2) return held((parsed && parsed.reason) || stderr || "held by the monitor");
81 const why = stderr.split("\n").filter(Boolean).pop() || (parsed && parsed.reason) || "";
82 return { verdict: "unavailable", reason: `monitor exited ${run.exitCode}${why ? ": " + why.slice(0, 240) : ""}` };
83}
84
85function held(reason) {
86 const text = String(reason || "held by the monitor").slice(0, 600);
87 const m = /hold_id=(h_[0-9a-f]+)/.exec(text);
88 return { verdict: "held", reason: text, holdId: m ? m[1] : null };
89}
90
91/** The text Claude reads when a call is held. */
92export function holdText(verdict, cfg) {
93 const how = verdict.holdId
94 ? ` Tell the user; they can decide it in their own terminal with: flywheel monitor approve ${verdict.holdId} --home ${cfg.monitorHome} .`
95 : " Tell the user.";
96 const reason = String(verdict.reason).replace(/[.\s]+$/, "");
97 return `Flywheel held this call and did not run it: ${reason}.${how} Do not retry it or work around it unless the user asks you to.`;
98}
99
100/** The text Claude reads when the monitor could not answer and the mod fails closed. */
101export function unavailableText(verdict) {
102 return `Flywheel could not check this call, so it was not run (the mod fails closed): ${verdict.reason}. ` +
103 "Tell the user the Flywheel monitor is unavailable. Do not retry it or work around it unless the user asks you to.";
104}
105hooks/lib/receipt.mjs 112 lines1// Turn receipts: one JSON line per turn, hash-chained, re-derivable from the
2// transcript.
3//
4// Canonical JSON matches Flywheel's contract.canonical_json (sorted keys, no
5// spaces, non-ASCII kept as UTF-8), so a call's `input_sha256` here equals the
6// monitor's `args_sha256` for the same call, and the receipt links to the
7// monitor's own record of it.
8//
9// Pure: no `$`. Uses Web Crypto, which a hooks module has.
10
11export const SCHEMA = "flywheel.mod-turn-receipt/v1";
12const RESERVED = new Set(["tool", "tool_use_id", "agentId", "consent"]);
13
14/** Canonical JSON text: sorted keys, no whitespace, undefined fields dropped. */
15export function canonicalJson(value) {
16 if (value === null || typeof value !== "object") {
17 return value === undefined ? "null" : JSON.stringify(value);
18 }
19 if (Array.isArray(value)) {
20 return "[" + value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",") + "]";
21 }
22 const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
23 return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
24}
25
26/** Hex SHA-256 of a string's UTF-8 bytes. */
27export async function sha256Hex(text) {
28 const bytes = new TextEncoder().encode(String(text));
29 const digest = await crypto.subtle.digest("SHA-256", bytes);
30 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
31}
32
33/** The tool's own arguments: the event minus the fields the engine reserves. */
34export function toolArgs(e) {
35 const out = {};
36 for (const k of Object.keys(e)) if (!RESERVED.has(k)) out[k] = e[k];
37 return out;
38}
39
40/** Digest of a call's arguments, the same value Flywheel's args_sha256 gives.
41 * Flywheel hashes empty arguments as zero bytes; so does this. */
42export async function inputDigest(args) {
43 const empty = !args || Object.keys(args).length === 0;
44 return sha256Hex(empty ? "" : canonicalJson(args));
45}
46
47/** The record for one turn, before it is sealed into the chain. */
48export function turnRecord({ sessionId, turnId, agentId, reason, isAborted, endedAt, calls }) {
49 const counts = { seen: calls.length, screened: 0, held: 0, passed: 0, unavailable: 0 };
50 for (const c of calls) {
51 if (c.screened) counts.screened += 1;
52 if (c.decision === "held") counts.held += 1;
53 else if (c.decision === "unavailable-deny") counts.unavailable += 1;
54 else counts.passed += 1;
55 }
56 return {
57 schema: SCHEMA,
58 session_id: sessionId ?? "",
59 turn_id: turnId ?? "",
60 agent_id: agentId ?? null,
61 reason: reason ?? "",
62 is_aborted: Boolean(isAborted),
63 ended_at_ms: endedAt ?? null,
64 counts,
65 calls,
66 };
67}
68
69/** Adds the link to the previous record and this record's own hash. */
70export async function seal(record, prevHash) {
71 const linked = { ...record, prev_hash: prevHash ?? null };
72 return { ...linked, record_hash: await sha256Hex(canonicalJson(linked)) };
73}
74
75/** The `record_hash` of the last line of a receipts file, or null. */
76export function lastHash(text) {
77 const lines = String(text ?? "").split("\n").filter((l) => l.trim() !== "");
78 if (lines.length === 0) return null;
79 try {
80 return JSON.parse(lines[lines.length - 1]).record_hash ?? null;
81 } catch {
82 return null;
83 }
84}
85
86/**
87 * Re-walks a receipts file: every line's hash recomputes and links to the line
88 * before it. Returns { ok, count, problems }. A broken or edited line shows as
89 * a problem; a deleted middle line shows as a broken link.
90 */
91export async function verifyChain(text, firstPrev = null) {
92 const lines = String(text ?? "").split("\n").filter((l) => l.trim() !== "");
93 const problems = [];
94 let prev = firstPrev;
95 for (let i = 0; i < lines.length; i += 1) {
96 let rec;
97 try {
98 rec = JSON.parse(lines[i]);
99 } catch {
100 problems.push({ line: i + 1, problem: "not JSON" });
101 prev = null;
102 continue;
103 }
104 const { record_hash: claimed, ...body } = rec;
105 const recomputed = await sha256Hex(canonicalJson(body));
106 if (recomputed !== claimed) problems.push({ line: i + 1, problem: "record_hash does not recompute" });
107 if ((rec.prev_hash ?? null) !== prev) problems.push({ line: i + 1, problem: "prev_hash does not link to the line before" });
108 prev = claimed ?? null;
109 }
110 return { ok: problems.length === 0, count: lines.length, problems };
111}
112hooks/lib/config.mjs 57 lines1// The mod's options, from the manifest's userConfig (snake_case keys), with
2// defaults filled and bad values replaced by the safe choice. Pure.
3
4const SCREEN = new Set(["rules", "all"]);
5const ON_UNAVAILABLE = new Set(["deny", "pass"]);
6const STATUS_SITE = new Set(["band", "status", "off"]);
7
8function pick(value, allowed, fallback) {
9 return allowed.has(value) ? value : fallback;
10}
11
12function num(value, fallback, min, max) {
13 const n = Number(value);
14 return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
15}
16
17/** Normalized config. An unknown `on_unavailable` falls back to `deny`. */
18export function normalizeConfig(options = {}) {
19 const o = options ?? {};
20 return {
21 python: String(o.python || "python"),
22 monitorSource: String(o.monitor_source || ""),
23 monitorHome: String(o.monitor_home || ".flywheel/monitor"),
24 monitorOwnerConfig: String(o.monitor_owner_config || ""),
25 monitorDeadlineSeconds: num(o.monitor_deadline_seconds, 8, 1, 60),
26 monitorTimeoutMs: num(o.monitor_timeout_ms, 15000, 2000, 600000),
27 screen: pick(o.screen, SCREEN, "rules"),
28 onUnavailable: pick(o.on_unavailable, ON_UNAVAILABLE, "deny"),
29 statusSite: pick(o.status_site, STATUS_SITE, "band"),
30 receiptsDir: String(o.receipts_dir || ".flywheel/mod-receipts"),
31 };
32}
33
34/** The status line text. */
35export function statusLine(counts, lastHash, cfg) {
36 const parts = [`Flywheel held ${counts.held} passed ${counts.passed}`];
37 if (counts.unavailable > 0) {
38 parts.push(cfg.onUnavailable === "deny"
39 ? `monitor unavailable ${counts.unavailable}x, failed closed`
40 : `monitor unavailable ${counts.unavailable}x, FAILED OPEN`);
41 }
42 parts.push(lastHash ? `receipt ${lastHash.slice(0, 12)}` : "no receipt yet");
43 return parts.join(" · ");
44}
45
46/** Joins a relative path onto the session folder; leaves an absolute one alone. */
47export function resolveIn(cwd, p) {
48 const s = String(p);
49 if (/^([a-zA-Z]:[\\/]|[\\/])/.test(s) || !cwd) return s;
50 return String(cwd).replace(/[\\/]+$/, "") + "/" + s;
51}
52
53/** A file-name-safe form of a session id. */
54export function safeName(id) {
55 return String(id ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "session";
56}
57types/index.d.ts 57 lines1// Types for the flywheel-mod plugin. The mod keeps no $.state values and adds
2// no noun to $, so plugin.json does not name this file as a contract. These
3// types describe its options and the receipt line it writes, for tools that
4// read the receipts.
5
6/** The userConfig values Claude Code passes to register(on, options). */
7export type FlywheelModOptions = {
8 python?: string;
9 monitor_source?: string;
10 monitor_home?: string;
11 monitor_owner_config?: string;
12 monitor_timeout_ms?: number;
13 monitor_deadline_seconds?: number;
14 screen?: "rules" | "all";
15 on_unavailable?: "deny" | "pass";
16 status_site?: "band" | "status" | "off";
17 receipts_dir?: string;
18};
19
20/** What the mod decided for one guarded tool call. */
21export type CallDecision = "pass" | "pass-unscreened" | "held" | "unavailable-deny" | "unavailable-pass";
22
23/** One guarded tool call inside a turn receipt. */
24export type ReceiptCall = {
25 tool_use_id: string;
26 tool: string;
27 /** SHA-256 of the canonical JSON of the tool's arguments; equals Flywheel's args_sha256. */
28 input_sha256: string;
29 screened: boolean;
30 rule_hits: string[];
31 decision: CallDecision;
32 monitor: { verdict: "pass" | "held" | "unavailable"; hold_id: string | null; reason: string | null } | null;
33 /**
34 * What happened after the mod's decision. `error` covers a tool error and
35 * a refusal by Claude Code's own permission check: core reports both to
36 * tool.call hooks as an errored result. `denied-downstream` means a hook
37 * after this mod answered `{ deny }`.
38 */
39 outcome: "pending" | "ran" | "error" | "denied-by-mod" | "denied-downstream";
40 file?: { path: string; before_sha256: string | null; after_sha256: string | null };
41};
42
43/** One line of .flywheel/mod-receipts/<session>.jsonl. */
44export type TurnReceipt = {
45 schema: "flywheel.mod-turn-receipt/v1";
46 session_id: string;
47 turn_id: string;
48 agent_id: string | null;
49 reason: string;
50 is_aborted: boolean;
51 ended_at_ms: number | null;
52 counts: { seen: number; screened: number; held: number; passed: number; unavailable: number };
53 calls: ReceiptCall[];
54 prev_hash: string | null;
55 record_hash: string;
56};
57