From 2ddd78667f24394517371dcf2af0a306d1c050fe Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Thu, 19 Mar 2026 15:04:42 +0900 Subject: [PATCH 1/6] external resolver --- Cargo.lock | 1 + crates/openshell-policy/src/lib.rs | 26 +++++++ crates/openshell-sandbox/Cargo.toml | 1 + .../data/sandbox-policy.rego | 17 +++++ crates/openshell-sandbox/src/l7/mod.rs | 23 ++++++ crates/openshell-sandbox/src/l7/relay.rs | 71 +++++++++++++++++++ crates/openshell-sandbox/src/l7/rest.rs | 45 +++++++++++- crates/openshell-sandbox/src/lib.rs | 1 + crates/openshell-sandbox/src/opa.rs | 7 ++ crates/openshell-sandbox/src/proxy.rs | 9 ++- proto/sandbox.proto | 12 ++++ 11 files changed, 210 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 305c8a08..8bf9ad0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,6 +2938,7 @@ dependencies = [ "rand_core 0.6.4", "rcgen", "regorus", + "reqwest", "russh", "rustls", "rustls-pemfile", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f1c15539..6b422318 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -99,6 +99,18 @@ 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, } fn is_zero(v: &u32) -> bool { @@ -180,6 +192,13 @@ 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, + } + }), } }) .collect(), @@ -280,6 +299,13 @@ 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(), + } + }), } }) .collect(), 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..957940f5 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -51,12 +51,21 @@ pub enum EnforcementMode { Enforce, } +/// External secret resolver configuration. +#[derive(Debug, Clone)] +pub struct ExternalResolverConfig { + pub url: String, + pub method: String, + pub body_template: 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. @@ -94,10 +103,24 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { _ => EnforcementMode::Audit, }; + let external_resolver = (|| { + let obj = val.as_object().ok()?; + 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(); + Some(ExternalResolverConfig { + url, + method, + body_template, + }) + })(); + 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..7352dc8f 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,25 @@ 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 + }; + // 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(), ) .await?; if !reusable { @@ -229,3 +244,59 @@ 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::new(); + + let body = resolver + .body_template + .replace("{sandbox_id}", sandbox_id) + .replace("{host}", &ctx.host) + .replace("{port}", &ctx.port.to_string()) + .replace("{binary}", &ctx.binary_path); + + let mut builder = match resolver.method.to_uppercase().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}"))?; + + 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()) +} diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 61b026f5..5840ec61 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,7 @@ pub(crate) async fn relay_http_request_with_resolver( client: &mut C, upstream: &mut U, resolver: Option<&crate::secrets::SecretResolver>, + external_secret: Option<&str>, ) -> Result where C: AsyncRead + AsyncWrite + Unpin, @@ -143,9 +144,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_auth_header(&rewritten_header, secret) + } else { + rewritten_header + }; upstream - .write_all(&rewritten_header) + .write_all(&final_header) .await .into_diagnostic()?; @@ -171,6 +177,41 @@ where relay_response(&req.action, upstream, client).await } +/// Inject or override Authorization header with a Bearer token. +fn inject_auth_header(raw: &[u8], 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 mut auth_injected = false; + for line in lines { + if line.is_empty() { + break; + } + + if line.to_lowercase().starts_with("authorization:") { + output.extend_from_slice(format!("Authorization: Bearer {secret}\r\n").as_bytes()); + auth_injected = true; + } else { + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + } + + if !auth_injected { + output.extend_from_slice(format!("Authorization: Bearer {secret}\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, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 754c3be0..287ad310 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..938f5813 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -681,6 +681,13 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { if !e.allowed_ips.is_empty() { ep["allowed_ips"] = e.allowed_ips.clone().into(); } + 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, + }); + } ep }) .collect(); diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index d662399b..5834d24a 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,7 +1574,9 @@ async fn handle_forward_proxy( entrypoint_pid: Arc, secret_resolver: Option>, denial_tx: Option<&mpsc::UnboundedSender>, + sandbox_id: Option, ) -> Result<()> { + let _ = sandbox_id; // Added to avoid unused warning in this specific function if not used yet // 1. Parse the absolute-form URI let (scheme, host, port, path) = match parse_proxy_uri(target_uri) { Ok(parsed) => parsed, diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 01925fbe..b7d1d3fb 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -85,6 +85,18 @@ 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; } // An L7 policy rule (allow-only). From 897a454db1c7d0a89842bcaf40d446e8dd310bee Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Thu, 19 Mar 2026 16:17:49 +0900 Subject: [PATCH 2/6] docs --- architecture/sandbox.md | 6 ++++-- architecture/security-policy.md | 11 +++++++++++ docs/reference/policy-schema.md | 11 +++++++++++ docs/sandboxes/policies.md | 24 +++++++++++++++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a8e4d247..38837219 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 **Authorization 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 cd4d697f..f1018f7f 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -415,6 +415,17 @@ 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`. | +| `body_template` | `string` | No | JSON body template with placeholders: `{sandbox_id}`, `{host}`, `{port}`, `{binary}`. | #### `NetworkBinary` diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index decece75..f4af2341 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,16 @@ 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`. | +| `body_template` | string | No | JSON body template for the request. Supports placeholders: `{sandbox_id}`, `{host}`, `{port}`, `{binary}`. | + Example with rules: ```yaml diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index 3ee7b50d..ec902d1c 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 the `Authorization` header.
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 @@ -231,6 +231,28 @@ 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 the `Authorization: Bearer` header. + +```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" + body_template: '{"sandbox_id": "{sandbox_id}", "target": "{host}"}' + 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, and injects it as a Bearer token. +:::: + ::::: ## Next Steps From 77b4cf8448b54c80f92ae093c016ac5eca38efd3 Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Fri, 20 Mar 2026 13:41:33 +0900 Subject: [PATCH 3/6] resolver --- crates/openshell-sandbox/src/l7/relay.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 7352dc8f..ce11c89d 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -250,14 +250,17 @@ async fn resolve_external_secret( sandbox_id: &str, ctx: &L7EvalContext, ) -> Result { - let client = reqwest::Client::new(); + 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("{sandbox_id}", sandbox_id) - .replace("{host}", &ctx.host) - .replace("{port}", &ctx.port.to_string()) - .replace("{binary}", &ctx.binary_path); + .replace("{{.SandboxID}}", sandbox_id) + .replace("{{.Host}}", &ctx.host) + .replace("{{.Port}}", &ctx.port.to_string()) + .replace("{{.Binary}}", &ctx.binary_path); let mut builder = match resolver.method.to_uppercase().as_str() { "POST" => client.post(&resolver.url), @@ -298,5 +301,6 @@ async fn resolve_external_secret( miette::miette!("external resolver response missing 'secret', 'token', or 'key' field") })?; + info!(host = %ctx.host, "Resolved external secret for L7 request"); Ok(secret.to_string()) } From ae6cfeca4987064ab3bbd4acd1f98d12a5ae0707 Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Fri, 20 Mar 2026 16:39:02 +0900 Subject: [PATCH 4/6] tmp --- architecture/sandbox.md | 2 +- architecture/security-policy.md | 3 ++- crates/openshell-policy/src/lib.rs | 33 ++++++++++++++++++++++++ crates/openshell-sandbox/src/l7/mod.rs | 14 ++++++++-- crates/openshell-sandbox/src/l7/relay.rs | 32 ++++++++++++++++++++--- crates/openshell-sandbox/src/l7/rest.rs | 33 +++++++++++++++++------- crates/openshell-sandbox/src/opa.rs | 14 +++++++--- crates/openshell-server/src/grpc.rs | 31 +++++++++++++++++++++- docs/reference/policy-schema.md | 3 ++- docs/sandboxes/policies.md | 9 ++++--- proto/sandbox.proto | 2 ++ 11 files changed, 149 insertions(+), 27 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 38837219..dd6e6fef 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, deny response generation, and **Authorization header injection** | +| `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 diff --git a/architecture/security-policy.md b/architecture/security-policy.md index f1018f7f..4274ea9b 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -425,7 +425,8 @@ Defines an external API to call for fetching secrets (e.g., API keys) before for |-------|------|----------|-------------| | `url` | `string` | Yes | The URL of the external resolver service. | | `method` | `string` | No | HTTP method to use (e.g., `POST`, `GET`). Default: `GET`. | -| `body_template` | `string` | No | JSON body template with placeholders: `{sandbox_id}`, `{host}`, `{port}`, `{binary}`. | +| `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}}`. | #### `NetworkBinary` diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 6b422318..dc3edd15 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -111,6 +111,8 @@ struct ExternalResolverDef { method: String, #[serde(default)] body_template: String, + #[serde(default)] + header: String, } fn is_zero(v: &u32) -> bool { @@ -197,6 +199,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { url: er.url, method: er.method, body_template: er.body_template, + header: er.header, } }), } @@ -304,6 +307,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { url: er.url.clone(), method: er.method.clone(), body_template: er.body_template.clone(), + header: er.header.clone(), } }), } @@ -1143,4 +1147,33 @@ 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" + 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.body_template, "{\"foo\": \"bar\"}"); + } } diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 957940f5..95d698f5 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; @@ -57,6 +59,7 @@ pub struct ExternalResolverConfig { pub url: String, pub method: String, pub body_template: String, + pub header: String, } /// L7 configuration for an endpoint, extracted from policy data. @@ -98,6 +101,7 @@ 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, @@ -105,15 +109,21 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { 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(); - Some(ExternalResolverConfig { + let header = get_object_str(v, "header").unwrap_or_else(|| "Authorization".to_string()); + let config = ExternalResolverConfig { url, method, body_template, - }) + header, + }; + info!(config = ?config, "Parsed external resolver config from OPA"); + Some(config) })(); Some(L7EndpointConfig { diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index ce11c89d..c06dbd18 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -82,6 +82,13 @@ where C: AsyncRead + AsyncWrite + Unpin + Send, U: AsyncRead + AsyncWrite + Unpin + Send, { + info!( + host = %ctx.host, + port = ctx.port, + protocol = ?config.protocol, + has_resolver = config.external_resolver.is_some(), + "Starting REST L7 relay" + ); loop { // Parse one HTTP request from client let req = match crate::l7::rest::RestProvider.parse_request(client).await { @@ -137,13 +144,21 @@ 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), + Ok(secret) => { + info!( + host = %ctx.host, + header = %resolver.header, + "Successfully resolved external secret" + ); + Some(secret) + } Err(e) => { - warn!(error = %e, "External secret resolution failed"); - None + warn!(error = %e, "External secret resolution failed"); + None } } } else { + info!(host = %ctx.host, "No external resolver configured for this endpoint"); None }; @@ -154,6 +169,7 @@ where upstream, ctx.secret_resolver.as_deref(), external_secret.as_deref(), + &config.external_resolver.as_ref().unwrap().header, ) .await?; if !reusable { @@ -262,7 +278,15 @@ async fn resolve_external_secret( .replace("{{.Port}}", &ctx.port.to_string()) .replace("{{.Binary}}", &ctx.binary_path); - let mut builder = match resolver.method.to_uppercase().as_str() { + let method = resolver.method.to_uppercase(); + info!( + url = %resolver.url, + method = %method, + body = %body, + "Resolving external secret" + ); + + let mut builder = match method.as_str() { "POST" => client.post(&resolver.url), "PUT" => client.put(&resolver.url), _ => client.get(&resolver.url), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 5840ec61..d3946e08 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, None).await + relay_http_request_with_resolver(req, client, upstream, None, None, "").await } pub(crate) async fn relay_http_request_with_resolver( @@ -132,6 +132,7 @@ pub(crate) async fn relay_http_request_with_resolver( upstream: &mut U, resolver: Option<&crate::secrets::SecretResolver>, external_secret: Option<&str>, + external_header: &str, ) -> Result where C: AsyncRead + AsyncWrite + Unpin, @@ -145,7 +146,7 @@ where let rewritten_header = rewrite_http_header_block(&req.raw_header[..header_end], resolver); let final_header = if let Some(secret) = external_secret { - inject_auth_header(&rewritten_header, secret) + inject_custom_header(&rewritten_header, external_header, secret) } else { rewritten_header }; @@ -177,8 +178,8 @@ where relay_response(&req.action, upstream, client).await } -/// Inject or override Authorization header with a Bearer token. -fn inject_auth_header(raw: &[u8], secret: &str) -> Vec { +/// 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 { @@ -189,23 +190,35 @@ fn inject_auth_header(raw: &[u8], secret: &str) -> Vec { output.extend_from_slice(request_line.as_bytes()); output.extend_from_slice(b"\r\n"); - let mut auth_injected = false; + 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("authorization:") { - output.extend_from_slice(format!("Authorization: Bearer {secret}\r\n").as_bytes()); - auth_injected = true; + 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 !auth_injected { - output.extend_from_slice(format!("Authorization: Bearer {secret}\r\n").as_bytes()); + 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"); diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 938f5813..5383e298 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; @@ -96,6 +97,7 @@ impl OpaEngine { .add_policy("policy.rego".into(), policy.into()) .map_err(|e| miette::miette!("{e}"))?; let data_json = preprocess_yaml_data(data_yaml)?; + info!(data = %data_json.to_string(), "Loading OPA data"); engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; @@ -681,11 +683,13 @@ 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, }); } ep @@ -707,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-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index a2c6a58f..adf210a6 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -725,9 +725,11 @@ impl OpenShell for OpenShellService { if let Some(record) = latest { let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) .map_err(|e| Status::internal(format!("decode policy failed: {e}")))?; - debug!( + let er_count = count_external_resolvers(&policy); + info!( sandbox_id = %sandbox_id, version = record.version, + er_count = er_count, "GetSandboxPolicy served from policy history" ); return Ok(Response::new(GetSandboxPolicyResponse { @@ -1042,6 +1044,12 @@ impl OpenShell for OpenShellService { ); } + let er_count = count_external_resolvers(&new_policy); + info!( + er_count = er_count, + "UpdateSandboxPolicy: checking external_resolvers in new_policy" + ); + // Determine next version number. let latest = self .state @@ -1052,6 +1060,14 @@ impl OpenShell for OpenShellService { // Compute hash and check if the policy actually changed. let payload = new_policy.encode_to_vec(); + let er_count_after_roundtrip = ProtoSandboxPolicy::decode(payload.as_slice()) + .map(|p| count_external_resolvers(&p)) + .unwrap_or(0); + info!( + er_count_before_encode = er_count, + er_count_after_encode_decode = er_count_after_roundtrip, + "UpdateSandboxPolicy: external_resolver count across server-side protobuf roundtrip" + ); let hash = deterministic_policy_hash(&new_policy); if let Some(ref current) = latest @@ -2296,6 +2312,19 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } +fn count_external_resolvers(policy: &ProtoSandboxPolicy) -> usize { + policy + .network_policies + .values() + .map(|rule| { + rule.endpoints + .iter() + .filter(|endpoint| endpoint.external_resolver.is_some()) + .count() + }) + .sum() +} + /// Check if a log line's source matches the filter list. /// Empty source is treated as "gateway" for backward compatibility. fn source_matches(log_source: &str, filters: &[String]) -> bool { diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index f4af2341..a34af04c 100644 --- a/docs/reference/policy-schema.md +++ b/docs/reference/policy-schema.md @@ -193,7 +193,8 @@ Used to fetch dynamic secrets (like API keys) from an external service before fo |---|---|---|---| | `url` | string | Yes | The URL of the external resolver service. | | `method` | string | No | HTTP method to use (e.g., `POST`, `GET`). Default: `GET`. | -| `body_template` | string | No | JSON body template for the request. Supports placeholders: `{sandbox_id}`, `{host}`, `{port}`, `{binary}`. | +| `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}}`. | Example with rules: diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index ec902d1c..126a8b7c 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 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 the `Authorization` header.
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 @@ -232,7 +232,7 @@ Endpoints with `protocol: rest` and `tls: terminate` enable HTTP request inspect :::: ::::{tab-item} External secrets -Dynamically fetch an API key from an internal sidecar before forwarding the request to OpenAI. The key is injected into the `Authorization: Bearer` header. +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: @@ -245,12 +245,13 @@ Dynamically fetch an API key from an internal sidecar before forwarding the requ external_resolver: url: "http://secret-resolver:8080/token" method: "POST" - body_template: '{"sandbox_id": "{sandbox_id}", "target": "{host}"}' + header: "Authorization" # Optional: defaults to Authorization + body_template: '{"sandbox_id": "{{.SandboxID}}", "target": "{{.Host}}"}' 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, and injects it as a Bearer token. +When the `claude` binary connects to `api.openai.com`, the supervisor first calls the `external_resolver` URL, extracts the secret from the JSON response, and injects it into the specified header. :::: ::::: diff --git a/proto/sandbox.proto b/proto/sandbox.proto index b7d1d3fb..9f51e5e7 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -97,6 +97,8 @@ message ExternalResolver { 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; } // An L7 policy rule (allow-only). From d68ca220a114b821c3d1f2ddcb4191a5629b6c60 Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Fri, 20 Mar 2026 17:58:51 +0900 Subject: [PATCH 5/6] response --- architecture/security-policy.md | 1 + crates/openshell-policy/src/lib.rs | 26 +++++++++---------- crates/openshell-sandbox/src/l7/mod.rs | 3 +++ crates/openshell-sandbox/src/l7/relay.rs | 33 +++++++++++++++++++++++- crates/openshell-sandbox/src/opa.rs | 1 + docs/reference/policy-schema.md | 1 + docs/sandboxes/policies.md | 3 ++- proto/sandbox.proto | 3 +++ 8 files changed, 55 insertions(+), 16 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 4274ea9b..6b163620 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -427,6 +427,7 @@ Defines an external API to call for fetching secrets (e.g., API keys) before for | `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 dc3edd15..b606035a 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -113,6 +113,8 @@ struct ExternalResolverDef { body_template: String, #[serde(default)] header: String, + #[serde(default)] + response_path: String, } fn is_zero(v: &u32) -> bool { @@ -200,6 +202,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { method: er.method, body_template: er.body_template, header: er.header, + response_path: er.response_path, } }), } @@ -308,6 +311,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { method: er.method.clone(), body_template: er.body_template.clone(), header: er.header.clone(), + response_path: er.response_path.clone(), } }), } @@ -1150,22 +1154,15 @@ network_policies: #[test] fn parse_external_resolver() { - let yaml = r#" + 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" - body_template: '{"foo": "bar"}' - binaries: - - { path: /usr/bin/curl } + 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]; @@ -1174,6 +1171,7 @@ network_policies: 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/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 95d698f5..f10b9aaa 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -60,6 +60,7 @@ pub struct ExternalResolverConfig { pub method: String, pub body_template: String, pub header: String, + pub response_path: String, } /// L7 configuration for an endpoint, extracted from policy data. @@ -116,11 +117,13 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { 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) diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index c06dbd18..be3f3a18 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -162,6 +162,12 @@ where 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, @@ -169,7 +175,7 @@ where upstream, ctx.secret_resolver.as_deref(), external_secret.as_deref(), - &config.external_resolver.as_ref().unwrap().header, + external_header, ) .await?; if !reusable { @@ -316,6 +322,25 @@ async fn resolve_external_secret( .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 + ) + })?; + + info!(host = %ctx.host, response_path = %resolver.response_path, "Resolved external secret for L7 request"); + return Ok(secret.to_string()); + } + let secret = json .get("secret") .or_else(|| json.get("token")) @@ -328,3 +353,9 @@ async fn resolve_external_secret( info!(host = %ctx.host, "Resolved external secret for L7 request"); 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/opa.rs b/crates/openshell-sandbox/src/opa.rs index 5383e298..2f291c04 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -690,6 +690,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { "method": er.method, "body_template": er.body_template, "header": er.header, + "response_path": er.response_path, }); } ep diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index a34af04c..1812e91a 100644 --- a/docs/reference/policy-schema.md +++ b/docs/reference/policy-schema.md @@ -195,6 +195,7 @@ Used to fetch dynamic secrets (like API keys) from an external service before fo | `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: diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index 126a8b7c..7500c4b9 100644 --- a/docs/sandboxes/policies.md +++ b/docs/sandboxes/policies.md @@ -247,11 +247,12 @@ Dynamically fetch an API key from an internal sidecar before forwarding the requ 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, and injects it into the specified header. +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. :::: ::::: diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 9f51e5e7..dff4bc0f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -99,6 +99,9 @@ message ExternalResolver { 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). From f9d33451c85ad73957ec387462d84a49d6c754a3 Mon Sep 17 00:00:00 2001 From: tatsuya ogawa Date: Sun, 22 Mar 2026 21:41:16 +0900 Subject: [PATCH 6/6] refactor --- crates/openshell-sandbox/src/l7/relay.rs | 21 ------------ crates/openshell-sandbox/src/l7/rest.rs | 4 +++ crates/openshell-sandbox/src/opa.rs | 1 - crates/openshell-sandbox/src/proxy.rs | 3 +- crates/openshell-server/src/grpc.rs | 41 ------------------------ 5 files changed, 5 insertions(+), 65 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index be3f3a18..3b5f4899 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -82,13 +82,6 @@ where C: AsyncRead + AsyncWrite + Unpin + Send, U: AsyncRead + AsyncWrite + Unpin + Send, { - info!( - host = %ctx.host, - port = ctx.port, - protocol = ?config.protocol, - has_resolver = config.external_resolver.is_some(), - "Starting REST L7 relay" - ); loop { // Parse one HTTP request from client let req = match crate::l7::rest::RestProvider.parse_request(client).await { @@ -145,11 +138,6 @@ where 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) => { - info!( - host = %ctx.host, - header = %resolver.header, - "Successfully resolved external secret" - ); Some(secret) } Err(e) => { @@ -158,7 +146,6 @@ where } } } else { - info!(host = %ctx.host, "No external resolver configured for this endpoint"); None }; @@ -285,12 +272,6 @@ async fn resolve_external_secret( .replace("{{.Binary}}", &ctx.binary_path); let method = resolver.method.to_uppercase(); - info!( - url = %resolver.url, - method = %method, - body = %body, - "Resolving external secret" - ); let mut builder = match method.as_str() { "POST" => client.post(&resolver.url), @@ -337,7 +318,6 @@ async fn resolve_external_secret( ) })?; - info!(host = %ctx.host, response_path = %resolver.response_path, "Resolved external secret for L7 request"); return Ok(secret.to_string()); } @@ -350,7 +330,6 @@ async fn resolve_external_secret( miette::miette!("external resolver response missing 'secret', 'token', or 'key' field") })?; - info!(host = %ctx.host, "Resolved external secret for L7 request"); Ok(secret.to_string()) } diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index d3946e08..f4854137 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -1120,6 +1120,8 @@ mod tests { &mut proxy_to_client, &mut proxy_to_upstream, resolver.as_ref(), + None, + "", ), ) .await @@ -1203,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/opa.rs b/crates/openshell-sandbox/src/opa.rs index 2f291c04..0b30c800 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -97,7 +97,6 @@ impl OpaEngine { .add_policy("policy.rego".into(), policy.into()) .map_err(|e| miette::miette!("{e}"))?; let data_json = preprocess_yaml_data(data_yaml)?; - info!(data = %data_json.to_string(), "Loading OPA data"); engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 5834d24a..3a28a3f6 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -1574,9 +1574,8 @@ async fn handle_forward_proxy( entrypoint_pid: Arc, secret_resolver: Option>, denial_tx: Option<&mpsc::UnboundedSender>, - sandbox_id: Option, + _sandbox_id: Option, ) -> Result<()> { - let _ = sandbox_id; // Added to avoid unused warning in this specific function if not used yet // 1. Parse the absolute-form URI let (scheme, host, port, path) = match parse_proxy_uri(target_uri) { Ok(parsed) => parsed, diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index adf210a6..42727492 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -725,13 +725,6 @@ impl OpenShell for OpenShellService { if let Some(record) = latest { let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) .map_err(|e| Status::internal(format!("decode policy failed: {e}")))?; - let er_count = count_external_resolvers(&policy); - info!( - sandbox_id = %sandbox_id, - version = record.version, - er_count = er_count, - "GetSandboxPolicy served from policy history" - ); return Ok(Response::new(GetSandboxPolicyResponse { policy: Some(policy), version: u32::try_from(record.version).unwrap_or(0), @@ -782,10 +775,6 @@ impl OpenShell for OpenShellService { warn!(sandbox_id = %sandbox_id, error = %e, "Failed to mark backfilled policy as loaded"); } - info!( - sandbox_id = %sandbox_id, - "GetSandboxPolicy served from spec (backfilled version 1)" - ); Ok(Response::new(GetSandboxPolicyResponse { policy: Some(policy), @@ -1038,17 +1027,8 @@ impl OpenShell for OpenShellService { .put_message(&sandbox) .await .map_err(|e| Status::internal(format!("backfill spec.policy failed: {e}")))?; - info!( - sandbox_id = %sandbox_id, - "UpdateSandboxPolicy: backfilled spec.policy from sandbox-discovered policy" - ); } - let er_count = count_external_resolvers(&new_policy); - info!( - er_count = er_count, - "UpdateSandboxPolicy: checking external_resolvers in new_policy" - ); // Determine next version number. let latest = self @@ -1058,16 +1038,7 @@ 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 er_count_after_roundtrip = ProtoSandboxPolicy::decode(payload.as_slice()) - .map(|p| count_external_resolvers(&p)) - .unwrap_or(0); - info!( - er_count_before_encode = er_count, - er_count_after_encode_decode = er_count_after_roundtrip, - "UpdateSandboxPolicy: external_resolver count across server-side protobuf roundtrip" - ); let hash = deterministic_policy_hash(&new_policy); if let Some(ref current) = latest @@ -2312,18 +2283,6 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } -fn count_external_resolvers(policy: &ProtoSandboxPolicy) -> usize { - policy - .network_policies - .values() - .map(|rule| { - rule.endpoints - .iter() - .filter(|endpoint| endpoint.external_resolver.is_some()) - .count() - }) - .sum() -} /// Check if a log line's source matches the filter list. /// Empty source is treated as "gateway" for backward compatibility.