sandbox: flow across a run's checkpoints — an untrusted read closes the sinks that follow #395

Merged
dcharlot merged 1 commit from feat/sandbox-run-flow into main 2026-10-21 13:08:39 -04:00
Owner

Sweep 2026-10-19 item #7, second half, built on the owner's decision. Each sandbox checkpoint was decided alone. A unit could read data an attacker writes and later reach an endpoint it is granted; each call passed its own check, and nothing connected the read to the egress. The Enforcer is stateless by design (C2), so the connection had to be state the run carries explicitly.

The sweep placed this in sandbox-exec-os. Checked on main: exec-os has no per-call checkpoints, since it compiles grants into one static policy and refuses network and process grants. The live checkpoints are in sandbox-exec-wasm (S2), and that is where this lands.

Design (SPEC §5.8)

  • Grants.flow declares which reads are untrusted (Path globs, Kv prefixes, a Program's output), each with an origin, and the sinks a tainted run may still reach (Egress / Exec, accepts: [origin]).
    • Omitted when empty, so a grant set without it keeps its bytes.
    • Bound into grants_content_id when present, so it is part of the capability surface (§1.2).
  • RunTaint: the origins a run has read. It only grows, so a later clean read restores nothing.
  • The enforcer stays pure.
    • taints(source) says which origins a read adds.
    • may_reach(sink, &taint) decides a sink: a tainted run reaches it only if tainted_may opens it to every origin carried. Entries combine, and * matches any target or origin. An unopened sink is closed.
    • This is in addition to the sink's own capability, never instead of it.
    • A decision is still a function of its inputs, with the run's taint now among them, just as the I/O ceiling already takes the run's counters.
  • S2 host. HostState carries the taint. Storage reads, kv reads and program output taint the run. A closed egress or exec returns TAINTED (-6), which is distinct from DENIED.
  • Conformance class C13. A vector is a run's calls in order, because no single query can show order-dependence. The vectors live in code and in conformance/v1/flow.json, and the pack count is now 13.

What it does not cover (stated in §5.8):

  • An unmarked source is read as the principal's data.
  • A tainted run's writes are not marked, so data can lose its taint between runs.

Docs: SPEC §10's class list was missing C11 and C12; it now lists C11–C13.

Verification (build host)

  • sandbox-rs: 428 tests (--locked); every conformance class passes, C13 in code and from flow.json.
  • Clippy -D warnings is clean on the touched crates.
  • End to end, through real WASM guests:
    • an untrusted storage read, kv read or program output closes the egress that follows it;
    • a clean read leaves it open;
    • an untrusted read closes a program launch;
    • a sink opens only to the origins named;
    • a grant set with no flow section is untouched.
  • Mutation testing: 17 mutants; 16 killed. The 17th is equivalent: removing the clean-run early return changes nothing, because an empty taint already satisfies "every origin".
  • Repo checks pass.

Unrelated drift, left out of this PR: running gen_vectors also rewrites attestation.json and negative.json with "mechanism": null. That predates this change.

🤖 Generated with Claude Code

Sweep 2026-10-19 item #7, second half, built on the owner's decision. Each sandbox checkpoint was decided alone. A unit could read data an attacker writes and later reach an endpoint it is granted; each call passed its own check, and nothing connected the read to the egress. The `Enforcer` is stateless by design (C2), so the connection had to be state the run carries **explicitly**. The sweep placed this in `sandbox-exec-os`. Checked on main: exec-os has no per-call checkpoints, since it compiles grants into one static policy and refuses network and process grants. The live checkpoints are in **`sandbox-exec-wasm`** (S2), and that is where this lands. ## Design (SPEC §5.8) - **`Grants.flow`** declares which reads are untrusted (`Path` globs, `Kv` prefixes, a `Program`'s output), each with an `origin`, and the sinks a tainted run may still reach (`Egress` / `Exec`, `accepts: [origin]`). - Omitted when empty, so a grant set without it keeps its bytes. - Bound into `grants_content_id` when present, so it is part of the capability surface (§1.2). - **`RunTaint`**: the origins a run has read. It only grows, so a later clean read restores nothing. - **The enforcer stays pure.** - `taints(source)` says which origins a read adds. - `may_reach(sink, &taint)` decides a sink: a tainted run reaches it only if `tainted_may` opens it to **every** origin carried. Entries combine, and `*` matches any target or origin. An unopened sink is closed. - This is in addition to the sink's own capability, never instead of it. - A decision is still a function of its inputs, with the run's taint now among them, just as the I/O ceiling already takes the run's counters. - **S2 host.** `HostState` carries the taint. Storage reads, kv reads and program output taint the run. A closed `egress` or `exec` returns **`TAINTED` (-6)**, which is distinct from `DENIED`. - **Conformance class C13.** A vector is a run's calls *in order*, because no single query can show order-dependence. The vectors live in code and in `conformance/v1/flow.json`, and the pack count is now 13. **What it does not cover** (stated in §5.8): - An unmarked source is read as the principal's data. - A tainted run's writes are not marked, so data can lose its taint between runs. **Docs:** SPEC §10's class list was missing C11 and C12; it now lists C11–C13. ## Verification (build host) - **sandbox-rs:** 428 tests (`--locked`); every conformance class passes, C13 in code and from `flow.json`. - **Clippy** `-D warnings` is clean on the touched crates. - **End to end, through real WASM guests:** - an untrusted storage read, kv read or program output closes the egress that follows it; - a clean read leaves it open; - an untrusted read closes a program launch; - a sink opens only to the origins named; - a grant set with no flow section is untouched. - **Mutation testing:** 17 mutants; 16 killed. The 17th is equivalent: removing the clean-run early return changes nothing, because an empty taint already satisfies "every origin". - **Repo checks** pass. Unrelated drift, left out of this PR: running `gen_vectors` also rewrites `attestation.json` and `negative.json` with `"mechanism": null`. That predates this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sandbox: flow across a run's checkpoints — an untrusted read closes the sinks that follow
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 5s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 48s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m41s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m10s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 2m18s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m38s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 2m43s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 5m23s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 13m57s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 4m28s
96cd5e98a0
Sweep 2026-10-19 item #7, second half, by the owner's decision. Each
checkpoint was decided alone: a unit could read data an attacker writes and
later reach an endpoint it is granted, each call passing its own check, and
nothing connected the read to the egress. The Enforcer is stateless by
design (C2), so the connection had to be state the run carries explicitly.

sandbox-capability (SPEC §5.8):
- Grants.flow: FlowGrant { untrusted: [FlowSource { kind: Path|Kv|Program,
  pattern, origin }], tainted_may: [FlowSink { kind: Egress|Exec, target,
  accepts }] }. Omitted when empty, so a grant set without it keeps its bytes.
- RunTaint: the origins of untrusted data a run has read; only grows.
- Two pure Enforcer functions: taints(source), the origins a read adds; and
  may_reach(sink, &taint), where a tainted run reaches a sink only where
  tainted_may opens it to EVERY origin it carries (entries combine; `*`
  opens to any target or origin; an unopened sink is closed). Additional to
  the sink's capability, never instead. A decision stays a function of its
  inputs, the run's taint among them, as I/O counters already are.

sandbox-exec-wasm (S2, where the live checkpoints are): HostState carries
the taint. A storage or kv read that returns data, and a program's output,
taint the run from a source the grant marks; egress and exec are refused
with TAINTED (-6, distinct from DENIED) when the grant has not opened them
to the run's origins. sandbox-exec-os is untouched: it refuses network and
process grants outright, so no sink is reachable there.

sandbox-runtime: grants_content_id binds flow_untrusted and
flow_tainted_may when present, so the flow grant is part of the capability
surface (SPEC §1.2); a grant without one keeps its id.

Conformance class C13: sequence vectors (a run's calls in order; reads
check the origins after, sinks whether reachable), in code and in the new
conformance/v1/flow.json. The pack count goes to 13.

SPEC §5.8, spec/capabilities.md (the enforcer contract: pure, with the
run's history as an input), spec/conformance.md, conformance/v1/README.md;
SPEC §10's class list was missing C11 and C12 and now lists C11–C13.

Residuals, stated in §5.8: an unmarked source is read as the principal's
data, and a tainted run's writes are not marked, so data can lose its taint
between runs.

Note: running gen_vectors also rewrites attestation.json and negative.json
with "mechanism": null, a drift that predates this change; left out of it.

Tests:
- capability: three unit tests (a read carries every matching source's
  origin, kinds do not cross, `*` is any program; a tainted run reaches
  only what is opened to all it carries, entries combine; the taint only
  grows and an empty flow grant is not encoded).
- exec-wasm: three end-to-end through real guests (an untrusted storage,
  kv or program read closes the egress that follows, a clean read leaves it
  open; an untrusted read closes the launch that follows; a sink opens only
  to the origins named, and no flow grant is untouched).
- runtime: the flow grant is bound into the grant id, every part of it.
- C13.
Mutation testing: 17 mutants; 16 killed, 1 equivalent (the clean-run early
return in may_reach: an empty taint already satisfies "every origin").

Verified on the build host: sandbox-rs 428 tests (--locked); every
conformance class passes, C13 in code and from flow.json; clippy -D
warnings clean on sandbox-capability, -exec-wasm, -runtime, -conformance.
Repo checks pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!395
No description provided.