From 2ea5ad3e9c1125fa1796221bc199d881b6ad7bfe Mon Sep 17 00:00:00 2001 From: David Charlot Date: Wed, 14 Oct 2026 10:04:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?sandbox=20=C2=A75.3:=20an=20enforced=20gran?= =?UTF-8?q?t=20field=20was=20outside=20the=20grant's=20content=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grants_content_id` is the only thing that puts the grant set into a signed attestation (`Attestation.grants_id`). It hand-builds a CBOR core of 27 keys and covers `net_outbound` and `net_inbound` — and never `g.network.credentials`. Those credentials are enforced. `Enforcer::credential_for` returns the credential the boundary injects on the unit's behalf for an authorised egress, so *which* secret is injected is part of the capability surface. Swapping a handle — `example/test-key` for `example/live-key` on an endpoint already in `outbound` — changes nothing else about the grant, so the id was identical and the mutation undetectable. Every attestation sealed under the old set went on verifying while describing a surface no longer in force. SPEC §1.2 requires the opposite in as many words: "a mutation to the granted capability set MUST terminate the run and invalidate its attestation." The repo already knew: a test beside this one is named `io_ceiling_is_bound_into_grants_content_id` and opens "SPEC §1.2: an enforced grant field MUST be part of the grant's content address, so loosening it mid-run is detectable." And the binding had been written. `EgressCredential::commitment()` documents itself as "binds the `(endpoint, secret_ref, mode)` decision into an attestation", and its only two callers in the tree were in its own test. The claim in that doc was not true of the code. It is now — this gives it its first real caller. The commitment, never the credential, so the handle does not travel in the grant id; the test asserts the handle is absent from it. `secret_ref` being a name rather than a key is the whole safety property, and committing to the credential itself would have spent it. **Emitted only when the set is non-empty**, which is deliberate and not the convention two keys above it. `opt_u64` always emits a presence-distinguishing array because it must tell `None` from `Some(0)`; an empty credential set is the same capability surface as a grant from before the field existed. So omitting keeps every such grant's id byte-identical — including the `b3:ONvIj2bsKw-2Kqe43mNV_3c0rrHTA3Df8xyM6D4ybnY` that `attestation.json` and `negative.json` pin. All eight conformance classes pass with no skips. Only grants that actually inject credentials get a new id, which is correct: theirs was the id that was wrong. SPEC §5.3 and spec/capabilities.md declared `NetworkGrant { outbound, inbound }` — the enforced field was absent from the normative type as well as from the address. Both now carry it, with the three rules: the handle is a name and never a key, a credential's endpoint MUST also be in `outbound` (already enforced by `injection_within_egress`), and the set MUST be in the content address, by commitment. Co-Authored-By: Claude Opus 5 --- sandbox/SPEC.md | 12 ++- .../crates/sandbox-runtime/src/lib.rs | 98 ++++++++++++++++++- sandbox/spec/capabilities.md | 6 +- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index a7e7afc66..4f9e4025d 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -135,11 +135,21 @@ A path operation **MUST** match an allow-glob for its access mode or be denied. ### 5.3 Network grant ``` -NetworkGrant { outbound : [Endpoint], inbound : bool } +NetworkGrant { outbound : [Endpoint], + inbound : bool, + credentials : [EgressCredential] } + +EgressCredential { endpoint : Endpoint, secret_ref : Handle, inject : Injection } ``` `outbound` is an allowlist of `host:port` endpoints; an egress to an endpoint not on the list **MUST** be denied. The empty grant denies all network. Network access is never a precondition of the host's own operation (§1.4). +`credentials` names the secrets the **boundary** injects on the unit's behalf, so that a unit can reach an authorized service without ever holding the secret. Three rules, and the third is what makes the other two attestable: + +1. `secret_ref` is a **handle**, not a key — an opaque name the boundary resolves in its own keystore. The grant, and every receipt and attestation over it, therefore carries no secret material. A grant that carries a secret by value does not conform. +2. A credential's `endpoint` **MUST** also appear in `outbound`. Injection can never reach further than egress itself; a credential for an endpoint egress does not allow **MUST** be refused, not silently ignored. +3. The credential set **MUST** be part of the grant's content address (§1.2), committed as the ordered list of per-credential commitments — `BLAKE3` over `(endpoint, secret_ref, inject)` — never as the credentials themselves. Swapping a handle on an endpoint that is already allowlisted changes nothing else about the grant, so without this the mutation is undetectable and every attestation sealed under the old set goes on verifying while describing a surface no longer in force. An empty set is omitted from the address, so a grant that injects nothing addresses as it did before this field existed. + ### 5.4 Key-value and host grants ``` diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 33ba6a692..bd80ace9b 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -517,7 +517,7 @@ pub fn grants_content_id(g: &Grants) -> ContentId { let storage = |v: &Vec| Cbor::Array(v.iter().cloned().map(Cbor::Text).collect()); // Option as a presence-distinguishing array: None → [], Some(v) → [v]. let opt_u64 = |o: Option| Cbor::Array(o.map(|v| vec![Cbor::Uint(v)]).unwrap_or_default()); - let core = Cbor::Map(vec![ + let mut fields = vec![ ( "compute_devices".into(), Cbor::Array(devices), @@ -573,7 +573,46 @@ pub fn grants_content_id(g: &Grants) -> ContentId { "display_max_h".into(), Cbor::Uint(u64::from(g.display.max_height)), ), - ]); + ]; + // The injected egress credentials, by commitment (SPEC §5.3). + // + // These are an enforced grant field: `Enforcer::credential_for` returns the + // credential the boundary injects on the unit's behalf for an authorised + // egress, so *which* secret is injected is part of the capability surface. + // It was absent from this core, which meant swapping a `secret_ref` — + // `stripe/test-key` for `stripe/live-key` on an already-allowlisted + // endpoint — left the grant id unchanged, and every attestation sealed + // under it went on verifying while describing a surface no longer in + // force. SPEC §1.2 requires the opposite: "a mutation to the granted + // capability set MUST terminate the run and invalidate its attestation." + // + // `EgressCredential::commitment()` was written for exactly this and had no + // caller outside its own test; its doc already claimed it "binds the + // (endpoint, secret_ref, mode) decision into an attestation". This is that + // claim becoming true. The commitment, not the credential, so the secret + // handle does not travel in the grant id. + // + // Emitted only when the set is non-empty, unlike `opt_u64` above which + // always emits a presence-distinguishing array. The difference is + // deliberate: `opt_u64` must tell `None` from `Some(0)`, while an empty + // credential set is the same capability surface as a grant from before the + // field existed — so omitting it keeps every such grant's content id + // byte-identical, including the one the published pack pins. Only grants + // that actually inject credentials get a new id, which is correct: theirs + // was the id that was wrong. + if !g.network.credentials.is_empty() { + fields.push(( + "net_credentials".into(), + Cbor::Array( + g.network + .credentials + .iter() + .map(|c| Cbor::Text(c.commitment().as_str().to_string())) + .collect(), + ), + )); + } + let core = Cbor::Map(fields); ContentId::of_bytes(&core.encode()) } @@ -903,6 +942,61 @@ mod tests { assert_ne!(grants_content_id(&base), grants_content_id(&bps)); } + #[test] + fn injected_credentials_are_bound_into_grants_content_id() { + // Same rule as the test above, for the field that was missing from it. + // `Enforcer::credential_for` injects on the unit's behalf, so *which* + // secret is injected is part of the capability surface; SPEC §1.2 makes + // a mutation to that surface invalidate the attestation, which it can + // only do if the grant id moves. + use sandbox_capability::{EgressCredential, Injection}; + + let endpoint = "api.example.com:443"; + let with = |secret: &str, inject: Injection| { + let mut g = Grants::default(); + g.network.outbound = vec![endpoint.to_string()]; + g.network.credentials = vec![EgressCredential::new(endpoint, secret, inject)]; + g + }; + let mut none = Grants::default(); + none.network.outbound = vec![endpoint.to_string()]; + + let test_key = with("example/test-key", Injection::Bearer); + let live_key = with("example/live-key", Injection::Bearer); + let as_header = with("example/test-key", Injection::Header("X-Api-Key".into())); + + // Injecting at all is a different surface from not injecting. + assert_ne!(grants_content_id(&none), grants_content_id(&test_key)); + // The defect this test exists for: swapping the handle on an endpoint + // that is already allowlisted. Egress is unchanged, so every other + // field of the grant is identical — before this binding the two sealed + // to the same id and the swap was undetectable. + assert_ne!( + grants_content_id(&test_key), + grants_content_id(&live_key), + "a credential swap on an allowlisted endpoint must move the grant id" + ); + // So is how the secret is applied: Bearer and a named header are + // different requests leaving the boundary. + assert_ne!(grants_content_id(&test_key), grants_content_id(&as_header)); + + // The grant id carries the commitment, never the handle — the whole + // point of `secret_ref` being a name is that it does not travel. + let core = format!("{:?}", grants_content_id(&live_key)); + assert!( + !core.contains("live-key"), + "the secret handle must not appear in the grant id: {core}" + ); + + // And an empty credential set is the surface a grant had before this + // field existed, so its id must not move — which is what keeps the + // published pack's pinned `grants_id` verifying. + let mut empty = Grants::default(); + empty.network.outbound = vec![endpoint.to_string()]; + empty.network.credentials = vec![]; + assert_eq!(grants_content_id(&none), grants_content_id(&empty)); + } + #[test] fn contention_sensitive_raises_floor_without_a_ceiling() { // SPEC §2.4 enforced in required_tier. diff --git a/sandbox/spec/capabilities.md b/sandbox/spec/capabilities.md index a55914cac..ebfafa6f3 100644 --- a/sandbox/spec/capabilities.md +++ b/sandbox/spec/capabilities.md @@ -57,7 +57,11 @@ The ceilings serialize **only when set** (`skip_serializing_if`): a grant that o ## NetworkGrant ``` -NetworkGrant { outbound : [Endpoint], inbound : bool } +NetworkGrant { outbound : [Endpoint], + inbound : bool, + credentials : [EgressCredential] } // SPEC §5.3 + +EgressCredential { endpoint : Endpoint, secret_ref : Handle, inject : Injection } ``` `outbound` is an allowlist of `host:port` strings; an egress to any endpoint not on the list **MUST** be denied. `inbound = false` denies all inbound connections. The empty grant denies all network. Per SPEC §1.4, the host's own operation never depends on network; a denied egress is a unit-level denial, not a host failure. -- 2.54.0 (Apple Git-157) From a79fb5ca465da59d3cd75ecdaf4a381d846433e2 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Wed, 14 Oct 2026 10:05:40 -0400 Subject: [PATCH 2/2] sandbox: drop an assertion of mine that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falsifying the test above found a defect in the test. It asserted that the secret handle does not appear in the grant id: let core = format!("{:?}", grants_content_id(&live_key)); assert!(!core.contains("live-key"), ...); `grants_content_id` returns a BLAKE3 address. No formatting of a hash can contain its own preimage, whatever went into it — so that assertion passes against any implementation, including one that commits the handle verbatim. It checked the hash function, not this code, which is the definition of decoration. I only noticed because mutation C — committing `endpoint|secret_ref`, which really does put the handle in the preimage — was caught by a *different* assertion (Bearer versus a named header, since that mutation also drops `inject`). The one written for it stayed green, which is what a vacuous assertion looks like from the outside. Removed, with the reason in its place so it is not reintroduced, and a pointer to the property that is testable: `commitment()` distinguishing its three fields, which the swap and injection-mode assertions cover, and `commitment_distinguishes_credentials_without_the_secret` in sandbox-capability for the shape. Re-falsified after the change: all three mutations still caught — the binding disabled, the commitment reduced to the endpoint, and the handle committed verbatim. Co-Authored-By: Claude Opus 5 --- .../crates/sandbox-runtime/src/lib.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index bd80ace9b..4681d73b6 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -980,13 +980,17 @@ mod tests { // different requests leaving the boundary. assert_ne!(grants_content_id(&test_key), grants_content_id(&as_header)); - // The grant id carries the commitment, never the handle — the whole - // point of `secret_ref` being a name is that it does not travel. - let core = format!("{:?}", grants_content_id(&live_key)); - assert!( - !core.contains("live-key"), - "the secret handle must not appear in the grant id: {core}" - ); + // Not asserted here, deliberately: that the handle does not travel in + // the grant id. `grants_content_id` returns a BLAKE3 address, so no + // formatting of it can contain its own preimage whatever went into the + // hash — a test of that shape passes against any implementation, + // including one that commits the secret handle verbatim. It would check + // the hash function, not this code. The property that is testable, and + // is tested above, is that the commitment distinguishes the three + // fields; that the boundary resolves `secret_ref` rather than carrying + // a key is a property of `EgressCredential`'s shape, covered by + // `commitment_distinguishes_credentials_without_the_secret` in + // sandbox-capability. // And an empty credential set is the surface a grant had before this // field existed, so its id must not move — which is what keeps the -- 2.54.0 (Apple Git-157)