Skip to content

feat(harness): govern ephemeral writes — see them, bound them, block the evasion - #2

Open
QuentinCody wants to merge 1 commit into
mainfrom
harness/ephemeral-write-governance
Open

feat(harness): govern ephemeral writes — see them, bound them, block the evasion#2
QuentinCody wants to merge 1 commit into
mainfrom
harness/ephemeral-write-governance

Conversation

@QuentinCody

Copy link
Copy Markdown
Owner

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.

# Change Where
1 Archive skips foreign project roots scratchpad-archive.ts
2 Hand-rolled patch appliers block evaluator/patch-applier-guard.ts (new)
3 External-agent output steered to .interlinked/agent-output/ evaluator/scratchpad-write-guard.ts
4 Every ephemeral write recorded, any extension ephemeral-write-log.ts (new)

1. Archive skips foreign project roots

A scratchpad subdirectory carrying .git / package.json / Cargo.toml / go.mod / pyproject.toml is a clone or extraction, not the session's work. Its whole subtree is skipped with reason vendored-tree; scratchpad_archive.archive_excludes takes 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: true and 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.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 straight into repo source. That is the Edit tool re-implemented with the content gates removed: tsc/biome overlay, pre_block registry, 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:

  1. a filesystem-write call (writeFileSync, appendFileSync, write_text, …), and
  2. a target outside the script's own sandbox (a src/**-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 under scripts/. 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.jsonlts / session / tool / path / ext / bytes / kind / blocked, for every extension. The placement guard only ever inspected CODE_FILE_EXT_RE, so the single largest ephemeral class in the corpus — .json gate-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 --write is 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_run is now set by the test-event builder and honored by the ephemeral ledger: the verdict still computes and displays, nothing persists.

multi-edit --stdin never 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 whether multi-edit should exist at all.

Verification

Applied the staged patch to a clean HEAD worktree to prove it stands alone:

  • tsgo --noEmit — clean
  • 170 tests across the 7 affected files — green
  • Live against the running daemon: the applier block fires on scratch/plm/apply.mjs and not on a read-only probe; the agent-output steer fires on codex-review-2-result.md; the ledger picked up a real .json manifest write from a concurrent session within a minute

Deliberately held back

The dry-run wiring in transient-debt-guard.ts, debt.ts transient-debt support, and the pre-tool-rules.ts block-message rewrite all depend on the transient-debt feature, which is not yet on main. They stay local until it lands. The pre-tool-rules.ts message in particular promises "a transient debt" that main cannot yet deliver.

🤖 Generated with Claude Code

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +76 to +78
const target = REPO_TARGET_RE.exec(content);
if (!target) return null;
return { writeCall: write[0].trim(), repoTarget: target[0].trim() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +190 to +194
* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant