In-Place Fixers
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:
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.
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.
// 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 theDevFileAccessfaç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:
- Still present on disk: Workspace contents are updated to match disk bytes.
- 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. - 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.