Building an agent tool surface in hostile conditions
Ask an agent to fix one lint error on an ordinary tool surface and count the round-trips. It reads the file. It issues an edit. It writes the result. It runs the linter and reads the output. Four model turns, and the file goes back into the context window on most of them — to change one line.
The dev-tools battery does that as one plan, one tool call, one composite result: edit -> write -> lint --fix -> check. Note where write is. It is not at the end. It is in the middle, because eslint --fix opens files, rewrites them, and exits, and a pipeline that stopped at the write would be reporting on content that is no longer on disk.
TL;DR
The interesting part is not the composition. It is what the composition costs once you refuse to lie about any of it: a string grammar that provably cannot carry source code, a path translator that throws for every file you are about to create, a filesystem contract with no delete, and a write path that is not atomic and says so. Each of those is stated below with the mechanism, not the apology.
We are about to state an opinion
A tool surface that turns a four-step task into four model turns is not neutral infrastructure. It is a design choice that spends the user's context window on orchestration the runtime should have done, and it is the default nearly everywhere. This battery is what it costs to not do that.
The pipe DSL fails on code: structured ops over string escaping
A string pipe grammar cannot carry source code, and this is checkable rather than arguable. unquote applies its replacements in a fixed order: \n to newline, then \t to tab, then a final pass unescaping an escaped delimiter (\\, \", \', \/). Write C:\temp correctly escaped as C:\\temp and the tab replacement fires on the \t sitting in the middle of it, before anything has had a chance to collapse the doubled backslash. You get C:\<TAB>emp.
Naive escaping, quote-only escaping, and double-quoted forms were all tried against it. All corrupt. A json argument does not rescue you either, because JSON.parse runs on the string the grammar has already unquoted.
The fact that settles it is not the escaping at all. apply_patch cannot use the pipe either — its own tests drive it through structured ops, because pipe strings exclude raw newlines by grammar. When two of the verbs you care about both need the surface's central mechanism switched off, the surface is telling you something.
// Structured ops carry the payload verbatim; nothing is re-encoded on the way in.
const result = await dp.ops(['src/config.ts'], [
{
step: 'edit',
args: {
path: 'src/config.ts',
edits: [{ find: 'C:\\temp', replace: 'C:\\var\\tmp' }],
},
},
{ step: 'write', args: {} },
{ step: 'lint', args: { fix: true } },
])Because two core editing verbs require the pipeline mechanism to be bypassed, the dev-tools battery ships zero pipe DSLs. Tool execution uses two front-ends: a thenable builder and structured operations. Native tool-call parameters carry [{find, replace}] payloads losslessly because the model provider parses the JSON structure directly before runtime ingestion.
The workspace abstraction and zero engine lock-in
The core abstraction is Map<path, WorkspaceFile>, not a single payload — and it is not an invention, since applyOperations already takes exactly that shape. A single-file abstraction collapses the moment a real tool attaches: a typechecker cannot judge one file, a rename crosses files, and a patch's Add File grows the set.
The battery ships zero engines. Not as a roadmap item — as the design. DevEngine is plain data:
export interface DevEngine {
readonly id: string
readonly formats?: readonly FormatCapability[]
readonly lints?: readonly LintCapability[]
readonly checks?: readonly CheckCapability[]
}The registry never calls engine code to learn the shape of the world. Whether your formatter runs in-process, shells out, or wraps something you already own is a composition decision, made at the composition site, where a reviewer can see it. What ships is the declaration contract, an arbitration rule that can narrow or reorder but never widen, and a diagnostic pipeline that knows which of three moments stamped each field.
The workspace is UTF-8 and LF, and writing back is lossy on purpose. A UTF-16 file read and written back comes out UTF-8; CRLF comes out LF. Threading an encoding tag through every delta, every re-read, and every engine-created file would buy you a workspace where two files with identical text have different bytes, and where a generated file has no defensible encoding at all. The design owes you the statement, not the preservation — so here it is.
One layer down, the shared hunk primitive does not take that liberty. applyUpdateHunks rejoins with the convention its input arrived in, because it is also called from outside this battery, where nothing has normalised first and a whole-file newline rewrite would bury a one-line change.
Three stamping moments for diagnostics
A diagnostic cannot be stamped in a single pass because its fields become knowable at three distinct lifecycle moments.
[ Engine Execution ] -> RawDiagnostic (no engineId)
|
[ Dispatch Layer ] -> Stamped with engineId
|
[ Runtime Pipeline ] -> Stamped with outOfScope (post-delta & post-re-read)- Engine emission: The engine produces a
RawDiagnostic. An engine is explicitly prohibited from self-reporting itsengineId, preventing compromised or buggy engines from spoofing origin identifiers. - Registry dispatch: The dispatch layer stamps
engineIdonto the diagnostic because the registry authoritatively knows which engine it invoked. - Runtime resolution: The runtime stamps
outOfScopeonly after the workspace delta has been applied, and for in-place capabilities, only after the authoritative disk re-read.
Stamping scope at dispatch is precisely backwards. A capability that creates new.ts and returns { added: {'new.ts'}, diagnostics: [{ path: 'new.ts' }] } would have its own new file marked out of scope, because at dispatch time the delta has not been applied yet. Scope is workspace membership, so it can only be decided once the step's effects are in.
Authoritative re-reads and containment boundaries
Commands like eslint --fix mutate the physical filesystem directly. Once an in-place capability completes, the runtime executes an authoritative disk re-read across its declared authorization envelope:
- If no selector was specified: the declared
scope. - If a selector was provided:
scope ∩ selectorcombined with any rename destinations recorded by the facade.
Admission uses an absent-then-present check against a pre-step snapshot. Files that existed on disk before the step but were not part of the acquired workspace are never imported silently.
The returned engine delta is treated as strictly advisory: the runtime consumes its diagnostics, but mutation fields are never applied directly to state. Instead, the runtime compares the advisory delta against the physical re-read using path-set membership. Discrepancies generate a warning covering up to four distinct path sets, but an inaccurate advisory delta will never fail a step.
Containment guarantee
Scope is ENFORCED on the façade and DECLARED on the subprocess path. Engines are trusted deployment code; nothing stops one importing node:fs and ignoring the façade entirely. What the façade does is make the correct path convenient and the incorrect path visible in review. It is a guardrail, not a sandbox, and saying otherwise would be a security claim the design does not support. A deployment that needs real containment routes its executor through SandboxPolicyEnforcer.run — a composition choice at the engine.
The eight-step write gauntlet and the absent-target problem
Every write operation must pass through an eight-stage verification pipeline in strict sequential order:
- Gate evaluation: Verify step-level permissions.
- Lexical translation: Pass through
classifySandboxPathRejectionthennormalizeSandboxPath. - Containment verification: Ensure path remains within sandbox root.
- Backend translation: Convert to target backend path via
toBackendPath. - Policy check: Evaluate permissions using
FsNode.canWrite(which validates globs and mandatory deny paths including.bashrc,.git/hooks, and.vscode/**). IfeffectivePolicy()returnsundefined, the gauntlet fails closed. - Symlink verification: Execute
assertNoExistingSymlinkComponents. - Filesystem mutation: Perform the physical write.
- Error normalization: Map exceptions through
rethrowAsIoFailure.
Standard path validators fail when asserting symlink boundaries on missing files. If a validator stats the full target path, calling toRelative('src/new.ts') throws immediately because SandboxFileSystem.stat lacks a typed not-found error. This breaks standard agent mutations: adding new files via patches, creating rename targets, and probing file existence.
The dev-tools battery implements a path traversal primitive that walks parent components sequentially and halts at the first missing component. Every existing ancestor component is stat'd and rejected if it is a symlink; absent trailing components cannot be symlinks and require no check.
Unresolved limitation in stat rejection
If a custom filesystem adapter throws a generic permission error on stat, the component walk treats that error as an absent path. This matches the sandbox's default catch behavior, but an adapter with misconfigured read permissions could theoretically halt the walk before encountering a real symlink. The comprehensive fix requires a typed not-found result on SandboxFileSystem.stat, which is currently outside the storage contract.
Filesystem capabilities, rename cycles, and non-atomic execution
The foundational SandboxFileSystem contract specifies only four methods: stat, list, read, and write. It lacks definitions for delete, rename, and mkdir. Attempting to process a patch containing *** Delete File: or Move to against this interface would silently leave deleted files or duplicate moved sources on disk while reporting success.
// Added to SandboxFileSystem itself, as OPTIONAL members. The duck-type guard is
// unchanged, so every existing four-method adapter keeps working untouched.
delete?(path: string, o?: { signal?: AbortSignal }): Promise<void>
rename?(from: string, to: string, o?: { signal?: AbortSignal }): Promise<void>
mkdir?(path: string, o?: { signal?: AbortSignal }): Promise<void>The runtime detects extended filesystem methods via capability probing. If an adapter lacks delete, a patch requesting file deletion is rejected immediately with an explicit error naming the missing adapter capability, rather than executing partially and lying about the outcome.
When resolving rename sets, circular moves (such as swapping fileA.ts and fileB.ts) cannot be applied sequentially. The rename subsystem executes a topological sort keyed on destination paths matching pending source paths, breaking ties lexically. Genuine cycles are broken using a temporary file created alongside the destination path, verified via stat, and regenerated on collision up to an 8-attempt bound. If execution fails mid-cycle, the runtime aborts and explicitly names the orphan temporary file in the thrown error. Net-zero operations (such as a -> b followed by b -> a) collapse to identity and emit no filesystem calls.
[ fileA.ts ] ---> [ temp_1234.ts ]
|
[ fileB.ts ] ---> [ fileA.ts ]
|
[ temp_1234.ts ] -> [ fileB.ts ]Writes are not atomic across files, and the design says so rather than implying otherwise. Real atomicity needs a staging directory and rename-into-place, which the filesystem contract cannot express portably. So bookkeeping advances per completed filesystem call rather than being committed at the end — after a failure, the runtime's record matches what is actually on disk, which is the entire point of keeping one. E_DEV_STEP_FAILED carries the prefix that landed:
try {
await dp.ops(['src/**/*.ts'], writePlan)
} catch (error) {
if (error instanceof E_DEV_STEP_FAILED) {
// `written` is the prefix that actually landed — for diagnosis, not resumption.
console.error('partial write:', (error as { written?: string[] }).written)
}
}Gating, stateless granular tools, and typed degradation
The gate fires once per step, never per file. Prompt an operator fifty times for one formatter run and you have taught them to approve without reading, which is worse than not asking. Acquisition is itself gated, once, before any file is read — reading is a discovery primitive, not a free action.
Because future files do not exist at gate invocation, the gate receives the authorization envelope: targets (resolved explicit paths) and mayCreate (glob patterns permitted for file creation). When evaluating apply_patch, the patch is parsed prior to gating; targets includes both endpoints of every rename, and mayCreate contains an exact list of addition targets. If a patch contains syntax errors, it throws before the security gate is ever invoked.
The execution pipeline owns gating exclusively. The forge layer does not expose a secondary gate option, ensuring that forged tools and direct builder calls present identical authorization boundaries without duplicate prompts.
Granular tools are entirely stateless: each tool call acquires files, executes its single step, persists modifications, and exits. Running an edit followed by an independent write tool would fail, as the second call would re-acquire the unmodified disk state and find no dirty buffers. Therefore, any granular tool capable of producing a delta automatically executes ⟨step⟩ -> write. The gate fires twice: once for acquire, and once for the step itself, which receives a synthesized persists: true argument.
// The gate call for a granular `edit`. `persists: true` is the ONE synthesized
// argument — everything else in `args` is authored input, verbatim.
{
step: 'edit',
args: { path: 'src/index.ts', edits: [/* ... */], persists: true },
targets: ['src/index.ts'],
mayCreate: [],
engines: undefined,
}When optional dependencies are omitted, feature degradation is reported as typed data rather than prose messages. Line diff calculations require the optional diff peer dependency. If diff is not installed, added and removed fields are omitted entirely from the result object (not reported as 0, which would falsely indicate zero line modifications), and DevResult.lineCountsAvailable is set to false.
Cross-package seam defects discovered during review
Reviewing packages in isolation fails to catch cross-boundary integration failures. A full-repository whole-diff review uncovered six critical defects across the battery interfaces:
| Defect Category | Root Cause in Subsystem | Resolution |
|---|---|---|
| Ungated I/O | read_lines bypassed step-level security gating | Routed through standard acquisition gate |
| Dead Code | Unreachable middleware in engine dispatch | Removed dead pipeline layers |
| Policy Bypass | write bypassed A6b gauntlet checks | Enforced FsNode.canWrite on all write steps |
| Scope Omission | Missing mayCreate patterns on file creation | Pre-computed creation globs in authorization |
| Forge Rejection | Forge threw on valid empty acquisition plans | Allowed empty workspace initializations |
| Layer Violation | Dev-tools imported internal helper from media battery | Inlined dependency-free replacement |
Two additional secondary regressions were introduced while patching the first-round defects and were caught by the same review pipeline. Verifying multi-tool execution across storage and policy layers requires analyzing the complete integration surface as a single system.