Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions architecture/sandbox-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ Provider is defined in `proto/datamodel.proto`:
- `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.)
- `credentials`: `map<string, string>` for secret values
- `config`: `map<string, string>` for non-secret settings
- `credential_placeholders`: optional `map<string, string>` keyed by the same
env var name as `credentials`. When present, the supervisor injects this
literal string into the child environment instead of the canonical
`openshell:resolve:env:<KEY>` placeholder. Used to satisfy SDKs that
validate credential format in-process before any network call (for
example, Slack's `xoxb-` / `xapp-` prefix check). See
[Custom Placeholder Formats](#custom-placeholder-formats) below.

The gRPC surface is defined in `proto/openshell.proto`:

Expand Down Expand Up @@ -320,17 +327,67 @@ request is forwarded upstream. Placeholders are resolved in four locations:
This applies to forward-proxy HTTP requests, L7-inspected REST requests inside CONNECT
tunnels, and credential-injection-only passthrough relays on TLS-terminated connections.

All rewriting fails closed: if any `openshell:resolve:env:*` placeholder is detected but
cannot be resolved, the proxy rejects the request with HTTP 500 instead of forwarding the
raw placeholder upstream. Resolved secret values are validated for prohibited control
characters (CR, LF, null byte) to prevent header injection (CWE-113). Path segment
All rewriting fails closed: if any registered placeholder string is detected in the output
but cannot be resolved, the proxy rejects the request with HTTP 500 instead of forwarding
the raw placeholder upstream. The fail-closed scan covers both canonical
`openshell:resolve:env:*` placeholders and custom-format placeholders registered via
`credential_placeholders` (see below). Resolved secret values are validated for prohibited
control characters (CR, LF, null byte) to prevent header injection (CWE-113). Path segment
credentials are additionally validated to reject traversal sequences, path separators, and
URI delimiters (CWE-22).

The real secret value remains in supervisor memory only; it is not re-injected into the
child process environment. See [Credential injection](sandbox.md#credential-injection) for
the full implementation details, encoding rules, and security properties.

### Custom Placeholder Formats

The default canonical placeholder `openshell:resolve:env:<KEY>` is distinctive and easy to
detect, but some SDKs validate credential format in-process before the request reaches the
network — and therefore before the proxy can substitute the real value. Slack's
`@slack/web-api` rejects bot tokens that do not start with `xoxb-`; AWS, Google, and several
OAuth libraries do similar prefix or charset checks. For those SDKs the canonical placeholder
fails validation and the request is never made.

To handle this, a provider may declare a `credential_placeholders` entry per credential
env var. When set:

- the supervisor injects the literal override string into the child environment instead of
the canonical placeholder;
- the supervisor's resolver registers `<override> -> <real value>` so the L7 proxy can
substitute it on the wire by exact-match lookup;
- the fail-closed scan also rejects requests in which the override survives rewriting.

The override string must be:

- non-empty;
- free of CR, LF, and NUL bytes (HTTP framing safety);
- distinctive enough not to collide with legitimate user data on the wire — the proxy
substitutes by exact-match against the registered string, so any header value, query
parameter, or path segment that exactly equals the override is replaced.

**Substitution scope for custom-format placeholders.** Custom-format placeholders are
substituted in:

- HTTP header values (full-value, `Bearer <override>` prefix, and Basic-auth-decoded forms);
- URL query parameter values.

They are **not** substituted in URL path segments. Path-segment extraction relies on the
canonical env-var-name grammar (`[A-Za-z_][A-Za-z0-9_]*`) to find placeholder boundaries
inside concatenated paths like Telegram's `/bot<token>/sendMessage`; arbitrary custom shapes
do not fit that grammar. If a credential is consumed in a path-embedded position, leave its
`credential_placeholders` entry unset so the canonical placeholder applies. Custom-format
placeholders that end up in a path are caught by the fail-closed post-rewrite scan and
rejected; they never leak upstream.

**When to use it.** Custom placeholders are a security trade-off. The canonical form
guarantees that no real-credential-shaped string appears in the agent process at all.
Custom placeholders look like real credentials but contain no real value, which is what
the SDK's format check needs to pass — and is still useless to anything that obtains it
(prompt injection, crash dumps, accidental logs) because the L7 proxy is the only path
that can substitute it. Use a custom placeholder only when an in-process SDK forces the
issue; otherwise the canonical form is preferable.

### End-to-End Flow

```text
Expand Down
46 changes: 46 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,22 @@ enum ProviderCommands {
/// Provider config key/value pair.
#[arg(long = "config", value_name = "KEY=VALUE")]
config: Vec<String>,

/// Per-credential format-preserving placeholder shown to the agent
/// process instead of the canonical `openshell:resolve:env:<KEY>`.
/// Use this when an SDK rejects the canonical placeholder by format
/// (for example, Slack expects `xoxb-` / `xapp-` prefixes). The L7
/// proxy still substitutes the real value on the wire.
#[arg(long = "credential-placeholder", value_name = "KEY=VALUE")]
credential_placeholders: Vec<String>,

/// Mark a credential as passthrough: inject the REAL value into the
/// agent process, with no placeholder and no L7 proxy substitution.
/// Use only when the SDK consumes the credential in-process and no
/// on-the-wire substitution is possible. This drops the
/// "agent never sees the real value" invariant for the listed key.
#[arg(long = "credential-passthrough", value_name = "KEY")]
credential_passthrough: Vec<String>,
},

/// Fetch a provider by name.
Expand Down Expand Up @@ -722,6 +738,26 @@ enum ProviderCommands {
/// Provider config key/value pair.
#[arg(long = "config", value_name = "KEY=VALUE")]
config: Vec<String>,

/// Per-credential format-preserving placeholder shown to the agent
/// process. Pass `KEY=` (empty value) to remove an existing override.
#[arg(long = "credential-placeholder", value_name = "KEY=VALUE")]
credential_placeholders: Vec<String>,

/// Mark credentials as passthrough (inject real value into the agent
/// process). Repeats build the new full list, replacing any prior
/// passthrough configuration. Pass `--clear-credential-passthrough`
/// to clear without replacing.
#[arg(
long = "credential-passthrough",
value_name = "KEY",
conflicts_with = "clear_credential_passthrough"
)]
credential_passthrough: Vec<String>,

/// Clear the passthrough credential list on this provider.
#[arg(long)]
clear_credential_passthrough: bool,
},

/// Delete providers by name.
Expand Down Expand Up @@ -2564,6 +2600,8 @@ async fn main() -> Result<()> {
from_existing,
credentials,
config,
credential_placeholders,
credential_passthrough,
} => {
run::provider_create(
endpoint,
Expand All @@ -2572,6 +2610,8 @@ async fn main() -> Result<()> {
from_existing,
&credentials,
&config,
&credential_placeholders,
&credential_passthrough,
&tls,
)
.await?;
Expand All @@ -2591,13 +2631,19 @@ async fn main() -> Result<()> {
from_existing,
credentials,
config,
credential_placeholders,
credential_passthrough,
clear_credential_passthrough,
} => {
run::provider_update(
endpoint,
&name,
from_existing,
&credentials,
&config,
&credential_placeholders,
&credential_passthrough,
clear_credential_passthrough,
&tls,
)
.await?;
Expand Down
91 changes: 91 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3324,6 +3324,8 @@ async fn auto_create_provider(
r#type: provider_type.to_string(),
credentials: discovered.credentials.clone(),
config: discovered.config.clone(),
credential_placeholders: std::collections::HashMap::new(),
passthrough_credentials: Vec::new(),
}),
};

Expand Down Expand Up @@ -3360,6 +3362,7 @@ async fn auto_create_provider(
r#type: provider_type.to_string(),
credentials: discovered.credentials.clone(),
config: discovered.config.clone(),
credential_placeholders: std::collections::HashMap::new(),
}),
};

Expand Down Expand Up @@ -3420,6 +3423,51 @@ fn parse_key_value_pairs(items: &[String], flag: &str) -> Result<HashMap<String,
Ok(map)
}

fn parse_passthrough_keys(items: &[String]) -> Result<Vec<String>> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(items.len());
for item in items {
let key = item.trim();
if key.is_empty() {
return Err(miette::miette!(
"--credential-passthrough key cannot be empty"
));
}
if !seen.insert(key.to_string()) {
continue;
}
out.push(key.to_string());
}
Ok(out)
}

fn validate_placeholder_and_passthrough_consistency(
credentials: &HashMap<String, String>,
placeholders: &HashMap<String, String>,
passthrough: &[String],
) -> Result<()> {
for key in placeholders.keys() {
if !credentials.contains_key(key) {
return Err(miette::miette!(
"--credential-placeholder {key} has no matching --credential"
));
}
}
for key in passthrough {
if !credentials.contains_key(key) {
return Err(miette::miette!(
"--credential-passthrough {key} has no matching --credential"
));
}
if placeholders.contains_key(key) {
return Err(miette::miette!(
"credential '{key}' cannot be both --credential-placeholder and --credential-passthrough"
));
}
}
Ok(())
}

fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();

Expand Down Expand Up @@ -3463,6 +3511,8 @@ pub async fn provider_create(
from_existing: bool,
credentials: &[String],
config: &[String],
credential_placeholders: &[String],
credential_passthrough: &[String],
tls: &TlsOptions,
) -> Result<()> {
if from_existing && !credentials.is_empty() {
Expand All @@ -3479,6 +3529,9 @@ pub async fn provider_create(

let mut credential_map = parse_credential_pairs(credentials)?;
let mut config_map = parse_key_value_pairs(config, "--config")?;
let placeholder_map =
parse_key_value_pairs(credential_placeholders, "--credential-placeholder")?;
let passthrough_list = parse_passthrough_keys(credential_passthrough)?;

if from_existing {
let registry = ProviderRegistry::new();
Expand Down Expand Up @@ -3506,6 +3559,12 @@ pub async fn provider_create(
));
}

validate_placeholder_and_passthrough_consistency(
&credential_map,
&placeholder_map,
&passthrough_list,
)?;

let response = client
.create_provider(CreateProviderRequest {
provider: Some(Provider {
Expand All @@ -3514,6 +3573,8 @@ pub async fn provider_create(
r#type: provider_type,
credentials: credential_map,
config: config_map,
credential_placeholders: placeholder_map,
passthrough_credentials: passthrough_list,
}),
})
.await
Expand Down Expand Up @@ -3640,6 +3701,9 @@ pub async fn provider_update(
from_existing: bool,
credentials: &[String],
config: &[String],
credential_placeholders: &[String],
credential_passthrough: &[String],
clear_credential_passthrough: bool,
tls: &TlsOptions,
) -> Result<()> {
if from_existing && !credentials.is_empty() {
Expand All @@ -3652,6 +3716,14 @@ pub async fn provider_update(

let mut credential_map = parse_credential_pairs(credentials)?;
let mut config_map = parse_key_value_pairs(config, "--config")?;
let placeholder_map =
parse_key_value_pairs(credential_placeholders, "--credential-placeholder")?;
let mut passthrough_list = parse_passthrough_keys(credential_passthrough)?;
if clear_credential_passthrough {
// Sentinel: a single empty-string entry tells the server to clear
// the existing passthrough list. See merge_passthrough on the server.
passthrough_list = vec![String::new()];
}

if from_existing {
// Fetch the existing provider to discover its type for credential lookup.
Expand Down Expand Up @@ -3684,6 +3756,23 @@ pub async fn provider_update(
}
}

// Best-effort cross-field validation: the server's authoritative check
// runs after the merge against the persisted provider, but failing fast
// here on the obvious case (the same key on both flags) gives a better
// error than waiting for the round trip.
if !placeholder_map.is_empty() && !passthrough_list.is_empty() {
for key in &passthrough_list {
if key.is_empty() {
continue;
}
if placeholder_map.contains_key(key) {
return Err(miette::miette!(
"credential '{key}' cannot be both --credential-placeholder and --credential-passthrough"
));
}
}
}

let response = client
.update_provider(UpdateProviderRequest {
provider: Some(Provider {
Expand All @@ -3692,6 +3781,8 @@ pub async fn provider_update(
r#type: String::new(),
credentials: credential_map,
config: config_map,
credential_placeholders: placeholder_map,
passthrough_credentials: passthrough_list,
}),
})
.await
Expand Down
26 changes: 26 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ impl TestOpenShell {
r#type: provider_type.to_string(),
credentials: HashMap::new(),
config: HashMap::new(),
credential_placeholders: HashMap::new(),
passthrough_credentials: Vec::new(),
},
);
}
Expand Down Expand Up @@ -273,12 +275,36 @@ impl OpenShell for TestOpenShell {
}
base
};
let merge_passthrough = |existing: Vec<String>, incoming: Vec<String>| -> Vec<String> {
if incoming.is_empty() {
return existing;
}
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(incoming.len());
for k in incoming {
if k.is_empty() {
continue;
}
if seen.insert(k.clone()) {
out.push(k);
}
}
out
};
let updated = Provider {
id: existing.id,
name: provider.name,
r#type: existing.r#type,
credentials: merge(existing.credentials, provider.credentials),
config: merge(existing.config, provider.config),
credential_placeholders: merge(
existing.credential_placeholders,
provider.credential_placeholders,
),
passthrough_credentials: merge_passthrough(
existing.passthrough_credentials,
provider.passthrough_credentials,
),
};
providers.insert(updated.name.clone(), updated.clone());
Ok(Response::new(ProviderResponse {
Expand Down
Loading
Loading