Skip to content

fix(sandbox): destroy.ts held literal NUL bytes, so grep skipped the file#498

Merged
SUaDtL merged 1 commit into
mainfrom
fix/destroy-nul-bytes
Jul 26, 2026
Merged

fix(sandbox): destroy.ts held literal NUL bytes, so grep skipped the file#498
SUaDtL merged 1 commit into
mainfrom
fix/destroy-nul-bytes

Conversation

@SUaDtL

@SUaDtL SUaDtL commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Five literal NUL bytes in plugins/ca-sandbox/tools/destroy.ts become escape sequences. Same string at runtime; the file stops being classified as binary.

How it was found

Incidentally, and it is my own slip from #481. A plain grep -n "export function" destroy.ts answered:

Binary file destroy.ts matches

The FailureLog fingerprints join their fields on a NUL separator. The source was supposed to carry the escape; it carried the actual byte.

Why it matters, given the runtime is unaffected

Searchability, on a file where that is a review control. grep and ripgrep classify any file containing a NUL byte as binary and print binary file matches instead of the matching lines. So every content search over the ca-sandbox teardown driver silently skipped it — including any security sweep for a pattern that happens to live there. Four exported functions were invisible to grep.

A file that cannot be searched cannot be reviewed by search. That is the whole defect; there is no runtime bug here.

Why nothing caught it

  • the built bundle contains zero NUL bytes — esbuild re-encodes them
  • the teardown tests pass on both forms
  • tools/ is outside the shipped payload, so no version gate looks at it

Verification

  • tsc --noEmit — 0 errors
  • teardown.test.ts + lifecycle.test.ts — 37/37 passed
  • the rebuilt bundle is byte-identical: sandbox.js SHA-256 abce329a7ab2f079… before and after, which proves this is a source-representation change with no runtime effect rather than asserting it
  • grep -c "export function" now returns 4 instead of a binary-file notice

Versions

No bump. plugins/ca-sandbox/tools/** is outside the shipped payload except for the committed bundles, and sandbox.js is unchanged. payload_scope.py --plugin plugins/ca-sandbox reports unchanged.

https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L

…file

The FailureLog fingerprints join their fields on a NUL separator. The source
carried five LITERAL NUL bytes where escape sequences were intended - my own
slip in #481, found incidentally when `grep -n "export function" destroy.ts`
answered "Binary file destroy.ts matches".

The runtime string is identical either way, which is why nothing caught it: the
built bundle contains zero NUL bytes because esbuild re-encodes them, and the
teardown tests pass on both forms. Proven rather than asserted - rebuilding
sandbox.js after this change produces a BYTE-IDENTICAL bundle.

What was actually broken is searchability, and on this file that matters. grep
and ripgrep classify a file containing a NUL byte as binary and report only
"binary file matches" instead of the lines, so every content search over the
ca-sandbox teardown driver silently skipped it - including any security sweep
for a pattern that happens to live there. Four exported functions were invisible
to `grep`. A file that cannot be searched cannot be reviewed by search.

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
SUaDtL added a commit that referenced this pull request Jul 26, 2026
Both are now async, and every dockerRun call inside destroy.ts is awaited.
Also carries the same NUL-byte source fix as #498, so the file is greppable on
this branch too - it will merge cleanly with that PR since the change is
identical.

Remaining: registry.ts, cli.ts, claude-inside-cli.ts, and the test doubles.
The error count RISES as each layer converts (220 now), because making a
function async lights up every caller; it falls to zero only when the boundary
reaches the CLI entry, which is already async and already awaited.

Refs #479
@SUaDtL
SUaDtL merged commit 09dbeab into main Jul 26, 2026
32 checks passed
@SUaDtL
SUaDtL deleted the fix/destroy-nul-bytes branch July 26, 2026 14:46
SUaDtL added a commit that referenced this pull request Jul 26, 2026
destroy.ts conflicted as BINARY rather than textually: the merge base still
carried the literal NUL bytes #498 removed, so git refused a line-level merge
and left this branch's copy in place. That copy is the correct one - it holds
both the NUL fix (applied here too, identically) and the async conversion.
Verified rather than assumed: 0 NUL bytes, escapes present, destroySandbox and
prune async, and the teardown/lifecycle/deadline suites green at 53/53.
SUaDtL added a commit that referenced this pull request Jul 26, 2026
#499)

* wip(#479): async, cancellable docker chokepoint

docker.ts only; the consumers do not compile yet. Pushed as recoverable WIP.

spawnSync is uninterruptible by construction, so #394 could set a deadline but
nothing could interrupt a call in flight - the thread sits inside the syscall
with no point at which an AbortSignal could be observed. Now async spawn with
an AbortSignal per CALL (not per runner: one runner serves several calls, and
cancellation has to reach whichever is in flight).

Three kills all arrive as close(null, "SIGKILL"), so the runner records WHICH
killer fired before killing rather than inferring after. Abort is typed apart
from timeout because they mean different things: a timeout is a wedged
operation, an abort is the operator asking to stop, and logging one as the
other would put "timed out" in an audit trail for a deliberate Ctrl-C.

maxBuffer moved out of the spawn options bag into the runner, because async
spawn has no such option - a caller still passing it inside `extra` would have
silently lost its bound. That also fixes a latent mis-classification: spawnSync
killed the child on a maxBuffer breach, which surfaced as status===null with a
signal, the same shape the deadline check read, so an output overflow reported
itself as a TIMEOUT with exit 124.

An already-aborted signal now refuses to spawn at all, so a cancelled multi-step
command cannot still create the next container.

Refs #479

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L

* wip(#479): thread the signal through cp, run, claude-inside, exec

exec.ts carries AC-2: an ABORTED exec now runs the same bounded teardown a
timed-out one does. The container is left running whether the deadline fired or
the operator pressed Ctrl-C, and an orphaned box is the same leak either way -
what differs is only what the report calls it, which is why docker.ts types the
two apart rather than collapsing both into timedOut.

The teardown stop deliberately carries NO signal. It is the cleanup for a call
that was just cancelled, so handing it the same aborted signal would cancel the
cleanup too and guarantee the orphan it exists to prevent.

Also fixes a bound that the async conversion would otherwise have dropped
silently: exec.ts passed maxBuffer inside the SPAWN options bag, which async
spawn ignores. It is now the runner's own option, so the 256MB backstop still
exists and capBytes stays the authoritative limit.

Consumers still mid-conversion; the tree does not typecheck yet.

Refs #479

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L

* wip(#479): create.ts and destroy.ts teardown paths

Both teardown paths deliberately carry NO signal. They run BECAUSE the operation
failed or was cancelled, so handing them the aborted signal would cancel the
cleanup and orphan exactly the containers and volumes they exist to reclaim.

Still converging: destroySandbox and pruneSandboxes need the async boundary
next, then registry.ts, cli.ts, and the test doubles.

Refs #479

* wip(#479): destroySandbox and prune cross the async boundary

Both are now async, and every dockerRun call inside destroy.ts is awaited.
Also carries the same NUL-byte source fix as #498, so the file is greppable on
this branch too - it will merge cleanly with that PR since the change is
identical.

Remaining: registry.ts, cli.ts, claude-inside-cli.ts, and the test doubles.
The error count RISES as each layer converts (220 now), because making a
function async lights up every caller; it falls to zero only when the boundary
reaches the CLI entry, which is already async and already awaited.

Refs #479

* wip(#479): registry.ts async, and the await sweep across consumers

registry.ts was the last large module: 10 lookups, all of which either call
dockerRun or delegate to one that does, now async. labelValue too - the per-object
inspect behind listSandboxes.

Production type errors: 50 -> 20. Remaining are cli.ts's dispatch, claude-inside,
claude-inside-cli, and two stragglers in run/destroy; then the test doubles.

Refs #479

* wip(#479): async keywords on runContainer, verifyScope, defaultShell

Refs #479

* wip(#479): production code fully converted - 0 type errors

The async boundary reached the CLI entry. Handlers is promise-returning across
the board, runCli awaits each dispatch arm, and claude-inside-cli's entry now
awaits its own promise rather than calling process.exit ON it - exiting on a
pending promise would have reported 0 while the container start was still in
flight.

Production: 0 type errors. Test doubles remain (218): they return plain objects
where DockerRun now returns a promise, and deadline.test.ts injects a spawnSync
shape where an async child is expected.

Refs #479

* wip(#479): convert the test doubles to async

The injected fakes return a promise now, because DockerRun does. Two shapes
needed converting: those annotated as DockerRun, and the inline ones passed
straight as an argument, which carry no annotation to key off and were matched
on their RunResult SHAPE instead.

Every it/test callback became async. Harmless where nothing is awaited, and
vitest treats the two identically - safer than deciding per-callback which
bodies gained an await and getting one wrong silently.

Test type errors 218 -> 44. Remaining: deadline.test.ts, whose spawn seam still
injects a spawnSync-shaped return where an async child (an EventEmitter with
stdout/stderr streams and a kill that emits close) is now expected.

Refs #479

* wip(#479): async child double for deadline.test.ts

The spawn seam injected a spawnSync-shaped RETURN; the runner now consumes a
child process. The double is an EventEmitter with stdout/stderr streams and a
kill that emits close(null, signal).

That last detail is deliberate and load-bearing. A real SIGKILL surfaces as
close(null, signal), IDENTICALLY whether the deadline fired, the caller aborted,
or the output cap tripped - which is exactly why the runner records which killer
fired rather than inferring it. A double that helpfully labelled its close event
would let a broken runner pass, so this one reproduces the ambiguity faithfully.

The deadline assertion changed shape with it: it used to read the
SPAWN OPTION, which async spawn does not have. It is now asserted through
observable behaviour - a child that never settles must still resolve, as a typed
timeout, at the bound.

Test type errors 218 -> 36.

Refs #479

* wip(#479): rejection assertions and handler awaits in tests

Two patterns the blanket await pass got wrong or left behind. `expect(() =>
await X).toThrow()` is invalid - an await inside a sync arrow - and the async
equivalent asserts on the REJECTION, not on a synchronous throw. Handler calls
through the defaultHandlers object are promises now.

Test type errors 218 -> 29; production still 0.

Refs #479

* wip(#479): the async conversion typechecks - 0 errors, production and tests

Last shapes converted: vi.fn() handler mocks (they return promises now), the
remaining sync docker fakes, and two await-precedence bugs the blanket pass
introduced - `await findSandbox(id)!.containers` awaits the MEMBER, not the
call, so it needed `(await findSandbox(id))!.containers`. That one would have
typechecked as `any` in a looser config and failed at runtime.

Two throw assertions became rejection assertions, which is a real semantic
change rather than a syntactic one: `expect(() => await X).toThrow()` cannot
work, and the async equivalent asserts the promise REJECTS. For the usage-error
case the intent was "returns a code rather than throwing", so it now asserts
the promise RESOLVES to a number.

Typecheck is clean; the suite has not been run yet.

Refs #479

* fix(#479): two real defects the first full test run exposed

1. `await expect(await createSandbox(...)).rejects.toThrow()` awaits the promise
   BEFORE expect can wrap it, so the rejection escaped as a test error instead of
   being asserted on. Six tests failed this way - my own blanket await pass put
   the await inside expect(). The .rejects form needs the promise itself.

2. A synchronous spawn failure rejected instead of returning a result. spawnSync
   reported a failed launch through r.error and the old runner mapped it to
   {code: 1}; async spawn normally EMITS an error event, but an invalid argument,
   a bad cwd, or a blocked exec can still throw straight out of spawn(). A throw
   inside a Promise executor rejects, and nothing in this driver catches - so an
   unusable docker would have surfaced as an unhandled rejection rather than the
   {code: 1} every caller is written against. Found by reading the diff, not by a
   failing test, which is why it is worth naming: no fixture spawns a real
   missing binary.

Refs #479

* feat(#479): the cancellation contract, tested

Eight tests covering the acceptance criteria that had none:

AC-1 - an in-flight call aborts rather than running to its deadline; the child
is KILLED rather than the promise merely resolving around it (resolving while
leaving the process alive would look like cancellation and leak a docker
client); and an ALREADY-aborted signal refuses to spawn at all, so a cancelled
multi-step command cannot still create the next container.

AC-2 - an aborted exec stops the container it targeted, exactly as a timed-out
one does, and an ordinary non-zero exec still does NOT (escalation destroys a
working box; doing it on exit 1 would tear one down every time a command failed).

AC-4 - abort and timeout are typed apart. Both arrive as close(null,
"SIGKILL"), so this is only true because the runner records which killer fired.

Also declares ExecResult.aborted, which the new tests caught: exec.ts SET the
flag but never declared it, so no caller could have read it.

The pre-spawn guard is proven non-vacuous by mutation: disabling it fails
exactly the already-aborted test and nothing else.

deadline.test.ts 16/16. Full suite was 291/291 before these were added.

Refs #479

* feat(#479): Ctrl-C cancels the in-flight call and lets teardown finish

The last piece of the cancellation contract. Node's default SIGINT handling ends
the CLI immediately, which leaves the box the operator was exec-ing into still
running with no one left to reclaim it - the same orphan a timeout left before
#394. The entry now aborts instead, so the runner kills its child, returns a
typed aborted result, and the command module runs the SAME bounded teardown a
timeout gets.

A SECOND Ctrl-C exits immediately. The first already asked nicely, and a CLI
that refuses to exit is how one earns a kill -9.

The signal reaches the command modules through a HANDLERS FACTORY rather than a
new runCli parameter, so the Handlers contract is unchanged and every test that
injects its own handlers is untouched. Threading it through runCli would have
changed that contract for every caller to serve exactly one of them.

Installed at the entry, not in the runner: the signal is a property of this
process, while docker.ts stays a primitive that observes a signal handed to it.
That is what keeps the cancellation path testable without a daemon.

Also threads the signal through createSandbox's own docker calls. It was
declared on CreateOptions and unused - a dormant seam, which is the thing this
issue exists to avoid shipping.

Refs #479

* build(#479): rebuild the shipped bundles against the async runner

Refs #479

* docs(#479): changelog entry for the cancellable docker boundary

No version bump: ca-sandbox 0.1.5 carries no tag yet, so the payload gate accepts
the change on the current version and this joins the open section.

Closes #479
Closes #394

---------

Co-authored-by: SUaDtL <SUaDtL@users.noreply.github.com>
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