Skip to content

[sprites 1-5] Apply egress lockdown on fresh create only - #2014

Merged
2witstudios merged 4 commits into
masterfrom
pu/sprites-1-5-egress-fresh-only
Jul 11, 2026
Merged

[sprites 1-5] Apply egress lockdown on fresh create only#2014
2witstudios merged 4 commits into
masterfrom
pu/sprites-1-5-egress-fresh-only

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Why

The Sprite egress policy lives at /.sprite/policy/network.json — a persistent file whose "changes reload live" (docs.sprites.dev/concepts/networking). It survives pause/hibernation. getOrCreate nonetheless re-pushed it (updateNetworkPolicy) plus a mkdir -p /workspace exec on every hand-back — every machine connect, every tab-back, and every 60s re-auth tick — paying a control-plane round-trip plus an exec, on the connect critical path, for a policy that was already in place. get() already skipped this ("the policy persists across hibernation"); getOrCreate is now consistent with it.

What is recorded: a lockdown token, not a policy hash

The skip is only safe if we can prove this VM is running this policy. A policy hash alone answers only the second half, and the two come apart on the vanish-and-recreate path (see the P1): given a session row whose hash matches but whose Sprite has vanished, two concurrent getOrCreate calls race — A creates the replacement (which starts on the platform's default open egress) and has not yet reached its lockdown; B then finds that new Sprite by name, sees fresh === false, matches the hash of the destroyed Sprite, and skips the push.

So the record is a lockdown token over (sprite instance id, policy hash). The SDK hydrates id from the API response on both getSprite and createSprite, so reading it costs no extra round-trip. A replacement VM has a different id → the token does not match → it is locked down. Two callers pushing the same policy is harmless; one of them skipping it is not.

Fail-closed throughout: no id → no token. An unprovable lockdown is never recorded as proof, so a future SDK that stops reporting the id degrades to "re-applies every time", never to "skips it".

Requirements

Requirement How it is satisfied
Resume with unchanged policy → neither updateNetworkPolicy nor the mkdir exec sprites.ts gates applyEgressLockdown behind the pure shouldApplyPolicy. Spy assertions in sprites.test.ts (calls.policies === 0, calls.spawned === []) and end-to-end through the real realtime connect wiring in cold-connect-sprite-reads.test.ts.
Fresh create → policy + SANDBOX_ROOT exactly once, and the session is linked only after lockdown succeeds fresh short-circuits shouldApplyPolicy to true. acquireMachineSession calls store.save after getOrCreate resolves — the ordering that closes the crash window the old file header defended, since an unlocked Sprite is never reachable from a session row. Tests assert one policy push + one mkdir, and that a failing lockdown writes zero session rows.
Changed policy → re-apply once and update the record Token mismatch → apply; the manager records the token the driver confirmed. Also covers the case the requirement did not anticipate: a replaced VM under an unchanged policy.
Lockdown failure on fresh create → refuse the hand-back Unchanged: applyEgressLockdown destroys the just-created Sprite (destroyOnFailure: fresh) and rethrows; normalized to SandboxProvisionError. Destroy-vs-retain is leaf 6-2, untouched.

Design

Pure core (services/sandbox/egress-lockdown.ts, no mocks in its tests):

  • hashPolicy(policy) — canonical, stable sha3-256 fingerprint. Rule key order and rule array order do not change it (the platform resolves precedence by specificity, not position, so a reordered-but-identical rule set is the same policy and must not thrash).
  • hashSandboxEgressPolicy(options) — hashes the built policy, so inputs that sanitize to the same host ('GitHub.com' vs ' github.com ') don't trigger a needless re-apply.
  • egressLockdownToken({ spriteId, policyHash }) — the proof; undefined when the platform reports no identity.
  • shouldApplyPolicy({ fresh, appliedToken, desiredToken }) — apply on fresh, on unknown (either side), on mismatch; skip only on a proven match.

Imperative shell: machine_sessions.egressPolicyToken (nullable, migration 0202). The driver returns the token it confirmed and the manager persists that — the record can never claim more than the driver proved. NULL (legacy rows, lost writes, no id) → the next hand-back re-applies and records.

Self-healing workspace root (review follow-up)

applyEgressLockdown was also the only thing that recreated SANDBOX_ROOT, so making it fresh-create-only left /workspace unrepaired after a sandbox command deletes it (P2). Fixed where the directory is consumed, rather than by buying the per-connect exec back:

  • spawnWithSelfHealingCwd (pure) wraps command+args in an sh that recreates + enters the cwd and then execs the real command (preserving the PTY, signals, exit code). Already runCommand's shape; now the single definition, shared by the batch path, MachineHost.stream(), and the realtime openPtyShell — which passes cwd to createSession, where the server chdirs before spawning and would otherwise fail the open outright.
  • fsRecoveryExec folds the same self-heal into the exec the fs path already pays between a failed op and its retry (the fs API is a bare fetch that cannot wake a hibernated Sprite): that one exec now both wakes the VM and mkdir -ps the write's parent. Zero extra round-trips on the happy path.

Both keep paths as positional data args, never interpolated into the script, so the arg-array no-injection invariant holds (tested with metacharacter-laden inputs).

Test evidence

bun run typecheck        Tasks: 16 successful, 16 total

packages/lib (sandbox)   Test Files  38 passed (38)
                              Tests  647 passed (647)

apps/realtime            Test Files  21 passed (21)
                              Tests  662 passed (662)

Notable new tests: the concurrent-recreate race (recorded token names sprite-1, control plane returns sprite-2 under the same name and unchanged policy → asserts the policy IS pushed), the no-id fail-closed case, the deleted-/workspace write recovery, and pure suites for every new function.

Notes for reviewers

  • Rebased onto master's Terminal→Machine rename (refactor(machine): Terminal→Machine naming coherence sweep (part 2) #2012): machine-session-manager, MachineSessionStore/Record, surface: 'machine'.
  • Migration 0202_aspiring_warpath.sql is a single nullable column add (ALTER TABLE "machine_sessions" ADD COLUMN "egressPolicyToken" text;) — no backfill; NULL means "unproven → re-apply once, then record".
  • machine-branches.ts provisions without a session row, so it passes no token and always applies — and it only provisions on first create or a vanished VM (warm reuse goes through attach), so this costs nothing on a hot path.
  • Policy CONTENT (leaf 6-5) and destroy-on-failure (leaf 6-2) are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PNvzrqmNYJbT3B6XKmr9dm

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b92941d5-65ee-4b89-ac3a-ccf3375e96fe

📥 Commits

Reviewing files that changed from the base of the PR and between e184457 and 918b7ee.

📒 Files selected for processing (21)
  • apps/realtime/src/terminal/__tests__/cold-connect-sprite-reads.test.ts
  • apps/realtime/src/terminal/__tests__/sprites-shell.test.ts
  • apps/realtime/src/terminal/sprites-shell.ts
  • packages/db/drizzle/0202_aspiring_warpath.sql
  • packages/db/drizzle/meta/0202_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/machine-sessions.ts
  • packages/lib/package.json
  • packages/lib/src/services/sandbox/__tests__/egress-lockdown.test.ts
  • packages/lib/src/services/sandbox/__tests__/machine-session-manager.test.ts
  • packages/lib/src/services/sandbox/__tests__/machine-session.test.ts
  • packages/lib/src/services/sandbox/__tests__/persistent-machine-fs.test.ts
  • packages/lib/src/services/sandbox/egress-lockdown.ts
  • packages/lib/src/services/sandbox/machine-host.ts
  • packages/lib/src/services/sandbox/machine-session-manager.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprite-machine-host.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprites.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/machine-host-adapter.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprite-machine-host.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprites.ts
  • packages/lib/src/services/sandbox/sandbox-client/types.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/sprites-1-5-egress-fresh-only

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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: a60a4a54eb

ℹ️ 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".

Comment thread packages/lib/src/services/sandbox/sandbox-client/sprites.ts Outdated
2witstudios and others added 2 commits July 11, 2026 17:24
The Sprite network policy lives at /.sprite/policy/network.json — a persistent
file that survives pause/hibernation (docs.sprites.dev/concepts/networking).
getOrCreate nonetheless re-pushed it, plus a SANDBOX_ROOT mkdir exec, on EVERY
hand-back: every terminal connect, every tab-back, and every 60s re-auth tick
paid a control-plane round-trip plus an exec for a policy that was already in
place.

The lockdown is now applied exactly when it is not already known-good: a fresh
create (a new Sprite starts on the platform's open outbound), a hash mismatch
(the desired policy changed), or an unknown recorded state (fail closed). The
decision is a pure function — shouldApplyPolicy({fresh, appliedPolicyHash,
desiredPolicyHash}) over a canonical hashPolicy — and the shell records the
confirmed hash on the terminal_sessions row.

The crash window the old unconditional re-apply defended (a crash between
createSprite and its lockdown leaving an open-egress Sprite reachable) is closed
by ORDERING instead: the session row is written only after getOrCreate resolves,
so an unlocked Sprite is never linked to a session. A lockdown failure on fresh
create still destroys the Sprite and rejects the hand-back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvzrqmNYJbT3B6XKmr9dm
Addresses the Codex P2 on #2014: `applyEgressLockdown` was also the only thing
that recreated SANDBOX_ROOT, so applying it on fresh create only left a warm
reconnect exposed if a sandbox command had deleted /workspace — the PTY passes
cwd to createSession, the server chdirs into it before spawning, and the session
open fails outright.

Fixed where the directory is actually consumed rather than by buying the mkdir
back on every hand-back (the exec this PR exists to remove): `spawnWithSelfHealingCwd`
(pure, in the driver) wraps command+args in an `sh` that recreates and enters the
cwd, then execs the real command — preserving the PTY, signals and exit code, with
cwd/command/args as positional data args so the no-injection invariant of the
arg-array form holds. This is the shape `runCommand` already used; it is now the
single definition, shared by the batch path, MachineHost.stream, and the realtime
PTY.

Also rebased onto master's Terminal→Machine rename (#2012): machine-session-manager,
MachineSessionStore/Record, surface 'machine', and the migration renumbered to
0202 on machine_sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvzrqmNYJbT3B6XKmr9dm
@2witstudios
2witstudios force-pushed the pu/sprites-1-5-egress-fresh-only branch from a60a4a5 to 2e708d5 Compare July 11, 2026 22:33
Second half of the same gap the PTY fix closed. The Sprite fs API does not create
parent directories, so `writeFiles` also depended on the egress lockdown's
per-hand-back `mkdir` to guarantee SANDBOX_ROOT existed. With the lockdown now
fresh-create-only, an agent that had `rm -rf`'d /workspace would fail every
subsequent file write.

Folded into the exec the fs path was already paying for: `fsWithWakeRetry`'s
recovery exec now takes the directories the op needs, so the single exec between
a failed op and its retry BOTH wakes the VM (the reason it exists — the fs API is
a bare fetch that cannot wake a hibernated Sprite) and `mkdir -p`s the parents.
Zero extra round-trips on the happy path; the read path is unchanged (`sh -c :`).

New pure functions + tests: `parentDir` (POSIX by construction — these are paths
inside the Linux VM, so node:path would apply Win32 semantics on a Windows host)
and `fsRecoveryExec`. Directories are positional data args, never interpolated
into the script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvzrqmNYJbT3B6XKmr9dm
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 54f149239a

ℹ️ 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".

Comment thread packages/lib/src/services/sandbox/sandbox-client/sprites.ts Outdated
…the policy

Addresses the Codex P1 on #2014: a policy-hash-only record could not tell one VM
from its replacement, which opened a containment hole on the vanish-and-recreate
path. Given a session row whose hash matches the desired policy but whose Sprite
has vanished, two concurrent getOrCreate calls race: A creates the replacement
(which starts on the platform's default OPEN egress) and has not yet reached its
lockdown; B then finds that new Sprite by name, sees fresh === false, and — since
the recorded hash still describes the DESTROYED Sprite's policy — skipped the
push and handed back an unlocked VM.

The record is now a LOCKDOWN TOKEN over (Sprite instance id, policy hash): proof
that a specific policy was applied to a specific VM. The SDK hydrates `id` from
the API response on both getSprite and createSprite, so reading it costs nothing.
A replacement VM has a different id, so B's token does not match and B locks it
down itself (both callers pushing the same policy is harmless; one skipping it is
not). An SDK that reports no id yields NO token — unprovable, so never recorded
as proof, and the next hand-back re-applies. Fail closed throughout.

The driver now returns the token it confirmed and the shell persists that, rather
than the shell computing what it hoped for: machine_sessions.egressPolicyToken
(migration 0202), threaded through SandboxHandle/MachineHandle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvzrqmNYJbT3B6XKmr9dm
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 918b7ee721

ℹ️ 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".

@2witstudios
2witstudios merged commit 654ba81 into master Jul 11, 2026
10 checks passed
2witstudios added a commit that referenced this pull request Jul 11, 2026
Resolves the conflict with [sprites 1-5] (#2014), the sibling leaf that also
touches sprites-shell.ts.

The only textual conflict was the test file's import block — both imports are
needed, so both are kept. The source merge is semantically clean but worth stating,
since both PRs edit `launchFreshSession`: #2014 wraps the fresh create in
`spawnWithSelfHealingCwd` (recreating a deleted /workspace, so the egress lockdown's
per-hand-back mkdir can go), while this PR resets `seenTail` there (a brand-new shell
shares no history with the one the client was watching, so its banner must not be
mistaken for a replay and suppressed). The two are orthogonal and both survive.

Verified on the merged tree: apps/realtime 699/699 tests, typecheck 16/16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTUvWzi9AkWgDQcKA8DwRH
@2witstudios
2witstudios deleted the pu/sprites-1-5-egress-fresh-only branch July 12, 2026 04:32
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