sandbox: injected egress credentials + sandbox-jcp cross-workspace fix (re-land of #33) #36
4 changed files with 163 additions and 2 deletions
1
sandbox/sandbox-rs/Cargo.lock
generated
1
sandbox/sandbox-rs/Cargo.lock
generated
|
|
@ -1764,6 +1764,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sandbox-schema",
|
"sandbox-schema",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@ description = "Sandbox — the capability-grant model and deny-by-default enforc
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sandbox-schema.workspace = true
|
sandbox-schema.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,82 @@ pub struct StorageGrant {
|
||||||
pub max_read_bps: Option<u64>,
|
pub max_read_bps: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Network grant: an outbound `host:port` allowlist and an inbound flag
|
/// How the boundary applies an injected secret to an outbound request. The mode
|
||||||
/// (SPEC §5.3).
|
/// 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)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NetworkGrant {
|
pub struct NetworkGrant {
|
||||||
pub outbound: Vec<String>,
|
pub outbound: Vec<String>,
|
||||||
pub inbound: bool,
|
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
|
/// Key-value grant: direction flags plus a set of permitted key prefixes
|
||||||
|
|
@ -278,6 +348,24 @@ impl<'a> Enforcer<'a> {
|
||||||
self.grants.network.inbound
|
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)
|
/// May the unit perform this kv access on this key? (SPEC §5.4)
|
||||||
pub fn may_kv(&self, dir: KvDir, key: &[u8]) -> bool {
|
pub fn may_kv(&self, dir: KvDir, key: &[u8]) -> bool {
|
||||||
let dir_ok = match dir {
|
let dir_ok = match dir {
|
||||||
|
|
@ -414,6 +502,73 @@ mod tests {
|
||||||
assert!(!e.may_egress("evil.example.com:443"));
|
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]
|
#[test]
|
||||||
fn kv_direction_and_prefix() {
|
fn kv_direction_and_prefix() {
|
||||||
let mut g = Grants::default();
|
let mut g = Grants::default();
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,8 @@ mod tests {
|
||||||
subject: did(&agent),
|
subject: did(&agent),
|
||||||
capability: Capability::new(image_resource(image), [EXECUTE_ACTION]),
|
capability: Capability::new(image_resource(image), [EXECUTE_ACTION]),
|
||||||
budget_uj: budget,
|
budget_uj: budget,
|
||||||
|
budget_funds: None,
|
||||||
|
caveats: Vec::new(),
|
||||||
expiry: 0,
|
expiry: 0,
|
||||||
parent: None,
|
parent: None,
|
||||||
delegate_depth: None,
|
delegate_depth: None,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue