sandbox: injected egress credentials + sandbox-jcp cross-workspace fix (re-land of #33) #36

Merged
dcharlot merged 2 commits from sandbox/cred-injection-2 into main 2026-07-21 08:19:55 -04:00
4 changed files with 163 additions and 2 deletions

View file

@ -1764,6 +1764,7 @@ version = "0.1.0"
dependencies = [
"sandbox-schema",
"serde",
"serde_json",
]
[[package]]

View file

@ -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

View file

@ -81,12 +81,82 @@ pub struct StorageGrant {
pub max_read_bps: Option<u64>,
}
/// 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 <secret>`.
Bearer,
/// A named request header, e.g. `X-Api-Key: <secret>`.
Header(String),
/// A query parameter, e.g. `?api_key=<secret>`.
Query(String),
/// HTTP Basic auth with this username and `<secret>` 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<String>, secret_ref: impl Into<String>, 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<String>,
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<EgressCredential>,
}
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();

View file

@ -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,