From 9f3a92bc6b30981ae55c86edc887148910d72b35 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 20 Jul 2026 19:46:35 -0400 Subject: [PATCH 1/2] =?UTF-8?q?sandbox-jcp:=20repair=20the=20jcp=5Fschema:?= =?UTF-8?q?:Grant=20literal=20=E2=80=94=20latent=20cross-workspace=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox-jcp path-depends on jcp-schema across a workspace boundary (../../../../jcp/jcp-rs/crates/jcp-schema), and its Grant literal was missing `budget_funds` and `caveats` — the two fields added to Grant by the dual-ceilings and caveats work in the JCP workspace. Those changes swept every Grant construction site inside the jcp workspace (the compiler forced it), but sandbox-jcp lives in the SEPARATE sandbox workspace, so its literal went unswept and a clean build of the sandbox workspace has been broken since. It went unnoticed because the two workspaces are tested independently and a warm target cache masked it in the sandbox tree. A fresh checkout surfaces it immediately. Adds the two fields (both default: no money ceiling, no caveats). Lesson: a required-field addition to a shared schema must be verified against EVERY workspace that path-depends on it, not only the schema's own. Co-Authored-By: Claude Opus 4.8 --- sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs index 3da324def..e1d15efc4 100644 --- a/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs @@ -207,6 +207,8 @@ mod tests { subject: did(&agent), capability: Capability::new(image_resource(image), [EXECUTE_ACTION]), budget_uj: budget, + budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, -- 2.54.0 (Apple Git-157) From b0559f3e0dc6e9a16f48bc6745cd4bbd76168eed Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 20 Jul 2026 19:46:35 -0400 Subject: [PATCH 2/2] =?UTF-8?q?sandbox-capability:=20injected=20egress=20c?= =?UTF-8?q?redentials=20=E2=80=94=20the=20secret=20never=20enters=20the=20?= =?UTF-8?q?unit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingests the credential-injection pattern the microVM-sandbox field converged on (Notion's Workers run on Vercel Firecracker microVMs with network-level credential injection), and adds the two things that pattern ships without: the secret held only by REFERENCE, and an audit commitment — both bound into the Sandbox standard's existing capability model. A NetworkGrant may now carry `credentials: Vec`. Each names an authorized egress `endpoint`, a `secret_ref` HANDLE the enforcement boundary resolves in its own keystore OUTSIDE the sandbox, and an `Injection` mode (Bearer / Header / Query / BasicAuth) describing how the resolved secret is applied to the request. The unit makes authenticated calls without ever holding the key: a compromised or hallucinating agent inside the sandbox cannot read, log, or exfiltrate a credential it was never given. The grant — and any receipt over it — carries only the reference, never the material. Two invariants: - injection ⊆ egress. `Enforcer::credential_for(endpoint)` returns a credential only when the endpoint is BOTH on the egress allowlist and has a declared credential, so injection can never reach further than egress even if the grant is malformed. `NetworkGrant::injection_within_egress()` flags such a grant at mint time; the enforcer is defensive regardless. - reference, not value. `EgressCredential::commitment()` content-addresses the (endpoint, secret_ref, mode) decision, so an attestation can record WHICH credential was injected for WHICH call without exposing the secret — the audit primitive Notion/Vercel lack. Backward compatible: `credentials` skips serialization when empty, so a grant with none is byte-identical to one minted before the field existed. The published sandbox conformance vectors are unchanged and still verify (empty credential set omitted; asserted by test). Five tests: injection only for authorized egress; injection can never exceed egress even for a malformed grant; the reference is recorded while no secret material ever appears in the encoding; commitments distinguish credentials without the secret; empty sets are omitted. sandbox-capability clippy-clean; full sandbox workspace green (one pre-existing warning in sandbox-conformance/vectors.rs, unrelated and untouched). Co-Authored-By: Claude Opus 4.8 --- sandbox/sandbox-rs/Cargo.lock | 1 + .../crates/sandbox-capability/Cargo.toml | 3 + .../crates/sandbox-capability/src/lib.rs | 159 +++++++++++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/sandbox/sandbox-rs/Cargo.lock b/sandbox/sandbox-rs/Cargo.lock index 027783422..473a6a92d 100644 --- a/sandbox/sandbox-rs/Cargo.lock +++ b/sandbox/sandbox-rs/Cargo.lock @@ -1764,6 +1764,7 @@ version = "0.1.0" dependencies = [ "sandbox-schema", "serde", + "serde_json", ] [[package]] diff --git a/sandbox/sandbox-rs/crates/sandbox-capability/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-capability/Cargo.toml index 91616a8de..f739624be 100644 --- a/sandbox/sandbox-rs/crates/sandbox-capability/Cargo.toml +++ b/sandbox/sandbox-rs/crates/sandbox-capability/Cargo.toml @@ -10,3 +10,6 @@ description = "Sandbox — the capability-grant model and deny-by-default enforc [dependencies] sandbox-schema.workspace = true serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs index cdb602000..11dc7a725 100644 --- a/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-capability/src/lib.rs @@ -81,12 +81,82 @@ pub struct StorageGrant { pub max_read_bps: Option, } -/// Network grant: an outbound `host:port` allowlist and an inbound flag -/// (SPEC §5.3). +/// How the boundary applies an injected secret to an outbound request. The mode +/// names the *shape* of the injection; it never carries the secret. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Injection { + /// `Authorization: Bearer `. + Bearer, + /// A named request header, e.g. `X-Api-Key: `. + Header(String), + /// A query parameter, e.g. `?api_key=`. + Query(String), + /// HTTP Basic auth with this username and `` as the password. + BasicAuth(String), +} + +/// A credential the boundary injects for one authorized egress endpoint — held +/// by **reference**, never by value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EgressCredential { + /// The egress endpoint this credential is injected for. MUST also appear in + /// the grant's `outbound` allowlist — injection can never reach further than + /// egress itself (enforced by [`NetworkGrant::injection_within_egress`]). + pub endpoint: String, + /// An opaque **handle** the enforcement boundary resolves to secret material + /// in its own keystore. This is the whole safety property: it is a name, not + /// a key, so the grant (and any receipt over it) carries no secret. + pub secret_ref: String, + /// How the resolved secret is applied to the request. + pub inject: Injection, +} + +impl EgressCredential { + pub fn new(endpoint: impl Into, secret_ref: impl Into, inject: Injection) -> Self { + EgressCredential { endpoint: endpoint.into(), secret_ref: secret_ref.into(), inject } + } + /// A content-addressed commitment to *which* credential was injected — + /// binds the `(endpoint, secret_ref, mode)` decision into an attestation + /// **without** exposing the secret, since `secret_ref` is a handle. + pub fn commitment(&self) -> ContentId { + ContentId::of_bytes( + format!("egress-cred\u{1f}{}\u{1f}{}\u{1f}{:?}", self.endpoint, self.secret_ref, self.inject).as_bytes(), + ) + } +} + +/// Network grant: an outbound `host:port` allowlist, an inbound flag, and any +/// boundary-injected egress credentials (SPEC §5.3). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct NetworkGrant { pub outbound: Vec, pub inbound: bool, + /// **Injected egress credentials** (SPEC §5.x). Each entry says: for this + /// authorized `endpoint`, the enforcement boundary applies the secret named + /// by `secret_ref`, resolving the material from its own keystore **outside + /// the sandbox** — so the secret never enters the executing unit. + /// + /// The unit therefore cannot read, log, or exfiltrate a credential it was + /// never given; a compromised or hallucinating agent inside the sandbox + /// makes authenticated calls without ever holding the key. The grant carries + /// only the *reference* (`secret_ref`) — never the bytes — so neither the + /// grant nor any receipt over it exposes secret material. + /// + /// Empty (the default) omits from the canonical encoding, so a grant with no + /// injected credentials is byte-identical to one minted before this existed. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub credentials: Vec, +} + +impl NetworkGrant { + /// The consistency invariant: every injected credential's endpoint is also on + /// the `outbound` allowlist. A credential for an endpoint the unit may not + /// even reach is a policy error — validate a grant with this at mint time. + /// (The [`Enforcer`] is defensive regardless: it never injects for a + /// non-allowlisted endpoint.) + pub fn injection_within_egress(&self) -> bool { + self.credentials.iter().all(|c| self.outbound.iter().any(|e| e == &c.endpoint)) + } } /// Key-value grant: direction flags plus a set of permitted key prefixes @@ -278,6 +348,24 @@ impl<'a> Enforcer<'a> { self.grants.network.inbound } + /// The credential the boundary injects for an **authorized** egress to + /// `endpoint`, or `None` (SPEC §5.x). + /// + /// Called by the enforcement boundary, never by the executing unit: the unit + /// asks to reach an endpoint (checked by [`may_egress`](Self::may_egress)), + /// and the boundary — outside the sandbox — resolves and applies the secret. + /// A credential is returned only when the endpoint is *both* on the egress + /// allowlist and has a declared credential, so injection can never exceed + /// egress even if the grant is malformed. The returned value carries only the + /// `secret_ref` handle; the material lives in the boundary keystore, never in + /// the unit. + pub fn credential_for(&self, endpoint: &str) -> Option<&EgressCredential> { + if !self.may_egress(endpoint) { + return None; // never inject where egress itself is denied + } + self.grants.network.credentials.iter().find(|c| c.endpoint == endpoint) + } + /// May the unit perform this kv access on this key? (SPEC §5.4) pub fn may_kv(&self, dir: KvDir, key: &[u8]) -> bool { let dir_ok = match dir { @@ -414,6 +502,73 @@ mod tests { assert!(!e.may_egress("evil.example.com:443")); } + /// The boundary injects a credential only for an AUTHORIZED egress, and only + /// as a reference — the secret material is never in the grant. + #[test] + fn credential_injected_only_for_authorized_egress() { + let mut g = Grants::default(); + g.network.outbound = vec!["api.stripe.com:443".into()]; + g.network.credentials = + vec![EgressCredential::new("api.stripe.com:443", "stripe/live-key", Injection::Bearer)]; + let e = Enforcer::new(&g); + + // The boundary gets a credential for the allowlisted endpoint… + let c = e.credential_for("api.stripe.com:443").expect("credential for authorized egress"); + assert_eq!(c.secret_ref, "stripe/live-key"); + assert_eq!(c.inject, Injection::Bearer); + + // …and NONE for an endpoint egress does not permit — injection ⊆ egress, + // enforced even though no credential is declared for it either. + assert!(e.credential_for("evil.example.com:443").is_none()); + + // The grant holds only a reference. Its serialization contains the + // handle, never anything resembling key material. + let json = serde_json::to_string(&g.network).unwrap(); + assert!(json.contains("stripe/live-key"), "the reference is recorded"); + assert!(!json.contains("sk_live"), "no secret material is ever present"); + } + + /// Defence in depth: even a MALFORMED grant (a credential for an endpoint not + /// on the egress allowlist) cannot cause an out-of-policy injection. + #[test] + fn injection_can_never_exceed_egress() { + let mut g = Grants::default(); + g.network.outbound = vec!["api.ok.com:443".into()]; + g.network.credentials = vec![ + EgressCredential::new("api.ok.com:443", "ok/key", Injection::Header("X-Api-Key".into())), + EgressCredential::new("api.leak.com:443", "leak/key", Injection::Bearer), // NOT on allowlist + ]; + // The consistency check flags the malformed grant at mint time… + assert!(!g.network.injection_within_egress()); + // …and the enforcer refuses to inject the stray credential regardless, + // because credential_for gates on may_egress first. + let e = Enforcer::new(&g); + assert!(e.credential_for("api.leak.com:443").is_none(), "no egress ⇒ no injection"); + assert!(e.credential_for("api.ok.com:443").is_some()); + } + + /// The commitment binds WHICH credential was injected, without the secret — + /// the audit primitive an attestation seals. + #[test] + fn commitment_distinguishes_credentials_without_the_secret() { + let a = EgressCredential::new("api.x.com:443", "team/key-a", Injection::Bearer); + let b = EgressCredential::new("api.x.com:443", "team/key-b", Injection::Bearer); + let same = EgressCredential::new("api.x.com:443", "team/key-a", Injection::Bearer); + assert_ne!(a.commitment(), b.commitment(), "different secret_ref ⇒ different commitment"); + assert_eq!(a.commitment(), same.commitment(), "same decision ⇒ same commitment"); + } + + /// Backward compatibility: a grant with no injected credentials serializes + /// exactly as it did before the field existed — the published capability + /// vectors are unaffected. + #[test] + fn empty_credentials_are_omitted_from_the_encoding() { + let g = NetworkGrant { outbound: vec!["a:443".into()], inbound: false, credentials: vec![] }; + let json = serde_json::to_string(&g).unwrap(); + assert!(!json.contains("credentials"), "an empty credential set must not appear"); + assert_eq!(json, r#"{"outbound":["a:443"],"inbound":false}"#); + } + #[test] fn kv_direction_and_prefix() { let mut g = Grants::default(); -- 2.54.0 (Apple Git-157)