Skip to content

feat(sandbox): file transfer, egress policy and suspend/resume on Azure - #476

Open
ItamarZand88 wants to merge 29 commits into
mainfrom
itamar/alien-75-fix-gcp-sandbox-create
Open

feat(sandbox): file transfer, egress policy and suspend/resume on Azure#476
ItamarZand88 wants to merge 29 commits into
mainfrom
itamar/alien-75-fix-gcp-sandbox-create

Conversation

@ItamarZand88

Copy link
Copy Markdown
Contributor

Summary

Adds capabilities to the Azure sandbox backend: reading and writing files inside a
session, enforcing a declared egress policy — including a hostname allowlist — and
suspending and resuming a session. Azure is the only backend that restricts egress to
hostnames, because its egress proxy matches on host pattern where the others filter by
CIDR or carry a single switch.

Step-by-step flow when a worker calls sandbox.runCommand:

  1. The binding reads the session, and refuses it unless the sandbox the data plane
    describes carries the egress policy the stack declared.
  2. The command is wrapped so the session holds it to its deadline, and the caller's
    variables travel through env so they reach the command and not the wrapper. ← the heart
  3. The data plane runs it once — a response that never arrives is not a reason to run
    untrusted code again.

This PR brings the Azure binding onto the file, egress and lifecycle operations the
data plane exposes.

What was broken

Four silent failures in the existing Azure path, each found by reading the SDK the
client cites as its source: the data-plane token asked for the wrong audience, the
declared image was discarded, session state was read from a field the service does not
send (so every sandbox reported Running), and declared environment variables never
reached the create body.

What I did

  • Implemented read, write and mkdir against the native file API, with a 32 MiB ceiling
    in both directions and path rules applied before anything reaches the wire.
  • Created every session under the declared egress policy, then read the effective
    policy back and refused a sandbox that came up without it.
  • Added suspend, resume and an idle auto-suspend policy, and made every verb that hands
    back or uses a session judge it against the declaration first.
  • Made create and exec single-attempt. Both were being retried three times by the
    transport: a lost response after a create minted a second sandbox that no id-holder
    could find, and reported success.
  • Refused a hostname allowlist on Helm, GCP and AWS instead of approximating it as
    "allow everything" or "deny everything".

Files touched

  • crates/alien-bindings/src/providers/sandbox/azure.rs — the backend: file ops, egress
    judgement, session lifecycle, command bounding.
  • crates/alien-azure-clients/src/azure/sandbox_data_plane.rs — the wire contract, read
    from the published preview SDK.
  • crates/alien-azure-clients/src/azure/common.rs — a single-attempt send for the two
    verbs a repeat performs twice.
  • crates/alien-core/src/resources/sandbox.rs — the capability set, and Azure's
    catalog-image rule at plan time.
  • crates/alien-bindings/src/providers/sandbox/gcp.rs — the Cloud Run launcher takes the
    session id positionally and needs --detach; ids are checked before they reach it.
  • Terraform, CloudFormation and Helm sandbox emitters — refusals rather than approximations.

Upgrading an existing Azure stack

egress and diskImage are now required on the Azure sandbox binding with no default.
A deployment whose binding was written by the older module will fail to load until
terraform apply regenerates it, so apply before or together with the binary upgrade.
Defaulting them was the alternative and is worse: there is no safe default for egress —
one opens the sandbox, the other silently tightens a running deployment.

code.image also narrows in opposite directions per platform. AWS builds a MicroVM from
an s3:// bundle; Azure names an entry in a public catalog, so a bare ubuntu. One
declaration cannot target both, and each refuses the other's shape while planning.

How I tested

  • Manually: drove the generated wrapper through a real /bin/sh with a real setsid
    in debian:stable-slim, alpine and rockylinux:9 — a variable declared on a command
    reaches it, a 1.5s deadline kills at 1.5s with exit 137, and a command that spawns
    children takes them with it. Ran the create path against a local HTTP server answering
    503 then 201 and confirmed exactly one PUT is delivered, against four before the
    change.

  • Unit tests: ~40 added across the sandbox provider, the data-plane client and the
    emitters. Every guard was checked by reverting it and confirming its test fails.

  • Anything I couldn't test: no live Azure data plane. Whether it reports
    egressPolicy for a stopped sandbox is unverified, and the code is deliberately safe
    either way — an absent policy on a sleeping record is treated as unknown, not as a
    mismatch. Whether an ADC sandbox has any route to private ranges is also unmeasured.

  • Attacks considered for this diff:

    • Handing back a session with more network reach than declared — every verb that uses a
      session judges the running record unconditionally; the read paths skip only where
      there is provably nothing to judge (judged_session vs judgeable).
    • Injecting a second command through a variable name — names are bounded to
      [A-Za-z_][A-Za-z0-9_]* and the whole NAME=value pair is one quoted argument.
    • Disabling the command deadline from the caller's environment — a session may not set
      PATH, IFS, SHELLOPTS, BASHOPTS or any LD_*, each of which changes what the
      wrapper resolves, splits, loads or traces.
    • Forging a deadline verdict — the report is a nonce the session draws, found by shape
      rather than position so a traced shell cannot displace it.
    • Publishing a cloud error's response body — request bodies are scrubbed from the error
      chain, and the wrapping errors inherit the source's internal flag.
    • A stale session id reaching a tightened declaration — refused, and not destroyed,
      because two revisions of a stack share one sandbox group.

    One thing did turn up and is not fixed here: the in-session deadline wrapper is an
    honest report about a cooperative command, not a boundary against a hostile one. Code
    running as root in a session can replace the od that draws the nonce, or kill the
    killer — a sibling with the same uid — and outlast its deadline. Both measured. This
    predates the change and defeats the existing Local backend identically; the enforced
    bound is the caller-side guard, which ends the session. The comments now say so rather
    than implying more.

`create` sent `run --id <id>`. There is no `--id` flag on `run` — the id is
positional — and without `--detach` the launcher stays attached until the
control deadline kills it. Measured against a live launcher: it answers
`unknown flag: --id`, exits 0, and leaves a session nothing can reach. So the
call reported success and handed back an id that addressed nothing.

Both env refusals go too. `--env` is accepted on `run` and on `exec`, and a
sandbox inherits nothing from the container, so refusing it denied the only
way to get a variable in.

The test fake accepted any argv, which is how this passed review: three
existing tests were green against the broken form. It now rejects unknown
verbs and flags, and reverting the argv fails five tests.

`fixtures/gcp-sandbox-cli-help.txt` records the launcher's real surface —
eight verbs where the published reference lists six.
`.code({image})` was accepted on Azure and then dropped: the provider was
constructed with a literal `"ubuntu"` and the binding had no field to carry
anything else. Every session ran a stock image whatever the declaration said,
and nothing failed, because a sandbox on the wrong image still starts. It is
the one Azure gap with no typed error and no capability bit behind it.

The binding now carries `diskImage` and the emitter fills it from `code`. A
registry reference is refused at plan time rather than reinterpreted, matching
the AWS emitter: the create body names a public catalog image, so
`ghcr.io/org/x:tag` has nowhere to go and saying so beats substituting.

Ceilings stay the service defaults, now named rather than inline. `.limits()`
is refused on Azure at plan time, so nothing declares them and there is no
value to carry; they move into the binding when `enforcedLimits` flips.

Reverting the plumbing fails the new provider-level test — the seam that was
wrong is the one under assertion, not just the provider it feeds.
…host

The scope was `management.azuredevcompute.io/.default` — the endpoint's own
host, which is the natural guess and had no citation. The SDK this module's
header already names as the source of its contract pins the audience for this
endpoint and api-version as `dynamicsessions.io/.default`.

Both audiences mint a token against our tenant, so a wrong one fails at the
data plane rather than at the token endpoint, where it looks like a missing
`SandboxGroup Data Owner` assignment. Which of the two the data plane accepts
cannot be settled without a provisioned sandbox group: the endpoint answers 404
to an unauthenticated request in our region, so nothing short of a real group
distinguishes them.

The test that pinned the old value was written from the same guess rather than
from the package, so it pinned the guess.
Azure was the one backend that refused `readFile`, `writeFiles` and `mkdir`,
so portable file code had to branch on the capability. The data plane has had
the verbs the whole time — `GET`/`PUT {sandbox}/files` and
`POST {sandbox}/files/mkdir` — and `write` takes `createDirs`, which is what
gives Azure the cross-backend rule that a write creates its parents.

Three things had to come first. The Azure request builder carried a `String`
body, which cannot hold a file; it now carries bytes. Every failure was one
`OperationNotSupported`, so a missing file and an unreachable data plane were
indistinguishable; they now split into a refusal, which is not retryable, and
an unknown outcome, which is retryable for a file operation and never for a
command. And a body is now truncated before it is echoed into an error, so a
32 MiB upload cannot become a 32 MiB log line.

Paths are checked before they leave the process: relative only, no `..`, no
empty components, no trailing slash, on all three operations. Whether the
server confines a path is undocumented, so the code says this is our rule and
not a guarantee. Transfers are capped at 32 MiB in both directions, the number
the agent-backed backends already enforce, with the read bounded as it arrives.

`files: true` came last, after the three methods worked. The wire tests run
against a server the test controls and each one fails against a plausible
wrong version: drop `createDirs`, move the mkdir path into the query, drop
either ceiling, or drop the path check, and a test goes red.

Azure's propagation-delay 400s arrive as `RemoteResourceConflict`, which the
client marks transient, so that variant stays out of the refusal set — a
refusal tells the caller never to retry.
…ables

Two silent failures in the same create path.

The data plane names the lifecycle field `state`; this client read `status`,
so every response deserialized to nothing and the provider's fallback arm
called that `Running`. A sandbox still being created, stopping, or being
deleted all came back as ready to run commands. The mapping now covers the
seven states the data plane reports and refuses one it does not recognise —
every default here is a lie a caller acts on.

The create body never carried `environment`, and a sandbox inherits nothing
from its group, so a declared variable simply did not exist inside the session.
The data plane accepts the body either way, which is why nothing failed.

`create_sandbox` takes a struct now: the create body keeps gaining fields that
decide what the sandbox can do, and each one added positionally is one a caller
can pass in the wrong slot.
Azure's data plane takes an egress policy at create, and this backend sent
none — so `deny` and a hostname list were both refused at plan time while the
cloud underneath could express either. The declaration now travels in the
binding and becomes the policy the sandbox is created with.

`deny` is a `Deny` default under `Full` traffic inspection, plus a catch-all
deny rule. Each part is load-bearing. Only `Full` blocks non-HTTP traffic —
under any other mode a `Deny` default is a label on a live network. The rule is
there because Microsoft documents `Partial` as evaluating only traffic a rule
matches and never says `Full` differs, so a policy holding no rules at all is
the one shape where "deny" could mean nothing. `allowDomains` becomes host
rules over the same `Deny` default. `allow` sends no policy: the data plane is
already open, and `Full` there would block the traffic `allow` promises.

Then it is checked. The create response carries the policy the sandbox is
actually running under, so a sandbox that came up without the one that was
asked for is deleted rather than handed back — a restriction that did not take
effect is worse than one nobody asked for, because the caller believes it held.
The check compares the default action, the inspection mode, and every host the
declaration named, not the whole object, so a normalised response does not fail
every create.

`egressDeny` and `domainEgressRules` flip last. Azure is the first backend to
express a hostname allowlist at all: the others match CIDRs or carry a single
switch.

The binding's `egress` field is required, so a binding JSON without one no
longer parses. Nothing is deployed, so there is nothing to migrate.

What a live sandbox still has to settle: whether traffic is actually blocked.
The response proves the policy is configured, never that a packet was dropped —
the same evidence every other backend flips its flag on.
Three backends turned `allowDomains` into their nearest expressible thing, and
only the core capability check stood between that and a rendered artifact.
Render a chart or a GCP module directly and the declaration changed meaning
with nothing anywhere saying so.

Helm folded it into the `allow` arm and emitted `0.0.0.0/0` — every address the
list existed to exclude. Its test asserted the widened policy, pinning the
behaviour rather than preventing it. GCP collapsed it to `allowEgress: false`,
denying everything the declaration asked to permit.

Both now refuse, as AWS already did. The AWS message ends "or use a platform
that supports it", which was advice to nowhere while every backend reported
`domainEgressRules: false`; all four messages now name Azure, whose egress
proxy matches on host pattern.
The containment check compared the effective policy inward only — every host
the declaration named had to be present — which is the right test pointed the
wrong way. A sandbox that came up allowing a host nobody asked for passed it,
and so did one whose `rules` list allowed everything, because this client never
writes that list and so did not read it either. A group-scoped policy is a
documented way for an entry nobody sent to appear.

Both directions now: nothing may allow a host the declaration did not name, in
either list, and an advanced rule that is not a `Deny` fails the create
outright. Extra denials stay harmless, so a normalised response still cannot
fail a create that was honoured.
`suspendResume` was false because the two verbs were unimplemented, not
because Azure lacks them: `POST {sandbox}/stop` saves the state and
`POST {sandbox}/resume` brings it back, both returning on acceptance. That is
the contract the trait gets — a caller that needs the session stopped polls
`get`, the same rule AWS follows.

Flipping the flag drags a second thing with it. `idleSuspendSeconds` is gated
on `suspendResume`, so the declaration becomes legal on Azure the moment the
flag flips — and the create body never carried a lifecycle policy, so the
number would have been accepted and dropped. It now travels in the binding and
arrives as `lifecycle.autoSuspendPolicy`, suspending to memory, which is what
makes the resume fast enough to be worth having.

Three capabilities stay false, and each is now a recorded decision rather than
an unbuilt feature:

- `preview`: a sandbox port's auth is anonymous or Entra ID with an allowlist
  of human email addresses. Neither is a credential scoped to a port for a
  fixed time, and returning the anonymous URL would publish the port.
- `snapshot`: the blocker is ours. `snapshot()` returns an id and
  `CreateSessionRequest` has nothing to consume one, so no backend can complete
  the round trip — and nothing in the resource model owns the artifact, which
  Microsoft says is never garbage collected.
- `sessionLifetime`: Azure suspends on idle and deletes after a stop, but has
  no wall-clock ceiling. Accepting `maxLifetimeSeconds` would be the silent
  no-op the capability set exists to prevent.

The comment those three replace claimed a per-port URL closed to anonymous
traffic and a 0.54s resume. Neither has a source, and the first is the opposite
of what the port model says.
The state mapping refused `Idle`, and `Idle` is what the SDK's stop poller
waits for. The package contradicts itself — it declares `Idle` as a reason a
sandbox stopped and then waits for a *state* of `Idle` — and an unrecognised
state is an error here on purpose, so the one state the idle policy is most
likely to produce would have failed every `get` on the sessions that policy
governs. It reads as suspended, which is true under either reading.

Also: `autoSuspendPolicy` mode is the SDK's own default rather than a claim
about what `Disk` does, which is documented nowhere.
Six things this branch got wrong, and the review caught each one.

The create body carries the caller's environment variables and a write carries
file bytes, and a failure echoes the request into an error chain that is
serialized into durable state. Both now go through `redact_request_body`, which
every other create-with-a-secret already used.

`create` owes the caller a session that can take work — the trait says a
backend whose start API returns early waits here — and it was handing back one
that could not. It waits now. Every failure after the sandbox exists deletes it
through one path: a `?` on an unreadable state was abandoning a running sandbox
that nothing could find, since Azure mints the id, has no enumeration verb and
sets no auto-delete.

The egress check guarded `create` alone, so a reconnect returned whatever a
session was built with. Azure has no session ceiling and an idle sandbox only
suspends, so one created under an older declaration outlives the change and was
being handed back under the label the stack has now. `get` checks too.

`policy_holds` had three holes: it compared case-sensitively where the data
plane normalises, it passed any host action that was not exactly `Allow` —
`Transform` and `Rewrite` reach a host by rewriting the request — and it could
not see a policy field this client does not model. It is now a whitelist in
both directions, and an unreadable policy fails the create.

The refusal named an env var that does not exist and flattened the delete's own
error through an `internal = false` boundary, publishing raw service text. It
is a typed `SandboxNotAsDeclared` carrying the session id — the one thing an
operator needs when the delete also failed.

`catalog_disk_image` rejected a registry path but not a tag, so `ubuntu:24.04`
rendered into a customer's module, planned, applied, and failed at the first
session.

Smaller, same review: `Stopping` is not stopped, so it no longer answers the
poll `suspend` documents; `terminate` polls the client directly rather than
dying on a state it cannot parse; an absolute path means "under the session
root" here as everywhere else; `create` is not idempotent and no longer claims
to be; an `allowDomains` naming no domain is refused at plan time; Helm refuses
in the function that renders rather than one upstream; and the four egress
refusals stopped pointing customers at a platform whose sandbox group nothing
in this repository creates.

The captured launcher fixture carries the launcher's own output, and a recipe
for re-capturing it that needs nothing but the container it came from.

The generated schemas and the TypeScript doc still described Azure as having no
file transfer and no backend as matching hostnames. Regenerated with
`pnpm -C packages/core run generate`, and the hand-written paragraph corrected.
The comment pass found eleven comments narrating the change rather than the
code: a regression test explaining the bug it came from, three "used to" and
"before this" asides, and a rationale stated twice — once at the orchestrating
function and again at the call site.

The deadline guard's reason now lives at `run_command`, and `execute_within`
carries the local note plus a pointer.
`run` takes the sandbox id positionally and `--allow-egress` is one of its own
flags, so an application passing `--allow-egress` as its session id was asking
for the egress its binding had refused it — the one setting the binding decides
rather than the caller. The doc comment three lines above says exactly that,
and the argv defeated it.

Reachable because create now works: the id used to sit behind a flag the
launcher rejected outright.

The id is checked wherever a caller supplies one — create, exec, the three file
operations and terminate — rather than at the one verb that has `--allow-egress`,
because every verb takes it positionally and each has its own flags.
The first fix round left three holes and opened one.

`execute_shell_command` was the third body a caller controls in that file and
the one that was missed: a shell command is where an app puts a token it wants
the session to have, and a failure echoes the request into an error chain that
reaches durable state.

`settle` judged the egress policy on the create response. A create answered
while the sandbox is still coming up need not carry the policy yet, and an
absent policy reads as "the restriction did not take" — so every deny-declared
create would have deleted the sandbox it just made. It waits for the sandbox to
be running and judges what came up, which is also the read the containment
check should have been making all along.

`Stopping` mapped to `Running` so `suspend`'s completion poll would not answer
early, and that routed a sandbox on its way down through the reconnect path as
ready for work. The four states the trait publishes have no word for it, so it
reads as unusable — the honest answer for everything that consumes the enum —
and the wait and reconnect paths read the raw state, where the difference is
the whole question.

A session whose policy no longer matches the declaration was a permanent error
from `get_or_create`, which owes the caller a usable session: it is now
terminated and replaced, like a terminated one.

`unmodelled` caught an unreadable field on the policy but not on its rules, so
an exception list on a rule that otherwise reads as a plain deny still passed.
The rule structs now model every field the SDK does and refuse anything past
it.

`SandboxCommandFailed` was fixed `internal = "false"` while wrapping cloud
client errors that carry response text, and `into_external` reads only the
outermost flag. It inherits, for the reason `SandboxUnreachable` already gives.

An Azure session id is interpolated into the data-plane URL, where `..`
resolves — reaching a sandbox group a stack-scoped identity can address but
this binding was never scoped to. It is checked wherever a caller supplies one,
as GCP's now is.

And `run_command` re-reads the policy: the SDK hands it an arbitrary session
id, so an id kept across a declaration change was the way around the check.
`get` read the egress policy before the state, so a session being deleted —
which carries no policy — was reported as one running the wrong one. Under a
`deny` declaration that sent a disappearing sandbox down the replace path
instead of the terminated one. The test that should have caught it declared
`allow`, which skips the check entirely; it declares `deny` now.

`resume` is the other verb that puts code back on the network, and it did no
policy read, so an id kept across a declaration change could be resumed around
the check that `run_command` performs. The wait resumes through an ungated path
instead, because a sandbox mid-boot has no policy to judge yet — gating both
would re-break the case `settle` was restructured to fix.

Reconnecting now re-judges the policy on the sandbox that came up rather than
the one that was found asleep: a group-scoped policy is set somewhere this
binding never writes, so the read before the wait is not the read that decides.

And `discard` names the sandbox it left behind rather than attributing every
failure to the egress policy — a readiness timeout reached the caller as a
containment failure, pointing at a restriction that was never the finding.
…his code woke

Two blockers, both found and reproduced by the review rather than read out of
the diff.

`get` skips the policy check for a session being deleted, because a sandbox on
its way out carries no policy to judge. The two gates that stand between a kept
session id and its egress then asked only whether the session exists — so a
`Deleting` sandbox passed both. Azure accepts a delete rather than completing
it, which is why `terminate` polls to a 404 instead of trusting the accept: the
workload is still running through that window. A session built under a looser
declaration, tightened since, with a delete in flight, would have run new code
under the egress it was built with. Both gates now refuse a session that cannot
take work, which is also the right answer for `resume`.

The reconnect path judged the policy after `await_running` had already resumed
the sandbox — putting whatever it was running back on the network — and then
returned the error with no cleanup. Every retry repeated it, and the argument
that retries converge does not hold: a stopped sandbox reporting the stale
policy sends each attempt back down the same branch. It is discarded now, like
every other sandbox this code creates or wakes and then refuses.

And a minted id this client cannot address is no longer sent to the delete —
the id it refuses to send is the id that delete would travel on.
The gate asked whether a session existed and whether it was not being deleted.
Neither is the question. A sandbox still coming up carries no policy yet, so
judging one reported a booting session as uncontained — and `get_or_create`
acts on that by deleting it and creating another. A suspended one carries the
record it stopped with, which is not what the work would run under.

So the gate now brings a session up before it judges it, and every verb that
starts code, wakes it, or moves the caller's own content into it goes through
the same path: `run_command`, `resume`, `write_files` and `get_or_create`.
`readFile` and `mkdir` stay ungated — a read returns to the caller who already
holds the session, and a directory plants nothing.

That also settles a question the two reconnect arms answered oppositely. A
session that cannot serve is replaced, whether the reason is that it is gone,
being deleted, or running a policy the declaration no longer matches. The
narrow part is deliberate: a readiness timeout is not one of those reasons,
because answering a slow data plane with a second sandbox makes it slower.

Two smaller ones from the same review. A minted id this client will not send is
still reaped unless the id is itself why the delete would be unsafe — an
over-long id is one path segment and nothing else can find that sandbox, while
a traversing one would send the delete into another group. And `write_files`
validates its paths before it spends a round trip, not after.
A stopped sandbox carries the policy it stopped under, so it can be judged
where it lies — and the gate was exempting exactly that state. The cost was
concrete: a session created under `allow`, a declaration since tightened to
`deny`, and a reconnect would wake it, put its workload back on the network for
the length of a boot, and only then refuse it. It is judged asleep now, and
again once it is up, because a group-scoped policy can change while it sleeps.
Only a session still coming up is exempt, which is the one state with nothing
to judge.

The resume in the readiness wait was latched on the attempt rather than the
outcome, so a single refusal meant nothing ever woke the sandbox again: the
wait spent its whole budget watching, returned `sessionNotReady`, and that
does not heal — wedging the id permanently. It retries every poll now, and
never fires at all while the sandbox is still `Stopping`, which is the state
the data plane refuses a resume in and the state a suspend leaves behind. The
timeout carries the last refusal, because "still not running after 120s" sends
a reader looking for a slow data plane when every resume was rejected.

`Failed` is a state the data plane reports and this client did not know, so it
became an unreadable-response error that nothing heals. It is terminal, and a
session found terminal mid-wait is now replaced like one found terminal at the
start — the same condition answered the same way whichever read observes it.

Two verbs stopped destroying what they refuse. `run_command`, `write_files` and
`resume` did not create the session and were not asked to replace it, and two
revisions of a stack share a sandbox group — so reaping there turns one
revision's tightened declaration into the other's outage. They refuse; `resume`
puts back a session it woke and then rejected. Only `create`, which owns what
it made, and `get_or_create`, which was asked for a usable session, replace.

And `suspend_and_resume_reach_their_own_verbs` proves the verb is sent again:
its mock answered `Running` on the first read, so the wait returned before any
resume, and the assertion that named the test passed with the call never made.
A resume that found the session already running, or watched it come up on
its own, stopped it on a policy mismatch — ending a command another
revision of the same stack was running, since both share the sandbox
group. Only the wait knows whether it issued the resume, so it reports
that instead of a pre-read inferring it from the state.

A failed session found on reconnect is now reaped rather than left beside
its replacement, and a sleeping record with no policy at all is left for
the post-wake judgement: whether the data plane reports egressPolicy for
a stopped sandbox is unverified, and refusing would churn every
idle-suspended session if it does not.
A create is a PUT to a collection with a service-minted id and no
idempotency key, and the transport retried it three times: a response
lost after the sandbox was minted made a second one, returned success,
and left the first running with the caller's environment variables and
no id-holder able to reap it. An exec answering late was re-sent the
same way, so untrusted code could run four times. Both now take a
single-attempt path; every other verb keeps its retry, which is what
the new test asserts against.

The rest of this change closes what the same review found. `get` and
the reconnect path now share one predicate for "is there a policy here
to judge", so they cannot disagree about a suspended session; `resume`
judges the sleeping record before waking anything; and the fact that
this call issued the resume now survives every exit from the wait, so a
session woken by a wait that then failed is still put back or named.

A command's own environment variables reached nothing: the exec
endpoint takes no environment, so they travel as shell assignments in
front of the command, with names checked because a name sits where
quoting cannot reach it.

Azure's catalog image rule moves to plan time beside the AWS one, so a
sandbox no worker binds is still refused, and `code.image` says what
Azure takes rather than offering two examples it rejects.
A refusal is the one answer that proves a session stayed asleep. A 5xx,
a timeout or a dropped connection does not: the data plane can take the
resume and answer nothing, and the session wakes. Counting that as "did
not wake" left the put-back inert, so a session this call returned to
the network under a policy the declaration forbids was abandoned there
— the hole the previous commit closed, reached through the other door.

A stop that answers 404 now ends the put-back quietly. The session has
reached the state the stop was for, and naming it as left awake sends
an operator looking for a sandbox that does not exist.
The exec endpoint takes no environment, so a command's variables travel
in the shell string. In front of the wrapper they applied to it: a
declared PATH could hand `setsid`, `sleep` and `kill` no-ops, and the
deadline that bounds untrusted code would never fire. They go through
`env` now, so they reach the command and nothing else.

The wrapper also unsets the names it uses before assigning them. An
inherited exported variable keeps its export attribute across
re-assignment, so a session created with `nonce` set was handing the
command the deadline token it uses to tell a kill from an exit.

Alongside: a reconnect refuses a session it did not wake rather than
deleting it, matching what resume already did — two revisions of a stack
share a sandbox group, and the replacement `get_or_create` owes its
caller does not require ending the other one's work. Azure's catalog
image is read while planning, where a sandbox no worker binds is still
seen, and `code.image` says that AWS and Azure narrow it in opposite
directions. `Allow` no longer claims a private-range denial that a
host-pattern matcher and a boolean switch cannot express.
Fifteen comments and doc-strings cut to the bar: the ones that had grown
to five or six lines saying what three could, one that restated the name
of the function under it, and one field doc whose middle sentence had no
verb.

Two that were not about wording. `judgeable`'s catch-all arm covers the
transitional and terminal states and said nothing about why they carry
nothing to judge. And the test for a command's declared variables never
polled the stream it was handed, so it proved the call returned rather
than that the command ran; it drains the stream and reads the exit now.
`env` reads operands as assignments until one is not, then execs that
one. The separator sat after the assignments, so it became the program
name and every Azure command carrying variables died with 127 before it
started. Dropped, and a test now runs the generated string through a
real shell instead of asserting its shape — the shape was exactly what
was intended, and what was intended was wrong.

A program whose own name carries `=` is refused when the call also
declares variables, because `env` would take it for a variable and run
the next argument in its place.

A session can no longer set `PATH`, `IFS`, `LD_PRELOAD` or
`LD_LIBRARY_PATH`. The wrapper that holds a command to its deadline runs
inside the session and inherits them: a session `PATH` chooses which
`od` draws the nonce, which is the whole basis for telling a kill from
an exit. The same names per command are fine — those reach the command
and nothing else.

The wrapper also quotes the last two expansions it had left bare, so an
inherited `IFS` cannot split a pid into words that are not children.

Comments that still described assignments in front of the wrapper, and
three that said a refused session is deleted, now say what the code
does. Schemas regenerated for a doc reworded after the last run.
`LD_AUDIT` runs attacker code inside every process the wrapper starts —
including `od`, which draws the nonce the deadline report rests on. Code
in the session can then write a nonce it chose to stderr before the
wrapper announces one, exit 137 itself, and be reported as killed at a
deadline nothing enforced. Demonstrated end to end against a real glibc
loader: a command that ran for no time at all claimed a 30-second kill.

Refused as `LD_*` rather than by name. The list was two entries long
because two were suggested, and the loader reads more than anyone
maintaining that list would remember.

A command naming no program is refused too. `env` with assignments and
no operand prints the environment it was handed and exits 0, so an empty
command returned the session's own variables to the caller as a command
that succeeded.
Bash imports `SHELLOPTS` from the environment and applies what it lists,
including tracing, even when it is called `sh`. The wrapper's own trace
then occupies the first line of stderr, the announcement is not where
the reader looked for it, and every command in that session comes back
as one the session could not bound — the ones that succeeded included.

The reader now takes the first line that is a nonce and nothing else.
The trace cannot be mistaken for it: a traced line carries the shell's
prefix, and bash does not take that prefix from the environment. What
precedes the announcement is dropped rather than returned, because it
was written before the command started and one of those lines is the
trace of the announcement itself.

The width is checked exactly. A single hex character on a line of its
own was an announcement, which made most of the stream its own repeat.

`SHELLOPTS` and `BASHOPTS` join the names a session may not set. The two
answers are deliberate: one keeps a name nobody listed from breaking the
report, the other keeps the ones we know about out of the session.
A session runs its command as root on a writable filesystem, so code in
it can replace the `od` that draws the nonce and hand every later
command a value it chose, or kill the killer — a sibling with the same
uid — and outlast its deadline. Both measured against real shells. The
doc claimed the nonce was unreachable and left the rest implied.

So the wrapper bounds a command that is merely slow and reports honestly
on one that is. It is not a boundary against a command working to escape
one; the caller-side guard, which ends the session, is that. The
process-group note is narrowed to match what it delivers: a child that
starts a session of its own leaves the group and outlives the kill.

The reader also trims a trailing carriage return. None of the transports
here produce one, but a 33-byte announcement is invisible and the first
line the command chose gets adopted in its place — an inversion resting
on a property of today's transports rather than on anything checked.
The note in `get_or_create` said such a session is left running. It is
left asleep when this call is what woke it, because `put_back` suspends
what it woke — which is what `reconnect`'s own doc and the test beside
it already said. Three statements of one rule, and this was the one that
disagreed.
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Greptile Summary

Azure sandbox support is expanded with file transfer, declared egress enforcement, lifecycle operations, corrected data-plane contracts, and platform-specific validation.

  • Adds native Azure read, write, mkdir, suspend, resume, and idle-suspend behavior.
  • Propagates declared images and environment variables while validating effective egress policy.
  • Adds provider-specific planning and infrastructure-emitter constraints.
  • Uses single-attempt delivery for create, exec, and lifecycle requests where replay is unsafe.

Confidence Score: 4/5

The PR is not yet safe to merge because Azure lifecycle calls can still report successful state transitions as failures and can mis-handle ownership after a conflicted resume.

A lost stop response is returned directly without checking whether Azure completed the transition, while the resume loop may reissue the operation and classify a production conflict as locally owned, leaving the lifecycle consistency issue from the prior review unresolved.

Files Needing Attention: crates/alien-azure-clients/src/azure/sandbox_data_plane.rs, crates/alien-azure-clients/src/azure/common.rs, crates/alien-bindings/src/providers/sandbox/azure.rs

Important Files Changed

Filename Overview
crates/alien-azure-clients/src/azure/common.rs Adds byte-oriented request bodies, bounded request-body diagnostics, and a single-attempt HTTP executor.
crates/alien-azure-clients/src/azure/sandbox_data_plane.rs Expands the Azure data-plane contract with file, egress, environment, and lifecycle operations.
crates/alien-bindings/src/providers/sandbox/azure.rs Implements Azure sandbox policy judgment, file operations, command bounding, and suspend/resume orchestration.
crates/alien-core/src/resources/sandbox.rs Extends sandbox capabilities and applies provider-specific image and egress validation.
crates/alien-terraform/src/emitters/azure/sandbox.rs Emits the required Azure sandbox disk-image and egress configuration.

Reviews (2): Last reviewed commit: "fix(sandbox): send a state transition on..." | Re-trigger Greptile

Comment thread crates/alien-azure-clients/src/azure/sandbox_data_plane.rs
Stop and resume move a sandbox between states, so a repeat is refused
for the state the first attempt produced. A transition that took effect
and lost its response was re-sent, the re-send answered 409, and the
caller was told a session it had suspended was still awake — the false
`sandboxLeftAwake` report the put-back exists to make trustworthy.

They join create and exec on the single-attempt path. The wait above
them already re-issues a resume itself, with the state in front of it,
so the transport had no business guessing.

The test that pins create now pins these too; it did not before, which
is why they were classified as safe to repeat.
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