From e891f7cd234cd708fdd143e41267cbefe9d3339c Mon Sep 17 00:00:00 2001 From: QuentinCody <33259999+QuentinCody@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:10:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(harness):=20govern=20ephemeral=20writes=20?= =?UTF-8?q?=E2=80=94=20see=20them,=20bound=20them,=20block=20the=20evasion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes, all driven by measurement over .interlinked/ rather than by taste. The corpus: 340 session-scratchpad writes across 148 distinct artifacts, against 7077 ordinary repo writes. 1. Archive skips foreign project roots. A scratchpad subdirectory carrying .git / package.json / Cargo.toml / go.mod / pyproject.toml is a clone, not the session's work; its subtree is skipped as `vendored-tree`, and scratchpad_archive.archive_excludes takes globs for marker-less bulk. Not hygiene — the difference between an archive and nothing: one cloned repo had spent the entire 2000-file cap, so both surviving manifests read truncated:true and every agent-authored artifact was evicted, including the patch applier that motivated change 2. The scratchpad ROOT is never foreign, so a lone package.json repro still archives. 2. Hand-rolled patch appliers block (`builtin-patch-applier`). Recovered from the archive: plm/apply.mjs plus six rN.anchor.txt/rN.new.txt pairs — an anchor/replacement applier that read the pairs and wrote into repo source. That is the Edit tool re-implemented with the gates removed. Two required signals: a filesystem-write call AND a target outside the script's own sandbox. Spans the scratchpad and the in-repo scratch/ probe dir; a probe that only READS repo source does not fire. 3. Captured external-agent output is steered to .interlinked/agent-output/. Codex/Sol audit results are the artifacts least able to afford archival roulette, and they were the ones being thrown away. 4. Every ephemeral write is recorded to .interlinked/ephemeral-writes.jsonl, any extension. The placement guard only ever inspected CODE extensions, so the largest ephemeral class in the corpus — .json gate-workaround manifests — passed with no warning and no trace at all. Also: HarnessEvent.dry_run, set by `interlinked harness test`, so a simulation cannot persist. Found the hard way — three dry-run probes opened a real transient debt against a file they never wrote, which then blocked an unrelated edit. A read-only command must not move the gate. Also: multi-edit's help text claimed --stdin needed a positional . It never did; the multi-file {batches} form always worked on stdin. That false claim is why ~40 recorded invocations each staged a manifest file in a temp directory — the very writes changes 3 and 4 exist to see. Held back from this commit: the dry-run wiring in transient-debt-guard.ts, debt.ts transient support, and the pre-tool-rules block message, all of which depend on the transient-debt feature that is still unlanded on main. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 15 ++ docs/plans/multi-edit-deletion-review.md | 131 ++++++++++++++++++ skills/interlinked-harness/SKILL.md | 1 + src/commands/harness-test-event.ts | 5 + src/commands/multi-edit.ts | 14 +- src/harness/ephemeral-write-log.test.ts | 125 +++++++++++++++++ src/harness/ephemeral-write-log.ts | 113 +++++++++++++++ .../evaluator/patch-applier-guard.test.ts | 109 +++++++++++++++ src/harness/evaluator/patch-applier-guard.ts | 98 +++++++++++++ .../evaluator/scratchpad-write-guard.test.ts | 48 ++++++- .../evaluator/scratchpad-write-guard.ts | 104 +++++++++++++- src/harness/scratchpad-archive.test.ts | 73 ++++++++++ src/harness/scratchpad-archive.ts | 43 +++++- src/harness/types/config.ts | 5 + src/harness/types/events.ts | 8 ++ src/registrars/quality.ts | 7 +- 16 files changed, 884 insertions(+), 15 deletions(-) create mode 100644 docs/plans/multi-edit-deletion-review.md create mode 100644 src/harness/ephemeral-write-log.test.ts create mode 100644 src/harness/ephemeral-write-log.ts create mode 100644 src/harness/evaluator/patch-applier-guard.test.ts create mode 100644 src/harness/evaluator/patch-applier-guard.ts diff --git a/CLAUDE.md b/CLAUDE.md index 08767172..9d290f15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,10 @@ is allowed by repo-confinement (the June triad carve-out) but governed by intent |---|---|---| | Agent-authored CODE (probes, drafts) | **Block-with-redirect to `/scratch/`** (default; `scratchpad_guard.code_write_mode: "warn"\|"off"` softens; `INTERLINKED_DISABLE_SCRATCH_GUARD=1` one-command bypass). Covers Write/Edit AND bash redirect/tee — bash targets are resolved through same-command `VAR=` assignments and `cd` hops (`resolveBashWriteTarget`). | `evaluator/scratchpad-write-guard.ts`, steer in `evaluator/pre-tool-rules.ts` | | Secrets to ANY ephemeral temp path | **Block unconditionally** (`builtin-tmp-secrets`; temp paths sit outside protected-file globs but are the classic exfil-staging surface). Escape hatch does NOT apply. | same guard | +| Hand-rolled PATCH APPLIER (script that writes into repo source) | **Block** (`builtin-patch-applier`) in the scratchpad AND in `/scratch/`. Two required signals: a filesystem-write call plus a target outside its own sandbox (`src/**`-shaped literal, `process.cwd()`, `..`). Bypass: `INTERLINKED_DISABLE_PATCH_APPLIER_GUARD=1`. | `evaluator/patch-applier-guard.ts` | | Downloads / extractions / non-code bulk | Allowed — belongs out-of-repo (in-tree it would poison rg + the trigram index) | — | +| Captured EXTERNAL-AGENT output (review/audit/report `.md`) | Allowed, but warned toward `/.interlinked/agent-output/` — hours-long Codex/Sol runs are the artifacts least able to afford archival roulette | `evaluator/scratchpad-write-guard.ts` | +| Every ephemeral write, ANY extension | **Recorded** to `.interlinked/ephemeral-writes.jsonl` (`ts/session/tool/path/ext/bytes/kind/blocked`); manifest-ish and unclassified kinds also warn. Closes the pre-2026-08 blind spot where the guard only inspected CODE extensions, so `.json` gate-workaround manifests left no trace at all. | `ephemeral-write-log.ts` | | Everything left at session end | **Archived** into `.interlinked/scratchpad-archive/` (content-addressed blobs + per-session manifest; caps + excludes recorded, no silent truncation; `scratchpad_archive` config, default ON) | `scratchpad-archive.ts`, wired in `server/lifecycle-events.ts` SessionEnd | `interlinked scratch init|status` provisions `scratch/` in any repo (README + @@ -165,6 +168,18 @@ is allowed by repo-confinement (the June triad carve-out) but governed by intent Both config sections are locally overridable (classified in `rules/merge.ts` + pinned by `merge-parity.test.ts`). +**The archive skips FOREIGN PROJECT ROOTS** (2026-08-04). A scratchpad +subdirectory carrying `.git` / `package.json` / `Cargo.toml` / `go.mod` / +`pyproject.toml` is a clone or extraction, not the session's work, and its whole +subtree is skipped with reason `vendored-tree`; `scratchpad_archive.archive_excludes` +takes extra globs for bulk that carries no marker. This is not hygiene — it is +the difference between an archive and nothing: before the rule, a single cloned +repo spent the entire 2000-file cap, so both surviving manifests read +`truncated: true` and every agent-authored artifact was evicted, including the +`plm/apply.mjs` patch applier that motivated the row above. The scratchpad ROOT +is never treated as foreign, so a lone `package.json` repro still archives. + + ## Harness (Guard + Lifecycle + Auto-Reservation) The CLI includes a **local harness server** (`src/harness/`) that runs on Node.js and evaluates agent actions via a Unix socket. Full documentation: `docs/harness.md`. Auto-generated reference docs: `docs/generated/`. diff --git a/docs/plans/multi-edit-deletion-review.md b/docs/plans/multi-edit-deletion-review.md new file mode 100644 index 00000000..b371513a --- /dev/null +++ b/docs/plans/multi-edit-deletion-review.md @@ -0,0 +1,131 @@ +# Multi-edit keep-vs-delete review — scheduled 2026-08-12 + +**Status:** deferred decision, awaiting execution +**Decided:** 2026-08-05 — delete, but not for one week +**Executed by:** cloud routine `trig_01D81bmfV4qWbt8NeRasDs3T` (one-shot, 2026-08-12 09:00 EDT) + +This file is the routine's entire brief. It assumes zero context. + +## The decision being revisited + +`interlinked multi-edit` was slated for deletion on 2026-08-05. The deletion was +held for one week so its replacement could accumulate real-world mileage. That +week is up. + +## Why it was slated for deletion + +**It documents its own obsolescence.** `src/commands/multi-edit.ts` lines 11–15: + +> This exists because the Edit tool applies one replacement at a time, and the +> tsc/biome diff-overlays check each intermediate state. Coordinated changes +> that cross multiple sites in one file … deadlock under serial Edits because +> one half of the change is invalid without the other. + +That is a bypass lane around a gate this repo installs itself — not a capability +an agent would otherwise want. + +**The gate stopped deadlocking.** `src/harness/transient-debt.ts` plus +`src/harness/evaluator/transient-debt-guard.ts` now *defer* that finding class: +the write is allowed, a transient debt opens, and the counterpart edit +discharges it. Verified live 2026-08-05 against the running daemon — a simulated +write adding an import of a not-yet-existing symbol returned `ALLOWED` with +`[interlinked:transient-debt] … Land that half next`. + +**The usage record shows no compelling case** (measured 2026-08-05 over +`.interlinked/activity.jsonl` and `scratchpad-archive/`): + +| Evidence | Result | +|---|---| +| Real invocations | ~40, **every one** via `--manifest ` | +| Archived uses of the multi-file `batches` shape | **0** | +| Multi-file uses in live scratchpads | 4, all one session, all 2-file | +| Those 4 | union-member + `Record` key, config field + consumer — exactly what transient debt now allows | + +**Its interface manufactured ephemeral writes.** `--manifest` takes a file path, +so every use staged a throwaway JSON manifest in a temp directory. ~20 of the +148 archived scratchpad artifacts are these manifests. (`--stdin` always +accepted the multi-file `{batches}` form with no temp file; the help text +wrongly claimed it needed a positional ``, which is what drove the +file-staging. Fixed 2026-08-05.) + +## Keep-side arguments — test these, do not assume they failed + +1. **Fallback if transient debt has a hole.** It was days old at decision time, + proven on one simulated case. +2. **Gate runs once, not N times.** A 6-file refactor pays the tsc/biome overlay + once. Latency only. +3. **True all-or-none across files.** Transient debt lets a half-landed state + exist on disk between edits; multi-edit never does. Git covers this. +4. **Already written and tested.** ~1,015 lines of passing tests, zero + maintenance cost while nothing around it changes. + +## Step 1 — gather evidence (report every item; skip none) + +- [ ] Does `src/harness/evaluator/transient-debt-guard.ts` still exist, and is + `applyTransientDebt` still called from + `src/harness/evaluator/write-content-guards.ts`? **If it was reverted or + disabled, STOP and recommend KEEP.** +- [ ] `git log --since=2026-08-05 --oneline` — any transient-debt revert, bug + fix, or commit message describing a coordinated-edit deadlock? Each is + evidence for KEEP. +- [ ] Production importers of `multi-edit*` outside `src/registrars/quality.ts` + (dynamic import) and `src/commands/completions.ts` (a string)? There were + **zero** on 2026-08-05. +- [ ] `npx vitest run src/harness/evaluator/transient-debt-guard.test.ts` — + green? +- [ ] `npm run typecheck` and the full `npx vitest run` — baseline green before + touching anything. + +**You cannot see the local evidence.** `.interlinked/activity.jsonl`, +`ephemeral-writes.jsonl`, and `scratchpad-archive/` are gitignored and do not +exist in a cloud checkout. Say so explicitly in your report, and note that the +usage half of the argument rests on the 2026-08-05 measurement recorded above. + +## Step 2 — decide + +Delete only if **all** hold: + +1. Transient debt is present, wired, and its tests pass. +2. No commit since 2026-08-05 indicates it was reverted, disabled, or worked + around. +3. No new production importer of the multi-edit modules appeared. + +Otherwise **KEEP** and report which condition failed. A keep is a legitimate +outcome, not a failure of this task. + +## Step 3 — execute the deletion (only if Step 2 says delete) + +1. Delete `src/commands/multi-edit.ts`, `multi-edit-apply.ts`, + `multi-edit-manifest.ts`, `multi-edit.test.ts`, + `src/commands/__tests__/multi-edit.test.ts`. +2. Remove the `multi-edit` command block from `src/registrars/quality.ts` and + the `"multi-edit"` entry from `src/commands/completions.ts`. +3. Remove the `--batch` path from `src/commands/write.ts` and its registrar + option — the second overlapping primitive for the same non-problem. +4. `src/registrars/quality.test.ts` pins the option list and **will fail** — + update it. +5. Check these before assuming they are clean: + - `isTscFindingBlocking` is only *re-exported* by `multi-edit-apply.ts`; + canonical home is `src/harness/diff-overlay.ts:176`. Re-point importers. + - `countOccurrences` in `multi-edit-apply.ts` duplicates + `src/harness/edit-diagnostics.ts:219`. Deleting removes the clone; check + nothing imported the multi-edit copy. + - `atomicBatchWrite` had zero consumers. +6. Grep for the string `multi-edit` across `src/`, `docs/`, `skills/` and update + every hit. Known: `skills/interlinked-verify/SKILL.md` (lines ~94–141), + `docs/design/multi-edit-atomic-coordinated-edits.md` (mark superseded, do not + delete the design record), `docs/generated/cli-reference.md` (regenerate with + `npm run docs`). +7. The block message in `src/harness/evaluator/pre-tool-rules.ts` mentions + `interlinked multi-edit --stdin` as the atomic escape hatch. If the command + is gone, that clause must go too — steer entirely to sequential Edits and + transient debt. `src/harness/evaluator/pre-tool-rules.test.ts` pins this + message; update it. +8. `npm run typecheck && npx vitest run` must be green. +9. Open a PR titled `refactor: delete multi-edit — transient debt replaced its + reason to exist`. Do **not** push to `main`. + +## Step 4 — report + +State the verdict, every evidence item with its result, what you changed, the +PR link, and anything you could not verify from a cloud checkout. diff --git a/skills/interlinked-harness/SKILL.md b/skills/interlinked-harness/SKILL.md index 8d70e51e..e6346725 100644 --- a/skills/interlinked-harness/SKILL.md +++ b/skills/interlinked-harness/SKILL.md @@ -51,6 +51,7 @@ defeat the pattern. | **Repo confinement** | any Write/Edit whose real (symlink-resolved) target is outside the repo root | paths under the allowlist / session scratchpad | | **Package installs** | any un-allowlisted `npm/pip/cargo/go/…` install; URL/git/tarball specs — see **interlinked-supply-chain** | allowlisted + exact-pinned | | **Bash-routed write bypass** | a `>` / `tee` redirect writing a tracked source file (dodges the content gate) | routed to Write/Edit or `interlinked write` | +| **Hand-rolled patch applier** | a throwaway script in the scratchpad or `scratch/` that calls `writeFileSync`/`appendFileSync`/`write_text` on a path outside its sandbox (`"src/…"`, `process.cwd()`, `../`) — a re-implementation of Edit with the gates removed | probes that only READ repo source; scripts writing beside themselves; committed codegen under `scripts/` | | **Content pre_block** (introduced-only) | edit that *introduces* merge-conflict markers, `eval()`, and other zero-FP checks | pre-existing instances (warn, not block) | ## When you're BLOCKED: what to do diff --git a/src/commands/harness-test-event.ts b/src/commands/harness-test-event.ts index 7cce020f..d1b0088a 100644 --- a/src/commands/harness-test-event.ts +++ b/src/commands/harness-test-event.ts @@ -80,6 +80,11 @@ export function buildHarnessTestEvent(input: HarnessTestInput): HarnessTestPlan const event: JsonObject = { hook_event: "PreToolUse", + // This command SIMULATES a tool call; nothing is written to disk. The + // marker tells the daemon's evaluators to compute the verdict but persist + // nothing — otherwise a probe opens real obligations against files it + // never touched and those block later, genuine edits. + dry_run: true, session_id: "cli-test", agent_source: "claude", agent_name: "test", diff --git a/src/commands/multi-edit.ts b/src/commands/multi-edit.ts index 593c2ab5..abe71a8b 100644 --- a/src/commands/multi-edit.ts +++ b/src/commands/multi-edit.ts @@ -187,11 +187,17 @@ async function readStdin(): Promise { /** * Commander action handler for `interlinked multi-edit`. * - * Supports two invocation shapes: + * Supports three invocation shapes. Stdin is the preferred one for BOTH + * single- and multi-file work — it needs no temp file, which matters because + * the whole point of this command is to unblock coordinated edits, and making + * the agent stage a manifest on disk first just relocates the friction: + * interlinked multi-edit --stdin + * Multi-file manifest ({ version: 1, batches: [{ path, edits }] }) on + * stdin. No positional path. THE default for coordinated cross-file edits. * interlinked multi-edit --stdin - * Reads a single-file manifest ({ version: 1, edits: [...] }) from stdin. + * Single-file manifest ({ version: 1, edits: [...] }) on stdin. * interlinked multi-edit --manifest - * Reads a single-file OR multi-file manifest from `path`. + * Either shape, read from a manifest already on disk. */ export async function multiEditCommand( path: string | undefined, @@ -223,7 +229,7 @@ export async function multiEditCommand( error_detail: { path: path || "", message: - "Must supply either ` --stdin` (single file, manifest on stdin) or `--manifest ` (single or multi-file manifest).", + "Must supply --stdin or --manifest. Preferred (no temp file): pipe {version:1,batches:[{path,edits}]} to `interlinked multi-edit --stdin` for any number of files, or {version:1,edits:[...]} with a for one file. `--manifest ` reads the same shapes from disk.", }, }); process.exitCode = 1; diff --git a/src/harness/ephemeral-write-log.test.ts b/src/harness/ephemeral-write-log.test.ts new file mode 100644 index 00000000..8690e7f5 --- /dev/null +++ b/src/harness/ephemeral-write-log.test.ts @@ -0,0 +1,125 @@ +// Tests for the ephemeral-write ledger: classification (the `.json` blind spot +// the placement guard never saw) and the never-throw append contract. + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + appendEphemeralWrite, + buildEphemeralWriteRecord, + classifyEphemeralWrite, +} from "./ephemeral-write-log.js"; + +const temps: string[] = []; +const makeRoot = (withInterlinked: boolean): string => { + const root = mkdtempSync(join(tmpdir(), "ephemeral-log-")); + temps.push(root); + if (withInterlinked) mkdirSync(join(root, ".interlinked"), { recursive: true }); + return root; +}; + +afterEach(() => { + while (temps.length > 0) { + const p = temps.pop(); + if (p) rmSync(p, { recursive: true, force: true }); + } +}); + +describe("classifyEphemeralWrite", () => { + it("classifies code extensions", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/probe.mjs")).toBe("code"); + expect(classifyEphemeralWrite("/tmp/s/scratchpad/fix.py")).toBe("code"); + }); + + it("classifies the .json manifest blind spot", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/def.json")).toBe("manifest"); + expect(classifyEphemeralWrite("/tmp/s/scratchpad/ci.yml")).toBe("manifest"); + }); + + it("classifies captured external-agent output", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/codex-review-2-result.md")).toBe( + "agent-output", + ); + expect(classifyEphemeralWrite("/tmp/s/scratchpad/sol-audit.md")).toBe("agent-output"); + }); + + it("does not claim every markdown note is agent output", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/notes.md")).toBe("other"); + }); + + it("classifies bulk downloads", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/pkg.tgz")).toBe("bulk"); + expect(classifyEphemeralWrite("/tmp/s/scratchpad/shot.png")).toBe("bulk"); + }); + + it("falls back to other for extensionless files", () => { + expect(classifyEphemeralWrite("/tmp/s/scratchpad/Makefile")).toBe("other"); + }); +}); + +describe("buildEphemeralWriteRecord", () => { + it("captures tool, byte length, extension, and blocked flag", () => { + const rec = buildEphemeralWriteRecord({ + sessionId: "s1", + tool: "Write", + absPath: "/tmp/s/scratchpad/def.json", + content: '{"a":1}', + blocked: true, + now: () => "2026-08-04T00:00:00.000Z", + }); + expect(rec).toEqual({ + ts: "2026-08-04T00:00:00.000Z", + session_id: "s1", + tool: "Write", + path: "/tmp/s/scratchpad/def.json", + ext: ".json", + bytes: 7, + kind: "manifest", + blocked: true, + }); + }); + + it("measures bytes, not characters", () => { + const rec = buildEphemeralWriteRecord({ + sessionId: undefined, + tool: "Edit", + absPath: "/tmp/s/scratchpad/x.txt", + content: "é", + blocked: false, + }); + expect(rec.bytes).toBe(2); + }); +}); + +describe("appendEphemeralWrite", () => { + const record = buildEphemeralWriteRecord({ + sessionId: "s1", + tool: "Write", + absPath: "/tmp/s/scratchpad/a.json", + content: "{}", + blocked: false, + now: () => "2026-08-04T00:00:00.000Z", + }); + + it("appends one JSON line per call", () => { + const root = makeRoot(true); + appendEphemeralWrite(root, record); + appendEphemeralWrite(root, record); + const lines = readFileSync(join(root, ".interlinked", "ephemeral-writes.jsonl"), "utf-8") + .trim() + .split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] as string).kind).toBe("manifest"); + }); + + it("no-ops when .interlinked/ is absent rather than creating it", () => { + const root = makeRoot(false); + appendEphemeralWrite(root, record); + expect(existsSync(join(root, ".interlinked"))).toBe(false); + }); + + it("never throws on an unwritable root", () => { + expect(() => appendEphemeralWrite("/proc/nonexistent-root", record)).not.toThrow(); + }); +}); diff --git a/src/harness/ephemeral-write-log.ts b/src/harness/ephemeral-write-log.ts new file mode 100644 index 00000000..8c8f3767 --- /dev/null +++ b/src/harness/ephemeral-write-log.ts @@ -0,0 +1,113 @@ +// =========================================== +// Ephemeral-write ledger +// =========================================== +// Every write aimed at a path the OS will purge — the session scratchpad, +// /tmp, any other temp root — gets one append-only record here, regardless of +// extension. The extension part matters: the placement guard only ever +// inspected CODE extensions (`CODE_FILE_EXT_RE`), so the single largest class +// of ephemeral write in the recorded corpus — `.json` gate-workaround manifests +// — passed with no warning and no trace. Writes that leave no record cannot be +// audited, and "I could not see it happening" was how a hand-rolled patch +// applier lived in a scratchpad for a whole session. +// +// This is a LEDGER, not a gate: it never blocks and never throws. Query it with +// `interlinked query .interlinked/ephemeral-writes.jsonl`. + +import { appendFileSync, existsSync } from "node:fs"; +import { extname, join } from "node:path"; + +/** What the write appears to be, from path + extension alone. Drives the + * advisory steer; recorded so the mix can be re-derived later without + * re-running the classifier. */ +export type EphemeralWriteKind = "code" | "manifest" | "agent-output" | "bulk" | "other"; + +export interface EphemeralWriteRecord { + /** ISO timestamp. */ + ts: string; + session_id: string | undefined; + /** Tool that issued the write (Write / Edit / MultiEdit). */ + tool: string; + /** Absolute resolved target. */ + path: string; + /** Lowercased extension including the dot, or "" when extensionless. */ + ext: string; + bytes: number; + kind: EphemeralWriteKind; + /** True when a guard blocked this write — the record is the ATTEMPT, so a + * blocked evasion still leaves a trace. */ + blocked: boolean; +} + +/** Extensions that carry captured output from an external agent/tool run — + * Codex/Sol audit results, review transcripts, long analyses. */ +const OUTPUT_EXT_RE = /\.(?:md|txt|log|html)$/i; +/** Filename shapes that mark such captured output specifically, as opposed to + * an incidental note. Kept to unambiguous vocabulary — a `notes.md` is not + * claimed to be an audit artifact. */ +const AGENT_OUTPUT_NAME_RE = + /(?:review|audit|finding|report|result|analysis|transcript|codex|sol-|prompt)/i; +const BULK_EXT_RE = /\.(?:tgz|tar|gz|zip|7z|whl|jar|bin|so|dylib|dll|pdf|png|jpe?g|gif|webp)$/i; +const CODE_EXT_RE = /\.(?:tsx?|jsx?|mjs|cjs|mts|cts|py|go|rs|rb|php|cs|java|kt|swift|sh|bash|zsh)$/i; + +/** + * Classify an ephemeral write from its path alone. Pure — no fs, no config. + * + * Public API: consumed by the scratchpad guard for the advisory steer and by + * this module's own record builder. + */ +export function classifyEphemeralWrite(absPath: string): EphemeralWriteKind { + const lower = absPath.toLowerCase(); + if (CODE_EXT_RE.test(lower)) return "code"; + if (BULK_EXT_RE.test(lower)) return "bulk"; + if (OUTPUT_EXT_RE.test(lower) && AGENT_OUTPUT_NAME_RE.test(lower)) return "agent-output"; + if (lower.endsWith(".json") || lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "manifest"; + } + return "other"; +} + +/** + * Append one ephemeral-write record to `/.interlinked/`. No-ops + * when that directory is absent — it always exists in a guarded repo (the + * harness socket lives there), so its absence means this is not a managed + * project and creating one would be an unasked-for side effect. Filesystem + * errors are swallowed: a ledger that can crash the daemon is worse than a + * ledger with a gap. + * + * Public API — consumed by evaluator/scratchpad-write-guard.ts. + */ +export function appendEphemeralWrite(projectRoot: string, record: EphemeralWriteRecord): void { + try { + const dir = join(projectRoot, ".interlinked"); + if (!existsSync(dir)) return; + appendFileSync(join(dir, "ephemeral-writes.jsonl"), `${JSON.stringify(record)}\n`); + } catch { + // Telemetry must never break the hook path. + } +} + +/** + * Build the record for one attempted ephemeral write. Split from the appender + * so callers can construct-and-inspect in tests without touching disk. + * + * Public API — consumed alongside {@link appendEphemeralWrite}. + */ +export function buildEphemeralWriteRecord(opts: { + sessionId: string | undefined; + tool: string; + absPath: string; + content: string; + blocked: boolean; + now?: () => string; +}): EphemeralWriteRecord { + return { + ts: (opts.now ?? (() => new Date().toISOString()))(), + session_id: opts.sessionId, + tool: opts.tool, + path: opts.absPath, + ext: extname(opts.absPath).toLowerCase(), + bytes: Buffer.byteLength(opts.content, "utf-8"), + kind: classifyEphemeralWrite(opts.absPath), + blocked: opts.blocked, + }; +} diff --git a/src/harness/evaluator/patch-applier-guard.test.ts b/src/harness/evaluator/patch-applier-guard.test.ts new file mode 100644 index 00000000..f67d41e4 --- /dev/null +++ b/src/harness/evaluator/patch-applier-guard.test.ts @@ -0,0 +1,109 @@ +// Tests for the hand-rolled patch-applier detector. The positive cases are +// modelled on the real artifact this guard exists for: the `plm/apply.mjs` +// anchor/replacement applier recovered from the 2026-07 scratchpad archive. + +import { afterEach, describe, expect, it } from "vitest"; +import { + buildPatchApplierReason, + detectPatchApplier, + isPatchApplierGuardDisabled, +} from "./patch-applier-guard.js"; + +describe("detectPatchApplier — positive (must fire)", () => { + it("P1: anchor/replacement applier writing into src/", () => { + const content = [ + 'import { readFileSync, writeFileSync } from "node:fs";', + 'const anchor = readFileSync("r1.anchor.txt", "utf-8");', + 'const next = readFileSync("r1.new.txt", "utf-8");', + 'const target = "src/harness/obligations.ts";', + "const src = readFileSync(target, 'utf-8');", + "writeFileSync(target, src.replace(anchor, next));", + ].join("\n"); + const hit = detectPatchApplier(content, "/tmp/s/scratchpad/plm/apply.mjs"); + expect(hit).not.toBeNull(); + expect(hit?.writeCall).toContain("writeFileSync"); + }); + + it("P2: inlined payload (no read) still fires — reading is not required", () => { + const content = 'writeFileSync("src/lib/config.ts", "export const X = 1;\\n");'; + expect(detectPatchApplier(content, "/tmp/s/scratchpad/gen.mjs")).not.toBeNull(); + }); + + it("P3: computed target via process.cwd()", () => { + const content = [ + 'const fs = require("fs");', + 'const p = require("path").join(process.cwd(), "lib", "x.ts");', + 'fs.writeFileSync(p, "…");', + ].join("\n"); + expect(detectPatchApplier(content, "/tmp/s/scratchpad/apply.cjs")).not.toBeNull(); + }); + + it("P4: python applier using write_text on a repo path", () => { + const content = ['from pathlib import Path', 'Path("src/a.py").write_text(payload)'].join( + "\n", + ); + expect(detectPatchApplier(content, "/tmp/s/scratchpad/fix_assembly.py")).not.toBeNull(); + }); + + it("P5: parent-escape relative target", () => { + const content = 'appendFileSync("../src/harness/notes.ts", chunk);'; + expect(detectPatchApplier(content, "/repo/scratch/probe.mjs")).not.toBeNull(); + }); +}); + +describe("detectPatchApplier — negative (must not fire)", () => { + it("N1: probe that only reads repo source", () => { + const content = [ + 'import { readFileSync } from "node:fs";', + 'const s = readFileSync("src/harness/large-file-policy.ts", "utf-8");', + "console.log(s.length);", + ].join("\n"); + expect(detectPatchApplier(content, "/tmp/s/scratchpad/probe.mjs")).toBeNull(); + }); + + it("N2: script writing only inside its own sandbox", () => { + const content = 'writeFileSync("out.json", JSON.stringify(rows));'; + expect(detectPatchApplier(content, "/tmp/s/scratchpad/collect.mjs")).toBeNull(); + }); + + it("N3: non-script extension is not a channel", () => { + const content = 'writeFileSync("src/a.ts", "x");'; + expect(detectPatchApplier(content, "/tmp/s/scratchpad/notes.md")).toBeNull(); + }); + + it("N4: prose mentioning a repo path with no write call", () => { + const content = 'const doc = "see src/harness/server.ts for the socket";'; + expect(detectPatchApplier(content, "/repo/scratch/notes.ts")).toBeNull(); + }); + + it("N5: empty content", () => { + expect(detectPatchApplier("", "/tmp/s/scratchpad/apply.mjs")).toBeNull(); + }); +}); + +describe("buildPatchApplierReason", () => { + it("names both matched fragments and the sanctioned channel", () => { + const reason = buildPatchApplierReason({ + target: "/tmp/s/scratchpad/plm/apply.mjs", + evidence: { writeCall: "writeFileSync(", repoTarget: '"src/a.ts"' }, + }); + expect(reason).toContain("apply.mjs"); + expect(reason).toContain("writeFileSync("); + expect(reason).toContain('"src/a.ts"'); + expect(reason).toContain("transient debt"); + }); +}); + +describe("isPatchApplierGuardDisabled", () => { + afterEach(() => { + delete process.env.INTERLINKED_DISABLE_PATCH_APPLIER_GUARD; + }); + + it("is off by default and on only for the exact opt-out value", () => { + expect(isPatchApplierGuardDisabled()).toBe(false); + process.env.INTERLINKED_DISABLE_PATCH_APPLIER_GUARD = "true"; + expect(isPatchApplierGuardDisabled()).toBe(false); + process.env.INTERLINKED_DISABLE_PATCH_APPLIER_GUARD = "1"; + expect(isPatchApplierGuardDisabled()).toBe(true); + }); +}); diff --git a/src/harness/evaluator/patch-applier-guard.ts b/src/harness/evaluator/patch-applier-guard.ts new file mode 100644 index 00000000..672ed752 --- /dev/null +++ b/src/harness/evaluator/patch-applier-guard.ts @@ -0,0 +1,98 @@ +// =========================================== +// Hand-rolled patch-applier guard (PreToolUse) +// =========================================== +// The evasion channel this closes was found in the archive, not theorised: a +// 2026-07 session wrote `plm/apply.mjs` plus six `rN.anchor.txt` / `rN.new.txt` +// pairs into its session scratchpad — an anchor/replacement patch applier that +// read the pairs and wrote the result straight into repo source. That is a +// re-implementation of the Edit tool with the content gates removed. Every +// quality signal the harness produces (tsc/biome overlay, pre_block registry, +// coverage + complexity ratchets, reservations, trajectory accounting) is +// attached to the Write/Edit tool path; a script that calls `writeFileSync` on +// `src/**` bypasses all of it while still landing the change. +// +// Scope is deliberately narrow — agent-authored SCRIPTS aimed at an ephemeral +// temp path or the in-repo probe dir (`scratch/`). A committed codegen script +// under `scripts/` or `tools/` writing into `src/` is the legitimate version of +// this shape and must not fire; those live in the repo, are reviewed, and are +// not what an agent reaches for mid-refactor. See `docs/design/`-adjacent notes +// in `scratchpad-write-guard.ts` for the sibling placement policy. +// +// Detection is two-signal and both must hold: +// 1. the script performs a filesystem WRITE (language-specific call set), and +// 2. it aims that write at a path outside its own sandbox — a repo-relative +// source path, `process.cwd()`, or a `..` escape. +// Requiring a READ as well was considered and rejected: inlining the payload +// would then be a one-line bypass, and a script that writes generated content +// into `src/` from inline strings is the same evasion. + +import { basename } from "node:path"; + +/** Script extensions this guard inspects. Non-scripts cannot execute a write, + * so they are not a channel. */ +const SCRIPT_EXT_RE = /\.(?:mjs|cjs|js|mts|cts|ts|py|sh|bash|zsh|rb)$/i; + +/** Filesystem-write calls, across the languages an agent writes probes in. + * Shell redirection is covered by the sibling bash-write detector, so this set + * is deliberately about in-script APIs. */ +const WRITE_CALL_RE = + /\b(?:writeFileSync|appendFileSync|createWriteStream|copyFileSync|renameSync|fs\.promises\.writeFile|fs\.writeFile|write_text|os\.replace|shutil\.(?:copy|move)|File\.write)\s*\(|\bopen\s*\([^)]*['"][wa]\+?['"]/; + +/** Evidence that the write escapes the script's own sandbox and lands in the + * guarded project. A quoted repo-relative source path is the dominant form + * (`"src/harness/foo.ts"`); `process.cwd()` and `..` cover the computed ones. */ +const REPO_TARGET_RE = + /['"`](?:\.\.\/|\/)?(?:src|lib|app|packages|tests?|docs)\/[^'"`\s]+['"`]|process\.cwd\s*\(\s*\)|\bos\.getcwd\s*\(\s*\)|['"`]\.\.\//; + +/** What fired, for the block reason. Both fields are the matched source text, + * trimmed — the agent needs to see its own line to know what to remove. */ +export interface PatchApplierEvidence { + writeCall: string; + repoTarget: string; +} + +/** One-command bypass, mirroring the sibling guards' convention. Separate from + * INTERLINKED_DISABLE_SCRATCH_GUARD so a placement-policy bypass does not + * silently also open the evasion channel. */ +export function isPatchApplierGuardDisabled(): boolean { + return process.env.INTERLINKED_DISABLE_PATCH_APPLIER_GUARD === "1"; +} + +/** + * Detect a hand-rolled patch applier in `content`. Returns the matched + * evidence, or null when the content is not a script, performs no write, or + * keeps its writes inside its own sandbox. + * + * Public API — exported for the guard wiring and for direct unit testing + * without constructing a hook event. + */ +export function detectPatchApplier( + content: string, + filePath: string, +): PatchApplierEvidence | null { + if (!SCRIPT_EXT_RE.test(filePath)) return null; + const write = WRITE_CALL_RE.exec(content); + if (!write) return null; + const target = REPO_TARGET_RE.exec(content); + if (!target) return null; + return { writeCall: write[0].trim(), repoTarget: target[0].trim() }; +} + +/** Block reason. Names the two matched fragments so the agent can see exactly + * which lines made it an applier, and points at the sanctioned channel. */ +export function buildPatchApplierReason(opts: { + target: string; + evidence: PatchApplierEvidence; +}): string { + return ( + `BLOCKED: ${basename(opts.target)} is a hand-rolled patch applier — a throwaway script ` + + `that writes into repo source (\`${opts.evidence.writeCall}\` … \`${opts.evidence.repoTarget}\`). ` + + `Landing edits this way bypasses every content gate the Write/Edit tools run ` + + `(tsc + biome diff-overlay, pre_block registry checks, coverage/complexity ratchets, ` + + `reservations, trajectory accounting) while still changing the code — the change lands ` + + `unmeasured and unattributed. Use the Edit tool directly: a transiently non-compiling ` + + `intermediate no longer blocks, it opens a transient debt you discharge with the ` + + `counterpart edit. If this script genuinely needs to write generated output, commit it ` + + `under scripts/ where it is reviewable. Bypass: INTERLINKED_DISABLE_PATCH_APPLIER_GUARD=1.` + ); +} diff --git a/src/harness/evaluator/scratchpad-write-guard.test.ts b/src/harness/evaluator/scratchpad-write-guard.test.ts index 13a854ca..e71a0b66 100644 --- a/src/harness/evaluator/scratchpad-write-guard.test.ts +++ b/src/harness/evaluator/scratchpad-write-guard.test.ts @@ -94,14 +94,60 @@ describe("evaluateScratchpadWriteGuard — authored-code placement", () => { // --- negative cases: legitimate patterns that must NOT fire --- - it("allows non-code scratchpad writes (downloads / outputs) untouched", () => { + it("never BLOCKS a non-code scratchpad write (downloads / outputs)", () => { for (const name of ["results.json", "report.md", "bundle.tgz", "LICENSE"]) { + expect(run(scratchpadPath(SESSION_ID, name)).decision).toBeNull(); + } + }); + + // Record-and-warn policy (operator decision 2026-08-04): the placement gate + // only ever inspected CODE extensions, so the single largest ephemeral class + // in the corpus — `.json` gate-workaround manifests — passed with no warning + // and no trace. Bulk downloads stay silent; they are the sanctioned use. + it("steers manifest-ish and unclassified ephemeral writes without blocking", () => { + for (const name of ["results.json", "LICENSE"]) { + const { decision, warnings } = run(scratchpadPath(SESSION_ID, name)); + expect(decision).toBeNull(); + expect(warnings.join("\n")).toContain("[interlinked:ephemeral]"); + } + }); + + it("steers captured external-agent output toward .interlinked/", () => { + const { decision, warnings } = run(scratchpadPath(SESSION_ID, "codex-review-2-result.md")); + expect(decision).toBeNull(); + expect(warnings.join("\n")).toContain(".interlinked/agent-output/"); + }); + + it("stays silent on bulk downloads — the scratchpad's sanctioned use", () => { + for (const name of ["bundle.tgz", "shot.png"]) { const { decision, warnings } = run(scratchpadPath(SESSION_ID, name)); expect(decision).toBeNull(); expect(warnings).toHaveLength(0); } }); + // The applier guard spans BOTH staging grounds: the ephemeral scratchpad and + // the durable in-repo probe dir. Recovered artifact it generalises: + // `plm/apply.mjs` + rN.anchor.txt/rN.new.txt (2026-07 scratchpad archive). + it("blocks a hand-rolled patch applier in the scratchpad", () => { + const applier = 'writeFileSync("src/harness/obligations.ts", patched);'; + const { decision } = run(scratchpadPath(SESSION_ID, "apply.mjs"), { content: applier }); + expect(decision?.decision).toBe("block"); + expect(decision?.rule_id).toBe("builtin-patch-applier"); + }); + + it("blocks the same applier staged in the in-repo scratch/ probe dir", () => { + const applier = 'appendFileSync("src/lib/config.ts", chunk);'; + const { decision } = run(join(ROOT, "scratch", "apply.mjs"), { content: applier }); + expect(decision?.decision).toBe("block"); + expect(decision?.rule_id).toBe("builtin-patch-applier"); + }); + + it("leaves an ordinary scratch/ probe alone", () => { + const probe = 'const s = readFileSync("src/harness/server.ts", "utf-8");\nconsole.log(s);'; + expect(run(join(ROOT, "scratch", "probe.mjs"), { content: probe }).decision).toBeNull(); + }); + it("ignores code writes inside the repo (not an ephemeral temp path)", () => { const { decision, warnings } = run(join(ROOT, "src", "real.ts")); expect(decision).toBeNull(); diff --git a/src/harness/evaluator/scratchpad-write-guard.ts b/src/harness/evaluator/scratchpad-write-guard.ts index f9d4062c..2cf1e240 100644 --- a/src/harness/evaluator/scratchpad-write-guard.ts +++ b/src/harness/evaluator/scratchpad-write-guard.ts @@ -22,7 +22,12 @@ // INTERLINKED_DISABLE_SCRATCH_GUARD=1. import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, sep } from "node:path"; +import { + appendEphemeralWrite, + buildEphemeralWriteRecord, + classifyEphemeralWrite, +} from "../ephemeral-write-log.js"; import { CODE_FILE_EXT_RE } from "../pre-checks-bash-write-detect.js"; import type { GuardRulesConfig, HarnessDecision, HarnessEvent } from "../types.js"; import { @@ -30,6 +35,11 @@ import { resolveWriteTargetPath, sessionScratchpadAllows, } from "./filesystem-guards.js"; +import { + buildPatchApplierReason, + detectPatchApplier, + isPatchApplierGuardDisabled, +} from "./patch-applier-guard.js"; import { containsSecrets } from "./pre-tool-helpers.js"; import { isFileWrite } from "./tool-classifiers.js"; @@ -107,11 +117,58 @@ export function evaluateScratchpadWriteGuard( const rawPath = (toolInput.file_path as string) || (toolInput.path as string) || ""; if (!rawPath) return null; const resolved = resolveWriteTargetPath(rawPath, event.cwd); - if (!isEphemeralTempPath(resolved)) return null; + const ephemeral = isEphemeralTempPath(resolved); + // The probe dir is durable, but it is the OTHER place an agent stages a + // throwaway script, so the applier check spans both. Nothing else here does. + if (!ephemeral && !isRepoScratchPath(resolved, event.cwd)) return null; // SAFETY: content/new_string are strings when present (hook payload shape). const content = (toolInput.content as string) || (toolInput.new_string as string) || ""; - if (content && containsSecrets(content)) { + const decision = decideEphemeralWrite({ + event, + rawPath, + resolved, + content, + ephemeral, + rules, + warnings, + }); + // A simulation must not leave a ledger trace either — see HarnessEvent.dry_run. + if (ephemeral && !event.dry_run) { + appendEphemeralWrite( + event.cwd, + buildEphemeralWriteRecord({ + sessionId: event.session_id, + tool: toolName, + absPath: resolved, + content, + blocked: decision?.decision === "block", + }), + ); + } + return decision; +} + +/** True when `resolved` sits under the repo's `scratch/` probe dir. */ +function isRepoScratchPath(resolved: string, projectRoot: string): boolean { + return resolved.startsWith(`${join(projectRoot, "scratch")}${sep}`); +} + +/** The ordered policy chain for one ephemeral/probe write. Split out so the + * entry point stays a thin decide-then-record shell. */ +function decideEphemeralWrite(opts: { + event: HarnessEvent; + rawPath: string; + resolved: string; + content: string; + ephemeral: boolean; + rules: GuardRulesConfig; + warnings: string[]; +}): HarnessDecision | null { + const { event, rawPath, resolved, content, ephemeral, rules, warnings } = opts; + const projectRoot = event.cwd as string; + + if (ephemeral && content && containsSecrets(content)) { return { decision: "block", reason: @@ -125,9 +182,23 @@ export function evaluateScratchpadWriteGuard( }; } + const applier = content ? detectPatchApplier(content, resolved) : null; + if (applier && !isPatchApplierGuardDisabled()) { + return { + decision: "block", + reason: buildPatchApplierReason({ target: rawPath, evidence: applier }), + warnings, + rule_id: "builtin-patch-applier", + severity: "high", + category: "harness-integrity", + }; + } + + if (!ephemeral) return null; + pushEphemeralSteer(resolved, rawPath, warnings); return evaluateCodePlacement({ sessionId: event.session_id, - projectRoot: event.cwd, + projectRoot, rawPath, resolved, rules, @@ -135,6 +206,31 @@ export function evaluateScratchpadWriteGuard( }); } +/** Advisory steer for the non-code ephemeral classes the placement gate never + * saw. Captured external-agent output is called out specifically: those runs + * cost hours, and the SessionEnd archive is a capped best-effort copy, not a + * guarantee — durable output belongs in `.interlinked/` from the start. */ +function pushEphemeralSteer(resolved: string, rawPath: string, warnings: string[]): void { + const kind = classifyEphemeralWrite(resolved); + if (kind === "agent-output") { + warnings.push( + `[interlinked:ephemeral] ${rawPath} looks like captured output from an external ` + + `agent/review run, written to the ephemeral scratchpad. That tree is purged by the ` + + `OS and only best-effort archived (the SessionEnd sweep is capped and CAN truncate). ` + + `Write durable run output under /.interlinked/agent-output/ instead.`, + ); + return; + } + if (kind === "manifest" || kind === "other") { + warnings.push( + `[interlinked:ephemeral] ${rawPath} written to the ephemeral scratchpad (recorded in ` + + `.interlinked/ephemeral-writes.jsonl). If this is a manifest staged to route an ` + + `edit around a gate, pipe it on stdin instead of persisting it — and if a gate is ` + + `forcing the detour, that gate is the bug worth reporting.`, + ); + } +} + /** Placement decision for the session scratchpad specifically. Non-scratchpad * temp paths fall through — repo confinement already owns those. */ function evaluateCodePlacement(opts: { diff --git a/src/harness/scratchpad-archive.test.ts b/src/harness/scratchpad-archive.test.ts index 22d22121..38f852a2 100644 --- a/src/harness/scratchpad-archive.test.ts +++ b/src/harness/scratchpad-archive.test.ts @@ -135,3 +135,76 @@ describe("deriveScratchpadCandidates", () => { ).toEqual([]); }); }); + +// Motivating incident: a cloned repo in one session's scratchpad spent the whole +// 2000-file cap, so both surviving manifests read `truncated: true` and every +// agent-authored artifact — including a hand-rolled patch applier — was evicted +// before it could be archived. +describe("archiveScratchpadDir — foreign-project-root exclusion", () => { + it("skips a cloned tree whole and keeps the session's own files", () => { + const { source, destRoot } = makeFixture(); + mkdirSync(join(source, "oh-my-pi", "src"), { recursive: true }); + writeFileSync(join(source, "oh-my-pi", "package.json"), "{}\n"); + writeFileSync(join(source, "oh-my-pi", "src", "a.ts"), "export const a = 1;\n"); + writeFileSync(join(source, "oh-my-pi", "src", "b.ts"), "export const b = 2;\n"); + const summary = archiveScratchpadDir({ sourceDir: source, destRoot, sessionId: "f1" }); + expect(summary?.fileCount).toBe(2); // only the fixture's own two files + expect(summary?.skipped.find((s) => s.path === "oh-my-pi")?.reason).toBe("vendored-tree"); + }); + + it("recognises a bare git checkout carrying no package.json", () => { + const { source, destRoot } = makeFixture(); + mkdirSync(join(source, "vendored", ".git"), { recursive: true }); + writeFileSync(join(source, "vendored", ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(join(source, "vendored", "README.md"), "theirs\n"); + const summary = archiveScratchpadDir({ sourceDir: source, destRoot, sessionId: "f2" }); + expect(summary?.fileCount).toBe(2); + expect(summary?.skipped.find((s) => s.path === "vendored")?.reason).toBe("vendored-tree"); + }); + + it("recognises Cargo / Go / Python roots too", () => { + const { source, destRoot } = makeFixture(); + for (const [dir, marker] of [ + ["rs", "Cargo.toml"], + ["go", "go.mod"], + ["py", "pyproject.toml"], + ] as const) { + mkdirSync(join(source, dir), { recursive: true }); + writeFileSync(join(source, dir, marker), "x\n"); + writeFileSync(join(source, dir, "code.txt"), "y\n"); + } + const summary = archiveScratchpadDir({ sourceDir: source, destRoot, sessionId: "f3" }); + expect(summary?.fileCount).toBe(2); + }); + + it("does NOT treat the scratchpad ROOT as foreign", () => { + const { source, destRoot } = makeFixture(); + writeFileSync(join(source, "package.json"), '{"name":"repro"}\n'); + const summary = archiveScratchpadDir({ sourceDir: source, destRoot, sessionId: "f4" }); + expect(summary?.fileCount).toBe(3); + }); +}); + +describe("archiveScratchpadDir — archive_excludes globs", () => { + it("skips paths matching a configured glob", () => { + const { source, destRoot } = makeFixture(); + mkdirSync(join(source, "bulk"), { recursive: true }); + writeFileSync(join(source, "bulk", "one.txt"), "a\n"); + const summary = archiveScratchpadDir({ + sourceDir: source, + destRoot, + sessionId: "g1", + config: { archive_excludes: ["bulk"] }, + }); + expect(summary?.fileCount).toBe(2); + expect(summary?.skipped.find((s) => s.path === "bulk")?.reason).toBe("excluded-glob"); + }); + + it("archives everything when no globs are configured", () => { + const { source, destRoot } = makeFixture(); + mkdirSync(join(source, "bulk"), { recursive: true }); + writeFileSync(join(source, "bulk", "one.txt"), "a\n"); + const summary = archiveScratchpadDir({ sourceDir: source, destRoot, sessionId: "g2" }); + expect(summary?.fileCount).toBe(3); + }); +}); diff --git a/src/harness/scratchpad-archive.ts b/src/harness/scratchpad-archive.ts index 91a285a2..9024ffa7 100644 --- a/src/harness/scratchpad-archive.ts +++ b/src/harness/scratchpad-archive.ts @@ -29,6 +29,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { matchesAnyGlob } from "../lib/path-glob.js"; import { sanitizeSessionId } from "./session-paths.js"; import type { GuardRulesConfig, ScratchpadArchiveConfig } from "./types.js"; @@ -42,7 +43,9 @@ export interface ScratchpadArchiveSkip { | "symlink" | "budget-exhausted" | "file-cap" - | "unreadable"; + | "unreadable" + | "vendored-tree" + | "excluded-glob"; } export interface ScratchpadArchiveSummary { @@ -76,6 +79,22 @@ const EXCLUDED_DIR_NAMES = new Set([ ]); const EXCLUDED_EXT_RE = /\.(tgz|tar|gz|zip|br|7z|dmg|iso)$/i; const BINARY_SNIFF_BYTES = 8192; +/** Marker files that make a scratchpad subdirectory a FOREIGN PROJECT ROOT — a + * cloned repo, an extracted package, a vendored checkout. Excluding the whole + * subtree (rather than just its `.git`/`node_modules`) is the difference + * between an archive of the session's own work and an archive of someone + * else's repo: a single 50k-file clone spends the entire file cap and evicts + * every agent-authored artifact, which is exactly what happened to the + * 2026-08 sessions (both surviving manifests: file_count 2000, truncated). */ +const FOREIGN_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml"]; + +/** True when `absDir` carries a foreign-project marker. Applied only to + * subdirectories — the scratchpad ROOT is never treated as foreign, so a + * session that drops a lone `package.json` at the top level to reproduce a + * manifest bug still gets archived. */ +function isForeignProjectRoot(absDir: string): boolean { + return FOREIGN_ROOT_MARKERS.some((m) => existsSync(join(absDir, m))); +} /** Candidate scratchpad locations for this (cwd, session) pair, following the * coding host's layout: `/claude-///scratchpad`. @@ -103,20 +122,31 @@ export function deriveScratchpadCandidates(opts: { type WalkResult = { files: string[]; skipped: ScratchpadArchiveSkip[] }; +/** Walk-wide inputs the per-entry classifier needs (kept as one object so the + * classifier's parameter list stays short). */ +type WalkContext = { sourceDir: string; excludeGlobs: string[] }; + /** Route one directory entry into the walk's files / skips / pending-dirs. */ function classifyWalkEntry( entry: Dirent, relPath: string, out: WalkResult, pending: string[], + ctx: WalkContext, ): void { if (entry.isSymbolicLink()) { out.skipped.push({ path: relPath, reason: "symlink" }); return; } + if (ctx.excludeGlobs.length > 0 && matchesAnyGlob(relPath, ctx.excludeGlobs)) { + out.skipped.push({ path: relPath, reason: "excluded-glob" }); + return; + } if (entry.isDirectory()) { if (EXCLUDED_DIR_NAMES.has(entry.name)) { out.skipped.push({ path: relPath, reason: "excluded-dir" }); + } else if (isForeignProjectRoot(join(ctx.sourceDir, relPath))) { + out.skipped.push({ path: relPath, reason: "vendored-tree" }); } else { pending.push(relPath); } @@ -128,15 +158,20 @@ function classifyWalkEntry( /** Enumerate archivable files (relative paths) under `sourceDir`, recording * symlink / excluded-dir skips. Enumeration is bounded: it stops once the * candidate list is comfortably past the file cap. */ -function collectCandidateFiles(sourceDir: string, maxFiles: number): WalkResult { +function collectCandidateFiles( + sourceDir: string, + maxFiles: number, + excludeGlobs: string[] = [], +): WalkResult { const out: WalkResult = { files: [], skipped: [] }; const pending: string[] = [""]; + const ctx: WalkContext = { sourceDir, excludeGlobs }; const scanCeiling = maxFiles + SKIP_LIST_CAP; while (pending.length > 0 && out.files.length <= scanCeiling) { const relDir = pending.pop() ?? ""; for (const entry of readdirSync(join(sourceDir, relDir), { withFileTypes: true })) { const relPath = relDir ? join(relDir, entry.name) : entry.name; - classifyWalkEntry(entry, relPath, out, pending); + classifyWalkEntry(entry, relPath, out, pending, ctx); } } out.files.sort(); @@ -218,7 +253,7 @@ export function archiveScratchpadDir(opts: { const blobsDir = join(destRoot, "blobs"); mkdirSync(blobsDir, { recursive: true }); - const walk = collectCandidateFiles(sourceDir, budget.maxFiles); + const walk = collectCandidateFiles(sourceDir, budget.maxFiles, config?.archive_excludes ?? []); const skipped: ScratchpadArchiveSkip[] = [...walk.skipped]; const entries: ManifestEntry[] = []; let totalBytes = 0; diff --git a/src/harness/types/config.ts b/src/harness/types/config.ts index bab85b60..b33d75fd 100644 --- a/src/harness/types/config.ts +++ b/src/harness/types/config.ts @@ -302,6 +302,11 @@ export interface ScratchpadArchiveConfig { max_total_bytes?: number; /** Maximum files archived per session (default 2000). */ max_files?: number; + /** Extra path globs (relative to the scratchpad root) excluded from the + * sweep, on top of the built-in dir/extension excludes and the + * foreign-project-root rule. Use when a bulk tree carries no + * `package.json`/`.git` marker of its own. */ + archive_excludes?: string[]; } /** Plan-capture configuration. Master toggle + structured-userprompt parser diff --git a/src/harness/types/events.ts b/src/harness/types/events.ts index 04e64d21..543c5f71 100644 --- a/src/harness/types/events.ts +++ b/src/harness/types/events.ts @@ -120,6 +120,14 @@ export interface HarnessEvent { cwd?: string; model?: string; timestamp: string; + /** SIMULATION marker. Set only by `interlinked harness test` (the synthetic + * event it fires at the daemon). The verdict is computed and displayed + * normally; what changes is that no evaluator may PERSIST anything from it — + * no obligation-ledger txn, no ephemeral-write record. A read-only probe + * that moves the gate is worse than no probe: on 2026-08-04 three + * `harness test --write` calls opened a real transient debt on a file they + * never wrote, which then blocked an unrelated edit. */ + dry_run?: boolean; // Subagent context parent_agent?: string; diff --git a/src/registrars/quality.ts b/src/registrars/quality.ts index 22b5949c..872a876c 100644 --- a/src/registrars/quality.ts +++ b/src/registrars/quality.ts @@ -51,10 +51,13 @@ export function registerQualityCommands(program: Command): void { .description( "Apply N old/new string edits atomically to one or more files. Gate runs once on final content. Ambiguity evaluated after prior edits.", ) - .option("--stdin", "Read a single-file manifest ({version,edits}) from stdin (requires )") + .option( + "--stdin", + "Read a manifest from stdin: {version,batches} for multi-file (no needed), or {version,edits} with for one file. PREFERRED — no temp file.", + ) .option( "--manifest ", - "Read a single- or multi-file manifest ({version,edits} or {version,batches}) from ", + "Read the same manifest shapes from . Only for a manifest you already have on disk; prefer --stdin.", ) .option("--json", "Machine-readable output (emits the design-doc error-code shape)") .action(async (path: string | undefined, opts: OptionValues) => {