feat(harness): govern ephemeral writes — see them, bound them, block the evasion - #2
feat(harness): govern ephemeral writes — see them, bound them, block the evasion#2QuentinCody wants to merge 1 commit into
Conversation
…the evasion
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 <path>. 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) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e891f7cd23
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 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, |
There was a problem hiding this comment.
Honor dry_run before reserving files
This adds the simulation marker, but the PreToolUse pipeline still runs evaluateAutoReservation, which ignores event.dry_run and calls reservations.checkAndReserve for every Write/Edit path (inspected src/harness/evaluator/pre-tool-decision-phases.ts:226-240). For interlinked harness test --write src/foo.ts that otherwise allows, the test can still leave a test lease and make a later real edit hit a reservation conflict, so the dry-run marker needs to suppress all persistence phases, not only the new ephemeral ledger.
Useful? React with 👍 / 👎.
| const target = REPO_TARGET_RE.exec(content); | ||
| if (!target) return null; | ||
| return { writeCall: write[0].trim(), repoTarget: target[0].trim() }; |
There was a problem hiding this comment.
Tie repo-target evidence to the write call
Because this searches for repo-target evidence independently from the write call, a legitimate scratch/ probe that writes beside itself but also uses process.cwd() to locate/read project data is blocked as a patch applier, even though the updated skill row explicitly allows “scripts writing beside themselves” (skills/interlinked-harness/SKILL.md:54). Under the pre_block path this creates a deterministic false positive; require the matched target to be part of the write expression, or narrow the process.cwd() signal.
AGENTS.md reference: AGENTS.md:L33-L33
Useful? React with 👍 / 👎.
| * 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 |
There was a problem hiding this comment.
Update the verify skill for stdin manifests
This change makes multi-edit --stdin the preferred no-temp-file interface, but the skill used for cross-file refactors still instructs agents to use multi-edit --manifest (inspected skills/interlinked-verify/SKILL.md:112-115). In sessions following that skill, agents will keep staging scratchpad JSON manifests, exactly the ephemeral-write detour this change warns/logs about; update the affected skill with the new stdin guidance.
AGENTS.md reference: AGENTS.md:L37-L37
Useful? React with 👍 / 👎.
What
Four changes to how the harness treats writes aimed at paths the OS will purge, plus two fixes found while verifying them. All driven by measurement over
.interlinked/rather than by taste.The corpus: 340 session-scratchpad writes across 148 distinct artifacts, against 7,077 ordinary repo writes.
scratchpad-archive.tsevaluator/patch-applier-guard.ts(new).interlinked/agent-output/evaluator/scratchpad-write-guard.tsephemeral-write-log.ts(new)1. Archive skips foreign project roots
A scratchpad subdirectory carrying
.git/package.json/Cargo.toml/go.mod/pyproject.tomlis a clone or extraction, not the session's work. Its whole subtree is skipped with reasonvendored-tree;scratchpad_archive.archive_excludestakes globs for marker-less bulk.This is not hygiene — it is the difference between an archive and nothing. Before the rule, a single cloned repo had spent the entire 2000-file cap, so both surviving manifests read
truncated: trueand every agent-authored artifact was evicted. Including the patch applier that motivated change 2.The scratchpad root is never treated as foreign, so a lone
package.jsonrepro still archives.2. Hand-rolled patch appliers block (
builtin-patch-applier)Recovered from the archive:
plm/apply.mjsplus sixrN.anchor.txt/rN.new.txtpairs — an anchor/replacement applier that read the pairs and wrote straight into repo source. That is the Edit tool re-implemented with the content gates removed: tsc/biome overlay,pre_blockregistry, coverage and complexity ratchets, reservations, trajectory accounting — all attached to the Write/Edit path, all bypassed, while the change still lands.Two required signals, both must hold:
writeFileSync,appendFileSync,write_text, …), andsrc/**-shaped literal,process.cwd(), or..).Spans the ephemeral scratchpad and the in-repo
scratch/probe dir — moving the script somewhere durable does not make it a legitimate write channel. A probe that only reads repo source does not fire; nor does committed codegen underscripts/. Bypass:INTERLINKED_DISABLE_PATCH_APPLIER_GUARD=1.Requiring a read as well was considered and rejected — inlining the payload would then be a one-line bypass.
3. External-agent output steered to
.interlinked/agent-output/Captured Codex/Sol review and audit results are the artifacts least able to afford archival roulette — hours of wall-clock each — and they were exactly what was being thrown away. Warn only; bulk downloads stay silent, since that is the scratchpad's sanctioned use.
4. Every ephemeral write recorded
.interlinked/ephemeral-writes.jsonl—ts / session / tool / path / ext / bytes / kind / blocked, for every extension. The placement guard only ever inspectedCODE_FILE_EXT_RE, so the single largest ephemeral class in the corpus —.jsongate-workaround manifests — passed with no warning and no trace at all. The record is the attempt, so a blocked evasion still leaves a trace.Never blocks, never throws; no-ops when
.interlinked/is absent rather than creating it.Two fixes found while verifying
A dry run was moving the gate.
interlinked harness test --writeis documented as a simulation, but three probes opened a real transient debt against a file that was never written — which then blocked an unrelated edit.HarnessEvent.dry_runis now set by the test-event builder and honored by the ephemeral ledger: the verdict still computes and displays, nothing persists.multi-edit --stdinnever needed a positional<path>. The multi-file{batches}form always worked on stdin. The help text claimed otherwise, and 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. Fixed in the option description, the error message, and the module docs.Also included:
docs/plans/multi-edit-deletion-review.md, the brief for a scheduled 2026-08-12 review of whethermulti-editshould exist at all.Verification
Applied the staged patch to a clean
HEADworktree to prove it stands alone:tsgo --noEmit— cleanscratch/plm/apply.mjsand not on a read-only probe; the agent-output steer fires oncodex-review-2-result.md; the ledger picked up a real.jsonmanifest write from a concurrent session within a minuteDeliberately held back
The dry-run wiring in
transient-debt-guard.ts,debt.tstransient-debt support, and thepre-tool-rules.tsblock-message rewrite all depend on the transient-debt feature, which is not yet onmain. They stay local until it lands. Thepre-tool-rules.tsmessage in particular promises "a transient debt" thatmaincannot yet deliver.🤖 Generated with Claude Code