SLOPSHOPPER

flywheel-mod

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…

newbandguardstatusprocess
★ 3v0.1.0FSL-1.1-MITupdated 2026-10-03HarperZ9/flywheel/integrations/claude-code-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flywheel-mod
› fix the failing auth test and add an audit log call ● flywheel-mod: Flywheel: receipt not written for turn turn-1: crypto is not defined ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by flywheel-mod: Flywheel could not check this call, so it was not run (the mod fails closed): the ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Flywheel held 0 passed 0 · monitor unavailable 7x, failed closed · no receipt yet ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Flywheel held 0 passed 0 · monitor unavailable 7x, failed closed · no receipt yet ⟨Claude Code's own drawing⟩
README

Flywheel mod for Claude Code (experimental)

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.

Install

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.

What it does

  • Holds risky calls. A risk pre-filter reads each Bash, PowerShell, Edit, Write, MultiEdit and NotebookEdit call. A flagged call goes to the Flywheel pre-action monitor. A hold returns { 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.
  • Writes a receipt per turn. Each turn appends one hash-chained JSON line to .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.
  • Shows a status line above the prompt: held and passed counts and the last receipt hash.
  • Never approves. It registers no 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.

Settings

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>.

What was run on Claude Code 2.1.286

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:

TestWhat it shows
held callcurl -sI https://example.com comes back denied with the hold id, and the tool never runs
benign callls -la runs without a monitor run
screen: alla benign call goes to the monitor, which passes it, and it runs
no monitor_source, on_unavailable: denydenied as unchecked; the tool never runs
no monitor_source, on_unavailable: passthe call goes through
receipttwo turns write two lines; each hash recomputes and links to the line before
bandthe 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:

BreakTests that failed
tool.call hook removedheld call, screen: all, unchecked deny, receipt, band, and 2 older tests
hold turned into a passheld call, receipt, band, and 1 older test
on_unavailable invertedboth unchecked tests, and 1 older test
benign call deniedbenign call
receipt writer removedreceipt
hash chain link droppedreceipt
band hook removedband

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:

  • the mod's hooks module loaded with session.start, tool.call, turn.complete, ui.render;
  • for 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;
  • for 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.

Data and privacy

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.

Directory criteria

The four attestations the Claude plugin directory asks a submitter to make, and where this mod stands on each:

  1. Follows the Software Directory Terms and Policy. The mod adds holds and never approves a call, so it cannot widen what Claude may do. Its receipts hold digests, not command text. One open item: the directory's pre-submission checklist asks that everything a hook runs live inside the plugin folder. This mod runs the Flywheel monitor from monitor_source, outside the plugin folder. Until the monitor ships inside the plugin, this attestation is not met.
  2. The privacy statement is accurate. The mod connects to no remote service, so no hosted privacy policy applies. The section above lists everything it writes and starts, checked against the live run.
  3. No credential exfiltration and no undeclared code. Claude Code loads the declared hooks module, and the module starts only the monitor the user configures. It reads no credentials and makes no network call. Tool arguments go to the local monitor process on standard input and stay on the machine.
  4. Contact. Support and security reports go to the issue tracker at https://github.com/HarperZ9/flywheel/issues.

Known gaps

  • MCP tools and WebFetch are not screened yet.
  • Each screened call starts a new monitor process (about 0.4 to 0.9 s in the live run). A long-lived monitor is not built.
  • Claude Code reports a tool error and a refusal by its own permission check to mods in the same way, so the receipt records both as error.
  • The approve round trip (flywheel monitor approve, then the call again) is untested.

License

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.

Source 6 files
hooks/module.mjs 244 lines
1// 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}
244
hooks/lib/rules.mjs 83 lines
1// 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");
83
hooks/lib/monitor.mjs 105 lines
1// 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}
105
hooks/lib/receipt.mjs 112 lines
1// 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}
112
hooks/lib/config.mjs 57 lines
1// 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}
57
types/index.d.ts 57 lines
1// 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