diff --git a/Cargo.lock b/Cargo.lock index 9d8247e5..b8aa5adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,6 +2950,7 @@ dependencies = [ "rand_core 0.6.4", "rcgen", "regorus", + "reqwest", "russh", "rustls", "rustls-pemfile", diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a9d80ac8..f58ad45a 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -31,7 +31,7 @@ All paths are relative to `crates/openshell-sandbox/src/`. | `l7/inference.rs` | Inference API pattern detection (`detect_inference_pattern()`), HTTP request/response parsing and formatting for intercepted inference connections | | `l7/tls.rs` | Ephemeral CA generation (`SandboxCa`), per-hostname leaf cert cache (`CertCache`), TLS termination/connection helpers | | `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation | -| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing (Content-Length, chunked), deny response generation | +| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing, deny response generation, and **custom header injection** | | `l7/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types | ## Startup and Orchestration @@ -281,7 +281,9 @@ The proxy calls `evaluate_network_action()` (not `evaluate_network()`) as its ma ### L7 endpoint config query -After L4 allows a connection, `query_endpoint_config(input)` evaluates `data.openshell.sandbox.matched_endpoint_config` to get the full endpoint object. If the endpoint has a `protocol` field, `l7::parse_l7_config()` extracts the L7 config for protocol-aware inspection. +After L4 allows a connection, `query_endpoint_config(input)` evaluates `data.openshell.sandbox.matched_endpoint_config` to get the full endpoint object. If the endpoint has a `protocol` field, `l7::parse_l7_config()` extracts the L7 config (including any `external_resolver` settings) for protocol-aware inspection. + +If an `external_resolver` is present, `l7::relay::resolve_external_secret()` is called to fetch a dynamic secret from an external API using a configurable HTTP method and JSON body template. This secret is then passed to the L7 REST relay and injected into the outbound request's `Authorization: Bearer` header. ### Engine cloning for L7 diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b63179c4..7d39b1b4 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -438,6 +438,19 @@ Each endpoint defines a network destination and, optionally, L7 inspection behav | `access` | `string` | `""` | Shorthand preset for common L7 rule sets. Mutually exclusive with `rules`. | | `rules` | `L7Rule[]` | `[]` | Explicit L7 allow rules. Mutually exclusive with `access`. | | `allowed_ips` | `string[]` | `[]` | IP allowlist for SSRF override. See [Private IP Access via `allowed_ips`](#private-ip-access-via-allowed_ips). | +| `external_resolver` | `ExternalResolver`| `None` | Configuration for dynamic secret resolution via external API. Only for `protocol: rest`. | + +#### `ExternalResolver` + +Defines an external API to call for fetching secrets (e.g., API keys) before forwarding a request. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | `string` | Yes | The URL of the external resolver service. | +| `method` | `string` | No | HTTP method to use (e.g., `POST`, `GET`). Default: `GET`. | +| `header` | `string` | No | HTTP header name to inject the secret into. Default: `Authorization`. | +| `body_template` | `string` | No | JSON body template with placeholders: `{{.SandboxID}}`, `{{.Host}}`, `{{.Port}}`, `{{.Binary}}`. | +| `response_path` | `string` | No | Dot-separated response path for extracting the secret (e.g., `data.token`). If omitted, fallback keys `secret`, `token`, `key` are used. | #### `NetworkBinary` diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f1c15539..b606035a 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -99,6 +99,22 @@ struct NetworkEndpointDef { rules: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] allowed_ips: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + external_resolver: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExternalResolverDef { + url: String, + #[serde(default)] + method: String, + #[serde(default)] + body_template: String, + #[serde(default)] + header: String, + #[serde(default)] + response_path: String, } fn is_zero(v: &u32) -> bool { @@ -180,6 +196,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(), allowed_ips: e.allowed_ips, + external_resolver: e.external_resolver.map(|er| { + openshell_core::proto::ExternalResolver { + url: er.url, + method: er.method, + body_template: er.body_template, + header: er.header, + response_path: er.response_path, + } + }), } }) .collect(), @@ -280,6 +305,15 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(), allowed_ips: e.allowed_ips.clone(), + external_resolver: e.external_resolver.as_ref().map(|er| { + ExternalResolverDef { + url: er.url.clone(), + method: er.method.clone(), + body_template: er.body_template.clone(), + header: er.header.clone(), + response_path: er.response_path.clone(), + } + }), } }) .collect(), @@ -1117,4 +1151,27 @@ network_policies: proto2.network_policies["test"].endpoints[0].host ); } + + #[test] + fn parse_external_resolver() { + let yaml = r#" +version: 1 +network_policies: + test: + name: test + endpoints: + - { host: "api.openai.com", port: 443, protocol: "rest", external_resolver: { url: "http://host.openshell.internal:5000/v1/resolve-secret", method: "POST", header: "X-OpenShell-Token", response_path: "data.token", body_template: '{"foo": "bar"}' } } + binaries: + - { path: /usr/bin/curl } +"#; + let policy = parse_sandbox_policy(yaml).expect("should parse"); + let ep = &policy.network_policies["test"].endpoints[0]; + assert_eq!(ep.host, "api.openai.com"); + let er = ep.external_resolver.as_ref().expect("external_resolver missing"); + assert_eq!(er.url, "http://host.openshell.internal:5000/v1/resolve-secret"); + assert_eq!(er.method, "POST"); + assert_eq!(er.header, "X-OpenShell-Token"); + assert_eq!(er.response_path, "data.token"); + assert_eq!(er.body_template, "{\"foo\": \"bar\"}"); + } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 8a0639a7..9f87aacf 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -50,6 +50,7 @@ rcgen = { workspace = true } webpki-roots = { workspace = true } # HTTP +reqwest = { workspace = true } bytes = { workspace = true } # IP network / CIDR parsing diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 61393e15..2ec5f4c1 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -285,3 +285,20 @@ endpoint_has_extended_config(ep) if { endpoint_has_extended_config(ep) if { count(object.get(ep, "allowed_ips", [])) > 0 } + +# --- External secret resolution (queried per-request within a tunnel) --- +# Returns the resolver metadata (url, method, body_template) if the request +# matches a policy that specifies an external_resolver. + +default external_resolver = undefined + +external_resolver := resolver if { + some name + policy := data.network_policies[name] + endpoint_allowed(policy, input.network) + binary_allowed(policy, input.exec) + some ep + ep := policy.endpoints[_] + endpoint_matches_request(ep, input.network) + resolver := ep.external_resolver +} diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 9b9ae473..f10b9aaa 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -8,6 +8,8 @@ //! doing a raw `copy_bidirectional`. Each request within the tunnel is parsed, //! evaluated against OPA policy, and either forwarded or denied. +use tracing::info; + pub mod inference; pub mod provider; pub mod relay; @@ -51,12 +53,23 @@ pub enum EnforcementMode { Enforce, } +/// External secret resolver configuration. +#[derive(Debug, Clone)] +pub struct ExternalResolverConfig { + pub url: String, + pub method: String, + pub body_template: String, + pub header: String, + pub response_path: String, +} + /// L7 configuration for an endpoint, extracted from policy data. #[derive(Debug, Clone)] pub struct L7EndpointConfig { pub protocol: L7Protocol, pub tls: TlsMode, pub enforcement: EnforcementMode, + pub external_resolver: Option, } /// Result of an L7 policy decision for a single request. @@ -89,15 +102,38 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { _ => TlsMode::Passthrough, }; + info!(val = ?val, "Parsing L7 config from OPA value"); let enforcement = match get_object_str(val, "enforcement").as_deref() { Some("enforce") => EnforcementMode::Enforce, _ => EnforcementMode::Audit, }; + let external_resolver = (|| { + let obj = val.as_object().ok()?; + let found = obj.get(®orus::Value::from("external_resolver")).is_some(); + info!(has_external_resolver = found, "Checking for external_resolver in OPA object"); + let v = obj.get(®orus::Value::from("external_resolver"))?; + let url = get_object_str(v, "url")?; + let method = get_object_str(v, "method")?; + let body_template = get_object_str(v, "body_template").unwrap_or_default(); + let header = get_object_str(v, "header").unwrap_or_else(|| "Authorization".to_string()); + let response_path = get_object_str(v, "response_path").unwrap_or_default(); + let config = ExternalResolverConfig { + url, + method, + body_template, + header, + response_path, + }; + info!(config = ?config, "Parsed external resolver config from OPA"); + Some(config) + })(); + Some(L7EndpointConfig { protocol, tls, enforcement, + external_resolver, }) } diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index c1c5bb27..3b5f4899 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -31,6 +31,8 @@ pub struct L7EvalContext { pub cmdline_paths: Vec, /// Supervisor-only placeholder resolver for outbound headers. pub(crate) secret_resolver: Option>, + /// Sandbox ID for external secret resolution. + pub sandbox_id: Option, } /// Run protocol-aware L7 inspection on a tunnel. @@ -133,12 +135,34 @@ where ); if allowed || config.enforcement == EnforcementMode::Audit { + let external_secret = if let Some(resolver) = &config.external_resolver { + match resolve_external_secret(resolver, ctx.sandbox_id.as_deref().unwrap_or("-"), ctx).await { + Ok(secret) => { + Some(secret) + } + Err(e) => { + warn!(error = %e, "External secret resolution failed"); + None + } + } + } else { + None + }; + + let external_header = config + .external_resolver + .as_ref() + .map(|r| r.header.as_str()) + .unwrap_or(""); + // Forward request to upstream and relay response let reusable = crate::l7::rest::relay_http_request_with_resolver( &req, client, upstream, ctx.secret_resolver.as_deref(), + external_secret.as_deref(), + external_header, ) .await?; if !reusable { @@ -229,3 +253,88 @@ fn evaluate_l7_request( Ok((allowed, reason)) } + +async fn resolve_external_secret( + resolver: &crate::l7::ExternalResolverConfig, + sandbox_id: &str, + ctx: &L7EvalContext, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| miette::miette!("failed to build reqwest client: {e}"))?; + + let body = resolver + .body_template + .replace("{{.SandboxID}}", sandbox_id) + .replace("{{.Host}}", &ctx.host) + .replace("{{.Port}}", &ctx.port.to_string()) + .replace("{{.Binary}}", &ctx.binary_path); + + let method = resolver.method.to_uppercase(); + + let mut builder = match method.as_str() { + "POST" => client.post(&resolver.url), + "PUT" => client.put(&resolver.url), + _ => client.get(&resolver.url), + }; + + if !body.is_empty() { + builder = builder + .header("Content-Type", "application/json") + .body(body); + } + + let resp = builder + .send() + .await + .map_err(|e| miette::miette!("external resolver request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(miette::miette!( + "external resolver returned error {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| miette::miette!("failed to parse resolver response: {e}"))?; + + if !resolver.response_path.is_empty() { + let value = extract_json_value_by_path(&json, &resolver.response_path).ok_or_else(|| { + miette::miette!( + "external resolver response missing value at response_path '{}'", + resolver.response_path + ) + })?; + + let secret = value.as_str().ok_or_else(|| { + miette::miette!( + "external resolver value at response_path '{}' must be a string", + resolver.response_path + ) + })?; + + return Ok(secret.to_string()); + } + + let secret = json + .get("secret") + .or_else(|| json.get("token")) + .or_else(|| json.get("key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + miette::miette!("external resolver response missing 'secret', 'token', or 'key' field") + })?; + + Ok(secret.to_string()) +} + +fn extract_json_value_by_path<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + path.split('.') + .filter(|segment| !segment.is_empty()) + .try_fold(root, |current, segment| current.get(segment)) +} diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 61b026f5..f4854137 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -123,7 +123,7 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - relay_http_request_with_resolver(req, client, upstream, None).await + relay_http_request_with_resolver(req, client, upstream, None, None, "").await } pub(crate) async fn relay_http_request_with_resolver( @@ -131,6 +131,8 @@ pub(crate) async fn relay_http_request_with_resolver( client: &mut C, upstream: &mut U, resolver: Option<&crate::secrets::SecretResolver>, + external_secret: Option<&str>, + external_header: &str, ) -> Result where C: AsyncRead + AsyncWrite + Unpin, @@ -143,9 +145,14 @@ where .map_or(req.raw_header.len(), |p| p + 4); let rewritten_header = rewrite_http_header_block(&req.raw_header[..header_end], resolver); + let final_header = if let Some(secret) = external_secret { + inject_custom_header(&rewritten_header, external_header, secret) + } else { + rewritten_header + }; upstream - .write_all(&rewritten_header) + .write_all(&final_header) .await .into_diagnostic()?; @@ -171,6 +178,53 @@ where relay_response(&req.action, upstream, client).await } +/// Inject or override a custom header with the given secret. +fn inject_custom_header(raw: &[u8], header_name: &str, secret: &str) -> Vec { + let header_str = String::from_utf8_lossy(raw); + let mut lines = header_str.split("\r\n"); + let Some(request_line) = lines.next() else { + return raw.to_vec(); + }; + + let mut output = Vec::with_capacity(raw.len() + secret.len() + 32); + output.extend_from_slice(request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + let header_lower = header_name.to_lowercase(); + + let mut injected = false; + for line in lines { + if line.is_empty() { + break; + } + + if line.to_lowercase().starts_with(&format!("{}:", header_lower)) { + let value = if header_lower == "authorization" { + format!("Bearer {secret}") + } else { + secret.to_string() + }; + output.extend_from_slice(format!("{header_name}: {value}\r\n").as_bytes()); + injected = true; + } else { + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + } + + if !injected { + let value = if header_lower == "authorization" { + format!("Bearer {secret}") + } else { + secret.to_string() + }; + output.extend_from_slice(format!("{header_name}: {value}\r\n").as_bytes()); + } + + output.extend_from_slice(b"\r\n"); + output +} + /// Send a 403 Forbidden JSON deny response. async fn send_deny_response( req: &L7Request, @@ -1066,6 +1120,8 @@ mod tests { &mut proxy_to_client, &mut proxy_to_upstream, resolver.as_ref(), + None, + "", ), ) .await @@ -1149,6 +1205,8 @@ mod tests { &mut proxy_to_client, &mut proxy_to_upstream, None, // <-- No resolver, as in the L4 raw tunnel path + None, + "", ), ) .await diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 493e4d23..4ad4d2c5 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -350,6 +350,7 @@ pub async fn run_sandbox( inference_ctx, secret_resolver.clone(), denial_tx, + sandbox_id.clone(), ) .await?; (Some(proxy_handle), denial_rx, bypass_denial_tx) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index bfe9c68f..0b30c800 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -9,6 +9,7 @@ use crate::policy::{FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy}; use miette::Result; +use tracing::info; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -681,6 +682,16 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { if !e.allowed_ips.is_empty() { ep["allowed_ips"] = e.allowed_ips.clone().into(); } + info!(has_er = e.external_resolver.is_some(), "Checking NetworkEndpoint proto for external_resolver"); + if let Some(er) = e.external_resolver.as_ref() { + ep["external_resolver"] = serde_json::json!({ + "url": er.url, + "method": er.method, + "body_template": er.body_template, + "header": er.header, + "response_path": er.response_path, + }); + } ep }) .collect(); @@ -700,13 +711,17 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { }) .collect(); - serde_json::json!({ + let mut data = serde_json::json!({ "filesystem_policy": filesystem_policy, "landlock": landlock, "process": process, "network_policies": network_policies, - }) - .to_string() + }); + + // Expand access presets to explicit rules so Rego can match them + crate::l7::expand_access_presets(&mut data); + + serde_json::to_string(&data).unwrap_or_default() } #[cfg(test)] diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index d662399b..3a28a3f6 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -133,6 +133,7 @@ impl ProxyHandle { inference_ctx: Option>, secret_resolver: Option>, denial_tx: Option>, + sandbox_id: Option, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -163,9 +164,10 @@ impl ProxyHandle { let inf = inference_ctx.clone(); let resolver = secret_resolver.clone(); let dtx = denial_tx.clone(); + let sid = sandbox_id.clone(); tokio::spawn(async move { if let Err(err) = handle_tcp_connection( - stream, opa, cache, spid, tls, inf, resolver, dtx, + stream, opa, cache, spid, tls, inf, resolver, dtx, sid, ) .await { @@ -266,6 +268,7 @@ async fn handle_tcp_connection( inference_ctx: Option>, secret_resolver: Option>, denial_tx: Option>, + sandbox_id: Option, // Added ) -> Result<()> { let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -310,6 +313,7 @@ async fn handle_tcp_connection( entrypoint_pid, secret_resolver, denial_tx.as_ref(), + sandbox_id.clone(), ) .await; } @@ -554,6 +558,7 @@ async fn handle_tcp_connection( .map(|p| p.to_string_lossy().into_owned()) .collect(), secret_resolver: secret_resolver.clone(), + sandbox_id, }; if l7_config.tls == crate::l7::TlsMode::Terminate { @@ -1569,6 +1574,7 @@ async fn handle_forward_proxy( entrypoint_pid: Arc, secret_resolver: Option>, denial_tx: Option<&mpsc::UnboundedSender>, + _sandbox_id: Option, ) -> Result<()> { // 1. Parse the absolute-form URI let (scheme, host, port, path) = match parse_proxy_uri(target_uri) { diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index b968be27..06bc7476 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1428,6 +1428,7 @@ impl OpenShell for OpenShellService { ); } + // Determine next version number. let latest = self .state @@ -1436,7 +1437,6 @@ impl OpenShell for OpenShellService { .await .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - // Compute hash and check if the policy actually changed. let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); @@ -2716,6 +2716,7 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } + /// Compute a fingerprint for the effective sandbox configuration. /// /// Returns the first 8 bytes of a SHA-256 hash over the policy, settings, diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index decece75..1812e91a 100644 --- a/docs/reference/policy-schema.md +++ b/docs/reference/policy-schema.md @@ -164,6 +164,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | HTTP access level. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. | | `rules` | list of rule objects | No | Fine-grained per-method, per-path allow rules. Mutually exclusive with `access`. | +| `external_resolver` | object | No | Configuration for fetching secrets from an external API. Only relevant when `protocol` is `rest`. | #### Access Levels @@ -184,6 +185,18 @@ Used when `access` is not set. Each rule explicitly allows a method and path com | `allow.method` | string | Yes | HTTP method to allow (for example, `GET`, `POST`). | | `allow.path` | string | Yes | URL path pattern. Supports `*` and `**` glob syntax. | +#### External Resolver Object + +Used to fetch dynamic secrets (like API keys) from an external service before forwarding a request. + +| Field | Type | Required | Description | +|---|---|---|---| +| `url` | string | Yes | The URL of the external resolver service. | +| `method` | string | No | HTTP method to use (e.g., `POST`, `GET`). Default: `GET`. | +| `header` | string | No | The HTTP header name to inject the secret into. Default: `Authorization` (injects as `Bearer {secret}`). | +| `body_template` | string | No | JSON body template for the request. Supports placeholders: `{{.SandboxID}}`, `{{.Host}}`, `{{.Port}}`, `{{.Binary}}`. | +| `response_path` | string | No | Dot-separated path to read the secret from resolver JSON response (e.g., `data.token`). If omitted, fallback keys `secret`, `token`, `key` are used. | + Example with rules: ```yaml diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index 191c9e79..a161e7ad 100644 --- a/docs/sandboxes/policies.md +++ b/docs/sandboxes/policies.md @@ -73,7 +73,7 @@ Dynamic sections can be updated on a running sandbox with `openshell policy set` | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (use the highest ABI the host kernel supports) or `hard_requirement` (fail if the required ABI is unavailable). | | `process` | Static | Sets the OS-level identity for the agent process. `run_as_user` and `run_as_group` default to `sandbox`. Root (`root` or `0`) is rejected. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the {doc}`policy engine <../about/architecture>` with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest` and `tls: terminate`, each HTTP request is also checked against that endpoint's `rules` (method and path).
Endpoints without `protocol` or `tls` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through {doc}`../inference/configure`. | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to reach them.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the {doc}`policy engine <../about/architecture>` with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest` and `tls: terminate`, each HTTP request is also checked against that endpoint's `rules` (method and path).
Additionally, `external_resolver` can be used to dynamically fetch secrets (like API keys) and inject them into a configurable header (default: `Authorization`).
Endpoints without `protocol` or `tls` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through {doc}`../inference/configure`. | ## Apply a Custom Policy @@ -256,6 +256,30 @@ For an end-to-end walkthrough that combines this policy with a GitHub credential Endpoints with `protocol: rest` and `tls: terminate` enable HTTP request inspection — the proxy decrypts TLS and checks each HTTP request against the `rules` list. :::: +::::{tab-item} External secrets +Dynamically fetch an API key from an internal sidecar before forwarding the request to OpenAI. The key is injected into a configurable header (default: `Authorization: Bearer`). + +```yaml + openai: + name: openai + endpoints: + - host: api.openai.com + port: 443 + protocol: rest + tls: terminate + external_resolver: + url: "http://secret-resolver:8080/token" + method: "POST" + header: "Authorization" # Optional: defaults to Authorization + body_template: '{"sandbox_id": "{{.SandboxID}}", "target": "{{.Host}}"}' + response_path: "data.token" # Optional: defaults to secret/token/key fallback + binaries: + - { path: /usr/local/bin/claude } +``` + +When the `claude` binary connects to `api.openai.com`, the supervisor first calls the `external_resolver` URL, extracts the secret from the JSON response (using `response_path` if provided), and injects it into the specified header. +:::: + ::::: ## Next Steps diff --git a/proto/sandbox.proto b/proto/sandbox.proto index a96ca33f..581cd197 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -85,6 +85,23 @@ message NetworkEndpoint { // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. // If both are set, `ports` takes precedence. repeated uint32 ports = 9; + // External secret resolver configuration. + ExternalResolver external_resolver = 10; +} + +// External secret resolver configuration. +message ExternalResolver { + // Resolver endpoint URL (HTTPS). + string url = 1; + // HTTP method (e.g. "POST"). + string method = 2; + // JSON body template for the resolver request. + string body_template = 3; + // Optional: Header name to inject the secret into (default: "Authorization"). + string header = 4; + // Optional: dot-separated path to read the secret from resolver JSON response. + // Example: "data.token". + string response_path = 5; } // An L7 policy rule (allow-only).