From 96cd5e98a04d2e1553cfdbf4d3514299aed992bf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 10:49:51 -0400 Subject: [PATCH] =?UTF-8?q?sandbox:=20flow=20across=20a=20run's=20checkpoi?= =?UTF-8?q?nts=20=E2=80=94=20an=20untrusted=20read=20closes=20the=20sinks?= =?UTF-8?q?=20that=20follow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sandbox/SPEC.md | 19 +- sandbox/conformance/v1/README.md | 1 + sandbox/conformance/v1/flow.json | 446 ++++++++++++++++++ .../crates/sandbox-capability/src/lib.rs | 278 +++++++++++ .../src/bin/gen_vectors.rs | 4 + .../crates/sandbox-conformance/src/lib.rs | 10 +- .../crates/sandbox-conformance/src/vectors.rs | 148 +++++- .../crates/sandbox-exec-wasm/src/host.rs | 43 +- .../crates/sandbox-exec-wasm/src/lib.rs | 156 ++++++ .../crates/sandbox-runtime/src/lib.rs | 63 +++ sandbox/spec/capabilities.md | 4 +- sandbox/spec/conformance.md | 3 + 12 files changed, 1168 insertions(+), 7 deletions(-) create mode 100644 sandbox/conformance/v1/flow.json diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index 9c1f06343..9d6ed61cd 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -189,6 +189,23 @@ Governs access to a **framebuffer / desktop** and an optional remote (VNC) expor > The filesystem capability is `storage` (§5.2) and a code-interpreter is a composition of `process` (§5.5) and `storage`; this standard models the underlying resources, not the composed product. +### 5.8 Flow across a run's checkpoints (OPTIONAL) + +``` +FlowGrant { untrusted : [ FlowSource { kind : Path | Kv | Program, pattern, origin } ], + tainted_may : [ FlowSink { kind : Egress | Exec, target, accepts : [origin] } ] } +``` + +The grants of §5.1–§5.7 are decided call by call. A unit can read data an attacker can write, and later reach an endpoint it is granted; each call passes its own check, and nothing connects the read to the egress. The flow grant connects them. + +- **Sources.** A read from an `untrusted` source **taints** the run with that source's `origin`. A source is a storage path glob (§5.2), a kv key prefix, or a program whose output the run reads (`*` for any). +- **Sinks.** Once a run is tainted, it **MUST NOT** reach a sink (an egress endpoint, or a program it launches) unless `tainted_may` opens that sink to **every** origin the run carries. Entries combine, and `*` opens a sink to any target or any origin. A sink no entry opens is closed. This is in addition to the sink's own capability, never instead of it. +- **The taint only grows.** It lasts until the run ends. A value read into the unit can be copied, transformed or summarised where no checkpoint sees it, so once the run has read it, anything the run does after may carry it. A later clean read restores nothing. +- **What decides.** The decision stays a pure function of its inputs, as the rest of §5 requires; the run's taint is one of them. A host carries it across the run's checkpoints and passes it into each sink's decision, as it carries I/O counters into the ceiling of §5.2. The reference S2 host refuses a closed sink with `TAINTED`, a code distinct from a missing capability. +- **Without one.** A grant set without a flow grant is never tainted and is decided as before. The flow grant is part of the capability surface, so it is bound into the grant's content id; it is omitted when empty, so a grant set without one keeps its bytes and its id. + +What it does not cover: a source a grant does not mark is read as the principal's data, and a tainted run's writes are not marked, so data written by one run and read by a later run that does not mark the path loses its taint between them. Conformance class C13 fixes the decisions for runs, step by step. + --- ## 6. The energy receipt (JCR-1) @@ -386,7 +403,7 @@ A host **MUST NOT** advertise a part at a readiness it does not deliver. ## 10. Conformance -A host is **conformant** if it satisfies every **REQUIRED** clause of §1–§7 for the tiers it advertises and round-trips the conformance vectors in [`conformance/`](conformance/). The test obligations are enumerated in [`spec/conformance.md`](spec/conformance.md): canonicality (C1), capability enforcement / deny-by-default (C2), per-device receipt integrity and provenance coupling (C3), attestation signing and verification (C4), tier-minimization (C5), durable run records (C6, §13), environment-manifest ingestion and addressing (C7, §14), content-addressed process control (C8, §5.5), warm-pool idle-energy accounting (C9, §15), and fleet merkle aggregation (C10, §16). A conforming host **MUST** pass the full pack with no skips for its advertised tiers. +A host is **conformant** if it satisfies every **REQUIRED** clause of §1–§7 for the tiers it advertises and round-trips the conformance vectors in [`conformance/`](conformance/). The test obligations are enumerated in [`spec/conformance.md`](spec/conformance.md): canonicality (C1), capability enforcement / deny-by-default (C2), per-device receipt integrity and provenance coupling (C3), attestation signing and verification (C4), tier-minimization (C5), durable run records (C6, §13), environment-manifest ingestion and addressing (C7, §14), content-addressed process control (C8, §5.5), warm-pool idle-energy accounting (C9, §15), fleet merkle aggregation (C10, §16), the enforcement mechanism (C11, §7.9), locality evidence (C12, §7.7), and flow across a run's checkpoints (C13, §5.8). A conforming host **MUST** pass the full pack with no skips for its advertised tiers. --- diff --git a/sandbox/conformance/v1/README.md b/sandbox/conformance/v1/README.md index 795809ee6..b48f0c4c7 100644 --- a/sandbox/conformance/v1/README.md +++ b/sandbox/conformance/v1/README.md @@ -12,5 +12,6 @@ The v1 wire vectors. Each file is regenerated by `gen_vectors` and verified by t | `durable.json` | C6/C7/C10 | a run-record trace → head id; a devcontainer → manifest id; a fleet → merkle root (all `b3:`) | | `negative.json` | C-neg | malformed receipts + attestations a conformant host MUST reject | | `confidential.json` | C-tee | SEV-SNP report verify (valid + negatives) + audit-chain check | +| `flow.json` | C13 | a run's calls in order → the origins each read adds, and whether each sink may be reached | The signing keys are fixed test keys embedded in the vector builders (`receipt` key `[3;32]`, supervisor key `[5;32]`); the public keys travel in the vector files. These are conformance fixtures, not operational keys. diff --git a/sandbox/conformance/v1/flow.json b/sandbox/conformance/v1/flow.json new file mode 100644 index 000000000..15bad9d13 --- /dev/null +++ b/sandbox/conformance/v1/flow.json @@ -0,0 +1,446 @@ +[ + { + "note": "a run that reads only its principal's data reaches every sink its capabilities allow", + "grants": { + "compute": { + "devices": [ + "Cpu" + ], + "energy_budget_uj": 0, + "cpu_time_ms": 0, + "memory_bytes": 0 + }, + "storage": { + "read": [ + "/work/**" + ], + "write": [], + "delete": [] + }, + "network": { + "outbound": [ + "api.example.com:443", + "mail.example.com:587" + ], + "inbound": false + }, + "kv": { + "read": false, + "write": false, + "prefixes": [] + }, + "host": { + "logging": false, + "clock": false, + "random": false, + "inference": false + }, + "process": { + "exec": true, + "programs": [ + "summarise", + "curl" + ], + "max_processes": 0 + }, + "browser": { + "enabled": false, + "origins": [], + "headless": false + }, + "display": { + "enabled": false, + "vnc": false, + "max_width": 0, + "max_height": 0 + }, + "flow": { + "untrusted": [ + { + "kind": "Path", + "pattern": "/work/inbox/*", + "origin": "inbox" + }, + { + "kind": "Kv", + "pattern": "web/", + "origin": "web" + }, + { + "kind": "Program", + "pattern": "curl", + "origin": "fetched" + } + ], + "tainted_may": [ + { + "kind": "Egress", + "target": "mail.example.com:587", + "accepts": [ + "inbox" + ] + }, + { + "kind": "Exec", + "target": "summarise", + "accepts": [ + "*" + ] + } + ] + } + }, + "steps": [ + { + "call": "read_path", + "arg": "/work/notes", + "origins_after": [] + }, + { + "call": "egress", + "arg": "api.example.com:443", + "reach": true + }, + { + "call": "exec", + "arg": "curl", + "reach": true + } + ] + }, + { + "note": "an untrusted read closes the sinks that follow it, and the order is what decides", + "grants": { + "compute": { + "devices": [ + "Cpu" + ], + "energy_budget_uj": 0, + "cpu_time_ms": 0, + "memory_bytes": 0 + }, + "storage": { + "read": [ + "/work/**" + ], + "write": [], + "delete": [] + }, + "network": { + "outbound": [ + "api.example.com:443", + "mail.example.com:587" + ], + "inbound": false + }, + "kv": { + "read": false, + "write": false, + "prefixes": [] + }, + "host": { + "logging": false, + "clock": false, + "random": false, + "inference": false + }, + "process": { + "exec": true, + "programs": [ + "summarise", + "curl" + ], + "max_processes": 0 + }, + "browser": { + "enabled": false, + "origins": [], + "headless": false + }, + "display": { + "enabled": false, + "vnc": false, + "max_width": 0, + "max_height": 0 + }, + "flow": { + "untrusted": [ + { + "kind": "Path", + "pattern": "/work/inbox/*", + "origin": "inbox" + }, + { + "kind": "Kv", + "pattern": "web/", + "origin": "web" + }, + { + "kind": "Program", + "pattern": "curl", + "origin": "fetched" + } + ], + "tainted_may": [ + { + "kind": "Egress", + "target": "mail.example.com:587", + "accepts": [ + "inbox" + ] + }, + { + "kind": "Exec", + "target": "summarise", + "accepts": [ + "*" + ] + } + ] + } + }, + "steps": [ + { + "call": "egress", + "arg": "api.example.com:443", + "reach": true + }, + { + "call": "read_path", + "arg": "/work/inbox/mail-1", + "origins_after": [ + "inbox" + ] + }, + { + "call": "egress", + "arg": "api.example.com:443", + "reach": false + }, + { + "call": "exec", + "arg": "curl", + "reach": false + }, + { + "call": "egress", + "arg": "mail.example.com:587", + "reach": true + }, + { + "call": "exec", + "arg": "summarise", + "reach": true + } + ] + }, + { + "note": "the taint only grows: a later clean read restores nothing, and a second origin closes a sink open to the first", + "grants": { + "compute": { + "devices": [ + "Cpu" + ], + "energy_budget_uj": 0, + "cpu_time_ms": 0, + "memory_bytes": 0 + }, + "storage": { + "read": [ + "/work/**" + ], + "write": [], + "delete": [] + }, + "network": { + "outbound": [ + "api.example.com:443", + "mail.example.com:587" + ], + "inbound": false + }, + "kv": { + "read": false, + "write": false, + "prefixes": [] + }, + "host": { + "logging": false, + "clock": false, + "random": false, + "inference": false + }, + "process": { + "exec": true, + "programs": [ + "summarise", + "curl" + ], + "max_processes": 0 + }, + "browser": { + "enabled": false, + "origins": [], + "headless": false + }, + "display": { + "enabled": false, + "vnc": false, + "max_width": 0, + "max_height": 0 + }, + "flow": { + "untrusted": [ + { + "kind": "Path", + "pattern": "/work/inbox/*", + "origin": "inbox" + }, + { + "kind": "Kv", + "pattern": "web/", + "origin": "web" + }, + { + "kind": "Program", + "pattern": "curl", + "origin": "fetched" + } + ], + "tainted_may": [ + { + "kind": "Egress", + "target": "mail.example.com:587", + "accepts": [ + "inbox" + ] + }, + { + "kind": "Exec", + "target": "summarise", + "accepts": [ + "*" + ] + } + ] + } + }, + "steps": [ + { + "call": "read_path", + "arg": "/work/inbox/mail-1", + "origins_after": [ + "inbox" + ] + }, + { + "call": "read_path", + "arg": "/work/notes", + "origins_after": [ + "inbox" + ] + }, + { + "call": "egress", + "arg": "mail.example.com:587", + "reach": true + }, + { + "call": "read_kv", + "arg": "web/page", + "origins_after": [ + "inbox", + "web" + ] + }, + { + "call": "egress", + "arg": "mail.example.com:587", + "reach": false + }, + { + "call": "program_output", + "arg": "curl", + "origins_after": [ + "fetched", + "inbox", + "web" + ] + }, + { + "call": "exec", + "arg": "summarise", + "reach": true + } + ] + }, + { + "note": "a grant set with no flow section is never tainted", + "grants": { + "compute": { + "devices": [ + "Cpu" + ], + "energy_budget_uj": 0, + "cpu_time_ms": 0, + "memory_bytes": 0 + }, + "storage": { + "read": [ + "/work/**" + ], + "write": [], + "delete": [] + }, + "network": { + "outbound": [ + "api.example.com:443", + "mail.example.com:587" + ], + "inbound": false + }, + "kv": { + "read": false, + "write": false, + "prefixes": [] + }, + "host": { + "logging": false, + "clock": false, + "random": false, + "inference": false + }, + "process": { + "exec": true, + "programs": [ + "summarise", + "curl" + ], + "max_processes": 0 + }, + "browser": { + "enabled": false, + "origins": [], + "headless": false + }, + "display": { + "enabled": false, + "vnc": false, + "max_width": 0, + "max_height": 0 + } + }, + "steps": [ + { + "call": "read_path", + "arg": "/work/inbox/mail-1", + "origins_after": [] + }, + { + "call": "egress", + "arg": "api.example.com:443", + "reach": true + } + ] + } +] diff --git a/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs index 11dc7a725..aa571eaff 100644 --- a/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs @@ -5,6 +5,14 @@ //! by a grant is denied. The [`Enforcer`] is a pure, deterministic decision //! function — same grants + same query ⇒ same decision, no float, no ambient //! state (vector class C2). +//! +//! What a run has already read is state, and it is not ambient: a run carries +//! it as a [`RunTaint`], explicitly, and passes it into each decision that +//! depends on it, as it passes its I/O counters into +//! [`Enforcer::may_sustain_io`]. The decision stays a function of its inputs; +//! the run's history is one of them (SPEC §5.8, vector class C13). + +use std::collections::BTreeSet; use sandbox_schema::{ContentId, DeviceClass}; use serde::{Deserialize, Serialize}; @@ -248,6 +256,145 @@ pub struct Grants { pub process: ProcessGrant, pub browser: BrowserGrant, pub display: DisplayGrant, + /// Which reads carry data that is not the principal's, and which sinks a + /// run that has read such data may still reach (SPEC §5.8). Omitted when + /// empty, so a grant set without it keeps its bytes and its content id. + #[serde(default, skip_serializing_if = "FlowGrant::is_empty")] + pub flow: FlowGrant, +} + +/// Flow across a run's checkpoints (SPEC §5.8). +/// +/// The capability checks decide each call on its own. A unit that reads data +/// an attacker can write, and later reaches the network, passes every one of +/// them, and nothing connected the read to the egress. This grant connects +/// them: a read from an `untrusted` source taints the run with that source's +/// origin, for the rest of the run, and a tainted run reaches a sink only +/// where `tainted_may` opens that sink to every origin the run carries. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowGrant { + /// Sources whose data is not the principal's. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub untrusted: Vec, + /// Sinks a tainted run may still reach, each for the origins it accepts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tainted_may: Vec, +} + +impl FlowGrant { + /// Whether the grant says nothing about flow. + pub fn is_empty(&self) -> bool { + self.untrusted.is_empty() && self.tainted_may.is_empty() + } +} + +/// A source of data that is not the principal's (SPEC §5.8). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowSource { + pub kind: SourceKind, + /// A path glob (as [`glob_match`]), a kv key prefix, or a program name + /// (`*` for any program). + pub pattern: String, + /// The origin a read from it taints the run with. + pub origin: String, +} + +/// What a [`FlowSource`] pattern is matched against. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceKind { + /// A storage read of a path. + Path, + /// A kv read of a key. + Kv, + /// The output of a program the run executed. + Program, +} + +impl SourceKind { + /// The tag the grant's content id carries (SPEC §5.8). + pub fn wire_tag(self) -> &'static str { + match self { + SourceKind::Path => "path", + SourceKind::Kv => "kv", + SourceKind::Program => "program", + } + } +} + +/// A sink a tainted run may still reach (SPEC §5.8). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowSink { + pub kind: SinkKind, + /// An egress endpoint (`host:port`) or a program name, exactly, or `*`. + pub target: String, + /// The origins it accepts, or `*` for any. + pub accepts: Vec, +} + +/// What a [`FlowSink`] target is matched against. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SinkKind { + /// An outbound connection to an endpoint. + Egress, + /// A program the run executes. + Exec, +} + +impl SinkKind { + /// The tag the grant's content id carries (SPEC §5.8). + pub fn wire_tag(self) -> &'static str { + match self { + SinkKind::Egress => "egress", + SinkKind::Exec => "exec", + } + } +} + +/// A read, for [`Enforcer::taints`]. +#[derive(Clone, Copy, Debug)] +pub enum Source<'q> { + Path(&'q str), + Kv(&'q [u8]), + Program(&'q str), +} + +/// A sink, for [`Enforcer::may_reach`]. +#[derive(Clone, Copy, Debug)] +pub enum Sink<'q> { + Egress(&'q str), + Exec(&'q str), +} + +/// The flow state a run carries across its checkpoints (SPEC §5.8): the +/// origins of the untrusted data it has read. It only grows. A value read +/// into the unit can be copied, transformed or summarised anywhere the +/// checkpoints cannot see, so once the run has read it, everything the run +/// does after may carry it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RunTaint { + origins: BTreeSet, +} + +impl RunTaint { + /// A run that has read nothing untrusted. + pub fn clean() -> Self { + RunTaint::default() + } + + /// Whether the run has read nothing untrusted. + pub fn is_clean(&self) -> bool { + self.origins.is_empty() + } + + /// The origins of the untrusted data the run has read. + pub fn origins(&self) -> &BTreeSet { + &self.origins + } + + /// The run has read data from `origin`. + pub fn join(&mut self, origin: &str) { + self.origins.insert(origin.to_string()); + } } impl Grants { @@ -449,6 +596,50 @@ impl<'a> Enforcer<'a> { pub fn may_vnc(&self) -> bool { self.grants.display.enabled && self.grants.display.vnc } + + /// The origins a read taints the run with (SPEC §5.8): those of every + /// untrusted source the read matches. Empty for a read of the principal's + /// own data. + pub fn taints(&self, source: Source<'_>) -> Vec<&'a str> { + self.grants + .flow + .untrusted + .iter() + .filter(|s| match (s.kind, source) { + (SourceKind::Path, Source::Path(p)) => glob_match(&s.pattern, p), + (SourceKind::Kv, Source::Kv(k)) => k.starts_with(s.pattern.as_bytes()), + (SourceKind::Program, Source::Program(p)) => s.pattern == "*" || s.pattern == p, + _ => false, + }) + .map(|s| s.origin.as_str()) + .collect() + } + + /// Whether a run carrying `taint` may reach `sink` (SPEC §5.8). A clean + /// run may reach any sink its capabilities allow. A tainted run reaches a + /// sink only where `tainted_may` opens it to every origin the run carries; + /// a sink no entry opens is closed. This is in addition to the capability + /// check for the sink, never instead of it. + pub fn may_reach(&self, sink: Sink<'_>, taint: &RunTaint) -> bool { + if taint.is_clean() { + return true; + } + let (kind, target) = match sink { + Sink::Egress(t) => (SinkKind::Egress, t), + Sink::Exec(t) => (SinkKind::Exec, t), + }; + let open: Vec<&FlowSink> = self + .grants + .flow + .tainted_may + .iter() + .filter(|s| s.kind == kind && (s.target == "*" || s.target == target)) + .collect(); + taint + .origins() + .iter() + .all(|o| open.iter().any(|s| s.accepts.iter().any(|a| a == "*" || a == o))) + } } #[cfg(test)] @@ -733,4 +924,91 @@ mod content_exec_tests { g.process.allow_hashes = vec![Enforcer::program_hash(bytes)]; assert!(!Enforcer::new(&g).may_exec_content("x", bytes)); } + + // ── flow across checkpoints (SPEC §5.8) ───────────────────────────────── + + fn flowing() -> Grants { + let mut g = Grants::default(); + let src = |kind, pattern: &str, origin: &str| FlowSource { kind, pattern: pattern.into(), origin: origin.into() }; + g.flow.untrusted = vec![ + src(SourceKind::Path, "/in/mail/*", "mail"), + src(SourceKind::Path, "/in/**", "inbound"), + src(SourceKind::Kv, "web/", "web"), + src(SourceKind::Program, "curl", "tool"), + ]; + g.flow.tainted_may = vec![ + FlowSink { kind: SinkKind::Egress, target: "mail.example:25".into(), accepts: vec!["mail".into(), "inbound".into()] }, + FlowSink { kind: SinkKind::Egress, target: "*".into(), accepts: vec!["web".into()] }, + FlowSink { kind: SinkKind::Exec, target: "summarise".into(), accepts: vec!["*".into()] }, + ]; + g + } + + /// A read taints the run with every untrusted source it matches, and with + /// nothing when it is the principal's own data. Kinds do not cross: a path + /// pattern never matches a kv key or a program. + #[test] + fn a_read_carries_the_origins_of_every_source_it_matches() { + let g = flowing(); + let e = Enforcer::new(&g); + assert_eq!(e.taints(Source::Path("/in/mail/1")), vec!["mail", "inbound"]); + assert_eq!(e.taints(Source::Path("/in/other")), vec!["inbound"]); + assert!(e.taints(Source::Path("/home/notes")).is_empty()); + assert_eq!(e.taints(Source::Kv(b"web/page")), vec!["web"]); + assert!(e.taints(Source::Kv(b"cache/x")).is_empty()); + assert_eq!(e.taints(Source::Program("curl")), vec!["tool"]); + assert!(e.taints(Source::Program("ls")).is_empty()); + assert!(e.taints(Source::Kv(b"/in/mail/1")).is_empty(), "a path pattern is not a kv prefix"); + + let mut any_tool = Grants::default(); + any_tool.flow.untrusted = vec![FlowSource { kind: SourceKind::Program, pattern: "*".into(), origin: "tools".into() }]; + assert_eq!(Enforcer::new(&any_tool).taints(Source::Program("ls")), vec!["tools"], "`*` is any program"); + } + + /// A clean run reaches every sink; a tainted one only a sink opened to + /// every origin it carries, in one entry or across several; an unopened + /// sink is closed; `*` opens to any target or any origin. + #[test] + fn a_tainted_run_reaches_only_what_is_opened_to_all_it_carries() { + let g = flowing(); + let e = Enforcer::new(&g); + let mut t = RunTaint::clean(); + assert!(e.may_reach(Sink::Egress("evil.example:443"), &t), "a clean run"); + + t.join("mail"); + assert!(e.may_reach(Sink::Egress("mail.example:25"), &t)); + assert!(!e.may_reach(Sink::Egress("evil.example:443"), &t), "an unopened sink is closed"); + t.join("inbound"); + assert!(e.may_reach(Sink::Egress("mail.example:25"), &t), "open to both origins it carries"); + t.join("web"); + assert!( + e.may_reach(Sink::Egress("mail.example:25"), &t), + "entries combine: mail and inbound at mail.example, web at any target" + ); + t.join("tool"); + assert!(!e.may_reach(Sink::Egress("mail.example:25"), &t), "no egress entry accepts tool"); + assert!(e.may_reach(Sink::Exec("summarise"), &t), "a sink open to any origin"); + assert!(!e.may_reach(Sink::Exec("sh"), &t)); + + let mut web = RunTaint::clean(); + web.join("web"); + assert!(e.may_reach(Sink::Egress("anywhere:443"), &web), "a sink entry for any target"); + assert!(!e.may_reach(Sink::Exec("summarise2"), &web), "egress entries do not open exec"); + assert!(!Enforcer::new(&Grants::default()).may_reach(Sink::Egress("x:1"), &web), "no flow grant: closed once tainted"); + } + + /// The taint only grows, and an empty flow grant is absent from the grant's + /// encoding. + #[test] + fn the_taint_only_grows_and_an_empty_flow_grant_is_not_encoded() { + let mut t = RunTaint::clean(); + t.join("a"); + t.join("b"); + t.join("a"); + assert_eq!(t.origins().iter().cloned().collect::>(), vec!["a", "b"]); + assert!(!t.is_clean()); + let json = serde_json::to_string(&Grants::default()).unwrap(); + assert!(!json.contains("flow"), "absent, so a grant set keeps its bytes"); + assert!(serde_json::to_string(&flowing()).unwrap().contains("tainted_may")); + } } diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/bin/gen_vectors.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/bin/gen_vectors.rs index 3f3c99cdf..c5f94db58 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/bin/gen_vectors.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/bin/gen_vectors.rs @@ -52,6 +52,10 @@ fn main() -> std::io::Result<()> { "confidential.json", serde_json::to_string_pretty(&vectors::confidential_pack()).unwrap(), )?; + write( + "flow.json", + serde_json::to_string_pretty(&vectors::flow_vectors()).unwrap(), + )?; println!("\nRegenerated conformance vectors. Verify with `cargo test -p sandbox-conformance`."); Ok(()) diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs index 531b6b5a7..a56ebb02f 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs @@ -573,6 +573,7 @@ pub fn run() -> Report { check("C10 fleet", c10_fleet), check("C11 enforcement-mechanism", c11_mechanism), check("C12 locality-evidence", c12_locality), + check("C13 run-flow", || vectors::check_flow(&vectors::flow_vectors())), ], } } @@ -600,6 +601,10 @@ pub fn run_file_backed() -> Report { serde_json::from_str(&load("capability.json")?).map_err(|e| e.to_string())?; check_capability(&v) }), + check("C13 flow.json", || { + let v: Vec = serde_json::from_str(&load("flow.json")?).map_err(|e| e.to_string())?; + check_flow(&v) + }), check("C3 receipt.json", || { let v: ReceiptVector = serde_json::from_str(&load("receipt.json")?).map_err(|e| e.to_string())?; @@ -703,10 +708,10 @@ mod tests { assert!(r.passed, "class {} failed: {}", r.class, r.detail); } assert!(report.ok()); - // C1–C12. Bump deliberately when a class is added, never to make a + // C1–C13. Bump deliberately when a class is added, never to make a // red build green: a silently shrinking pack is a standard quietly // certifying less than it claims. - assert_eq!(report.results.len(), 12); + assert_eq!(report.results.len(), 13); } #[test] @@ -716,6 +721,7 @@ mod tests { use vectors::*; check_content(&content_vectors()).unwrap(); check_capability(&capability_vectors()).unwrap(); + check_flow(&flow_vectors()).unwrap(); check_receipt(&receipt_vector()).unwrap(); check_attestation(&attestation_vector()).unwrap(); check_tier(&tier_vectors()).unwrap(); diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs index c4e148b84..ac0e383b9 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs @@ -14,7 +14,10 @@ use sandbox_attest::runrecord::{RunRecord, SnapshotRef}; use sandbox_attest::{ replay_compare, Attestation, DeviceIdentity, Journal, JournalEntry, Outcome, Replay, }; -use sandbox_capability::{ComputeGrant, Enforcer, Grants, HostService, KvDir, PathMode}; +use sandbox_capability::{ + ComputeGrant, Enforcer, FlowSink, FlowSource, Grants, HostService, KvDir, PathMode, RunTaint, Sink, SinkKind, Source, + SourceKind, +}; use sandbox_meter::{DeviceEnergy, Provenance, Receipt}; use sandbox_runtime::{grants_content_id, required_tier, TierRequest}; use sandbox_schema::{ContentId, DeviceClass, Tier}; @@ -182,6 +185,146 @@ pub fn check_capability(v: &[CapabilityVector]) -> Result<(), String> { Ok(()) } +// ---- C13: flow across a run's checkpoints ----------------------------------- + +/// One run's calls, in order (SPEC §5.8). A read taints the run with the +/// origins of every untrusted source it matches; a sink is decided against +/// everything the run has read before it. A sequence, because what a run may +/// do depends on what it did, which no single query can show. +#[derive(Serialize, Deserialize)] +pub struct FlowVector { + pub note: String, + pub grants: Grants, + pub steps: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct FlowStep { + /// `read_path`, `read_kv` or `program_output` (reads), or `egress` or + /// `exec` (sinks). + pub call: String, + pub arg: String, + /// For a read: the run's origins after it, in order. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origins_after: Option>, + /// For a sink: whether the run may reach it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reach: Option, +} + +fn flow_grants() -> Grants { + let mut g = Grants::default(); + g.storage.read = vec!["/work/**".into()]; + g.network.outbound = vec!["api.example.com:443".into(), "mail.example.com:587".into()]; + g.process.exec = true; + g.process.programs = vec!["summarise".into(), "curl".into()]; + let src = |kind, pattern: &str, origin: &str| FlowSource { kind, pattern: pattern.into(), origin: origin.into() }; + g.flow.untrusted = vec![ + src(SourceKind::Path, "/work/inbox/*", "inbox"), + src(SourceKind::Kv, "web/", "web"), + src(SourceKind::Program, "curl", "fetched"), + ]; + g.flow.tainted_may = vec![ + FlowSink { kind: SinkKind::Egress, target: "mail.example.com:587".into(), accepts: vec!["inbox".into()] }, + FlowSink { kind: SinkKind::Exec, target: "summarise".into(), accepts: vec!["*".into()] }, + ]; + g +} + +fn read(call: &str, arg: &str, after: &[&str]) -> FlowStep { + FlowStep { call: call.into(), arg: arg.into(), origins_after: Some(after.iter().map(|o| o.to_string()).collect()), reach: None } +} + +fn sink(call: &str, arg: &str, reach: bool) -> FlowStep { + FlowStep { call: call.into(), arg: arg.into(), origins_after: None, reach: Some(reach) } +} + +pub fn flow_vectors() -> Vec { + vec![ + FlowVector { + note: "a run that reads only its principal's data reaches every sink its capabilities allow".into(), + grants: flow_grants(), + steps: vec![ + read("read_path", "/work/notes", &[]), + sink("egress", "api.example.com:443", true), + sink("exec", "curl", true), + ], + }, + FlowVector { + note: "an untrusted read closes the sinks that follow it, and the order is what decides".into(), + grants: flow_grants(), + steps: vec![ + sink("egress", "api.example.com:443", true), + read("read_path", "/work/inbox/mail-1", &["inbox"]), + sink("egress", "api.example.com:443", false), + sink("exec", "curl", false), + sink("egress", "mail.example.com:587", true), + sink("exec", "summarise", true), + ], + }, + FlowVector { + note: "the taint only grows: a later clean read restores nothing, and a second origin closes a sink open to the first".into(), + grants: flow_grants(), + steps: vec![ + read("read_path", "/work/inbox/mail-1", &["inbox"]), + read("read_path", "/work/notes", &["inbox"]), + sink("egress", "mail.example.com:587", true), + read("read_kv", "web/page", &["inbox", "web"]), + sink("egress", "mail.example.com:587", false), + read("program_output", "curl", &["fetched", "inbox", "web"]), + sink("exec", "summarise", true), + ], + }, + FlowVector { + note: "a grant set with no flow section is never tainted".into(), + grants: { + let mut g = flow_grants(); + g.flow = Default::default(); + g + }, + steps: vec![ + read("read_path", "/work/inbox/mail-1", &[]), + sink("egress", "api.example.com:443", true), + ], + }, + ] +} + +pub fn check_flow(v: &[FlowVector]) -> Result<(), String> { + for fv in v { + let e = Enforcer::new(&fv.grants); + let mut taint = RunTaint::clean(); + for (i, step) in fv.steps.iter().enumerate() { + let source = match step.call.as_str() { + "read_path" => Some(Source::Path(&step.arg)), + "read_kv" => Some(Source::Kv(step.arg.as_bytes())), + "program_output" => Some(Source::Program(&step.arg)), + _ => None, + }; + if let Some(source) = source { + for o in e.taints(source) { + taint.join(o); + } + let got: Vec = taint.origins().iter().cloned().collect(); + if step.origins_after.as_ref() != Some(&got) { + return Err(format!("{}: step {i} {} {}: origins {got:?}, expected {:?}", fv.note, step.call, step.arg, step.origins_after)); + } + continue; + } + let sink = match step.call.as_str() { + "egress" => Sink::Egress(&step.arg), + "exec" => Sink::Exec(&step.arg), + other => return Err(format!("bad flow call {other}")), + }; + let got = e.may_reach(sink, &taint); + if step.reach != Some(got) { + return Err(format!("{}: step {i} {} {}: reach {got}, expected {:?}", fv.note, step.call, step.arg, step.reach)); + } + } + } + Ok(()) +} + // ---- C3: receipt ----------------------------------------------------------- #[derive(Serialize, Deserialize)] @@ -854,7 +997,7 @@ pub fn vectors_dir() -> std::path::PathBuf { .join("../../../conformance/v1") } -pub const FILES: [(&str, &str); 8] = [ +pub const FILES: [(&str, &str); 9] = [ ("content_addressing.json", "C1"), ("capability.json", "C2"), ("receipt.json", "C3"), @@ -863,4 +1006,5 @@ pub const FILES: [(&str, &str); 8] = [ ("durable.json", "C6/C7/C10"), ("negative.json", "C-neg"), ("confidential.json", "C-tee"), + ("flow.json", "C13"), ]; diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/host.rs b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/host.rs index 26a27fad1..40ca7db0b 100644 --- a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/host.rs +++ b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/host.rs @@ -8,9 +8,14 @@ //! receives (a kv read, the clock, a random draw) is recorded in the //! [`Journal`] (SPEC §8), so the run's attestation carries a `journal_root` and //! is replayable. +//! +//! The run's flow state crosses the checkpoints with it (SPEC §5.8): a read +//! from a source the grant marks untrusted taints the run, in +//! [`HostState::taint`], and from then on an egress or a program launch is +//! refused unless the grant opens that sink to every origin the run carries. use sandbox_attest::{Journal, JournalEntry}; -use sandbox_capability::{Enforcer, Grants, KvDir, PathMode}; +use sandbox_capability::{Enforcer, Grants, KvDir, PathMode, RunTaint, Sink, Source}; use std::collections::HashMap; use wasmtime::{Caller, Extern, Linker, StoreLimits}; @@ -24,6 +29,10 @@ pub const EXEC_FAILED: i32 = -4; /// storm. Distinct from `DENIED` so a guest can tell throttling from a policy /// denial. pub const THROTTLED: i32 = -5; +/// The sink is within its capability allowlist, but the run has read untrusted +/// data the grant does not open this sink to (SPEC §5.8). Distinct from +/// `DENIED` so a guest can tell a closed flow from a missing capability. +pub const TAINTED: i32 = -6; /// The store data for a WASM run: the resource limiter plus the brokered-call /// state — the grants in force, the replay journal, and a small in-memory kv @@ -46,6 +55,9 @@ pub struct HostState { pub storage_ops: u64, /// Storage bytes the unit has read this run. pub storage_read_bytes: u64, + /// The origins of the untrusted data the run has read (SPEC §5.8). It only + /// grows, and every sink is decided against it. + pub taint: RunTaint, } impl HostState { @@ -62,6 +74,17 @@ impl HostState { rng_state: 0x5EED_5EED_5EED_5EED, storage_ops: 0, storage_read_bytes: 0, + taint: RunTaint::clean(), + } + } + + /// The run has read data from `source`: carry every untrusted origin it + /// matches from now on. + fn read_from(&mut self, source: Source<'_>) { + let origins: Vec = + Enforcer::new(&self.grants).taints(source).into_iter().map(str::to_string).collect(); + for o in origins { + self.taint.join(&o); } } } @@ -182,6 +205,7 @@ pub fn add_to_linker(linker: &mut Linker) -> Result<(), String> { if write_mem(&mut caller, out_ptr, &v[..n]).is_none() { return BAD_MEMORY; } + caller.data_mut().read_from(Source::Kv(&key)); n as i32 } None => NOT_FOUND, @@ -268,6 +292,7 @@ pub fn add_to_linker(linker: &mut Linker) -> Result<(), String> { if write_mem(&mut caller, out_ptr, &v[..n]).is_none() { return BAD_MEMORY; } + caller.data_mut().read_from(Source::Path(&path)); n as i32 } None => NOT_FOUND, @@ -288,6 +313,14 @@ pub fn add_to_linker(linker: &mut Linker) -> Result<(), String> { if !Enforcer::new(&caller.data().grants).may_egress(&endpoint) { return DENIED; } + // The run's flow state (SPEC §5.8): an endpoint the unit may + // reach is still closed to untrusted data it has read. + { + let st = caller.data(); + if !Enforcer::new(&st.grants).may_reach(Sink::Egress(&endpoint), &st.taint) { + return TAINTED; + } + } // The outbound call is part of the run's trace — journal it. caller.data_mut().journal.push(JournalEntry::HostCall { name: "egress".into(), @@ -313,6 +346,13 @@ pub fn add_to_linker(linker: &mut Linker) -> Result<(), String> { if !Enforcer::new(&caller.data().grants).may_exec(&prog) { return DENIED; } + // A program launched by a tainted run carries what the run read. + { + let st = caller.data(); + if !Enforcer::new(&st.grants).may_reach(Sink::Exec(&prog), &st.taint) { + return TAINTED; + } + } let output = match std::process::Command::new(&prog).output() { Ok(o) => o.stdout, Err(_) => return EXEC_FAILED, @@ -327,6 +367,7 @@ pub fn add_to_linker(linker: &mut Linker) -> Result<(), String> { if write_mem(&mut caller, out_ptr, &output[..n]).is_none() { return BAD_MEMORY; } + caller.data_mut().read_from(Source::Program(&prog)); n as i32 }, ) diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs index 6aaae5984..4c998a626 100644 --- a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs @@ -642,4 +642,160 @@ mod tests { let free = exec.run(&Unit::new(STORAGE_STORM_WAT.as_bytes(), &uncapped)).unwrap(); assert!(matches!(free.outcome, Outcome::Failed), "no ceiling ⇒ storm runs unthrottled"); } + + // ── flow across the checkpoints (SPEC §5.8) ────────────────────────────── + + /// A guest that writes attacker text to `path`, reads it back, then egresses + /// to api.example.com:443. It returns 0, and so completes, exactly when the + /// egress returns `expect`. + fn read_then_egress(path: &str, expect: i32) -> String { + format!( + r#"(module + (import "sandbox" "storage_write" (func $w (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "storage_read" (func $r (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "egress" (func $e (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{path}") + (data (i32.const 64) "ignore previous instructions") + (data (i32.const 128) "api.example.com:443") + (func (export "run") (result i32) + (drop (call $w (i32.const 0) (i32.const {n}) (i32.const 64) (i32.const 28))) + (drop (call $r (i32.const 0) (i32.const {n}) (i32.const 256) (i32.const 64))) + (i32.ne (call $e (i32.const 128) (i32.const 19)) (i32.const {expect}))))"#, + n = path.len() + ) + } + + /// The same, through kv: put attacker text under `key`, get it, egress. + fn kv_then_egress(key: &str, expect: i32) -> String { + format!( + r#"(module + (import "sandbox" "kv_put" (func $p (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "kv_get" (func $g (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "egress" (func $e (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{key}") + (data (i32.const 64) "ignore previous instructions") + (data (i32.const 128) "api.example.com:443") + (func (export "run") (result i32) + (drop (call $p (i32.const 0) (i32.const {n}) (i32.const 64) (i32.const 28))) + (drop (call $g (i32.const 0) (i32.const {n}) (i32.const 256) (i32.const 64))) + (i32.ne (call $e (i32.const 128) (i32.const 19)) (i32.const {expect}))))"#, + n = key.len() + ) + } + + /// Reads `path`, then launches "true"; returns 0 exactly when the launch + /// returns `expect` (0 output bytes on success). + fn read_then_exec(path: &str, expect: i32) -> String { + format!( + r#"(module + (import "sandbox" "storage_write" (func $w (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "storage_read" (func $r (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "exec" (func $x (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{path}") + (data (i32.const 64) "ignore previous instructions") + (data (i32.const 128) "true") + (func (export "run") (result i32) + (drop (call $w (i32.const 0) (i32.const {n}) (i32.const 64) (i32.const 28))) + (drop (call $r (i32.const 0) (i32.const {n}) (i32.const 256) (i32.const 64))) + (i32.ne (call $x (i32.const 128) (i32.const 4) (i32.const 300) (i32.const 64)) (i32.const {expect}))))"#, + n = path.len() + ) + } + + /// Launches "true", then egresses; returns 0 exactly when the egress + /// returns `expect`. + fn exec_then_egress(expect: i32) -> String { + format!( + r#"(module + (import "sandbox" "exec" (func $x (param i32 i32 i32 i32) (result i32))) + (import "sandbox" "egress" (func $e (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "true") + (data (i32.const 128) "api.example.com:443") + (func (export "run") (result i32) + (drop (call $x (i32.const 0) (i32.const 4) (i32.const 300) (i32.const 64))) + (i32.ne (call $e (i32.const 128) (i32.const 19)) (i32.const {expect}))))"# + ) + } + + /// Grants that may read and write /work, reach api.example.com:443, use kv + /// and run "true", and mark /work/inbox, kv "mail/" and the program "true" + /// as untrusted sources. + fn flow_grants() -> Grants { + use sandbox_capability::{FlowSource, SourceKind}; + let mut g = Grants { + compute: ComputeGrant::cpu_only(1_000_000, 100, 16 * 1024 * 1024), + ..Grants::default() + }; + g.storage.read = vec!["/work/**".into()]; + g.storage.write = vec!["/work/**".into()]; + g.network.outbound = vec!["api.example.com:443".into()]; + g.kv = sandbox_capability::KvGrant { read: true, write: true, prefixes: vec![] }; + g.process.exec = true; + g.process.programs = vec!["true".into()]; + let src = |kind, pattern: &str, origin: &str| FlowSource { kind, pattern: pattern.into(), origin: origin.into() }; + g.flow.untrusted = vec![ + src(SourceKind::Path, "/work/inbox/*", "inbox"), + src(SourceKind::Kv, "mail/", "mail"), + src(SourceKind::Program, "true", "tool"), + ]; + g + } + + fn completes(exec: &WasmExecutor, wat: &str, g: &Grants) -> bool { + matches!(exec.run(&Unit::new(wat.as_bytes(), g)).unwrap().outcome, Outcome::Completed) + } + + /// Once a run has read untrusted data, an egress its capabilities allow is + /// refused as tainted, whether the data came from storage, kv or a + /// program's output. A run that read only its principal's data reaches the + /// endpoint. Before, each checkpoint decided alone, and the read and the + /// egress were never connected. + #[test] + fn an_untrusted_read_closes_the_egress_that_follows() { + let exec = WasmExecutor::with_defaults().unwrap(); + let g = flow_grants(); + assert!(completes(&exec, &read_then_egress("/work/inbox/mail", host::TAINTED), &g), "storage"); + assert!(completes(&exec, &kv_then_egress("mail/1", host::TAINTED), &g), "kv"); + assert!(completes(&exec, &exec_then_egress(host::TAINTED), &g), "a program's output"); + assert!(completes(&exec, &read_then_egress("/work/notes", 0), &g), "a clean read leaves it open"); + assert!(completes(&exec, &kv_then_egress("notes/1", 0), &g), "a clean kv read leaves it open"); + } + + /// A launch is a sink too: a run that has read untrusted data cannot hand + /// it to a program the grant has not opened to it. + #[test] + fn an_untrusted_read_closes_the_launch_that_follows() { + let exec = WasmExecutor::with_defaults().unwrap(); + let g = flow_grants(); + assert!(completes(&exec, &read_then_exec("/work/inbox/mail", host::TAINTED), &g)); + assert!(completes(&exec, &read_then_exec("/work/notes", 0), &g)); + } + + /// The grant can open a sink to the origins it is meant for: open to the + /// inbox, the egress goes through; open only to another origin, it does + /// not. A grant without a flow section is untouched. + #[test] + fn a_sink_opens_only_to_the_origins_the_grant_names() { + use sandbox_capability::{FlowSink, SinkKind}; + let exec = WasmExecutor::with_defaults().unwrap(); + let open_to = |origins: &[&str]| { + let mut g = flow_grants(); + g.flow.tainted_may = vec![FlowSink { + kind: SinkKind::Egress, + target: "api.example.com:443".into(), + accepts: origins.iter().map(|o| o.to_string()).collect(), + }]; + g + }; + assert!(completes(&exec, &read_then_egress("/work/inbox/mail", 0), &open_to(&["inbox"]))); + assert!(completes(&exec, &read_then_egress("/work/inbox/mail", host::TAINTED), &open_to(&["crm"]))); + + let mut no_flow = flow_grants(); + no_flow.flow = Default::default(); + assert!(completes(&exec, &read_then_egress("/work/inbox/mail", 0), &no_flow)); + } } diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 2c755cb2f..1ffef5e06 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -727,6 +727,37 @@ pub fn grants_content_id(g: &Grants) -> ContentId { ), )); } + // The flow grant (SPEC §5.8) decides what a run may reach once it has read + // untrusted data, so it is part of the capability surface. Emitted only + // when present, as the credentials are, so a grant without it keeps its id. + let text = |s: &str| Cbor::Text(s.to_string()); + if !g.flow.untrusted.is_empty() { + fields.push(( + "flow_untrusted".into(), + Cbor::Array( + g.flow + .untrusted + .iter() + .map(|s| Cbor::Array(vec![text(s.kind.wire_tag()), text(&s.pattern), text(&s.origin)])) + .collect(), + ), + )); + } + if !g.flow.tainted_may.is_empty() { + fields.push(( + "flow_tainted_may".into(), + Cbor::Array( + g.flow + .tainted_may + .iter() + .map(|s| { + let accepts = Cbor::Array(s.accepts.iter().map(|a| text(a)).collect()); + Cbor::Array(vec![text(s.kind.wire_tag()), text(&s.target), accepts]) + }) + .collect(), + ), + )); + } let core = Cbor::Map(fields); ContentId::of_bytes(&core.encode()) } @@ -1331,4 +1362,36 @@ mod tests { req2.contention_sensitive = true; assert_eq!(required_tier(&req2), Tier::S0, "ceiling bounds it → stays at base"); } + + /// The flow grant (SPEC §5.8) decides what a run may reach after an + /// untrusted read, so changing it is changing the capability surface: it + /// is bound into the grant id, every part of it, and a grant without one + /// keeps its id. + #[test] + fn the_flow_grant_is_bound_into_the_grant_id() { + use sandbox_capability::{FlowSink, FlowSource, SinkKind, SourceKind}; + let plain = Grants::default(); + let mut untrusted = plain.clone(); + untrusted.flow.untrusted.push(FlowSource { kind: SourceKind::Path, pattern: "/in/*".into(), origin: "in".into() }); + let mut other_origin = untrusted.clone(); + other_origin.flow.untrusted[0].origin = "elsewhere".into(); + let mut other_kind = untrusted.clone(); + other_kind.flow.untrusted[0].kind = SourceKind::Kv; + let mut opened = untrusted.clone(); + opened.flow.tainted_may.push(FlowSink { kind: SinkKind::Egress, target: "x:1".into(), accepts: vec!["in".into()] }); + let mut wider = opened.clone(); + wider.flow.tainted_may[0].accepts.push("elsewhere".into()); + let mut other_target = opened.clone(); + other_target.flow.tainted_may[0].target = "y:1".into(); + let ids: Vec = + [&plain, &untrusted, &other_origin, &other_kind, &opened, &wider, &other_target].iter().map(|g| grants_content_id(g)).collect(); + for (i, a) in ids.iter().enumerate() { + for b in &ids[i + 1..] { + assert_ne!(a, b); + } + } + let mut emptied = untrusted.clone(); + emptied.flow = Default::default(); + assert_eq!(grants_content_id(&emptied), grants_content_id(&plain), "an empty flow grant leaves the id"); + } } diff --git a/sandbox/spec/capabilities.md b/sandbox/spec/capabilities.md index ebfafa6f3..fd2c975be 100644 --- a/sandbox/spec/capabilities.md +++ b/sandbox/spec/capabilities.md @@ -115,4 +115,6 @@ The **filesystem** capability is `storage`; a **code-interpreter** is `process` The reference `sandbox-capability` crate exposes an `Enforcer` that, given a `Grants` set, answers each access query (`may_dispatch(class)`, `may_path(mode, path)`, `may_egress(endpoint)`, `may_kv(dir, key)`, `may_host(service)`, `may_exec(program)`, `may_navigate(origin)`, `may_display()`, `may_vnc()`) with allow/deny. The enforcer is **pure and deterministic**: the same grant set and query always yield the same decision, with no float and no ambient state. Conformance vector class **C2** (SPEC §10, [`conformance.md`](conformance.md)) fixes the decisions for a canonical grant set, including the deny-by-default negatives. -At tier S2 the enforcement is **live**, not advisory: the reference WASM executor (`sandbox-exec-wasm`) brokers every capability the guest exercises through a host-call boundary — `kv_get`/`kv_put`, `storage_read`/`storage_write`, `egress`, `clock_now`, `random_u64`, `log` — and each call consults the `Enforcer` before acting. A denied call returns an error code to the guest (denial is not a crash), so capability enforcement is exercised through real execution. Each nondeterministic input the guest receives (a kv or storage read, the clock, a random draw, an outbound call) is recorded in the run's replay journal (SPEC §8), so the attestation carries a `journal_root`. +What a run has already read is state, and it is not ambient (SPEC §5.8). The run carries it as a `RunTaint`, the origins of the untrusted data it has read, which only grows. `taints(source)` says which origins a read adds, and `may_reach(sink, taint)` decides a sink against what the run carries, so a decision is still a function of its inputs, the run's history among them. Conformance vector class **C13** fixes these decisions for whole runs, step by step, since what a run may do depends on what it did before. + +At tier S2 the enforcement is **live**, not advisory: the reference WASM executor (`sandbox-exec-wasm`) brokers every capability the guest exercises through a host-call boundary — `kv_get`/`kv_put`, `storage_read`/`storage_write`, `egress`, `clock_now`, `random_u64`, `log` — and each call consults the `Enforcer` before acting. A denied call returns an error code to the guest (denial is not a crash), so capability enforcement is exercised through real execution. The run's flow state crosses these calls with it: a storage or kv read, or a program's output, from a source the flow grant marks untrusted taints the run, and an `egress` or `exec` the grant has not opened to the run's origins is refused with `TAINTED`, distinct from `DENIED`. Each nondeterministic input the guest receives (a kv or storage read, the clock, a random draw, an outbound call) is recorded in the run's replay journal (SPEC §8), so the attestation carries a `journal_root`. diff --git a/sandbox/spec/conformance.md b/sandbox/spec/conformance.md index 77559dcbf..066e54407 100644 --- a/sandbox/spec/conformance.md +++ b/sandbox/spec/conformance.md @@ -39,6 +39,9 @@ Every item in a fleet proves against the merkle aggregate root with a logarithmi ### C11 — Enforcement mechanism (§7.9) An attestation without a `mechanism` canonicalizes to byte-identical JSON under the `/1` profile, so historical signatures and published vectors still verify; one carrying a mechanism is `/2`, and the commitment is inside the signed bytes. The commitment is injective across field boundaries — moving bytes between the runtime name and the profile changes the digest. A mechanism at S0 is refused, and the relying-party requirement rejects a bare S1–S5 tier label while an evidenced one satisfies it. +### C13 — Flow across a run's checkpoints (§5.8) +A run's calls, in order: a read from an untrusted source adds its origins to the run's taint, and a sink is decided against everything read before it. A clean run reaches every sink its capabilities allow. An untrusted read closes the egress and launch that follow it, except where the flow grant opens a sink to every origin the run carries. The taint only grows, so a later clean read restores nothing, and a second origin closes a sink opened to the first. A grant set without a flow grant is never tainted. The order is part of the vector, since no single query can show what a run may do after what it did. + ## Adversarial obligation The most valuable vectors are **negatives** — runs a conformant host must reject. A standard whose vectors only assert the happy path certifies nothing. The committed pack ([`../conformance/v1/negative.json`](../conformance/v1/negative.json)) carries a **C-neg** class the harness asserts is rejected: -- 2.54.0 (Apple Git-157)