diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a9d80ac8..97a15bc7 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -33,6 +33,7 @@ All paths are relative to `crates/openshell-sandbox/src/`. | `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/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types | +| `credential_injector.rs` | L7 proxy credential injection for non-inference providers -- extracts injection configs from policy, resolves against provider env, injects credentials at the proxy layer | ## Startup and Orchestration @@ -81,11 +82,13 @@ flowchart TD - Priority 1: `--policy-rules` + `--policy-data` provided -- load OPA engine from local Rego file and YAML data file via `OpaEngine::from_files()`. Query `query_sandbox_config()` for filesystem/landlock/process settings. Network mode forced to `Proxy`. - Priority 2: `--sandbox-id` + `--openshell-endpoint` provided -- fetch typed proto policy via `grpc_client::fetch_policy()`. Create OPA engine via `OpaEngine::from_proto()` using baked-in Rego rules. Convert proto to `SandboxPolicy` via `TryFrom`, which always forces `NetworkMode::Proxy` so that all egress passes through the proxy and the `inference.local` virtual host is always addressable. - Neither present: return fatal error. - - Output: `(SandboxPolicy, Option>)` + - Output: `(SandboxPolicy, Option>, proto::SandboxPolicy)` 2. **Provider environment fetching**: If sandbox ID and endpoint are available, call `grpc_client::fetch_provider_environment()` to get a `HashMap` of credential environment variables. On failure, log a warning and continue with an empty map. -3. **Binary identity cache**: If OPA engine is active, create `Arc` for SHA256 TOFU enforcement. +3. **Credential injection extraction**: Scan the proto policy's network endpoints for `credential_injection` configs. For each match, look up the referenced credential in the provider environment, remove it from the env map (so it is not exposed to the sandbox process), and build a `CredentialInjector` that the L7 proxy will use to inject credentials at the network layer. See [Credential Injection](#credential-injection). + +4. **Binary identity cache**: If OPA engine is active, create `Arc` for SHA256 TOFU enforcement. 4. **Filesystem preparation** (`prepare_filesystem()`): For each path in `filesystem.read_write`, create the directory if it does not exist and `chown` to the configured `run_as_user`/`run_as_group`. Runs as the supervisor (root) before forking. @@ -1001,6 +1004,56 @@ Implements `L7Provider` for HTTP/1.1: 5. If allowed (or audit mode): relay request to upstream and response back to client, then loop 6. If denied in enforce mode: send 403 and close the connection +## Credential Injection + +**File:** `crates/openshell-sandbox/src/credential_injector.rs` + +Credential injection extends the L7 proxy to inject API credentials at the network layer for arbitrary REST endpoints. This generalizes the `inference.local` credential injection pattern to any service in `network_policies`. + +### Problem + +When provider credentials are injected as environment variables, the agent process can read raw API keys from `process.env`. A prompt injection attack, malicious skill, or compromised dependency can read and exfiltrate these values. The network policy limits where a leaked key can be sent, but does not prevent the agent from reading it. + +### Architecture + +When an endpoint has a `credential_injection` configuration in the policy YAML: + +1. **Sandbox startup** (`lib.rs`): `CredentialInjector::extract_from_policy()` scans the proto policy for `credential_injection` entries, cross-references them with the provider environment, removes the matched credentials from the child env map, and builds a `CredentialInjector` keyed by `(host, port)`. +2. **Proxy startup**: The `CredentialInjector` is passed through to `L7EvalContext` alongside the existing `SecretResolver`. +3. **Request relay** (`l7/rest.rs`): After the OPA policy allows a request, `relay_http_request_with_resolver()` applies credential injection: + - For header injection: strips any existing header with the same name (case-insensitive) and appends the injected header with the real credential. + - For query parameter injection: appends the credential as a URL query parameter. +4. **Agent process**: never sees the credential. It is not in `process.env` and not in any placeholder form. + +### Injection types + +| Type | YAML fields | Example | +|---|---|---| +| Header | `header: x-api-key` | `x-api-key: ` | +| Header + prefix | `header: Authorization`, `value_prefix: "Bearer "` | `Authorization: Bearer ` | +| Query parameter | `query_param: key` | URL appended with `?key=` | + +### Relationship to SecretResolver + +`SecretResolver` and `CredentialInjector` serve different purposes: + +| | SecretResolver | CredentialInjector | +|---|---|---| +| **Mechanism** | Placeholder rewriting | Direct injection | +| **Agent visibility** | Agent sees placeholder env vars | Agent sees nothing | +| **When applied** | All provider credentials (default) | Only credentials with `credential_injection` | +| **Auth header source** | Agent constructs the header using placeholder | Proxy adds the header from scratch | +| **Spoofing risk** | Agent could send placeholders to wrong endpoint | Proxy strips any existing header first | + +Both are applied in `relay_http_request_with_resolver()`: `SecretResolver` rewrites first, then `CredentialInjector` injects. + +### Validation rules + +- `credential_injection` requires `protocol: rest` and `tls: terminate` +- Exactly one of `header` or `query_param` must be set +- `credential` and `provider` are required +- `value_prefix` is only valid with `header` + ## Process Identity ### SHA256 TOFU (Trust-On-First-Use) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 8bccef54..1fe28318 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -25,10 +25,12 @@ url = { workspace = true } ## Off by default so production builds have an empty registry. ## Enabled by e2e tests and during development. dev-settings = [] +## Use bundled protoc from protobuf-src instead of system protoc. +bundled-protoc = ["protobuf-src"] [build-dependencies] tonic-build = { workspace = true } -protobuf-src = { workspace = true } +protobuf-src = { workspace = true, optional = true } [dev-dependencies] tempfile = "3" diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index f44cdc75..ea5b7f22 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -17,15 +17,18 @@ fn main() -> Result<(), Box> { } // --- Protobuf compilation --- - // Use bundled protoc from protobuf-src. The system protoc (from apt-get) - // does not bundle the well-known type includes (google/protobuf/struct.proto - // etc.), so we must use protobuf-src which ships both the binary and the - // include tree. - // SAFETY: This is run at build time in a single-threaded build script context. - // No other threads are reading environment variables concurrently. - #[allow(unsafe_code)] - unsafe { - env::set_var("PROTOC", protobuf_src::protoc()); + // Prefer PROTOC env var (e.g., from mise or system install) when available. + // Fall back to bundled protoc from protobuf-src if the feature is enabled. + if env::var("PROTOC").is_err() { + #[cfg(feature = "bundled-protoc")] + { + // SAFETY: This is run at build time in a single-threaded build script context. + // No other threads are reading environment variables concurrently. + #[allow(unsafe_code)] + unsafe { + env::set_var("PROTOC", protobuf_src::protoc()); + } + } } let proto_files = [ diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f1c15539..2170f9c5 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -15,8 +15,8 @@ use std::path::Path; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ - FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary, NetworkEndpoint, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + CredentialInjection, FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary, + NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy, }; use serde::{Deserialize, Serialize}; @@ -99,6 +99,11 @@ struct NetworkEndpointDef { rules: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] allowed_ips: Vec, + /// Optional credential injection. When set, the referenced provider + /// credential is withheld from the sandbox environment and injected + /// at the L7 proxy layer instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + credential_injection: Option, } fn is_zero(v: &u32) -> bool { @@ -132,6 +137,34 @@ struct NetworkBinaryDef { harness: bool, } +/// Credential injection configuration for an L7 endpoint. +/// +/// When attached to an endpoint, the referenced provider credential is not +/// injected as an environment variable. Instead, the L7 proxy injects it +/// into outbound requests at the network layer. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CredentialInjectionDef { + /// HTTP header name (e.g., "x-api-key", "Authorization"). + /// Mutually exclusive with `query_param`. + #[serde(default, skip_serializing_if = "String::is_empty")] + header: String, + /// Optional prefix prepended to the credential value (e.g., "Bearer "). + /// Only valid when `header` is set. + #[serde(default, skip_serializing_if = "String::is_empty")] + value_prefix: String, + /// URL query parameter name (e.g., "key"). + /// Mutually exclusive with `header`. + #[serde(default, skip_serializing_if = "String::is_empty")] + query_param: String, + /// Provider name that holds the credential. + #[serde(default, skip_serializing_if = "String::is_empty")] + provider: String, + /// Credential key within the provider (e.g., "EXA_API_KEY"). + #[serde(default, skip_serializing_if = "String::is_empty")] + credential: String, +} + // --------------------------------------------------------------------------- // YAML → proto conversion // --------------------------------------------------------------------------- @@ -180,6 +213,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(), allowed_ips: e.allowed_ips, + credential_injection: e.credential_injection.map( + |ci| CredentialInjection { + header: ci.header, + value_prefix: ci.value_prefix, + query_param: ci.query_param, + provider: ci.provider, + credential: ci.credential, + }, + ), } }) .collect(), @@ -280,6 +322,15 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(), allowed_ips: e.allowed_ips.clone(), + credential_injection: e.credential_injection.as_ref().map( + |ci| CredentialInjectionDef { + header: ci.header.clone(), + value_prefix: ci.value_prefix.clone(), + query_param: ci.query_param.clone(), + provider: ci.provider.clone(), + credential: ci.credential.clone(), + }, + ), } }) .collect(), @@ -1117,4 +1168,157 @@ network_policies: proto2.network_policies["test"].endpoints[0].host ); } + + #[test] + fn round_trip_preserves_credential_injection_header() { + let yaml = r#" +version: 1 +network_policies: + exa_api: + name: exa-search-api + endpoints: + - host: api.exa.ai + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + credential_injection: + header: x-api-key + provider: exa + credential: EXA_API_KEY + rules: + - allow: + method: POST + path: /search + binaries: + - path: /usr/bin/node +"#; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let ci1 = proto1.network_policies["exa_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection missing"); + assert_eq!(ci1.header, "x-api-key"); + assert_eq!(ci1.provider, "exa"); + assert_eq!(ci1.credential, "EXA_API_KEY"); + assert!(ci1.value_prefix.is_empty()); + assert!(ci1.query_param.is_empty()); + + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + let ci2 = proto2.network_policies["exa_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection lost in round-trip"); + assert_eq!(ci1.header, ci2.header); + assert_eq!(ci1.provider, ci2.provider); + assert_eq!(ci1.credential, ci2.credential); + } + + #[test] + fn round_trip_preserves_credential_injection_bearer() { + let yaml = r#" +version: 1 +network_policies: + perplexity_api: + name: perplexity-api + endpoints: + - host: api.perplexity.ai + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + credential_injection: + header: Authorization + value_prefix: "Bearer " + provider: perplexity + credential: PERPLEXITY_API_KEY + rules: + - allow: + method: POST + path: /chat/completions + binaries: + - path: /usr/bin/node +"#; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let ci1 = proto1.network_policies["perplexity_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection missing"); + assert_eq!(ci1.header, "Authorization"); + assert_eq!(ci1.value_prefix, "Bearer "); + assert_eq!(ci1.credential, "PERPLEXITY_API_KEY"); + + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + let ci2 = proto2.network_policies["perplexity_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection lost in round-trip"); + assert_eq!(ci1.value_prefix, ci2.value_prefix); + } + + #[test] + fn round_trip_preserves_credential_injection_query_param() { + let yaml = r#" +version: 1 +network_policies: + youtube_api: + name: youtube-data-api + endpoints: + - host: www.googleapis.com + port: 443 + protocol: rest + tls: terminate + credential_injection: + query_param: key + provider: youtube + credential: YOUTUBE_API_KEY + binaries: + - path: /usr/bin/node +"#; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let ci1 = proto1.network_policies["youtube_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection missing"); + assert_eq!(ci1.query_param, "key"); + assert_eq!(ci1.credential, "YOUTUBE_API_KEY"); + assert!(ci1.header.is_empty()); + + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + let ci2 = proto2.network_policies["youtube_api"].endpoints[0] + .credential_injection + .as_ref() + .expect("credential_injection lost in round-trip"); + assert_eq!(ci1.query_param, ci2.query_param); + assert_eq!(ci1.credential, ci2.credential); + } + + #[test] + fn no_credential_injection_preserves_none() { + let yaml = r#" +version: 1 +network_policies: + test: + endpoints: + - host: example.com + port: 443 + binaries: + - path: /usr/bin/curl +"#; + let proto = parse_sandbox_policy(yaml).expect("parse failed"); + assert!( + proto.network_policies["test"].endpoints[0] + .credential_injection + .is_none() + ); + + let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed"); + assert!( + !yaml_out.contains("credential_injection"), + "credential_injection should not appear in output when not set" + ); + } } diff --git a/crates/openshell-sandbox/src/credential_injector.rs b/crates/openshell-sandbox/src/credential_injector.rs new file mode 100644 index 00000000..8663e0bc --- /dev/null +++ b/crates/openshell-sandbox/src/credential_injector.rs @@ -0,0 +1,864 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! L7 proxy credential injection for non-inference providers. +//! +//! When a network policy endpoint has a `credential_injection` configuration, +//! the referenced provider credential is **not** injected as an environment +//! variable into the sandbox. Instead, the L7 proxy injects it into outbound +//! HTTP requests at the network layer — the agent process never sees the raw +//! API key. +//! +//! Supports three injection styles: +//! - **Header**: sets an HTTP header (e.g., `x-api-key: `) +//! - **Header with prefix**: sets a header with a prefix (e.g., `Authorization: Bearer `) +//! - **Query parameter**: appends a URL query parameter (e.g., `?key=`) + +use std::collections::HashMap; + +use openshell_core::proto::SandboxPolicy; +use tracing::{debug, warn}; + +/// How to inject the credential into the outbound HTTP request. +#[derive(Debug, Clone)] +pub(crate) enum InjectionTarget { + /// Set an HTTP header. If `value_prefix` is non-empty, the header value + /// is `{prefix}{credential}` (e.g., `Bearer sk-xxx`). + Header { + name: String, + value_prefix: String, + }, + /// Append a URL query parameter. + QueryParam { + name: String, + }, +} + +/// A fully resolved credential injection — target + actual secret value. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedInjection { + pub target: InjectionTarget, + pub value: String, +} + +/// Maps network endpoints to their credential injection configurations. +/// +/// Built at sandbox startup by cross-referencing policy `credential_injection` +/// entries with the provider credential environment. Passed to the L7 proxy +/// for runtime injection. +#[derive(Debug, Clone, Default)] +pub(crate) struct CredentialInjector { + /// Entries keyed by `(host_pattern, port)`. Host patterns may contain + /// glob wildcards (e.g., `*.example.com`). + entries: Vec, +} + +#[derive(Debug, Clone)] +struct InjectionEntry { + host: String, + ports: Vec, + injection: ResolvedInjection, +} + +impl CredentialInjector { + /// Scan a sandbox policy for `credential_injection` configs and resolve + /// them against the provider environment. + /// + /// Returns `(injector, filtered_env)`: + /// - `injector` contains all resolved credential injections for the proxy. + /// - `filtered_env` has the injected credentials **removed** so they won't + /// be exposed as environment variables in the sandbox. + pub(crate) fn extract_from_policy( + policy: &SandboxPolicy, + mut provider_env: HashMap, + ) -> (Self, HashMap) { + let mut entries = Vec::new(); + let mut used_credentials = std::collections::HashSet::new(); + + for (policy_name, rule) in &policy.network_policies { + for (i, endpoint) in rule.endpoints.iter().enumerate() { + let Some(ci) = &endpoint.credential_injection else { + continue; + }; + + if ci.credential.is_empty() { + warn!( + policy = %policy_name, + endpoint = i, + "credential_injection has empty credential key, skipping" + ); + continue; + } + + let Some(secret_value) = provider_env.get(&ci.credential).cloned() else { + warn!( + policy = %policy_name, + endpoint = i, + credential = %ci.credential, + provider = %ci.provider, + "credential_injection references credential not found in provider environment, skipping" + ); + continue; + }; + + let target = if !ci.header.is_empty() { + InjectionTarget::Header { + name: ci.header.clone(), + value_prefix: ci.value_prefix.clone(), + } + } else if !ci.query_param.is_empty() { + InjectionTarget::QueryParam { + name: ci.query_param.clone(), + } + } else { + warn!( + policy = %policy_name, + endpoint = i, + "credential_injection has neither header nor query_param, skipping" + ); + continue; + }; + + let ports = if endpoint.ports.is_empty() && endpoint.port > 0 { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + + debug!( + policy = %policy_name, + endpoint = i, + host = %endpoint.host, + credential = %ci.credential, + "credential injection configured" + ); + + used_credentials.insert(ci.credential.clone()); + + entries.push(InjectionEntry { + host: endpoint.host.clone(), + ports, + injection: ResolvedInjection { + target, + value: secret_value, + }, + }); + } + } + + // Remove used credentials from provider env after the loop so + // multiple endpoints can share the same credential. + for key in &used_credentials { + provider_env.remove(key); + } + + (Self { entries }, provider_env) + } + + /// Look up the credential injection for a given host and port. + /// + /// Returns `None` if no injection is configured for this endpoint. + /// Supports exact host match (case-insensitive) and glob patterns + /// using `.` as delimiter (matching the OPA policy behavior). + pub(crate) fn lookup(&self, host: &str, port: u16) -> Option<&ResolvedInjection> { + let host_lower = host.to_ascii_lowercase(); + let port_u32 = u32::from(port); + + self.entries.iter().find_map(|entry| { + if !entry.ports.contains(&port_u32) { + return None; + } + + let entry_host = entry.host.to_ascii_lowercase(); + if entry_host == host_lower { + return Some(&entry.injection); + } + + if entry_host.contains('*') && glob_match_host(&entry_host, &host_lower) { + return Some(&entry.injection); + } + + None + }) + } + + /// Returns `true` if no credential injections are configured. + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Returns the number of configured credential injections. + pub(crate) fn entries_count(&self) -> usize { + self.entries.len() + } +} + +/// Apply credential injection to a raw HTTP request. +/// +/// For header injection: strips any existing header with the same name and +/// appends the injected header. For query parameter injection: appends the +/// parameter to the request URL. +/// +/// Returns the modified request bytes. +pub(crate) fn inject_credential(raw: &[u8], injection: &ResolvedInjection) -> Vec { + match &injection.target { + InjectionTarget::Header { name, value_prefix } => { + inject_header(raw, name, value_prefix, &injection.value) + } + InjectionTarget::QueryParam { name } => { + inject_query_param(raw, name, &injection.value) + } + } +} + +/// Inject a credential as an HTTP header. +/// +/// 1. Strip any existing header with the same name (case-insensitive). +/// 2. Append the new header before the final `\r\n\r\n`. +fn inject_header(raw: &[u8], header_name: &str, value_prefix: &str, value: &str) -> Vec { + let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) else { + return raw.to_vec(); + }; + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + let mut lines = header_str.split("\r\n"); + let Some(request_line) = lines.next() else { + return raw.to_vec(); + }; + + let header_name_lower = header_name.to_ascii_lowercase(); + + let mut output = Vec::with_capacity(raw.len() + header_name.len() + value_prefix.len() + value.len() + 6); + output.extend_from_slice(request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + // Copy headers, stripping any existing header with the same name + for line in lines { + if line.is_empty() { + break; + } + + if let Some((name, _)) = line.split_once(':') { + if name.trim().to_ascii_lowercase() == header_name_lower { + continue; // Strip existing header + } + } + + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + // Append injected header + output.extend_from_slice(header_name.as_bytes()); + output.extend_from_slice(b": "); + output.extend_from_slice(value_prefix.as_bytes()); + output.extend_from_slice(value.as_bytes()); + output.extend_from_slice(b"\r\n"); + + // End of headers + output.extend_from_slice(b"\r\n"); + + // Append body + output.extend_from_slice(&raw[header_end..]); + + output +} + +/// Inject a credential as a URL query parameter. +/// +/// Modifies the request line to append `?name=value` or `&name=value`. +fn inject_query_param(raw: &[u8], param_name: &str, value: &str) -> Vec { + let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) else { + return raw.to_vec(); + }; + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + let mut lines = header_str.split("\r\n"); + let Some(request_line) = lines.next() else { + return raw.to_vec(); + }; + + // Parse request line: METHOD URI HTTP/VERSION + let parts: Vec<&str> = request_line.splitn(3, ' ').collect(); + if parts.len() != 3 { + return raw.to_vec(); + } + let method = parts[0]; + let uri = parts[1]; + let version = parts[2]; + + // URL-encode the value (minimal: encode &, =, ?, #, space, and non-ASCII) + let encoded_value = url_encode_param(value); + + let separator = if uri.contains('?') { "&" } else { "?" }; + let new_uri = format!("{uri}{separator}{param_name}={encoded_value}"); + + let new_request_line = format!("{method} {new_uri} {version}"); + + let mut output = Vec::with_capacity(raw.len() + param_name.len() + encoded_value.len() + 2); + output.extend_from_slice(new_request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + // Copy remaining headers + for line in lines { + if line.is_empty() { + break; + } + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(&raw[header_end..]); + + output +} + +/// Minimal URL percent-encoding for query parameter values. +fn url_encode_param(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{byte:02X}")); + } + } + } + encoded +} + +/// Glob match a hostname pattern against a target hostname. +/// +/// Uses `.` as the delimiter (matching OPA policy behavior): +/// - `*.example.com` matches `api.example.com` but not `sub.api.example.com` +/// - `**.example.com` matches `api.example.com` and `sub.api.example.com` +fn glob_match_host(pattern: &str, target: &str) -> bool { + if pattern.starts_with("**.") { + let suffix = &pattern[3..]; + target.ends_with(suffix) + && target.len() > suffix.len() + && target.as_bytes()[target.len() - suffix.len() - 1] == b'.' + } else if pattern.starts_with("*.") { + let suffix = &pattern[2..]; + if !target.ends_with(suffix) { + return false; + } + let prefix = &target[..target.len() - suffix.len()]; + // Single label: no dots allowed in the matched prefix + !prefix.is_empty() && prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.') + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{ + CredentialInjection, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, + }; + + fn make_policy_with_injection( + host: &str, + port: u32, + ci: CredentialInjection, + ) -> SandboxPolicy { + let mut network_policies = std::collections::BTreeMap::new(); + network_policies.insert( + "test_api".to_string(), + NetworkPolicyRule { + name: "test-api".to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port, + ports: vec![port], + credential_injection: Some(ci), + ..Default::default() + }], + ..Default::default() + }, + ); + SandboxPolicy { + network_policies, + ..Default::default() + } + } + + #[test] + fn extract_header_injection() { + let policy = make_policy_with_injection( + "api.exa.ai", + 443, + CredentialInjection { + header: "x-api-key".to_string(), + provider: "exa".to_string(), + credential: "EXA_API_KEY".to_string(), + ..Default::default() + }, + ); + let provider_env: HashMap = [ + ("EXA_API_KEY".to_string(), "test-key-123".to_string()), + ("OTHER_KEY".to_string(), "other-value".to_string()), + ] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + // Injected credential should be removed from env + assert!(!filtered_env.contains_key("EXA_API_KEY")); + // Other credentials should remain + assert_eq!(filtered_env.get("OTHER_KEY").unwrap(), "other-value"); + // Injector should have the entry + assert!(!injector.is_empty()); + let injection = injector.lookup("api.exa.ai", 443).unwrap(); + assert!(matches!(&injection.target, InjectionTarget::Header { name, .. } if name == "x-api-key")); + assert_eq!(injection.value, "test-key-123"); + } + + #[test] + fn extract_header_with_prefix() { + let policy = make_policy_with_injection( + "api.perplexity.ai", + 443, + CredentialInjection { + header: "Authorization".to_string(), + value_prefix: "Bearer ".to_string(), + provider: "perplexity".to_string(), + credential: "PERPLEXITY_API_KEY".to_string(), + ..Default::default() + }, + ); + let provider_env: HashMap = + [("PERPLEXITY_API_KEY".to_string(), "pplx-xxx".to_string())] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(filtered_env.is_empty()); + let injection = injector.lookup("api.perplexity.ai", 443).unwrap(); + match &injection.target { + InjectionTarget::Header { name, value_prefix } => { + assert_eq!(name, "Authorization"); + assert_eq!(value_prefix, "Bearer "); + } + _ => panic!("expected header injection"), + } + assert_eq!(injection.value, "pplx-xxx"); + } + + #[test] + fn extract_query_param_injection() { + let policy = make_policy_with_injection( + "www.googleapis.com", + 443, + CredentialInjection { + query_param: "key".to_string(), + provider: "youtube".to_string(), + credential: "YOUTUBE_API_KEY".to_string(), + ..Default::default() + }, + ); + let provider_env: HashMap = + [("YOUTUBE_API_KEY".to_string(), "AIza-test".to_string())] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(filtered_env.is_empty()); + let injection = injector.lookup("www.googleapis.com", 443).unwrap(); + assert!(matches!(&injection.target, InjectionTarget::QueryParam { name } if name == "key")); + assert_eq!(injection.value, "AIza-test"); + } + + #[test] + fn missing_credential_skips_and_preserves_env() { + let policy = make_policy_with_injection( + "api.example.com", + 443, + CredentialInjection { + header: "x-api-key".to_string(), + provider: "example".to_string(), + credential: "MISSING_KEY".to_string(), + ..Default::default() + }, + ); + let provider_env: HashMap = + [("OTHER_KEY".to_string(), "value".to_string())] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(injector.is_empty()); + assert_eq!(filtered_env.get("OTHER_KEY").unwrap(), "value"); + } + + #[test] + fn shared_credential_across_endpoints() { + let mut network_policies = std::collections::BTreeMap::new(); + network_policies.insert( + "exa_api".to_string(), + NetworkPolicyRule { + name: "exa-api".to_string(), + endpoints: vec![ + NetworkEndpoint { + host: "api.exa.ai".to_string(), + port: 443, + ports: vec![443], + credential_injection: Some(CredentialInjection { + header: "x-api-key".to_string(), + provider: "exa".to_string(), + credential: "EXA_API_KEY".to_string(), + ..Default::default() + }), + ..Default::default() + }, + NetworkEndpoint { + host: "backup.exa.ai".to_string(), + port: 443, + ports: vec![443], + credential_injection: Some(CredentialInjection { + header: "x-api-key".to_string(), + provider: "exa".to_string(), + credential: "EXA_API_KEY".to_string(), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }, + ); + let policy = SandboxPolicy { + network_policies, + ..Default::default() + }; + let provider_env: HashMap = + [("EXA_API_KEY".to_string(), "shared-key".to_string())] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + // Both endpoints should have injections + assert!( + injector.lookup("api.exa.ai", 443).is_some(), + "primary endpoint should have injection" + ); + assert!( + injector.lookup("backup.exa.ai", 443).is_some(), + "backup endpoint should also have injection (shared credential)" + ); + // Credential should be removed from env + assert!( + !filtered_env.contains_key("EXA_API_KEY"), + "shared credential should be removed from env" + ); + } + + #[test] + fn lookup_case_insensitive() { + let policy = make_policy_with_injection( + "API.Exa.AI", + 443, + CredentialInjection { + header: "x-api-key".to_string(), + provider: "exa".to_string(), + credential: "KEY".to_string(), + ..Default::default() + }, + ); + let provider_env = [("KEY".to_string(), "val".to_string())] + .into_iter() + .collect(); + + let (injector, _) = CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(injector.lookup("api.exa.ai", 443).is_some()); + assert!(injector.lookup("API.EXA.AI", 443).is_some()); + assert!(injector.lookup("api.exa.ai", 80).is_none()); + } + + #[test] + fn lookup_glob_single_label() { + let policy = make_policy_with_injection( + "*.example.com", + 443, + CredentialInjection { + header: "x-api-key".to_string(), + provider: "example".to_string(), + credential: "KEY".to_string(), + ..Default::default() + }, + ); + let provider_env = [("KEY".to_string(), "val".to_string())] + .into_iter() + .collect(); + + let (injector, _) = CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(injector.lookup("api.example.com", 443).is_some()); + assert!( + injector.lookup("sub.api.example.com", 443).is_none(), + "*.example.com should not match multiple subdomain labels" + ); + assert!(injector.lookup("example.com", 443).is_none()); + } + + #[test] + fn lookup_glob_multi_label() { + let policy = make_policy_with_injection( + "**.example.com", + 443, + CredentialInjection { + header: "x-api-key".to_string(), + provider: "example".to_string(), + credential: "KEY".to_string(), + ..Default::default() + }, + ); + let provider_env = [("KEY".to_string(), "val".to_string())] + .into_iter() + .collect(); + + let (injector, _) = CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(injector.lookup("api.example.com", 443).is_some()); + assert!( + injector.lookup("sub.api.example.com", 443).is_some(), + "**.example.com should match multiple subdomain labels" + ); + assert!(injector.lookup("example.com", 443).is_none()); + } + + #[test] + fn inject_header_plain() { + let raw = b"GET /search HTTP/1.1\r\nHost: api.exa.ai\r\nContent-Length: 0\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::Header { + name: "x-api-key".to_string(), + value_prefix: String::new(), + }, + value: "test-key".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.contains("x-api-key: test-key\r\n")); + assert!(result_str.contains("Host: api.exa.ai\r\n")); + assert!(result_str.starts_with("GET /search HTTP/1.1\r\n")); + } + + #[test] + fn inject_header_with_prefix() { + let raw = b"POST /chat/completions HTTP/1.1\r\nHost: api.perplexity.ai\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::Header { + name: "Authorization".to_string(), + value_prefix: "Bearer ".to_string(), + }, + value: "pplx-xxx".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.contains("Authorization: Bearer pplx-xxx\r\n")); + } + + #[test] + fn inject_header_strips_existing() { + let raw = + b"GET /search HTTP/1.1\r\nHost: api.exa.ai\r\nx-api-key: agent-fake-key\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::Header { + name: "x-api-key".to_string(), + value_prefix: String::new(), + }, + value: "real-key".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!( + result_str.contains("x-api-key: real-key\r\n"), + "should contain injected header" + ); + assert!( + !result_str.contains("agent-fake-key"), + "should strip agent's fake header" + ); + // Verify only one x-api-key header + assert_eq!( + result_str.matches("x-api-key").count(), + 1, + "should have exactly one x-api-key header" + ); + } + + #[test] + fn inject_header_strips_case_insensitive() { + let raw = + b"GET /search HTTP/1.1\r\nHost: api.exa.ai\r\nX-Api-Key: agent-fake-key\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::Header { + name: "x-api-key".to_string(), + value_prefix: String::new(), + }, + value: "real-key".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.contains("x-api-key: real-key\r\n")); + assert!( + !result_str.contains("agent-fake-key"), + "case-insensitive strip should remove X-Api-Key" + ); + } + + #[test] + fn inject_header_preserves_body() { + let raw = + b"POST /v1 HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 5\r\n\r\nhello"; + let injection = ResolvedInjection { + target: InjectionTarget::Header { + name: "x-api-key".to_string(), + value_prefix: String::new(), + }, + value: "key".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.ends_with("\r\n\r\nhello")); + assert!(result_str.contains("Content-Length: 5\r\n")); + } + + #[test] + fn inject_query_param_no_existing_query() { + let raw = b"GET /search HTTP/1.1\r\nHost: www.googleapis.com\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::QueryParam { + name: "key".to_string(), + }, + value: "AIza-test".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.starts_with("GET /search?key=AIza-test HTTP/1.1\r\n")); + } + + #[test] + fn inject_query_param_with_existing_query() { + let raw = b"GET /search?q=hello HTTP/1.1\r\nHost: www.googleapis.com\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::QueryParam { + name: "key".to_string(), + }, + value: "AIza-test".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.starts_with("GET /search?q=hello&key=AIza-test HTTP/1.1\r\n")); + } + + #[test] + fn inject_query_param_encodes_special_chars() { + let raw = b"GET /search HTTP/1.1\r\nHost: example.com\r\n\r\n"; + let injection = ResolvedInjection { + target: InjectionTarget::QueryParam { + name: "key".to_string(), + }, + value: "val=ue&more".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.starts_with("GET /search?key=val%3Due%26more HTTP/1.1\r\n")); + } + + #[test] + fn inject_query_param_preserves_body() { + let raw = + b"POST /data HTTP/1.1\r\nHost: example.com\r\nContent-Length: 3\r\n\r\nabc"; + let injection = ResolvedInjection { + target: InjectionTarget::QueryParam { + name: "key".to_string(), + }, + value: "val".to_string(), + }; + + let result = inject_credential(raw, &injection); + let result_str = String::from_utf8(result).unwrap(); + + assert!(result_str.ends_with("\r\n\r\nabc")); + assert!(result_str.starts_with("POST /data?key=val HTTP/1.1\r\n")); + } + + #[test] + fn url_encode_preserves_safe_chars() { + assert_eq!(url_encode_param("abc123-_.~"), "abc123-_.~"); + } + + #[test] + fn url_encode_encodes_special_chars() { + assert_eq!(url_encode_param("a=b&c"), "a%3Db%26c"); + assert_eq!(url_encode_param("hello world"), "hello%20world"); + } + + #[test] + fn glob_match_single_label() { + assert!(glob_match_host("*.example.com", "api.example.com")); + assert!(!glob_match_host( + "*.example.com", + "sub.api.example.com" + )); + assert!(!glob_match_host("*.example.com", "example.com")); + } + + #[test] + fn glob_match_multi_label() { + assert!(glob_match_host("**.example.com", "api.example.com")); + assert!(glob_match_host( + "**.example.com", + "sub.api.example.com" + )); + assert!(!glob_match_host("**.example.com", "example.com")); + } + + #[test] + fn no_injection_returns_empty() { + let policy = SandboxPolicy::default(); + let provider_env: HashMap = + [("KEY".to_string(), "val".to_string())] + .into_iter() + .collect(); + + let (injector, filtered_env) = + CredentialInjector::extract_from_policy(&policy, provider_env); + + assert!(injector.is_empty()); + assert_eq!(filtered_env.get("KEY").unwrap(), "val"); + } +} diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 9b9ae473..34c10a3d 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -262,6 +262,69 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< } } } + + // Validate credential_injection + if let Some(ci) = ep.get("credential_injection").and_then(|v| v.as_object()) { + let ci_header = ci + .get("header") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ci_query_param = ci + .get("query_param") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ci_credential = ci + .get("credential") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ci_provider = ci + .get("provider") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ci_value_prefix = ci + .get("value_prefix") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let has_header = !ci_header.is_empty(); + let has_query_param = !ci_query_param.is_empty(); + + if has_header && has_query_param { + errors.push(format!( + "{loc}: credential_injection: header and query_param are mutually exclusive" + )); + } + if !has_header && !has_query_param { + errors.push(format!( + "{loc}: credential_injection: one of header or query_param is required" + )); + } + if ci_credential.is_empty() { + errors.push(format!( + "{loc}: credential_injection: credential is required" + )); + } + if ci_provider.is_empty() { + errors.push(format!( + "{loc}: credential_injection: provider is required" + )); + } + if !ci_value_prefix.is_empty() && !has_header { + errors.push(format!( + "{loc}: credential_injection: value_prefix is only valid with header" + )); + } + if protocol != "rest" { + errors.push(format!( + "{loc}: credential_injection requires protocol: rest" + )); + } + if tls != "terminate" { + errors.push(format!( + "{loc}: credential_injection requires tls: terminate" + )); + } + } } } diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index c1c5bb27..def9e100 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>, + /// Credential injector for L7 endpoints with credential_injection config. + pub(crate) credential_injector: Option>, } /// Run protocol-aware L7 inspection on a tunnel. @@ -133,12 +135,19 @@ where ); if allowed || config.enforcement == EnforcementMode::Audit { + // Look up credential injection for this endpoint + let injection = ctx + .credential_injector + .as_ref() + .and_then(|ci| ci.lookup(&ctx.host, ctx.port)); + // 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(), + injection, ) .await?; if !reusable { diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 61b026f5..1b50ebd7 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>, + credential_injection: Option<&crate::credential_injector::ResolvedInjection>, ) -> Result where C: AsyncRead + AsyncWrite + Unpin, @@ -142,13 +143,34 @@ where .position(|w| w == b"\r\n\r\n") .map_or(req.raw_header.len(), |p| p + 4); + // Step 1: Rewrite placeholder-based secrets (existing SecretResolver) let rewritten_header = rewrite_http_header_block(&req.raw_header[..header_end], resolver); + // Step 2: Apply credential injection (strip + inject header or query param) + let rewritten_header = if let Some(injection) = credential_injection { + crate::credential_injector::inject_credential(&rewritten_header, injection) + } else { + rewritten_header + }; + + // Recalculate header_end after potential credential injection rewrites + let final_header_end = rewritten_header + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(rewritten_header.len(), |p| p + 4); + upstream - .write_all(&rewritten_header) + .write_all(&rewritten_header[..final_header_end]) .await .into_diagnostic()?; + // Write any overflow from the rewritten header (should be empty after injection) + let final_overflow = &rewritten_header[final_header_end..]; + if !final_overflow.is_empty() { + upstream.write_all(final_overflow).await.into_diagnostic()?; + } + + // Original overflow from the raw request (body data after original header end) let overflow = &req.raw_header[header_end..]; if !overflow.is_empty() { upstream.write_all(overflow).await.into_diagnostic()?; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 493e4d23..8da8929a 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -7,6 +7,7 @@ pub mod bypass_monitor; mod child_env; +mod credential_injector; pub mod denial_aggregator; mod grpc_client; mod identity; @@ -170,7 +171,7 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (policy, opa_engine) = load_policy( + let (policy, opa_engine, proto_policy) = load_policy( sandbox_id.clone(), sandbox, openshell_endpoint.clone(), @@ -202,6 +203,22 @@ pub async fn run_sandbox( std::collections::HashMap::new() }; + // Extract credential injections from policy endpoints and remove + // injected credentials from the provider environment. Credentials + // referenced by credential_injection configs are withheld from the + // sandbox process and injected at the L7 proxy layer instead. + let (credential_injector, provider_env) = + credential_injector::CredentialInjector::extract_from_policy(&proto_policy, provider_env); + let credential_injector = if credential_injector.is_empty() { + None + } else { + info!( + count = credential_injector.entries_count(), + "Credential injection configured — credentials withheld from sandbox environment" + ); + Some(Arc::new(credential_injector)) + }; + let (provider_env, secret_resolver) = SecretResolver::from_provider_env(provider_env); let secret_resolver = secret_resolver.map(Arc::new); @@ -349,6 +366,7 @@ pub async fn run_sandbox( tls_state, inference_ctx, secret_resolver.clone(), + credential_injector.clone(), denial_tx, ) .await?; @@ -959,7 +977,11 @@ async fn load_policy( openshell_endpoint: Option, policy_rules: Option, policy_data: Option, -) -> Result<(SandboxPolicy, Option>)> { +) -> Result<( + SandboxPolicy, + Option>, + openshell_core::proto::SandboxPolicy, +)> { // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { info!( @@ -983,7 +1005,7 @@ async fn load_policy( process: config.process, }; enrich_sandbox_baseline_paths(&mut policy); - return Ok((policy, Some(Arc::new(engine)))); + return Ok((policy, Some(Arc::new(engine)), Default::default())); } // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data @@ -1042,8 +1064,8 @@ async fn load_policy( info!("Creating OPA engine from proto policy data"); let opa_engine = Some(Arc::new(OpaEngine::from_proto(&proto_policy)?)); - let policy = SandboxPolicy::try_from(proto_policy)?; - return Ok((policy, opa_engine)); + let policy = SandboxPolicy::try_from(proto_policy.clone())?; + return Ok((policy, opa_engine, proto_policy)); } // No policy source available diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index d662399b..82d5eefb 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -132,6 +132,7 @@ impl ProxyHandle { tls_state: Option>, inference_ctx: Option>, secret_resolver: Option>, + credential_injector: Option>, denial_tx: Option>, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default @@ -162,10 +163,11 @@ impl ProxyHandle { let tls = tls_state.clone(); let inf = inference_ctx.clone(); let resolver = secret_resolver.clone(); + let injector = credential_injector.clone(); let dtx = denial_tx.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, injector, dtx, ) .await { @@ -265,6 +267,7 @@ async fn handle_tcp_connection( tls_state: Option>, inference_ctx: Option>, secret_resolver: Option>, + credential_injector: Option>, denial_tx: Option>, ) -> Result<()> { let mut buf = vec![0u8; MAX_HEADER_BYTES]; @@ -554,6 +557,7 @@ async fn handle_tcp_connection( .map(|p| p.to_string_lossy().into_owned()) .collect(), secret_resolver: secret_resolver.clone(), + credential_injector: credential_injector.clone(), }; if l7_config.tls == crate::l7::TlsMode::Terminate { diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index decece75..64dbfbdd 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`. | +| `credential_injection` | object | No | Inject a provider credential at the proxy layer instead of as an environment variable. Requires `protocol: rest` and `tls: terminate`. | #### Access Levels @@ -196,6 +197,83 @@ rules: path: /**/git-upload-pack ``` +#### Credential Injection Object + +When set on an endpoint, the referenced provider credential is not injected as an environment variable into the sandbox. Instead, the L7 proxy strips any existing matching header from the agent's request and injects the real credential at the network layer before forwarding upstream. The agent process never sees the raw API key. + +Requires `protocol: rest` and `tls: terminate` on the endpoint. + +| Field | Type | Required | Description | +|---|---|---|---| +| `header` | string | Conditional | HTTP header name to inject (for example, `x-api-key`, `Authorization`). Mutually exclusive with `query_param`. | +| `value_prefix` | string | No | Prefix prepended to the credential value (for example, `Bearer `). Only valid with `header`. | +| `query_param` | string | Conditional | URL query parameter name (for example, `key`). Mutually exclusive with `header`. | +| `provider` | string | Yes | Provider name that holds the credential. | +| `credential` | string | Yes | Credential key within the provider (for example, `EXA_API_KEY`). | + +One of `header` or `query_param` must be set. + +**Injection types:** + +| Style | Fields | Example Header | +|---|---|---| +| Plain header | `header: x-api-key` | `x-api-key: ` | +| Header with prefix | `header: Authorization`, `value_prefix: "Bearer "` | `Authorization: Bearer ` | +| Query parameter | `query_param: key` | URL appended with `?key=` | + +Example with header injection: + +```yaml +endpoints: + - host: api.exa.ai + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + credential_injection: + header: x-api-key + provider: exa + credential: EXA_API_KEY + rules: + - allow: + method: POST + path: /search +``` + +Example with Bearer prefix: + +```yaml +endpoints: + - host: api.perplexity.ai + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + credential_injection: + header: Authorization + value_prefix: "Bearer " + provider: perplexity + credential: PERPLEXITY_API_KEY + rules: + - allow: + method: POST + path: /chat/completions +``` + +Example with query parameter: + +```yaml +endpoints: + - host: www.googleapis.com + port: 443 + protocol: rest + tls: terminate + credential_injection: + query_param: key + provider: youtube + credential: YOUTUBE_API_KEY +``` + ### Binary Object Identifies an executable that is permitted to use the associated endpoints. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index a96ca33f..1c065267 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -85,6 +85,29 @@ 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; + // Optional credential injection configuration. When set, the referenced + // provider credential is withheld from the sandbox environment and injected + // at the L7 proxy layer instead. Requires protocol = "rest" and tls = "terminate". + CredentialInjection credential_injection = 10; +} + +// Credential injection configuration for L7 proxy endpoints. +// The proxy strips any existing header matching the target and injects +// the real credential before forwarding the request upstream. +message CredentialInjection { + // HTTP header name for header-based injection (e.g., "x-api-key", "Authorization"). + // Mutually exclusive with query_param. + string header = 1; + // Optional prefix prepended to the credential value (e.g., "Bearer "). + // Only valid when header is set. + string value_prefix = 2; + // URL query parameter name for query-param-based injection (e.g., "key"). + // Mutually exclusive with header. + string query_param = 3; + // Provider name that holds the credential. + string provider = 4; + // Credential key within the provider (e.g., "EXA_API_KEY"). + string credential = 5; } // An L7 policy rule (allow-only).