Skip to content

Commit 07aae1a

Browse files
committed
feat(sandbox): L7 credential injection for non-inference providers
Extend the L7 proxy to inject API credentials at the network layer for arbitrary REST endpoints, generalizing the inference.local pattern to any service in network_policies. When an endpoint has a credential_injection configuration, the referenced provider credential is withheld from the sandbox environment and injected by the proxy before forwarding upstream. The agent process never sees the raw API key. Supports three injection styles: - Header (e.g., x-api-key: <value>) - Header with prefix (e.g., Authorization: Bearer <value>) - Query parameter (e.g., ?key=<value>) Changes: - Add CredentialInjection proto message to sandbox.proto - Add CredentialInjectionDef YAML schema with round-trip conversion - Add validation rules (requires rest + tls terminate) - Create credential_injector.rs module with extraction, lookup, and HTTP modification logic - Integrate into sandbox startup to filter provider env vars - Thread CredentialInjector through proxy to L7 relay - Apply injection in relay_http_request_with_resolver() - Make protobuf-src optional to support system protoc - Update policy-schema.md and architecture/sandbox.md Closes NVIDIA#538
1 parent b4e20c1 commit 07aae1a

12 files changed

Lines changed: 1369 additions & 22 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ All paths are relative to `crates/openshell-sandbox/src/`.
3333
| `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation |
3434
| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing (Content-Length, chunked), deny response generation |
3535
| `l7/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types |
36+
| `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 |
3637

3738
## Startup and Orchestration
3839

@@ -81,11 +82,13 @@ flowchart TD
8182
- 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`.
8283
- 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.
8384
- Neither present: return fatal error.
84-
- Output: `(SandboxPolicy, Option<Arc<OpaEngine>>)`
85+
- Output: `(SandboxPolicy, Option<Arc<OpaEngine>>, proto::SandboxPolicy)`
8586

8687
2. **Provider environment fetching**: If sandbox ID and endpoint are available, call `grpc_client::fetch_provider_environment()` to get a `HashMap<String, String>` of credential environment variables. On failure, log a warning and continue with an empty map.
8788

88-
3. **Binary identity cache**: If OPA engine is active, create `Arc<BinaryIdentityCache::new()>` for SHA256 TOFU enforcement.
89+
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).
90+
91+
4. **Binary identity cache**: If OPA engine is active, create `Arc<BinaryIdentityCache::new()>` for SHA256 TOFU enforcement.
8992

9093
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.
9194

@@ -1001,6 +1004,56 @@ Implements `L7Provider` for HTTP/1.1:
10011004
5. If allowed (or audit mode): relay request to upstream and response back to client, then loop
10021005
6. If denied in enforce mode: send 403 and close the connection
10031006

1007+
## Credential Injection
1008+
1009+
**File:** `crates/openshell-sandbox/src/credential_injector.rs`
1010+
1011+
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`.
1012+
1013+
### Problem
1014+
1015+
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.
1016+
1017+
### Architecture
1018+
1019+
When an endpoint has a `credential_injection` configuration in the policy YAML:
1020+
1021+
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)`.
1022+
2. **Proxy startup**: The `CredentialInjector` is passed through to `L7EvalContext` alongside the existing `SecretResolver`.
1023+
3. **Request relay** (`l7/rest.rs`): After the OPA policy allows a request, `relay_http_request_with_resolver()` applies credential injection:
1024+
- For header injection: strips any existing header with the same name (case-insensitive) and appends the injected header with the real credential.
1025+
- For query parameter injection: appends the credential as a URL query parameter.
1026+
4. **Agent process**: never sees the credential. It is not in `process.env` and not in any placeholder form.
1027+
1028+
### Injection types
1029+
1030+
| Type | YAML fields | Example |
1031+
|---|---|---|
1032+
| Header | `header: x-api-key` | `x-api-key: <value>` |
1033+
| Header + prefix | `header: Authorization`, `value_prefix: "Bearer "` | `Authorization: Bearer <value>` |
1034+
| Query parameter | `query_param: key` | URL appended with `?key=<value>` |
1035+
1036+
### Relationship to SecretResolver
1037+
1038+
`SecretResolver` and `CredentialInjector` serve different purposes:
1039+
1040+
| | SecretResolver | CredentialInjector |
1041+
|---|---|---|
1042+
| **Mechanism** | Placeholder rewriting | Direct injection |
1043+
| **Agent visibility** | Agent sees placeholder env vars | Agent sees nothing |
1044+
| **When applied** | All provider credentials (default) | Only credentials with `credential_injection` |
1045+
| **Auth header source** | Agent constructs the header using placeholder | Proxy adds the header from scratch |
1046+
| **Spoofing risk** | Agent could send placeholders to wrong endpoint | Proxy strips any existing header first |
1047+
1048+
Both are applied in `relay_http_request_with_resolver()`: `SecretResolver` rewrites first, then `CredentialInjector` injects.
1049+
1050+
### Validation rules
1051+
1052+
- `credential_injection` requires `protocol: rest` and `tls: terminate`
1053+
- Exactly one of `header` or `query_param` must be set
1054+
- `credential` and `provider` are required
1055+
- `value_prefix` is only valid with `header`
1056+
10041057
## Process Identity
10051058

10061059
### SHA256 TOFU (Trust-On-First-Use)

‎crates/openshell-core/Cargo.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ url = { workspace = true }
2525
## Off by default so production builds have an empty registry.
2626
## Enabled by e2e tests and during development.
2727
dev-settings = []
28+
## Use bundled protoc from protobuf-src instead of system protoc.
29+
bundled-protoc = ["protobuf-src"]
2830

2931
[build-dependencies]
3032
tonic-build = { workspace = true }
31-
protobuf-src = { workspace = true }
33+
protobuf-src = { workspace = true, optional = true }
3234

3335
[dev-dependencies]
3436
tempfile = "3"

‎crates/openshell-core/build.rs‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
1717
}
1818

1919
// --- Protobuf compilation ---
20-
// Use bundled protoc from protobuf-src. The system protoc (from apt-get)
21-
// does not bundle the well-known type includes (google/protobuf/struct.proto
22-
// etc.), so we must use protobuf-src which ships both the binary and the
23-
// include tree.
24-
// SAFETY: This is run at build time in a single-threaded build script context.
25-
// No other threads are reading environment variables concurrently.
26-
#[allow(unsafe_code)]
27-
unsafe {
28-
env::set_var("PROTOC", protobuf_src::protoc());
20+
// Prefer PROTOC env var (e.g., from mise or system install) when available.
21+
// Fall back to bundled protoc from protobuf-src if the feature is enabled.
22+
if env::var("PROTOC").is_err() {
23+
#[cfg(feature = "bundled-protoc")]
24+
{
25+
// SAFETY: This is run at build time in a single-threaded build script context.
26+
// No other threads are reading environment variables concurrently.
27+
#[allow(unsafe_code)]
28+
unsafe {
29+
env::set_var("PROTOC", protobuf_src::protoc());
30+
}
31+
}
2932
}
3033

3134
let proto_files = [

‎crates/openshell-policy/src/lib.rs‎

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use std::path::Path;
1515

1616
use miette::{IntoDiagnostic, Result, WrapErr};
1717
use openshell_core::proto::{
18-
FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary, NetworkEndpoint,
19-
NetworkPolicyRule, ProcessPolicy, SandboxPolicy,
18+
CredentialInjection, FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary,
19+
NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy,
2020
};
2121
use serde::{Deserialize, Serialize};
2222

@@ -99,6 +99,11 @@ struct NetworkEndpointDef {
9999
rules: Vec<L7RuleDef>,
100100
#[serde(default, skip_serializing_if = "Vec::is_empty")]
101101
allowed_ips: Vec<String>,
102+
/// Optional credential injection. When set, the referenced provider
103+
/// credential is withheld from the sandbox environment and injected
104+
/// at the L7 proxy layer instead.
105+
#[serde(default, skip_serializing_if = "Option::is_none")]
106+
credential_injection: Option<CredentialInjectionDef>,
102107
}
103108

104109
fn is_zero(v: &u32) -> bool {
@@ -132,6 +137,34 @@ struct NetworkBinaryDef {
132137
harness: bool,
133138
}
134139

140+
/// Credential injection configuration for an L7 endpoint.
141+
///
142+
/// When attached to an endpoint, the referenced provider credential is not
143+
/// injected as an environment variable. Instead, the L7 proxy injects it
144+
/// into outbound requests at the network layer.
145+
#[derive(Debug, Serialize, Deserialize)]
146+
#[serde(deny_unknown_fields)]
147+
struct CredentialInjectionDef {
148+
/// HTTP header name (e.g., "x-api-key", "Authorization").
149+
/// Mutually exclusive with `query_param`.
150+
#[serde(default, skip_serializing_if = "String::is_empty")]
151+
header: String,
152+
/// Optional prefix prepended to the credential value (e.g., "Bearer ").
153+
/// Only valid when `header` is set.
154+
#[serde(default, skip_serializing_if = "String::is_empty")]
155+
value_prefix: String,
156+
/// URL query parameter name (e.g., "key").
157+
/// Mutually exclusive with `header`.
158+
#[serde(default, skip_serializing_if = "String::is_empty")]
159+
query_param: String,
160+
/// Provider name that holds the credential.
161+
#[serde(default, skip_serializing_if = "String::is_empty")]
162+
provider: String,
163+
/// Credential key within the provider (e.g., "EXA_API_KEY").
164+
#[serde(default, skip_serializing_if = "String::is_empty")]
165+
credential: String,
166+
}
167+
135168
// ---------------------------------------------------------------------------
136169
// YAML → proto conversion
137170
// ---------------------------------------------------------------------------
@@ -180,6 +213,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
180213
})
181214
.collect(),
182215
allowed_ips: e.allowed_ips,
216+
credential_injection: e.credential_injection.map(
217+
|ci| CredentialInjection {
218+
header: ci.header,
219+
value_prefix: ci.value_prefix,
220+
query_param: ci.query_param,
221+
provider: ci.provider,
222+
credential: ci.credential,
223+
},
224+
),
183225
}
184226
})
185227
.collect(),
@@ -280,6 +322,15 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
280322
})
281323
.collect(),
282324
allowed_ips: e.allowed_ips.clone(),
325+
credential_injection: e.credential_injection.as_ref().map(
326+
|ci| CredentialInjectionDef {
327+
header: ci.header.clone(),
328+
value_prefix: ci.value_prefix.clone(),
329+
query_param: ci.query_param.clone(),
330+
provider: ci.provider.clone(),
331+
credential: ci.credential.clone(),
332+
},
333+
),
283334
}
284335
})
285336
.collect(),
@@ -1117,4 +1168,157 @@ network_policies:
11171168
proto2.network_policies["test"].endpoints[0].host
11181169
);
11191170
}
1171+
1172+
#[test]
1173+
fn round_trip_preserves_credential_injection_header() {
1174+
let yaml = r#"
1175+
version: 1
1176+
network_policies:
1177+
exa_api:
1178+
name: exa-search-api
1179+
endpoints:
1180+
- host: api.exa.ai
1181+
port: 443
1182+
protocol: rest
1183+
tls: terminate
1184+
enforcement: enforce
1185+
credential_injection:
1186+
header: x-api-key
1187+
provider: exa
1188+
credential: EXA_API_KEY
1189+
rules:
1190+
- allow:
1191+
method: POST
1192+
path: /search
1193+
binaries:
1194+
- path: /usr/bin/node
1195+
"#;
1196+
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
1197+
let ci1 = proto1.network_policies["exa_api"].endpoints[0]
1198+
.credential_injection
1199+
.as_ref()
1200+
.expect("credential_injection missing");
1201+
assert_eq!(ci1.header, "x-api-key");
1202+
assert_eq!(ci1.provider, "exa");
1203+
assert_eq!(ci1.credential, "EXA_API_KEY");
1204+
assert!(ci1.value_prefix.is_empty());
1205+
assert!(ci1.query_param.is_empty());
1206+
1207+
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
1208+
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
1209+
let ci2 = proto2.network_policies["exa_api"].endpoints[0]
1210+
.credential_injection
1211+
.as_ref()
1212+
.expect("credential_injection lost in round-trip");
1213+
assert_eq!(ci1.header, ci2.header);
1214+
assert_eq!(ci1.provider, ci2.provider);
1215+
assert_eq!(ci1.credential, ci2.credential);
1216+
}
1217+
1218+
#[test]
1219+
fn round_trip_preserves_credential_injection_bearer() {
1220+
let yaml = r#"
1221+
version: 1
1222+
network_policies:
1223+
perplexity_api:
1224+
name: perplexity-api
1225+
endpoints:
1226+
- host: api.perplexity.ai
1227+
port: 443
1228+
protocol: rest
1229+
tls: terminate
1230+
enforcement: enforce
1231+
credential_injection:
1232+
header: Authorization
1233+
value_prefix: "Bearer "
1234+
provider: perplexity
1235+
credential: PERPLEXITY_API_KEY
1236+
rules:
1237+
- allow:
1238+
method: POST
1239+
path: /chat/completions
1240+
binaries:
1241+
- path: /usr/bin/node
1242+
"#;
1243+
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
1244+
let ci1 = proto1.network_policies["perplexity_api"].endpoints[0]
1245+
.credential_injection
1246+
.as_ref()
1247+
.expect("credential_injection missing");
1248+
assert_eq!(ci1.header, "Authorization");
1249+
assert_eq!(ci1.value_prefix, "Bearer ");
1250+
assert_eq!(ci1.credential, "PERPLEXITY_API_KEY");
1251+
1252+
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
1253+
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
1254+
let ci2 = proto2.network_policies["perplexity_api"].endpoints[0]
1255+
.credential_injection
1256+
.as_ref()
1257+
.expect("credential_injection lost in round-trip");
1258+
assert_eq!(ci1.value_prefix, ci2.value_prefix);
1259+
}
1260+
1261+
#[test]
1262+
fn round_trip_preserves_credential_injection_query_param() {
1263+
let yaml = r#"
1264+
version: 1
1265+
network_policies:
1266+
youtube_api:
1267+
name: youtube-data-api
1268+
endpoints:
1269+
- host: www.googleapis.com
1270+
port: 443
1271+
protocol: rest
1272+
tls: terminate
1273+
credential_injection:
1274+
query_param: key
1275+
provider: youtube
1276+
credential: YOUTUBE_API_KEY
1277+
binaries:
1278+
- path: /usr/bin/node
1279+
"#;
1280+
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
1281+
let ci1 = proto1.network_policies["youtube_api"].endpoints[0]
1282+
.credential_injection
1283+
.as_ref()
1284+
.expect("credential_injection missing");
1285+
assert_eq!(ci1.query_param, "key");
1286+
assert_eq!(ci1.credential, "YOUTUBE_API_KEY");
1287+
assert!(ci1.header.is_empty());
1288+
1289+
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
1290+
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
1291+
let ci2 = proto2.network_policies["youtube_api"].endpoints[0]
1292+
.credential_injection
1293+
.as_ref()
1294+
.expect("credential_injection lost in round-trip");
1295+
assert_eq!(ci1.query_param, ci2.query_param);
1296+
assert_eq!(ci1.credential, ci2.credential);
1297+
}
1298+
1299+
#[test]
1300+
fn no_credential_injection_preserves_none() {
1301+
let yaml = r#"
1302+
version: 1
1303+
network_policies:
1304+
test:
1305+
endpoints:
1306+
- host: example.com
1307+
port: 443
1308+
binaries:
1309+
- path: /usr/bin/curl
1310+
"#;
1311+
let proto = parse_sandbox_policy(yaml).expect("parse failed");
1312+
assert!(
1313+
proto.network_policies["test"].endpoints[0]
1314+
.credential_injection
1315+
.is_none()
1316+
);
1317+
1318+
let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed");
1319+
assert!(
1320+
!yaml_out.contains("credential_injection"),
1321+
"credential_injection should not appear in output when not set"
1322+
);
1323+
}
11201324
}

0 commit comments

Comments
 (0)