---
url: 'https://adk.nht.io/batteries/dev-tools/in-place.md'
description: >-
  `eslint --fix` mutates disk, not your workspace — so the pipeline hands it a
  scoped façade, re-reads what it actually touched, and states plainly where the
  guarantee stops.
---

# In-Place Fixers

## LLM summary — Dev Tools in-place fixers

* Real fixers (`eslint --fix`, `prettier --write`, codemods) mutate FILES ON DISK. `write` is therefore mid-chain, not terminal, and the runtime re-reads after an in-place capability runs.
* `DevFileAccess` (in-place capabilities only, fresh per invocation): `read`, `write`, `delete`, `rename`, `mkdir`, `exists`, plus `scope`. Authorization differs per method: `read`/`exists` need `canRead` AND `canWrite`; mutations need `canWrite`; `rename` checks both endpoints. `exists` omits the symlink walk, THROWS on a refused path, returns `false` on any stat rejection. A leading `/` is refused at this façade.
* Mutations take the LEXICAL route, never `toRelative` (which stats the target itself and throws for anything absent — i.e. most mutation targets): classify → normalize → containment → `toBackendPath` → policy → `assertNoExistingSymlinkComponents` → mutate. The guard runs immediately before the mutation; parents are created shallowest-first, each policy-checked and guarded individually.
* `effectivePolicy()` returning `undefined` FAILS CLOSED here, diverging deliberately from the sandbox's `isDenied`, which treats it as allow. Policy uses `FsNode.canWrite` (globs + mandatory denies), not the weaker prefix matcher.
* The re-read covers the invocation's authorization envelope: declared scope with no selector; `scope ∩ selector` plus façade-recorded rename destinations with one. Admission is ABSENT-THEN-PRESENT against a pre-step snapshot — a pre-existing unacquired file inside the envelope is NOT admitted. One exception: a path already in `unreadable`.
* A previously acquired file the re-read cannot admit drops into `unreadable` with an ERROR diagnostic; it is NOT reported as deleted. `unreadable` is current state, not a log.
* The in-place capability's returned delta is ADVISORY: only its diagnostics are used, mutation fields are neither validated nor applied. Compared by path-set membership (never content) to emit a discrepancy warning naming up to four sets. A malformed advisory delta never fails the step.
* Dirty-path withholding: a path dirtied IN MEMORY after the step's preflight is withheld from later in-place allowlists that step. Exception: a path the capability's own re-read reconciled is NOT withheld — that is what makes two in-place fixers compose.
* Gating is once per step, not per file. The gate names each capability with its DECLARED scope (the resolved allowlist does not exist yet) plus `mayCreate`; `check` sends `[]`.
* Stated limit: scope is ENFORCED on the façade, DECLARED on the subprocess path. Engines are trusted code; the façade is a guardrail, not a sandbox.

`eslint --fix` does not care about your in-memory workspace. It opens files, rewrites them, and exits. The same is true of `prettier --write` and of every codemod worth running.

That single fact is why `write` sits mid-chain here instead of terminating it, and it is the one place the media battery's model does not transfer. There, a write is the end: bytes flow forward and the chain stops. Here the pipeline has to keep going, which means it has to answer a question media never faces — what, exactly, did that tool just do to the disk?

Real toolchains do not edit an in-memory workspace. Linters, formatters, and codemods—`eslint --fix`, `prettier --write`, `biome check --write`—mutate files directly on disk. A developer workflow that matters is `write → eslint --fix → check`, and it must execute cleanly in a single plan.

This is where the dev-tools execution model diverges sharply from media pipelines. In a media pipeline, a write-equivalent step is terminal: bytes flow downstream, hit storage, and the execution chain ends. In dev tools, disk mutation is an ordinary intermediate step, and the pipeline continues right through it. The immediate hazard is staleness: once an in-place fixer touches disk, any in-memory workspace snapshot is instantly untrustworthy.

Solving this requires two distinct mechanisms: a scoped, policy-gated disk façade (`DevFileAccess`) that stops engines from wandering across the host filesystem, and an authoritative post-execution re-read that reconciles disk reality back into workspace state.

## `DevFileAccess`: per-method authorization

In-place capabilities are never handed raw absolute root paths, nor are they handed read-only workspace maps. A `ReadonlyMap` cannot mutate disk at all, while an unrestricted host root directory bypasses path translation, containment rules, and policy checks entirely.

The runtime constructs a fresh `DevFileAccess` façade per invocation, pre-scoped to the files the step is permitted to touch:

```typescript
export interface DevFileAccess {
  readonly scope: readonly string[]
  read(path: string): Promise<string>
  write(path: string, content: string): Promise<void>
  delete(path: string): Promise<void>
  rename(from: string, to: string): Promise<void>
  mkdir(path: string): Promise<void>
  exists(path: string): Promise<boolean>
}
```

The authorization axis differs by method. Subjecting every method to a generic write gauntlet is broken:

| Method | Path safety pipeline | Authorization check |
|---|---|---|
| `read` | translate → containment → symlink refusal | `canRead` AND `canWrite`; verified against step allowlist |
| `exists` | translate → containment (no symlink walk) | Allowlist membership OR create-authorized |
| `write` / `delete` / `mkdir` | translate → containment → policy → symlink walk | `canWrite` |
| `rename` | translate → containment → policy → symlink walk | `canWrite` on BOTH source and destination endpoints |

Reads require both `canRead` and `canWrite` deliberately. Checking `canWrite` alone would allow an in-place engine to read and disclose confidential files outside its read policy; checking `canRead` alone would let it inspect files it has no mandate to mutate. An in-place fixer's operational remit is strictly "files I am permitted to rewrite," so demanding both checks is the only honest gate.

`exists` behaves uniquely: it omits the existing-component symlink walk because its entire purpose is answering questions before taking action, and a non-existent path has no inode to stat. It throws on lexical, containment, or policy refusal, and returns `false` on stat rejections. Asking about an unauthorized path yields an error rather than a silent `false`, preventing callers from probing for existence across forbidden boundaries. If `exists` encounters a symlink, it returns `true`—returning `false` would be an actionable lie to the engine, and any subsequent `read` or `write` will execute the full symlink walk and fail appropriately.

A leading `/` is rejected at the façade. While interactive sandbox tools often tolerate `/src/a.ts` because a language model treats a leading slash as the workspace root, an engine is programmatic code operating against a workspace-relative contract. A leading slash in engine code almost certainly represents an unintended host filesystem path leaking in from host environment configuration or an un-sanitized `path.join`.

## The lexical mutation route and symlinks

Standard path resolution tools often finish with `toRelative`, which stats every component along the path including the target file itself. When `stat` encounters a missing path, it throws. This makes `toRelative('src/new.ts')` fail on virtually every mutation target: new files generated by a patch, rename targets, and deleted files.

The mutation route resolves this lexically:

$$\text{classify} \longrightarrow \text{normalize} \longrightarrow \text{containment} \longrightarrow \text{toBackendPath} \longrightarrow \text{policy} \longrightarrow \text{assertNoExistingSymlinkComponents} \longrightarrow \text{mutate}$$

`assertNoExistingSymlinkComponents` walks path components from the root forward and stops at the first component that does not exist on disk. Every component that *does* exist is verified to ensure it is not a symlink; non-existent components cannot be symlinks and require no check.

Running this check immediately prior to mutation keeps the time-of-check to time-of-use (TOCTOU) window as narrow as possible. Parent directory creation creates directories shallowest-first, with each intermediate component individually policy-checked and guarded against symlinks before executing its `mkdir`. A blanket `mkdir -p` with a single terminal check is unsafe because an intermediate directory component could be denied by policy or substituted via a concurrent symlink race.

::: info Stat rejection conflation
In the filesystem adapter abstraction, "does not exist" is defined as "`stat` rejected." Because standard filesystem contracts lack a dedicated absent-path discriminant and unified error taxonomy, an adapter's `stat` may reject interchangeably for non-existence, operating system permission denials, or storage I/O errors.

If an adapter's `stat` implementation is unreliable, symlink verification degrades silently. What bounds this failure mode is that `canWrite` executes independently across the entire path, and sandbox read operations already share this identical conflation.
:::

## Policy fails closed

When computing authorization, `effectivePolicy()` can return `undefined`. Standard sandbox implementations treat `undefined` as an implicit allow (`isDenied` returns `false`).

This battery does the exact opposite: when persisting workspace modifications across turns, an unresolved policy throws an error. Operating with no policy decision is treated as an explicit failure.

```typescript
// Policy compiles globs and the mandatory deny set, unlike the weaker prefix matcher
const isPermitted = createFsNode(policy).canWrite(backendPath)
```

Policy verification relies on `FsNode.canWrite`. This compiles full globs and enforces the hardcoded mandatory-deny set (`.bashrc`, `.git/hooks`, `.vscode/**`), rejecting the weaker local prefix matching that lacks glob semantics and mandatory deny guarantees.

## The authoritative re-read

After an in-place capability completes, the runtime does not blindly accept engine claims. It re-reads the invocation's **authorization envelope**:

* If the step specified explicit selectors: `scope ∩ selector`, plus any rename destination paths recorded by the `DevFileAccess` façade.
* If the step named no paths: the engine's declared `scope`.

The re-read never scans the entire workspace. A formatter declaring `src/**/*.ts` does not trigger a scan of static asset directories, and a single-file invocation inspects only that single file.

```
                  ┌───────────────────────────────┐
                  │   Pre-Step Workspace State    │
                  └──────────────┬────────────────┘
                                 │
                     [ In-Place Fixer Runs ]
                                 │
                  ┌──────────────┴────────────────┐
                  │    Authoritative Re-Read      │
                  │   (Authorization Envelope)    │
                  └──────────────┬────────────────┘
                                 │
         ┌───────────────────────┼───────────────────────┐
         ▼                       ▼                       ▼
    [ Still Present ]       [ Now Absent ]       [ Newly Present ]
   Update workspace text    Drop from workspace   Absent-then-present?
                            (no pendingDeletions)   ├── Yes: admit
                                                    └── No:  ignore (untracked)
```

Every path within the envelope yields one of three outcomes:

1. **Still present on disk:** Workspace contents are updated to match disk bytes.
2. **Now absent from disk:** The file was removed by the fixer. It is dropped from the workspace map immediately; because it is already gone from disk, nothing is added to `pendingDeletions`.
3. **Newly present on disk:** The file was created by the fixer.

Admission of new files uses an **absent-then-present** check evaluated against a workspace snapshot captured immediately before the capability ran. If a file existed on disk before the step started but was never part of the acquired workspace, it is rejected: the fixer did not create it, the caller did not request it, and admitting it would violate workspace acquisition boundaries. The sole exception is a path already marked in `unreadable`—it was present on disk but unparseable, and its membership in `unreadable` proves the pipeline was already tracking it.

If a previously acquired file cannot be admitted during the re-read—due to read errors, non-textual contents, unresolvable MIME types, or size limits—it is moved into `unreadable` alongside an `ERROR` diagnostic. It is not marked as deleted because the file remains on disk. Retaining the stale in-memory text is unacceptable, as downstream `check` operations would analyze ghost content. The `unreadable` set tracks current state rather than historical logs: any subsequent valid delta or successful re-read clears the entry.

## The advisory delta

In-place capabilities still return a standard delta object, but only its diagnostics are consumed. Mutation operations in the delta are neither validated nor applied to the workspace. The disk re-read is fully authoritative; applying the delta's mutations on top of the re-read would double-apply changes.

The engine's mutation claims are compared against the physical re-read using **set membership on paths**, not byte-for-byte content diffs:

* **Claimed but unchanged:** The engine reported a write to a path whose bytes did not change.
* **Changed but unclaimed:** The engine modified a file on disk without declaring it in the delta.
* **Claimed unauthorized:** The engine claimed modifications to paths outside its envelope.
* **Unparseable:** The engine returned malformed delta structures.

A malformed or inaccurate advisory delta produces a warning diagnostic and **never fails the step**. Because delta mutations are discarded, an engine's reporting bug must not abort a pipeline run whose actual filesystem modifications completed cleanly.

## Dirty-path withholding and composition

If a file is dirtied in memory prior to an in-place fixer step, it is withheld from the fixer's allowlist for that step.

Without withholding, an in-memory `edit` could leave unsaved modifications in `a.ts`; a subsequent formatter would read the stale on-disk file, format it, write it back, and the authoritative re-read would wipe out the in-memory edit.

```
Step 1: edit(a.ts) ─────────► a.ts is DIRTY IN MEMORY (disk has old bytes)
                                 │
Step 2: eslint --fix ────────► a.ts is WITHHELD from allowlist
                               (formatter cannot read stale disk bytes)
                                 │
Step 3: write() ─────────────► a.ts flushed to disk (dirt cleared)
                                 │
Step 4: prettier --write ────► a.ts permitted (disk is fresh)
```

The exception is critical: a path that has already been reconciled by an in-place capability's *own* re-read is no longer considered dirty in memory. This rule allows in-place fixers to compose back-to-back—running `eslint --fix` followed immediately by `prettier --write` within the same step.

Predecessor edits that result in identical text do not withhold paths, which is why allowlists are dynamically recomputed before each capability invocation rather than frozen at step initialization.

## Single-gate dispatch

In-place fixers are gated once per step, covering the full batch of paths. Prompting an operator fifty consecutive times during a multi-file formatting run induces prompt fatigue and leads to blind approvals.

The gating payload specifies every capability scheduled to execute alongside its declared scope. It uses declared scope rather than a resolved allowlist because gating occurs once before dispatch, before downstream allowlists can be computed.

`mayCreate` is declared upfront alongside scope, since file creation cannot be predicted prior to engine execution. Pre-step gating approves the authorization envelope rather than an after-the-fact diff. Pure inspection steps (`check`) explicitly set `mayCreate: []`.

## The honest containment boundary

`DevFileAccess` enforces scope boundaries for in-process TypeScript engines. When an engine spawns an external process (such as executing an external `eslint` binary), filesystem containment depends entirely on the process executor configured in the hosting environment. This battery provides no native process isolation and does not intercept system calls made by child processes.

Engines are trusted code. Nothing prevents a rogue engine from importing `node:fs` directly and ignoring the `DevFileAccess` façade. The façade exists to make safe patterns seamless and unsafe patterns immediately obvious in code review. It does not provide sandboxed security containment against hostile engine code, and does not pretend to.
