diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index ad973bf3..31bdae15 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -461,6 +461,8 @@ async fn run_command(args: RunArgs) -> Result { if !pb.extra_deny_syscalls.is_empty() { builder = builder.extra_deny_syscalls(pb.extra_deny_syscalls.clone()); } for rule in &pb.http_allow { builder = builder.http_allow(rule); } for rule in &pb.http_deny { builder = builder.http_deny(rule); } + for c in &pb.credentials { builder = builder.credential_spec(c); } + for rule in &pb.http_inject { builder = builder.http_inject(rule); } for port in &pb.http_ports { builder = builder.http_port(*port); } if let Some(ref ca) = pb.http_ca { builder = builder.http_ca(ca); } if let Some(ref key) = pb.http_key { builder = builder.http_key(key); } diff --git a/crates/sandlock-core/src/checkpoint/capture.rs b/crates/sandlock-core/src/checkpoint/capture.rs index 3c674e9e..e58f9a82 100644 --- a/crates/sandlock-core/src/checkpoint/capture.rs +++ b/crates/sandlock-core/src/checkpoint/capture.rs @@ -297,6 +297,18 @@ fn parse_fdinfo(pid: i32, fd: i32) -> io::Result<(i32, u64)> { /// Capture a checkpoint from a running, stopped sandbox. /// The sandbox must already be frozen (SIGSTOP'd and fork-held). pub(crate) fn capture(pid: i32, policy: &Sandbox) -> Result { + // Credential-injection rules hold supervisor-only secrets that are + // deliberately not serialized (`#[serde(skip)]`). A checkpoint image would + // therefore restore with no injection rules and send every request + // uncredentialed — reject rather than silently drop them. + if !policy.inject.is_empty() { + return Err(SandlockError::Runtime(SandboxRuntimeError::Child( + "checkpoint is not supported with credential injection (--http-inject); \ + the injected secrets cannot be serialized into the image" + .into(), + ))); + } + // Seize via ptrace (PTRACE_SEIZE + PTRACE_INTERRUPT -- doesn't auto-SIGSTOP) ptrace_seize(pid).map_err(|e| { SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace seize: {}", e))) diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 9d41ce32..d4ef7f23 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -498,6 +498,15 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { std::env::remove_var(&key); } } + // Remove env vars whose value was loaded into the supervisor as a credential + // source, so the agent can't read the real secret straight from its own + // environment (this is the child; the mutation is process-local and post-fork). + // This runs *before* applying `sandbox.env`, so the inherited real secret is + // dropped but a deliberate placeholder the user passes (e.g. + // `--env OPENAI_API_KEY=dummy`, which SDKs need set to start) still survives. + for name in &sandbox.inject_env_strip { + std::env::remove_var(name); + } for (key, value) in &sandbox.env { std::env::set_var(key, value); } diff --git a/crates/sandlock-core/src/credential.rs b/crates/sandlock-core/src/credential.rs new file mode 100644 index 00000000..3d7e138d --- /dev/null +++ b/crates/sandlock-core/src/credential.rs @@ -0,0 +1,654 @@ +//! Credential injection (RFC #66, Phase 1 — Layer 2 primitives, transparent mode). +//! +//! A named secret lives only in the supervisor. Requests the sandboxed child +//! makes to a matching upstream have the secret rendered into an auth header (or +//! query parameter) inside the MITM proxy — after the ACL check, so a denied +//! request never touches the secret — and the child never sees the real value. +//! +//! This module holds the primitives: [`SecretString`] (zeroed on drop, never +//! printable), the [`AuthShape`] renderings, source loading (`env:`/`file:`/ +//! `fd:`), and [`InjectRule`] (a matcher + auth shape + secret). Higher layers +//! (`--service openai`) and the phantom-token swap build on top of these. + +use std::io::Read; +use std::ptr; +use std::sync::Arc; + +use crate::error::SandboxError; +use crate::http::HttpRule; + +/// A secret held in the supervisor, zeroed on drop. +/// +/// It deliberately does **not** implement `Display`, `ToString`, or +/// `serde::Serialize`, and is not reachable through any `std::error::Error` +/// chain — so a stray `format!("{}", ..)` or a serialized policy cannot re-open +/// the leak the type exists to close. Only [`SecretString::expose`], used at the +/// single point where the value is rendered into an outbound request, reaches +/// the bytes. +pub struct SecretString(Vec); + +impl SecretString { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// The raw secret bytes. Call this only to render the value into an outbound + /// header/query at send time — never to log, store, or return it. + fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl Drop for SecretString { + fn drop(&mut self) { + // Volatile writes so the zeroing can't be optimized away. + for b in self.0.iter_mut() { + unsafe { ptr::write_volatile(b as *mut u8, 0) }; + } + } +} + +impl std::fmt::Debug for SecretString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretString()") + } +} + +/// Load a secret into the supervisor from a source spec. `literal:` is +/// intentionally unsupported — an inline value leaks via `ps` and shell history +/// even when used correctly; use `env:`/`file:`/`fd:`. +/// +/// A single trailing newline (`\n` or `\r\n`) is stripped, since files and +/// heredocs commonly add one. +pub fn load_secret(source: &str) -> Result { + let (kind, val) = source.split_once(':').ok_or_else(|| { + SandboxError::Invalid(format!( + "credential source must be env:/file:/fd:<...>, got {source:?}" + )) + })?; + let mut bytes = match kind { + "env" => std::env::var_os(val) + .ok_or_else(|| SandboxError::Invalid(format!("credential env var {val} is not set")))? + .into_encoded_bytes(), + "file" => read_capped( + &mut std::fs::File::open(val) + .map_err(|e| SandboxError::Invalid(format!("credential file {val}: {e}")))?, + &format!("file {val}"), + )?, + "fd" => { + let n: i32 = val + .parse() + .map_err(|_| SandboxError::Invalid(format!("credential fd must be an integer, got {val:?}")))?; + read_fd(n)? + } + "literal" => { + return Err(SandboxError::Invalid( + "credential source 'literal:' is unsupported (it leaks via ps / shell history); \ + use env:/file:/fd:" + .into(), + )) + } + other => return Err(SandboxError::Invalid(format!("unknown credential source {other:?}"))), + }; + if bytes.last() == Some(&b'\n') { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + } + if bytes.is_empty() { + return Err(SandboxError::Invalid(format!("credential from {source:?} is empty"))); + } + Ok(SecretString::new(bytes)) +} + +/// Largest secret we read from a `file:`/`fd:` source. Bounds supervisor memory +/// so a hostile/careless `file:/dev/zero` or an endless `fd:` pipe can't OOM it. +const MAX_SECRET_BYTES: u64 = 64 << 10; + +/// Read at most `MAX_SECRET_BYTES` from `r`, erroring if the source is larger. +fn read_capped(r: &mut R, what: &str) -> Result, SandboxError> { + let mut buf = Vec::new(); + // Read one past the cap so an oversized source is detected, not truncated. + r.take(MAX_SECRET_BYTES + 1) + .read_to_end(&mut buf) + .map_err(|e| SandboxError::Invalid(format!("credential {what}: {e}")))?; + if buf.len() as u64 > MAX_SECRET_BYTES { + return Err(SandboxError::Invalid(format!( + "credential {what} exceeds {MAX_SECRET_BYTES} bytes" + ))); + } + Ok(buf) +} + +/// Read a credential from an already-open fd (e.g. a shell `<(...)` process +/// substitution passed as `fd:N`). Reads through a *dup*, so the caller's fd is +/// left open, and refuses the std streams (0/1/2) so a typo like `fd:1` can't +/// close/consume stdout. +fn read_fd(n: i32) -> Result, SandboxError> { + use std::os::fd::FromRawFd; + if n <= 2 { + return Err(SandboxError::Invalid(format!( + "credential fd {n} refers to a standard stream (0/1/2); pass a dedicated fd" + ))); + } + let dup = unsafe { libc::dup(n) }; + if dup < 0 { + return Err(SandboxError::Invalid(format!( + "credential fd {n}: {}", + std::io::Error::last_os_error() + ))); + } + // Owns `dup` (not `n`), so only the dup is closed on drop. + let mut f = unsafe { std::fs::File::from_raw_fd(dup) }; + read_capped(&mut f, &format!("fd {n}")) +} + +/// How the secret is attached to a matching request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthShape { + /// `Authorization: Bearer `. + Bearer, + /// `Authorization: Basic base64(:)`. + Basic { username: String }, + /// A custom header carrying the raw secret, e.g. `x-api-key: `. + Header { name: String }, + /// A query parameter carrying the raw secret, e.g. `?key=`. + /// + /// Less private than the header shapes: unlike an injected header, a query + /// value can't be marked sensitive, so it lands in the upstream's access + /// logs and any `Referer`, and a request-level tracing subscriber would log + /// it. Prefer `bearer`/`header` when the upstream accepts them; use `query` + /// only for APIs that require it, and not in a traced environment. + Query { param: String }, +} + +/// What to do when the target header/param already exists on the child's request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OnExistingHeader { + /// Overwrite the value the child already set (default). The proxy owns the + /// credential, and every major SDK (openai, anthropic, …) *requires* an API + /// key to be set and always sends its own `Authorization: Bearer ` + /// — so keeping the child's value would forward the placeholder and never + /// inject. Replacing that one header the rule targets is what makes + /// `--http-inject "* api.openai.com/* bearer openai"` work at all. + #[default] + Replace, + /// Leave the header/param the child already set, injecting only when absent. + /// Opt in with the trailing `add-only` token when the agent legitimately owns + /// the credential and the rule is a fallback. + AddOnly, +} + +/// A credential-injection rule: match a request, then attach `secret` per `auth`. +/// `name` is the credential's declared name, recorded in the audit trail — never +/// the value. +pub struct InjectRule { + pub name: String, + pub matcher: HttpRule, + pub auth: AuthShape, + /// Shared so several rules can reference one credential without re-loading + /// its source (an `fd:` source is consumed on first read). + pub secret: Arc, + pub on_existing: OnExistingHeader, +} + +impl std::fmt::Debug for InjectRule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InjectRule") + .field("name", &self.name) + .field("matcher", &self.matcher) + .field("auth", &self.auth) + .field("on_existing", &self.on_existing) + .field("secret", &self.secret) // redacted + .finish() + } +} + +impl InjectRule { + /// True if this rule applies to the given request. + pub fn matches(&self, method: &str, host: &str, path: &str) -> bool { + self.matcher.matches(method, host, path) + } + + /// Render the secret into `parts` (header or query), honoring `on_existing`. + /// The rendered header is marked sensitive so downstream logging redacts it. + /// + /// `Err(())` means the secret could not be rendered (e.g. it contains bytes + /// illegal in an HTTP header value) — the caller must reject the request + /// rather than forward it with no (or a partial) credential. A no-op skip + /// under `AddOnly` (the agent already set the header) is `Ok`. + /// + /// Zeroing is best-effort: [`SecretString`] itself is wiped on drop, but the + /// transient buffers built here (the `Bearer `/`Basic ` byte vecs, the base64 + /// string, the query pair) hold a copy of the secret and are dropped without + /// volatile zeroing, so a copy briefly lives in freed heap. + pub fn apply(&self, parts: &mut hyper::http::request::Parts) -> Result<(), ()> { + match &self.auth { + AuthShape::Bearer => { + let mut v = b"Bearer ".to_vec(); + v.extend_from_slice(self.secret.expose()); + self.set_header(parts, "authorization", &v) + } + AuthShape::Basic { username } => { + let mut raw = username.clone().into_bytes(); + raw.push(b':'); + raw.extend_from_slice(self.secret.expose()); + let mut v = b"Basic ".to_vec(); + v.extend_from_slice(base64_encode(&raw).as_bytes()); + self.set_header(parts, "authorization", &v) + } + AuthShape::Header { name } => self.set_header(parts, name, self.secret.expose()), + AuthShape::Query { param } => self.set_query(parts, param), + } + } + + fn set_header( + &self, + parts: &mut hyper::http::request::Parts, + name: &str, + value: &[u8], + ) -> Result<(), ()> { + let hn = hyper::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| ())?; + if self.on_existing == OnExistingHeader::AddOnly && parts.headers.contains_key(&hn) { + return Ok(()); // agent already set it — leave it, not a failure + } + let mut hv = hyper::header::HeaderValue::from_bytes(value).map_err(|_| ())?; + hv.set_sensitive(true); + parts.headers.insert(hn, hv); + Ok(()) + } + + fn set_query(&self, parts: &mut hyper::http::request::Parts, param: &str) -> Result<(), ()> { + let uri = &parts.uri; + let path = uri.path(); + let existing = uri.query(); + let prefix = format!("{}=", urlencode_bytes(param.as_bytes())); + // Honor AddOnly: don't append a param the request already carries. + if self.on_existing == OnExistingHeader::AddOnly { + if let Some(q) = existing { + if q.split('&').any(|kv| kv.starts_with(&prefix)) { + return Ok(()); + } + } + } + // Percent-encode the raw secret bytes (never lossy-stringify — a binary + // key would be corrupted). + let pair = format!("{}{}", prefix, urlencode_bytes(self.secret.expose())); + // Drop any existing occurrence of this param before appending, so + // `Replace` actually replaces instead of appending a duplicate (most + // frameworks read the first occurrence, so a duplicate would leave the + // child's placeholder winning). For `AddOnly` this only runs when the + // param is absent, so the filter is a no-op there. + let kept: Option = existing.map(|q| { + q.split('&') + .filter(|kv| !kv.is_empty() && !kv.starts_with(&prefix)) + .collect::>() + .join("&") + }); + let new_pq = match kept { + Some(ref k) if !k.is_empty() => format!("{path}?{k}&{pair}"), + _ => format!("{path}?{pair}"), + }; + let mut b = hyper::http::uri::Builder::new(); + if let Some(s) = uri.scheme() { + b = b.scheme(s.clone()); + } + if let Some(a) = uri.authority() { + b = b.authority(a.clone()); + } + parts.uri = b.path_and_query(new_pq).build().map_err(|_| ())?; + Ok(()) + } +} + +/// Parse an `AuthShape` from the auth token of an `--http-inject` rule: +/// `bearer` | `basic:` | `header:` | `apikey:` | `query:`. +pub fn parse_auth(spec: &str, credential: &str) -> Result { + let (kind, arg) = match spec.split_once(':') { + Some((k, a)) => (k, Some(a)), + None => (spec, None), + }; + let shape = match (kind, arg) { + ("bearer", None) => AuthShape::Bearer, + // RFC 7617 forbids ':' in the user-id: it would shift the `user:pass` + // boundary in the base64 payload (upstream would parse part of the secret + // as the password), so reject it rather than silently mis-encode. + ("basic", Some(user)) if user.contains(':') => { + return Err(SandboxError::Invalid(format!( + "basic auth user-id must not contain ':' (RFC 7617), got {user:?}" + ))) + } + ("basic", Some(user)) if !user.is_empty() => AuthShape::Basic { username: user.to_string() }, + // `apikey:
` and `header:` are the same rendering. + ("header" | "apikey", Some(name)) if !name.is_empty() => AuthShape::Header { name: name.to_string() }, + ("query", Some(param)) if !param.is_empty() => AuthShape::Query { param: param.to_string() }, + _ => { + return Err(SandboxError::Invalid(format!( + "invalid auth shape {spec:?} for credential {credential:?} \ + (expected bearer | basic: | header: | apikey: | query:)" + ))) + } + }; + Ok(shape) +} + +/// Resolve `--credential`/`--http-inject` specs into ready-to-apply rules, +/// loading each secret into the supervisor. +/// +/// - `credentials`: `NAME=SOURCE` where SOURCE is `env:`/`file:`/`fd:` (see +/// [`load_secret`]). +/// - `inject`: `METHOD HOST/PATH AUTHSPEC CREDNAME [replace|add-only]`, e.g. +/// `"* api.openai.com/* bearer openai"` or `"GET x.com/* header:x-api-key key add-only"`. +/// AUTHSPEC is `bearer | basic: | header: | apikey: | query:`. +/// The trailing token defaults to `replace` (the proxy overwrites the +/// placeholder auth SDKs always send); pass `add-only` to keep a value the +/// child set. +/// +/// Each rule loads its own secret from the named credential's source, so a +/// credential may back several rules (an `fd:` source is single-use, so it can +/// back only one). Referencing an undeclared credential is an error. +pub fn resolve_inject_rules( + credentials: &[String], + inject: &[String], +) -> Result<(Vec, Vec), SandboxError> { + use std::collections::HashMap; + + let mut sources: HashMap<&str, &str> = HashMap::new(); + for c in credentials { + let (name, source) = c.split_once('=').ok_or_else(|| { + SandboxError::Invalid(format!("--credential must be NAME=SOURCE, got {c:?}")) + })?; + if name.is_empty() { + return Err(SandboxError::Invalid(format!("--credential has empty name: {c:?}"))); + } + if sources.insert(name, source).is_some() { + return Err(SandboxError::Invalid(format!("--credential {name:?} declared twice"))); + } + } + + // Parse every rule first so we know which credentials are actually used. + struct Parsed<'a> { + name: &'a str, + matcher: HttpRule, + auth: AuthShape, + on_existing: OnExistingHeader, + source: &'a str, + } + let mut parsed: Vec = Vec::with_capacity(inject.len()); + for spec in inject { + let toks: Vec<&str> = spec.split_whitespace().collect(); + if toks.len() < 4 { + return Err(SandboxError::Invalid(format!( + "--http-inject must be 'METHOD HOST/PATH AUTHSPEC CREDNAME [replace|add-only]', got {spec:?}" + ))); + } + let matcher = HttpRule::parse(&format!("{} {}", toks[0], toks[1]))?; + let cred = toks[3]; + let auth = parse_auth(toks[2], cred)?; + let on_existing = match toks.get(4) { + // Default Replace: the proxy owns the credential, and SDKs always send + // a placeholder auth header (see OnExistingHeader::Replace). + None | Some(&"replace") => OnExistingHeader::Replace, + Some(&"add-only") => OnExistingHeader::AddOnly, + Some(other) => { + return Err(SandboxError::Invalid(format!( + "--http-inject trailing token must be 'replace', 'add-only', or absent, got {other:?}" + ))) + } + }; + let source = *sources.get(cred).ok_or_else(|| { + SandboxError::Invalid(format!("--http-inject references undeclared credential {cred:?}")) + })?; + parsed.push(Parsed { name: cred, matcher, auth, on_existing, source }); + } + + // Load each referenced credential exactly once (so an `fd:` source is read + // once and several rules can share the secret), and collect the env-var + // names of `env:` sources so the child can be denied them — otherwise the + // agent would just read the value straight from its own environment. + let mut loaded: HashMap<&str, Arc> = HashMap::new(); + let mut env_strip: Vec = Vec::new(); + for p in &parsed { + if !loaded.contains_key(p.name) { + loaded.insert(p.name, Arc::new(load_secret(p.source)?)); + if let Some(var) = p.source.strip_prefix("env:") { + if !env_strip.iter().any(|v| v == var) { + env_strip.push(var.to_string()); + } + } + } + } + + let rules = parsed + .into_iter() + .map(|p| InjectRule { + name: p.name.to_string(), + matcher: p.matcher, + auth: p.auth, + secret: Arc::clone(&loaded[p.name]), + on_existing: p.on_existing, + }) + .collect(); + Ok((rules, env_strip)) +} + +/// Standard base64 (RFC 4648) — small inline encoder to avoid a dependency. +fn base64_encode(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' }); + out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); + } + out +} + +/// Percent-encode raw bytes as a query component (encode everything not +/// unreserved, so `&`/`=`/`#`/`%` and any binary byte can't break out). +fn urlencode_bytes(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parts_of(uri: &str, headers: &[(&str, &str)]) -> hyper::http::request::Parts { + let mut b = hyper::Request::builder().uri(uri); + for (k, v) in headers { + b = b.header(*k, *v); + } + b.body(()).unwrap().into_parts().0 + } + + fn rule(auth: AuthShape, secret: &str, on_existing: OnExistingHeader) -> InjectRule { + InjectRule { + name: "test".into(), + matcher: HttpRule::parse("* api.example.com/*").unwrap(), + auth, + secret: Arc::new(SecretString::new(secret.as_bytes().to_vec())), + on_existing, + } + } + + #[test] + fn secret_debug_is_redacted() { + let s = SecretString::new(b"sk-supersecret".to_vec()); + assert_eq!(format!("{s:?}"), "SecretString()"); + // The rule's Debug must not leak the secret either. + let r = rule(AuthShape::Bearer, "sk-supersecret", OnExistingHeader::AddOnly); + assert!(!format!("{r:?}").contains("supersecret")); + } + + #[test] + fn load_secret_rejects_literal_and_unknown() { + assert!(load_secret("literal:sk-x").is_err()); + assert!(load_secret("weird:x").is_err()); + assert!(load_secret("no-colon").is_err()); + } + + #[test] + fn load_secret_env_strips_newline() { + std::env::set_var("SANDLOCK_TEST_CRED", "sk-abc\n"); + let s = load_secret("env:SANDLOCK_TEST_CRED").unwrap(); + assert_eq!(s.expose(), b"sk-abc"); + std::env::remove_var("SANDLOCK_TEST_CRED"); + assert!(load_secret("env:SANDLOCK_TEST_CRED").is_err()); + } + + #[test] + fn bearer_injects_authorization() { + let mut p = parts_of("https://api.example.com/v1/x", &[]); + rule(AuthShape::Bearer, "sk-abc", OnExistingHeader::AddOnly).apply(&mut p).unwrap(); + assert_eq!(p.headers.get("authorization").unwrap(), "Bearer sk-abc"); + assert!(p.headers.get("authorization").unwrap().is_sensitive()); + } + + #[test] + fn basic_injects_base64() { + let mut p = parts_of("https://api.example.com/x", &[]); + rule(AuthShape::Basic { username: "user".into() }, "pass", OnExistingHeader::AddOnly).apply(&mut p).unwrap(); + // base64("user:pass") == "dXNlcjpwYXNz" + assert_eq!(p.headers.get("authorization").unwrap(), "Basic dXNlcjpwYXNz"); + } + + #[test] + fn header_shape_sets_named_header() { + let mut p = parts_of("https://api.example.com/x", &[]); + rule(AuthShape::Header { name: "x-api-key".into() }, "k123", OnExistingHeader::AddOnly).apply(&mut p).unwrap(); + assert_eq!(p.headers.get("x-api-key").unwrap(), "k123"); + } + + #[test] + fn add_only_does_not_overwrite_but_replace_does() { + let mut p = parts_of("https://api.example.com/x", &[("authorization", "Bearer child-set")]); + rule(AuthShape::Bearer, "sk-real", OnExistingHeader::AddOnly).apply(&mut p).unwrap(); + assert_eq!(p.headers.get("authorization").unwrap(), "Bearer child-set"); + + let mut p2 = parts_of("https://api.example.com/x", &[("authorization", "Bearer child-set")]); + rule(AuthShape::Bearer, "sk-real", OnExistingHeader::Replace).apply(&mut p2).unwrap(); + assert_eq!(p2.headers.get("authorization").unwrap(), "Bearer sk-real"); + } + + #[test] + fn query_shape_appends_param() { + let mut p = parts_of("https://api.example.com/v1/x?a=1", &[]); + rule(AuthShape::Query { param: "key".into() }, "s e/cret", OnExistingHeader::AddOnly).apply(&mut p).unwrap(); + assert_eq!(p.uri.query().unwrap(), "a=1&key=s%20e%2Fcret"); + + let mut p2 = parts_of("https://api.example.com/v1/x", &[]); + rule(AuthShape::Query { param: "key".into() }, "abc", OnExistingHeader::AddOnly).apply(&mut p2).unwrap(); + assert_eq!(p2.uri.query().unwrap(), "key=abc"); + } + + #[test] + fn parse_auth_shapes() { + assert_eq!(parse_auth("bearer", "c").unwrap(), AuthShape::Bearer); + assert_eq!(parse_auth("basic:user", "c").unwrap(), AuthShape::Basic { username: "user".into() }); + assert_eq!(parse_auth("header:x-api-key", "c").unwrap(), AuthShape::Header { name: "x-api-key".into() }); + assert_eq!(parse_auth("apikey:x-key", "c").unwrap(), AuthShape::Header { name: "x-key".into() }); + assert_eq!(parse_auth("query:token", "c").unwrap(), AuthShape::Query { param: "token".into() }); + assert!(parse_auth("basic:", "c").is_err()); + assert!(parse_auth("bogus", "c").is_err()); + // RFC 7617: ':' in the user-id is rejected (would shift the user:pass + // boundary and leak part of the secret into the password field). + assert!(parse_auth("basic:a:b", "c").is_err()); + assert!(matches!(parse_auth("basic:alice", "c"), Ok(AuthShape::Basic { username }) if username == "alice")); + } + + #[test] + fn apply_fails_on_secret_with_illegal_header_bytes() { + // A secret containing CR/LF can't be a header value — apply must report + // failure (so the caller rejects the request) rather than silently drop. + let mut p = parts_of("https://api.example.com/x", &[]); + let r = rule(AuthShape::Bearer, "sk\r\nx-evil: 1", OnExistingHeader::AddOnly); + assert!(r.apply(&mut p).is_err()); + assert!(p.headers.get("authorization").is_none()); + assert!(p.headers.get("x-evil").is_none()); // no header injection + } + + #[test] + fn query_add_only_keeps_child_replace_overwrites() { + // AddOnly: a param the child already carries is left untouched. + let mut p = parts_of("https://api.example.com/x?key=child", &[]); + rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::AddOnly) + .apply(&mut p).unwrap(); + assert_eq!(p.uri.query().unwrap(), "key=child"); + + // Replace must *replace*, not append a duplicate — otherwise a framework + // reading the first occurrence authenticates against the child's value. + let mut p2 = parts_of("https://api.example.com/x?key=child", &[]); + rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace) + .apply(&mut p2).unwrap(); + assert_eq!(p2.uri.query().unwrap(), "key=sk-real"); + + // Replace preserves other params and drops only the target's duplicates. + let mut p3 = parts_of("https://api.example.com/x?a=1&key=old&b=2", &[]); + rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace) + .apply(&mut p3).unwrap(); + assert_eq!(p3.uri.query().unwrap(), "a=1&b=2&key=sk-real"); + + // Replace on a request without the param just appends it. + let mut p4 = parts_of("https://api.example.com/x", &[]); + rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace) + .apply(&mut p4).unwrap(); + assert_eq!(p4.uri.query().unwrap(), "key=sk-real"); + } + + #[test] + fn resolve_dedups_credentials_and_collects_env_strip() { + std::env::set_var("SANDLOCK_TEST_RESOLVE", "sk-resolve"); + let creds = vec!["a=env:SANDLOCK_TEST_RESOLVE".to_string()]; + let inject = vec![ + "GET x.com/* bearer a".to_string(), + "POST x.com/* header:x-key a".to_string(), // same credential, second rule + ]; + let (rules, env_strip) = resolve_inject_rules(&creds, &inject).unwrap(); + assert_eq!(rules.len(), 2); + // Both rules share one loaded secret (one Arc). + assert!(Arc::ptr_eq(&rules[0].secret, &rules[1].secret)); + assert_eq!(env_strip, vec!["SANDLOCK_TEST_RESOLVE".to_string()]); + std::env::remove_var("SANDLOCK_TEST_RESOLVE"); + } + + #[test] + fn resolve_rejects_undeclared_and_std_fd() { + assert!(resolve_inject_rules(&[], &["GET x/* bearer missing".to_string()]).is_err()); + assert!(load_secret("fd:1").is_err()); // stdout + assert!(load_secret("fd:0").is_err()); // stdin + } + + #[test] + fn resolve_default_is_replace_add_only_is_opt_in() { + std::env::set_var("SANDLOCK_TEST_ONEX", "sk-x"); + let creds = vec!["a=env:SANDLOCK_TEST_ONEX".to_string()]; + // No trailing token → Replace, so an SDK's placeholder Authorization is + // overwritten (the whole point of the feature — regression guard). + let (r, _) = resolve_inject_rules(&creds, &["* x.com/* bearer a".to_string()]).unwrap(); + assert_eq!(r[0].on_existing, OnExistingHeader::Replace); + // Prove it at the apply level: a child-set Authorization is replaced. + let mut p = parts_of("https://x.com/v1", &[("authorization", "Bearer sk-placeholder")]); + r[0].apply(&mut p).unwrap(); + assert_eq!(p.headers.get("authorization").unwrap(), "Bearer sk-x"); + + let (r2, _) = resolve_inject_rules(&creds, &["* x.com/* bearer a add-only".to_string()]).unwrap(); + assert_eq!(r2[0].on_existing, OnExistingHeader::AddOnly); + // Unknown trailing token is rejected. + assert!(resolve_inject_rules(&creds, &["* x.com/* bearer a keep".to_string()]).is_err()); + std::env::remove_var("SANDLOCK_TEST_ONEX"); + } +} diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index b4330768..4b5e886c 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod error; pub mod http; +pub(crate) mod credential; pub mod sandbox; // formerly `policy`; contains Sandbox + SandboxBuilder + Confinement pub mod profile; pub mod result; diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index ca99f9f5..50b36440 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -145,6 +145,7 @@ impl TryFrom<&Sandbox> for Confinement { if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); } if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); } if !sandbox.http_deny.is_empty() { unsupported.push("http_deny"); } + if !sandbox.inject.is_empty() { unsupported.push("http_inject"); } if !sandbox.http_ports.is_empty() { unsupported.push("http_ports"); } if sandbox.http_ca.is_some() { unsupported.push("http_ca"); } if sandbox.http_key.is_some() { unsupported.push("http_key"); } @@ -341,6 +342,17 @@ pub struct Sandbox { // HTTP ACL pub http_allow: Vec, pub http_deny: Vec, + /// Credential-injection rules, applied in the MITM proxy after the ACL + /// check. `Arc` so the (non-Clone) secrets flow to the proxy by sharing. + /// Not serialized: the resolved secrets live only in the supervisor and are + /// re-loaded from their sources on each build, never persisted in a policy. + #[serde(skip)] + pub(crate) inject: std::sync::Arc>, + /// `env:` var names to remove from the child's environment (so an env-sourced + /// credential can't be read straight out of the agent's own env). Just names, + /// no secrets — safe to serialize, but tied to `inject` which isn't restored. + #[serde(skip)] + pub(crate) inject_env_strip: Vec, /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when /// http_ca is set). Override with `http_ports` to intercept custom ports. pub http_ports: Vec, @@ -479,6 +491,8 @@ impl Clone for Sandbox { net_deny_bind: self.net_deny_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), + inject: self.inject.clone(), + inject_env_strip: self.inject_env_strip.clone(), http_ports: self.http_ports.clone(), http_ca: self.http_ca.clone(), http_key: self.http_key.clone(), @@ -1489,6 +1503,7 @@ impl Sandbox { let handle = crate::transparent_proxy::spawn_transparent_proxy( self.http_allow.clone(), self.http_deny.clone(), + std::sync::Arc::clone(&self.inject), cert_pem, key_pem, ) diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0cdd3b70..60fe3e72 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -59,6 +59,25 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "http-deny", value_name = "RULE"))] pub http_deny: Vec, + /// Named credential loaded into the supervisor: `NAME=SOURCE`, where SOURCE + /// is `env:VAR`, `file:/path`, or `fd:N`. The resolved secret is never handed + /// to the child: an `env:` var is stripped from the child's environment and an + /// `fd:` is read supervisor-side only. A `file:` source, however, is only as + /// private as the path — keep it outside the sandbox's readable paths, or the + /// child can open it directly. + #[cfg_attr(feature = "cli", arg(long = "credential", value_name = "NAME=SOURCE"))] + pub credentials: Vec, + + /// Credential-injection rule (needs an HTTPS MITM proxy, i.e. `--http-ca` / + /// `--http-inject-ca`): `METHOD HOST/PATH AUTHSPEC CREDNAME [replace|add-only]`, + /// where AUTHSPEC is `bearer | basic: | header: | apikey: | + /// query:`. The matching request gets the credential attached in the + /// proxy, after the ACL check. The trailing token defaults to `replace` (the + /// proxy overwrites the placeholder auth SDKs send); pass `add-only` to keep a + /// value the child set. + #[cfg_attr(feature = "cli", arg(long = "http-inject", value_name = "RULE"))] + pub http_inject: Vec, + /// TCP ports to intercept for HTTP ACL (default: 80, plus 443 with --http-ca) #[cfg_attr(feature = "cli", arg(long = "http-port", value_name = "PORT"))] pub http_ports: Vec, @@ -229,6 +248,8 @@ impl Clone for SandboxBuilder { net_deny_bind: self.net_deny_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), + credentials: self.credentials.clone(), + http_inject: self.http_inject.clone(), http_ports: self.http_ports.clone(), http_ca: self.http_ca.clone(), http_key: self.http_key.clone(), @@ -397,6 +418,24 @@ impl SandboxBuilder { self } + /// Declare a named credential: `name` and a `source` (`env:`/`file:`/`fd:`). + pub fn credential(mut self, name: &str, source: &str) -> Self { + self.credentials.push(format!("{name}={source}")); + self + } + + /// Declare a credential from a raw `NAME=SOURCE` spec (as the CLI parses it). + pub fn credential_spec(mut self, spec: &str) -> Self { + self.credentials.push(spec.to_string()); + self + } + + /// Add a credential-injection rule (see the `--http-inject` field docs). + pub fn http_inject(mut self, rule: &str) -> Self { + self.http_inject.push(rule.to_string()); + self + } + pub fn http_port(mut self, port: u16) -> Self { self.http_ports.push(port); self @@ -680,6 +719,38 @@ impl SandboxBuilder { .map(|s| HttpRule::parse(&s)) .collect::>()?; + // Credential injection happens inside the ACL proxy, so it needs the + // proxy to run at all (i.e. some http rule). Injecting into HTTPS + // additionally needs a CA (--http-ca / --http-inject-ca) to MITM 443; + // without one only plaintext HTTP is intercepted. Reject a rule that + // could never fire (no proxy). + if !self.http_inject.is_empty() && http_allow.is_empty() && http_deny.is_empty() { + return Err(SandboxError::Invalid( + "--http-inject requires an HTTP ACL proxy (--http-allow or --http-deny); \ + HTTPS injection additionally needs --http-ca or --http-inject-ca" + .into(), + )); + } + // Without a CA only port 80 is intercepted, so a rule for an HTTPS host + // (the common case: `bearer openai` → api.openai.com:443) silently never + // fires — the request bypasses the proxy and goes out uncredentialed. We + // can't know a rule's scheme at build time, so warn rather than reject. + if !self.http_inject.is_empty() && self.http_ca.is_none() && self.http_inject_ca.is_empty() { + eprintln!( + "sandlock: warning: --http-inject with no CA (--http-ca/--http-inject-ca) only \ + injects into plaintext HTTP (port 80); requests to HTTPS hosts bypass the proxy \ + and are sent without the credential" + ); + } + // Resolve credentials + injection rules, loading each secret into the + // supervisor. Wrapped in Arc so it flows to the proxy without cloning + // the (deliberately non-Clone) secrets. `inject_env_strip` is the set of + // `env:` var names to remove from the child, so an env-sourced secret + // can't just be read out of the child's own environment. + let (inject_rules, inject_env_strip) = + crate::credential::resolve_inject_rules(&self.credentials, &self.http_inject)?; + let inject = std::sync::Arc::new(inject_rules); + // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { let mut ports = vec![80]; @@ -744,6 +815,8 @@ impl SandboxBuilder { net_deny_bind, http_allow, http_deny, + inject, + inject_env_strip, http_ports, http_ca: self.http_ca, http_key: self.http_key, diff --git a/crates/sandlock-core/src/transparent_proxy/mod.rs b/crates/sandlock-core/src/transparent_proxy/mod.rs index e3fbc785..4215d59e 100644 --- a/crates/sandlock-core/src/transparent_proxy/mod.rs +++ b/crates/sandlock-core/src/transparent_proxy/mod.rs @@ -49,6 +49,7 @@ fn is_tls_client_hello(first_byte: u8) -> bool { pub(crate) async fn spawn_transparent_proxy( allow: Vec, deny: Vec, + inject: Arc>, ca_cert_pem: Option<&str>, ca_key_pem: Option<&str>, ) -> std::io::Result { @@ -56,7 +57,7 @@ pub(crate) async fn spawn_transparent_proxy( let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())); let forwarder = Forwarder::new()?; - let svc = AclService::new(allow, deny, Arc::clone(&orig_dest), forwarder); + let svc = AclService::new(allow, deny, inject, Arc::clone(&orig_dest), forwarder); let signer = match (ca_cert_pem, ca_key_pem) { (Some(c), Some(k)) => Some(Arc::new(CertSigner::new(c, k)?)), @@ -181,7 +182,7 @@ mod tests { .expect("resolve_ca ok") .expect("ephemeral CA generated"); let allow = vec![crate::http::HttpRule::parse("GET allowed.test/*").expect("rule parses")]; - let handle = super::spawn_transparent_proxy(allow, vec![], Some(&ca.cert_pem), Some(&ca.key_pem)) + let handle = super::spawn_transparent_proxy(allow, vec![], Arc::new(vec![]), Some(&ca.cert_pem), Some(&ca.key_pem)) .await .expect("proxy spawns"); let addr = handle.addr; diff --git a/crates/sandlock-core/src/transparent_proxy/service.rs b/crates/sandlock-core/src/transparent_proxy/service.rs index 4835502d..d8557264 100644 --- a/crates/sandlock-core/src/transparent_proxy/service.rs +++ b/crates/sandlock-core/src/transparent_proxy/service.rs @@ -14,6 +14,7 @@ use hyper::{Request, Response, StatusCode}; use tokio::sync::Mutex; use super::upstream::{box_incoming, Forwarder}; +use crate::credential::InjectRule; use crate::http::{http_acl_check, HttpRule}; type BoxError = Box; @@ -31,6 +32,7 @@ struct DnsEntry { pub(crate) struct AclService { pub(crate) allow: Arc>, pub(crate) deny: Arc>, + pub(crate) inject: Arc>, pub(crate) orig_dest: OrigDestMap, pub(crate) forwarder: Forwarder, dns_cache: Arc>>, @@ -40,12 +42,14 @@ impl AclService { pub(crate) fn new( allow: Vec, deny: Vec, + inject: Arc>, orig_dest: OrigDestMap, forwarder: Forwarder, ) -> Self { Self { allow: Arc::new(allow), deny: Arc::new(deny), + inject, orig_dest, forwarder, dns_cache: Arc::new(Mutex::new(HashMap::new())), @@ -150,6 +154,33 @@ impl AclService { let (mut parts, body) = req.into_parts(); parts.uri = uri; + + // ACL passed: attach a credential if a rule matches. First match wins. + // The secret is rendered into the outbound request only here — never on + // the deny path above — and only its name is recorded, never the value. + for r in self.inject.iter() { + if r.matches(&method, &host, &path) { + if r.apply(&mut parts).is_err() { + // Rendering failed (e.g. the secret has bytes illegal in a + // header) — fail the request rather than forward it with no + // credential, which would look like an auth bug to the caller. + eprintln!( + "sandlock: credential {:?} could not be rendered for {} {}{} — rejecting", + r.name, method, host, path + ); + return text_response( + StatusCode::BAD_GATEWAY, + "Blocked by sandlock: credential could not be applied", + ); + } + eprintln!( + "sandlock: injected credential {:?} for {} {}{}", + r.name, method, host, path + ); + break; + } + } + let out_req = Request::from_parts(parts, box_incoming(body)); match self.forwarder.forward(out_req).await { diff --git a/crates/sandlock-core/tests/integration/test_http_acl.rs b/crates/sandlock-core/tests/integration/test_http_acl.rs index 301d693f..8d4dd2a4 100644 --- a/crates/sandlock-core/tests/integration/test_http_acl.rs +++ b/crates/sandlock-core/tests/integration/test_http_acl.rs @@ -529,3 +529,154 @@ async fn test_http_acl_ipv6_method_filtering() { let _ = std::fs::remove_file(&out_get); let _ = std::fs::remove_file(&out_post); } + +/// Spawn a server that records the `Authorization` header of the one request it +/// receives, for credential-injection tests. +fn spawn_capturing_http_server() -> ( + u16, + thread::JoinHandle<()>, + std::sync::Arc>>, +) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let cap = captured.clone(); + let handle = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if line.to_lowercase().starts_with("authorization:") { + *cap.lock().unwrap() = + Some(line.split_once(':').unwrap().1.trim().to_string()); + } + if line == "\r\n" || line == "\n" { + break; + } + } + let resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + (port, handle, captured) +} + +/// A credential declared in the supervisor is injected into the outbound request +/// inside the proxy — the child never carries it in env/argv/headers — and +/// reaches the upstream, after the ACL check. +#[tokio::test] +async fn test_credential_injected_into_upstream() { + let out = temp_file("cred-inject"); + let secret_file = temp_file("cred-secret"); + std::fs::write(&secret_file, "sk-phase1-secret\n").unwrap(); + let (port, srv, captured) = spawn_capturing_http_server(); + + let policy = base_policy() + .http_allow("GET 127.0.0.1/*") + .http_port(port) + .credential("api", &format!("file:{}", secret_file.display())) + .http_inject("GET 127.0.0.1/* bearer api") + .build() + .unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/data", port), &out); + let result = policy.with_name("test").run_interactive(&["python3", "-c", &script]) + .await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "child request should succeed, got: {}", content); + + srv.join().unwrap(); + let got = captured.lock().unwrap().clone(); + let _ = std::fs::remove_file(&out); + let _ = std::fs::remove_file(&secret_file); + + // The child sent no Authorization header; the proxy injected the credential + // by value while the child only ever knew its name. + assert_eq!( + got.as_deref(), Some("Bearer sk-phase1-secret"), + "upstream must receive the injected credential (got {got:?})" + ); +} + +/// Deny-path invariant: a request that matches an inject rule but is denied by +/// the ACL must be blocked (403) and the credential must never be rendered — the +/// upstream sees no connection at all. Proves injection sits strictly after the +/// ACL check. +#[tokio::test] +async fn test_denied_request_does_not_inject_credential() { + let out = temp_file("cred-deny"); + let secret_file = temp_file("cred-deny-secret"); + std::fs::write(&secret_file, "sk-must-not-leak\n").unwrap(); + let (port, _srv, captured) = spawn_capturing_http_server(); + + // ACL allows only /allowed; the inject rule matches every path. A GET to + // /secret is denied by the ACL, so injection must not run. + let policy = base_policy() + .http_allow("GET 127.0.0.1/allowed") + .http_port(port) + .credential("api", &format!("file:{}", secret_file.display())) + .http_inject("GET 127.0.0.1/* bearer api") + .build() + .unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/secret", port), &out); + let result = policy.with_name("test").run_interactive(&["python3", "-c", &script]) + .await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + + let content = std::fs::read_to_string(&out).unwrap_or_default(); + // Give the (never-reached) upstream a moment; it must not have been contacted. + std::thread::sleep(std::time::Duration::from_millis(200)); + let got = captured.lock().unwrap().clone(); + let _ = std::fs::remove_file(&out); + let _ = std::fs::remove_file(&secret_file); + + assert!(content.starts_with("HTTP:403"), "denied request should be 403, got: {}", content); + assert_eq!(got, None, "credential must not reach the upstream on a denied request"); +} + +/// An `env:`-sourced credential must be scrubbed from the child's environment, +/// otherwise the agent could read the real secret straight out of its own env +/// instead of relying on proxy-side injection. The child does no network here — +/// it just reports whether it can still see the variable. +#[tokio::test] +async fn test_env_sourced_credential_stripped_from_child() { + std::env::set_var("SANDLOCK_TEST_SECRET_ENV", "sk-env-secret"); + let out = temp_file("cred-envstrip"); + let (port, _srv) = spawn_http_server(0); // just to obtain a valid intercept port + let policy = base_policy() + .http_allow("GET 127.0.0.1/*") + .http_port(port) + .credential("api", "env:SANDLOCK_TEST_SECRET_ENV") + .http_inject("GET 127.0.0.1/* bearer api") + .build() + .unwrap(); + + let script = format!( + "import os; open('{}', 'w').write(os.environ.get('SANDLOCK_TEST_SECRET_ENV', 'ABSENT'))", + out.display() + ); + let result = policy.with_name("test").run_interactive(&["python3", "-c", &script]) + .await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + std::env::remove_var("SANDLOCK_TEST_SECRET_ENV"); + assert_eq!( + content, "ABSENT", + "env-sourced credential must be stripped from the child's environment, got {content:?}" + ); +} + +// NOTE (RFC #66 follow-up): injection over the HTTPS/MITM path is not covered +// here — this suite has no TLS upstream or ephemeral-CA harness. The rendering +// code is scheme-agnostic (service.rs runs the same `apply` after the ACL on the +// "https" path), and the plaintext tests above exercise it; a TLS end-to-end +// test needs a CA-trust harness and is tracked as a separate follow-up.