From 459668f38bf9d572c90c79d98454b5b7c0b0b57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:32:34 +0200 Subject: [PATCH 01/11] feat(agent): add [config_env] for non-sensitive agent environment Profiles can now set plaintext config variables for the sandboxed agent. Both backends apply them; the Docker backend previously had no way to set any. Values may be strings, numbers or booleans. Names the sandbox owns (proxy/CA vars, HOME, TMPDIR, FORCE_COLOR, STASHBASE_*) or that a [secrets]/[personal_credentials] binding provides are rejected. Values that look like credentials (name, known token prefix, URL password, high entropy) warn in `agent validate`, and `agent run` asks to confirm them, failing when it can't ask. A value is acknowledged with `{ value = "...", not_secret = true }`. --- README.md | 12 + docs/agent-profiles.md | 23 ++ docs/agent-profiles/examples/config-env.toml | 27 ++ src/handlers/agent/env_check.rs | 399 +++++++++++++++++++ src/handlers/agent/mcp.rs | 2 + src/handlers/agent/mod.rs | 1 + src/handlers/agent/policy_test.rs | 1 + src/handlers/agent/profiles.rs | 3 + src/handlers/agent/validate.rs | 128 ++++++ src/handlers/entry/root.rs | 7 + src/handlers/run/entry.rs | 45 ++- src/handlers/run/proxy.rs | 21 +- src/models/agent.rs | 61 ++- 13 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 docs/agent-profiles/examples/config-env.toml create mode 100644 src/handlers/agent/env_check.rs diff --git a/README.md b/README.md index 598a5f06..03217fad 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,18 @@ deny_hosts = ["api.stashbase.dev"] Stashbase will warn when starting this mode. +#### Config environment variables + +Pass non-sensitive config to the agent with `[config_env]`. The values are plaintext and work with both sandbox backends: + +```toml +[config_env] +NODE_ENV = "development" +RUST_LOG = "info" +``` + +Values that look like credentials are flagged: `agent run` asks before starting and fails when it can't ask. Credentials belong in `[secrets]`. See [Config Environment Variables](docs/agent-profiles.md#config-environment-variables) for the rules and an [example profile](docs/agent-profiles/examples/config-env.toml). + ### Filesystem and Network Containment #### Read and write restrictions diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 13bda690..e7527c90 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -98,6 +98,29 @@ worktree = true Or per run: `stashbase agent run --profile coding --worktree -- claude` (`--worktree=false` skips it when the profile turns it on). Works with the native backend and, on macOS/Linux, the Docker backend. Review and merge the agent's work from your checkout with `stashbase agent worktrees list`, `merge ` and `clean` — see **[Worktrees](sandboxing.md#worktrees)**. +## Config Environment Variables + +Set non-sensitive configuration for the agent with `[config_env]`. Values are stored in plaintext in the profile, and both sandbox backends apply them. A value can be a string, a number or a boolean (`PORT = 3000` and `DEBUG = true` work without quotes), and the agent always receives it as a string. The Docker backend otherwise starts the container with an empty environment. + +```toml +[config_env] +NODE_ENV = "development" +RUST_LOG = "info" +DATABASE_URL = "postgres://app@localhost:5432/app" +``` + +- `[config_env]` values take precedence over variables inherited from your shell. They can't replace anything the sandbox sets: proxy and CA variables, `HOME`, `TMPDIR`, `FORCE_COLOR` and anything starting with `STASHBASE_` are rejected. +- A name that a `[secrets]` or `[personal_credentials]` binding already provides (its `env` name, or a renamed `from` source) is rejected. +- Credentials don't belong here. Stashbase checks every value: a credential-like name (`*TOKEN*`, `*PASSWORD*`, `*API_KEY*`, …), a known token format, a URL containing a password, or a long random-looking string triggers a warning in `agent validate`. `agent run` then asks for confirmation before starting, and fails when it can't ask (non-interactive or `--silent`). Values are never printed. +- If a flagged value really isn't secret, acknowledge it so the check skips it: + + ```toml + [config_env] + BUILD_HASH = { value = "9f86d081884c7d659a2feaa0c55ad015", not_secret = true } + ``` + +A complete example profile is in [examples/config-env.toml](agent-profiles/examples/config-env.toml). It includes commented-out lines that show the warning and the validation errors. + ## Network Access and HTTP Rules By default, the proxy denies all connections. Allow specific destinations: diff --git a/docs/agent-profiles/examples/config-env.toml b/docs/agent-profiles/examples/config-env.toml new file mode 100644 index 00000000..eb25c639 --- /dev/null +++ b/docs/agent-profiles/examples/config-env.toml @@ -0,0 +1,27 @@ +# Plaintext, non-sensitive config for the agent via [config_env]. +# Egress-only (no secrets), so it runs without a Stashbase project: +# cp docs/agent-profiles/examples/config-env.toml .stashbase/agents/ +# stashbase agent validate --profile config-env +# stashbase agent run --profile config-env -- env +egress_hosts = ["api.anthropic.com", "registry.npmjs.org"] + +# Uncomment to check the Docker backend, which starts from an empty env. +# [sandbox] +# backend = "docker" + +[config_env] +NODE_ENV = "development" +RUST_LOG = "info,stashbase=debug" +# No password in the URL, so it isn't flagged. +DATABASE_URL = "postgres://app@localhost:5432/app" +# Long hex looks like a key; not_secret = true skips the check. +BUILD_HASH = { value = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b", not_secret = true } + +# Uncomment to see the check: validate warns, and run asks for confirmation +# (or fails when non-interactive / --silent). +# UPLOAD_URL = "https://deploy:hunter2@uploads.example.com" +# DEPLOY_TOKEN = "abc" + +# Uncomment to see hard failures: +# HTTPS_PROXY = "http://elsewhere:8080" # owned by the sandbox +# STASHBASE_API_KEY = "x" # STASHBASE_* is reserved diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs new file mode 100644 index 00000000..a78f97cb --- /dev/null +++ b/src/handlers/agent/env_check.rs @@ -0,0 +1,399 @@ +//! Checks for `[config_env]`, the profile's plaintext environment for the +//! agent: which names it may not set, and which values look like credentials +//! that belong in `[secrets]` instead. + +use std::io::IsTerminal; + +use crate::models::agent::AgentProfile; + +/// Variables the sandbox itself sets or strips. Letting a profile set one +/// would break the session or weaken its containment (proxy routing, CA +/// trust, broker access). Compared case-insensitively, since tools read both +/// `HTTP_PROXY` and `http_proxy`. Everything prefixed `STASHBASE_` is +/// reserved too, see [`reserved_env_name`]. +const RESERVED_CHILD_ENV: &[&str] = &[ + "HOME", + "TMPDIR", + "FORCE_COLOR", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "NODE_USE_ENV_PROXY", + "NPM_CONFIG_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_NOPROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CODEX_CA_CERTIFICATE", +]; + +/// Whether the sandbox owns `name`, so `[config_env]` must not set it. +pub(crate) fn reserved_env_name(name: &str) -> bool { + name.to_ascii_uppercase().starts_with("STASHBASE_") + || RESERVED_CHILD_ENV + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(name)) +} + +/// The binding that already provides `name` to the child, as a +/// `[secrets.X]` / `[personal_credentials.X]` label. A binding's exposed +/// `env` name carries the proxy placeholder, and a renamed source (`from`) +/// is stripped from the child on purpose, so neither may be set as plaintext. +pub(crate) fn conflicting_binding(profile: &AgentProfile, name: &str) -> Option { + let tables = [ + ("secrets", &profile.secrets.bindings), + ("personal_credentials", &profile.personal_credentials), + ]; + for (table, bindings) in tables { + let mut binding_names = bindings.keys().collect::>(); + binding_names.sort(); + for binding_name in binding_names { + let binding = &bindings[binding_name]; + let env = binding.env.as_deref().unwrap_or(binding_name); + let from = binding.from.as_deref().unwrap_or(binding_name); + if env == name || from == name { + return Some(format!("[{table}.{binding_name}]")); + } + } + } + None +} + +const CREDENTIAL_NAME_PARTS: &[&str] = &[ + "TOKEN", + "SECRET", + "PASSWORD", + "PASSWD", + "API_KEY", + "APIKEY", + "PRIVATE_KEY", + "CREDENTIAL", +]; + +const CREDENTIAL_PREFIXES: &[&str] = &[ + "ghp_", + "gho_", + "ghs_", + "ghu_", + "github_pat_", + "sk-", + "xoxa-", + "xoxb-", + "xoxp-", + "xoxr-", + "AKIA", + "glpat-", + "-----BEGIN", +]; + +/// Why `value` (set as `name`) looks like a credential, or `None` when it +/// looks like ordinary config. A heuristic: it errs toward flagging, and a +/// profile acknowledges a false positive with `not_secret = true`. +pub(crate) fn secret_likeness(name: &str, value: &str) -> Option<&'static str> { + let upper_name = name.to_ascii_uppercase(); + if CREDENTIAL_NAME_PARTS + .iter() + .any(|part| upper_name.contains(part)) + { + return Some("its name looks like a credential"); + } + let value = value.trim(); + if CREDENTIAL_PREFIXES + .iter() + .any(|prefix| value.starts_with(prefix)) + { + return Some("its value matches a known credential format"); + } + if url_has_password(value) { + return Some("its value is a URL containing a password"); + } + if high_entropy(value) { + return Some("its value looks randomly generated, like a key or token"); + } + None +} + +fn url_has_password(value: &str) -> bool { + reqwest::Url::parse(value).is_ok_and(|url| url.password().is_some_and(|p| !p.is_empty())) +} + +fn high_entropy(value: &str) -> bool { + if value.len() < 20 + || value + .chars() + .any(|ch| ch.is_whitespace() || ch == '/' || !ch.is_ascii()) + || is_uuid(value) + { + return false; + } + let entropy = shannon_entropy(value); + if value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + value.len() >= 32 && entropy >= 3.0 + } else { + entropy >= 4.0 + } +} + +fn is_uuid(value: &str) -> bool { + let groups = value.split('-').collect::>(); + groups.len() == 5 + && groups + .iter() + .zip([8, 4, 4, 4, 12]) + .all(|(group, len)| group.len() == len && group.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// Shannon entropy in bits per byte. +fn shannon_entropy(value: &str) -> f64 { + let mut counts = [0usize; 256]; + for byte in value.bytes() { + counts[byte as usize] += 1; + } + let len = value.len() as f64; + counts + .iter() + .filter(|&&count| count > 0) + .map(|&count| { + let probability = count as f64 / len; + -probability * probability.log2() + }) + .sum() +} + +/// `[config_env]` entries that look like credentials and have not been +/// acknowledged with `not_secret = true`, as `(name, reason)` in name order. +pub(crate) fn flagged_config_env(profile: &AgentProfile) -> Vec<(String, &'static str)> { + profile + .config_env + .iter() + .filter(|(_, value)| !value.acknowledged()) + .filter_map(|(name, value)| { + secret_likeness(name, &value.value()).map(|reason| (name.clone(), reason)) + }) + .collect() +} + +pub(crate) const NOT_SECRET_HINT: &str = + "Move credentials to [secrets], or confirm a value is safe with `NAME = { value = \"...\", not_secret = true }`."; + +/// Before a session starts, asks the user to confirm `[config_env]` values +/// that look like credentials. They would be handed to the agent in +/// plaintext, so a run that can't ask (CI, piped stdin, `--silent`) fails +/// instead. Values are never printed. +pub fn confirm_config_env(profile: &AgentProfile, silent: bool) -> anyhow::Result<()> { + let flagged = flagged_config_env(profile); + if flagged.is_empty() { + return Ok(()); + } + let list = flagged + .iter() + .map(|(name, reason)| format!("- {name}: {reason}")) + .collect::>() + .join("\n"); + let interactive = !silent && std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + if !interactive { + anyhow::bail!( + "[config_env] has values that look like credentials:\n{list}\n{NOT_SECRET_HINT}" + ); + } + eprintln!("[config_env] has values that look like credentials:\n{list}\n{NOT_SECRET_HINT}"); + let proceed = crate::utils::interaction::confirm_opt( + "Pass these values to the agent in plaintext anyway?", + ) + .unwrap_or(false); + // See ensure_docker_sandbox_image_available: dialoguer can leave the + // cursor hidden when the prompt is dismissed with Ctrl+C. + let _ = dialoguer::console::Term::stdout().show_cursor(); + if !proceed { + anyhow::bail!("agent run cancelled: [config_env] values were not confirmed"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::models::agent::{ + AgentBindingProfile, AgentEnvScalar, AgentEnvValue, AgentEnvValueDetails, + }; + + #[test] + fn flags_credential_names() { + for name in ["GITHUB_TOKEN", "db_password", "OPENAI_API_KEY", "MY_SECRET"] { + assert!(secret_likeness(name, "x").is_some(), "{name}"); + } + } + + #[test] + fn flags_known_credential_formats() { + for value in [ + "ghp_abc", + "github_pat_11ABC", + "sk-ant-api03-xyz", + "xoxb-123", + "AKIAIOSFODNN7EXAMPLE", + "glpat-abc", + "-----BEGIN OPENSSH PRIVATE KEY-----", + ] { + assert!(secret_likeness("VALUE", value).is_some(), "{value}"); + } + } + + #[test] + fn flags_urls_with_passwords() { + assert!(secret_likeness("DATABASE_URL", "postgres://app:hunter2@db:5432/app").is_some()); + assert!(secret_likeness("DATABASE_URL", "postgres://app@localhost/app").is_none()); + assert!(secret_likeness("API_BASE", "https://api.example.com/v1").is_none()); + } + + #[test] + fn flags_high_entropy_values() { + assert!(secret_likeness("VALUE", "Zq8vT2mXk4Lr9Wp1Ys6Bn3Hd").is_some()); + assert!(secret_likeness("VALUE", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b").is_some()); + } + + #[test] + fn leaves_ordinary_config_alone() { + for value in [ + "development", + "info,my_crate=debug", + "1.2.3", + "3000", + "true", + "/usr/local/share/data", + "123e4567-e89b-12d3-a456-426614174000", + "deadbeef", + "some sentence with several words in it", + ] { + assert!(secret_likeness("VALUE", value).is_none(), "{value}"); + } + } + + #[test] + fn reserves_sandbox_owned_names_case_insensitively() { + for name in [ + "HTTPS_PROXY", + "https_proxy", + "SSL_CERT_FILE", + "HOME", + "STASHBASE_API_KEY", + "stashbase_sandbox", + ] { + assert!(reserved_env_name(name), "{name}"); + } + assert!(!reserved_env_name("NODE_ENV")); + } + + fn profile_with( + bindings: HashMap, + personal: HashMap, + ) -> AgentProfile { + let mut profile: AgentProfile = toml::from_str("").unwrap(); + profile.secrets = bindings.into(); + profile.personal_credentials = personal; + profile + } + + fn binding(from: Option<&str>, env: Option<&str>) -> AgentBindingProfile { + let mut binding: AgentBindingProfile = toml::from_str("").unwrap(); + binding.from = from.map(str::to_owned); + binding.env = env.map(str::to_owned); + binding + } + + #[test] + fn finds_conflicting_bindings() { + let profile = profile_with( + HashMap::from([ + ("GITHUB_TOKEN".to_owned(), binding(None, None)), + ( + "NPM".to_owned(), + binding(Some("NPM_SOURCE"), Some("NPM_TOKEN")), + ), + ]), + HashMap::from([("LINEAR".to_owned(), binding(None, Some("LINEAR_API_KEY")))]), + ); + assert_eq!( + conflicting_binding(&profile, "GITHUB_TOKEN").as_deref(), + Some("[secrets.GITHUB_TOKEN]") + ); + assert_eq!( + conflicting_binding(&profile, "NPM_TOKEN").as_deref(), + Some("[secrets.NPM]") + ); + assert_eq!( + conflicting_binding(&profile, "NPM_SOURCE").as_deref(), + Some("[secrets.NPM]") + ); + assert_eq!( + conflicting_binding(&profile, "LINEAR_API_KEY").as_deref(), + Some("[personal_credentials.LINEAR]") + ); + assert_eq!(conflicting_binding(&profile, "NODE_ENV"), None); + } + + #[test] + fn acknowledged_values_are_not_flagged() { + let mut profile = profile_with(HashMap::new(), HashMap::new()); + profile.config_env.insert( + "BUILD_HASH".to_owned(), + AgentEnvValue::Detailed(AgentEnvValueDetails { + value: AgentEnvScalar::String( + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b".to_owned(), + ), + not_secret: true, + }), + ); + profile.config_env.insert( + "DEPLOY_TOKEN".to_owned(), + AgentEnvValue::Plain(AgentEnvScalar::String("abc".to_owned())), + ); + assert_eq!( + flagged_config_env(&profile), + vec![( + "DEPLOY_TOKEN".to_owned(), + "its name looks like a credential" + )] + ); + } + + #[test] + fn parses_both_value_forms_and_rejects_unknown_fields() { + let profile: AgentProfile = toml::from_str( + "[config_env]\nNODE_ENV = \"development\"\nBUILD = { value = \"abc\", not_secret = true }\n", + ) + .unwrap(); + assert_eq!(profile.config_env["NODE_ENV"].value(), "development"); + assert!(!profile.config_env["NODE_ENV"].acknowledged()); + assert_eq!(profile.config_env["BUILD"].value(), "abc"); + assert!(profile.config_env["BUILD"].acknowledged()); + + assert!(toml::from_str::( + "[config_env]\nBUILD = { value = \"abc\", typo = true }\n" + ) + .is_err()); + } + + #[test] + fn accepts_toml_scalars_as_strings() { + let profile: AgentProfile = toml::from_str( + "[config_env]\nPORT = 3000\nDEBUG = true\nRATIO = 1.0\nSCALE = 0.25\nLEGACY = { value = 8080, not_secret = true }\n", + ) + .unwrap(); + assert_eq!(profile.config_env["PORT"].value(), "3000"); + assert_eq!(profile.config_env["DEBUG"].value(), "true"); + assert_eq!(profile.config_env["RATIO"].value(), "1.0"); + assert_eq!(profile.config_env["SCALE"].value(), "0.25"); + assert_eq!(profile.config_env["LEGACY"].value(), "8080"); + assert!(profile.config_env["LEGACY"].acknowledged()); + + assert!(toml::from_str::("[config_env]\nLIST = [1, 2]\n").is_err()); + } +} diff --git a/src/handlers/agent/mcp.rs b/src/handlers/agent/mcp.rs index dc2822a7..08ba0551 100644 --- a/src/handlers/agent/mcp.rs +++ b/src/handlers/agent/mcp.rs @@ -684,6 +684,7 @@ async fn proxied_client( worktree: profile.workspace.worktree, worktree_resume: None, sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), + config_env: Default::default(), }; let proxy = Proxy::start_with_port(secrets, policy, None, None).await?; let proxy_url = proxy.child_env()["HTTPS_PROXY"].clone(); @@ -848,6 +849,7 @@ async fn remote_proxied_client( worktree: profile.workspace.worktree, worktree_resume: None, sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), + config_env: Default::default(), }; let proxy = Proxy::start_remote_with_port( RemoteProxyConfig { diff --git a/src/handlers/agent/mod.rs b/src/handlers/agent/mod.rs index fbc5570f..c7d34b20 100644 --- a/src/handlers/agent/mod.rs +++ b/src/handlers/agent/mod.rs @@ -1,5 +1,6 @@ pub mod docker; pub mod doctor; +pub mod env_check; pub mod explain; pub mod init; pub mod mcp; diff --git a/src/handlers/agent/policy_test.rs b/src/handlers/agent/policy_test.rs index a8068b99..dbe10556 100644 --- a/src/handlers/agent/policy_test.rs +++ b/src/handlers/agent/policy_test.rs @@ -353,6 +353,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), } } diff --git a/src/handlers/agent/profiles.rs b/src/handlers/agent/profiles.rs index 3eaa00e1..7275bcc1 100644 --- a/src/handlers/agent/profiles.rs +++ b/src/handlers/agent/profiles.rs @@ -371,6 +371,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let effective = effective_profile(&profile); @@ -411,6 +412,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let secret = &effective_profile(&profile).secrets.bindings["API_KEY"]; @@ -442,6 +444,7 @@ mod tests { personal_credentials: HashMap::from([("LINEAR_API_KEY".to_owned(), binding)]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert_eq!(binding_count(&profile), 2); diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index a239f9a1..f4e29ad5 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -18,6 +18,7 @@ use serde::Serialize; use crate::{ cmd::agent::{AgentProfileSource, AgentValidateCommand}, config::config, + handlers::agent::env_check, models::{ agent::{AgentHttpRule, AgentHttpRuleEffect, AgentMcpServer, AgentProfile}, config::Config, @@ -119,6 +120,7 @@ pub async fn handle_agent_validate_command( checks.extend(validate_profile(&profile)); checks.extend(validate_hook_capabilities(&profile)); + checks.extend(validate_config_env(&profile)); checks.extend(validate_runtime_requirements(&profile)); if command.remote { checks.extend(validate_remote_profile(&profile)); @@ -133,6 +135,7 @@ pub fn ensure_profile_is_valid_for_run(profile: &AgentProfile) -> Result<()> { let failures = validate_profile(profile) .into_iter() .chain(validate_hook_capabilities(profile)) + .chain(validate_config_env(profile)) .chain(validate_runtime_requirements(profile)) .filter(|check| check.status == Status::Fail) .map(|check| format!("{}: {}", check.name, check.message)) @@ -727,6 +730,61 @@ fn validate_hook_capabilities(profile: &AgentProfile) -> Vec { )] } +/// `[config_env]` names must be valid, unowned by the sandbox and unused by a +/// binding. Values that look like credentials only warn here: `agent run` +/// asks before passing them (see `env_check::confirm_config_env`). +fn validate_config_env(profile: &AgentProfile) -> Vec { + if profile.config_env.is_empty() { + return Vec::new(); + } + let mut checks = Vec::new(); + for name in profile.config_env.keys() { + if !valid_environment_name(name) { + checks.push(fail( + "Config env", + format!("[config_env] {name} is not a valid environment variable name."), + )); + } else if env_check::reserved_env_name(name) { + checks.push(fail( + "Config env", + format!( + "[config_env] {name} is set by the Stashbase sandbox and can't be overridden." + ), + )); + } else if let Some(binding) = env_check::conflicting_binding(profile, name) { + checks.push(fail( + "Config env", + format!("[config_env] {name} is already provided by {binding}; remove it from [config_env]."), + )); + } + } + for (name, reason) in env_check::flagged_config_env(profile) { + checks.push(warn( + "Config env", + format!( + "[config_env] {name}: {reason}. {}", + env_check::NOT_SECRET_HINT + ), + )); + } + if checks.is_empty() { + checks.push(ok( + "Config env", + format!( + "{} plaintext variable(s): {}.", + profile.config_env.len(), + profile + .config_env + .keys() + .cloned() + .collect::>() + .join(", ") + ), + )); + } + checks +} + fn valid_filesystem_path(path: &str) -> bool { !path.is_empty() && path == path.trim() && !path.contains(['\r', '\n']) } @@ -1129,6 +1187,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: vec!["anything_else".to_owned()], + config_env: Default::default(), }; assert!(validate_hook_capabilities(&profile) @@ -1155,6 +1214,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: vec!["dependency_check".to_owned(), "secret_scan".to_owned()], + config_env: Default::default(), }; let checks = validate_hook_capabilities(&profile); @@ -1185,6 +1245,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; profile.sandbox.backend = crate::models::agent::SandboxBackend::Docker; @@ -1214,6 +1275,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; profile.workspace.worktree = true; profile.sandbox.backend = backend; @@ -1252,6 +1314,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; profile.sandbox.image = Some("myorg/img:tag".to_owned()); profile.sandbox.dockerfile = Some("./Cargo.toml".to_owned()); @@ -1278,6 +1341,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; profile.sandbox.dockerfile = Some("./does-not-exist.Dockerfile".to_owned()); @@ -1333,6 +1397,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; profile.sandbox.memory = Some("not-a-memory-value".to_owned()); profile.sandbox.cpus = Some("not-a-number".to_owned()); @@ -1449,6 +1514,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert!(validate_profile(&profile) .iter() @@ -1485,6 +1551,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert!(validate_profile(&profile) @@ -1507,6 +1574,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert!(validate_profile(&profile).iter().any(|check| { @@ -1540,6 +1608,7 @@ mod tests { )]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert!(!validate_profile(&profile) @@ -1594,6 +1663,7 @@ mod tests { personal_credentials: HashMap::new(), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let error = ensure_profile_is_valid_for_run(&profile).unwrap_err(); @@ -1630,6 +1700,7 @@ mod tests { personal_credentials: HashMap::from([("API_KEY".to_owned(), binding)]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let error = ensure_profile_is_valid_for_run(&profile).unwrap_err(); @@ -1696,4 +1767,61 @@ mod tests { .iter() .any(|check| check.name == "Filesystem patterns")); } + + fn config_env_profile(toml_source: &str) -> AgentProfile { + toml::from_str(toml_source).unwrap() + } + + fn config_env_statuses(profile: &AgentProfile) -> Vec<(Status, String)> { + validate_config_env(profile) + .into_iter() + .map(|check| (check.status, check.message)) + .collect() + } + + #[test] + fn config_env_rejects_reserved_invalid_and_bound_names() { + let profile = config_env_profile( + "[config_env]\nHTTPS_PROXY = \"http://evil\"\n\"1BAD\" = \"x\"\nGITHUB_TOKEN = \"x\"\n\n[secrets]\nproject = \"p\"\nenvironment = \"e\"\n[secrets.GITHUB_TOKEN]\n", + ); + let checks = config_env_statuses(&profile); + let failures = checks + .iter() + .filter(|(status, _)| *status == Status::Fail) + .map(|(_, message)| message.as_str()) + .collect::>(); + assert_eq!(failures.len(), 3, "{checks:?}"); + assert!(failures + .iter() + .any(|m| m.contains("HTTPS_PROXY is set by the Stashbase sandbox"))); + assert!(failures.iter().any(|m| m.contains("1BAD is not a valid"))); + assert!(failures + .iter() + .any(|m| m.contains("GITHUB_TOKEN is already provided by [secrets.GITHUB_TOKEN]"))); + assert!(ensure_profile_is_valid_for_run(&profile) + .unwrap_err() + .to_string() + .contains("is already provided by")); + } + + #[test] + fn config_env_warns_on_secret_looking_values_without_printing_them() { + let profile = config_env_profile( + "[config_env]\nNODE_ENV = \"development\"\nUPLOAD_URL = \"https://u:hunter2@example.com\"\nBUILD = { value = \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b\", not_secret = true }\n", + ); + let checks = config_env_statuses(&profile); + assert_eq!(checks.len(), 1, "{checks:?}"); + assert_eq!(checks[0].0, Status::Warn); + assert!(checks[0].1.contains("UPLOAD_URL")); + assert!(!checks[0].1.contains("hunter2")); + } + + #[test] + fn config_env_reports_plain_variables_ok() { + let profile = config_env_profile("[config_env]\nNODE_ENV = \"development\"\n"); + let checks = config_env_statuses(&profile); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].0, Status::Ok); + assert!(config_env_statuses(&config_env_profile("")).is_empty()); + } } diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index ad976c27..a584341b 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -849,6 +849,7 @@ pub async fn handle_cli(args: Cli) -> Exit { } } crate::handlers::agent::validate::ensure_profile_is_valid_for_run(&profile)?; + crate::handlers::agent::env_check::confirm_config_env(&profile, silent)?; // Egress policy is meaningful only when the child cannot opt out of // its proxy environment. Contain every session to the loopback // proxy, including remote sessions, so `env -u HTTPS_PROXY …` is @@ -1040,6 +1041,11 @@ pub async fn handle_cli(args: Cli) -> Exit { worktree: profile.workspace.worktree, worktree_resume: agent_run.resume.clone(), sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), + config_env: profile + .config_env + .iter() + .map(|(name, value)| (name.clone(), value.value())) + .collect(), }; let policy_fingerprint = policy.fingerprint(); let profile_source = directory_source @@ -2669,6 +2675,7 @@ mod tests { )]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let bindings = remote_bindings(&profile); diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 897bd124..74e60b65 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -474,6 +474,7 @@ pub async fn handle_remote_agent_run( let mut denied_write_paths = policy.denied_write_paths.clone(); let allow_network_listeners = policy.allow_network_listeners; let backend = policy.backend; + let config_env = policy.config_env.clone(); ensure_hooks_can_be_confined(hooks, backend)?; let agent_image_source = super::docker_sandbox::AgentImageSource::from_profile( policy.sandbox_image.as_deref(), @@ -598,6 +599,7 @@ pub async fn handle_remote_agent_run( } else { proxy.child_env().clone() }; + apply_config_env(&mut child_env, &config_env); let mut setup_spinner = (!silent && docker_network.is_some()).then(|| { crate::utils::spinner::new_spinner( "Starting network namespace holder and firewall...", @@ -1749,6 +1751,10 @@ async fn handle_run( .as_ref() .map(|policy| policy.sandbox_isolated_paths.clone()) .unwrap_or_default(); + let config_env = proxy_policy + .as_ref() + .map(|policy| policy.config_env.clone()) + .unwrap_or_default(); // Proxy mode gives the child placeholders, never the loaded secret values. // The temporary proxy owns the placeholder-to-secret mapping until the command exits. @@ -1873,6 +1879,7 @@ async fn handle_run( } else { proxy.child_env().clone() }; + apply_config_env(&mut child_env, &config_env); let mut setup_spinner = (!silent && docker_network.is_some()).then(|| { crate::utils::spinner::new_spinner( "Starting network namespace holder and firewall...", @@ -1982,6 +1989,20 @@ async fn handle_run( Ok(()) } +/// Adds the profile's `[config_env]` to the child environment. Validation +/// already rejects names the sandbox owns; keeping any value the sandbox set +/// is the backstop, so a profile can never replace the proxy or CA settings. +fn apply_config_env( + child_env: &mut HashMap, + config_env: &std::collections::BTreeMap, +) { + for (name, value) in config_env { + child_env + .entry(name.clone()) + .or_insert_with(|| value.clone()); + } +} + fn apply_secret_bindings( secrets: &mut Vec, bindings: &HashMap, @@ -2079,10 +2100,11 @@ fn missing_secret_labels( #[cfg(test)] mod tests { use super::{ - apply_secret_bindings, can_prompt_for_image_build, declined_image_build_error, - hook_broker_config, load_run_secrets_from_file, loaded_message, loading_message, - merge_remote_and_local_secrets, missing_image_build_command, missing_image_error, - missing_secret_labels, needs_remote_fetch, prepare_local_run_secrets, shell_quote, + apply_config_env, apply_secret_bindings, can_prompt_for_image_build, + declined_image_build_error, hook_broker_config, load_run_secrets_from_file, loaded_message, + loading_message, merge_remote_and_local_secrets, missing_image_build_command, + missing_image_error, missing_secret_labels, needs_remote_fetch, prepare_local_run_secrets, + shell_quote, }; use crate::models::{agent::EnabledHooks, secrets::SecretWithoutComment}; use std::{ @@ -2092,6 +2114,21 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + #[test] + fn config_env_is_added_without_replacing_sandbox_values() { + let mut child_env = + HashMap::from([("HTTPS_PROXY".to_owned(), "http://127.0.0.1:1234".to_owned())]); + let config_env = std::collections::BTreeMap::from([ + ("NODE_ENV".to_owned(), "development".to_owned()), + ("HTTPS_PROXY".to_owned(), "http://elsewhere".to_owned()), + ]); + + apply_config_env(&mut child_env, &config_env); + + assert_eq!(child_env["NODE_ENV"], "development"); + assert_eq!(child_env["HTTPS_PROXY"], "http://127.0.0.1:1234"); + } + #[test] fn hook_broker_config_scans_the_run_workdir() { let workdir = std::path::Path::new("/repo/.stashbase/worktrees/feat"); diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index 83816061..a73cd90b 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -11,7 +11,7 @@ //! proxy or isolation boundary. use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, convert::Infallible, fs::{self, OpenOptions}, future::Future, @@ -809,6 +809,10 @@ pub struct ProxyPolicy { /// Docker backend only: repo-relative directories backed by a per-repo /// volume instead of the host's copy (see `append_isolated_path_mounts`). pub sandbox_isolated_paths: Vec, + /// The profile's plaintext `[config_env]`, added to the child's + /// environment underneath everything the sandbox sets. Configuration the + /// agent sees, not what it may do — so not part of the fingerprint. + pub config_env: BTreeMap, } /// How a placeholder is represented in a child request and rewritten by the proxy. @@ -953,6 +957,7 @@ impl ProxyPolicy { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -4089,6 +4094,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -4479,6 +4485,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; let proxy = Proxy::start_remote_with_port(remote, policy, None, None) .await @@ -4614,6 +4621,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -5377,6 +5385,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; assert!(policy_allows_connect(&policy, "api.github.com")); @@ -5426,6 +5435,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -5621,6 +5631,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; assert!(secret_allows_request( &policy, @@ -6160,6 +6171,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; let proxy = Proxy::start( HashMap::from([("GITHUB_TOKEN".to_owned(), "real-token".to_owned())]), @@ -6208,6 +6220,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; assert!(policy_allows_egress(&policy, "example.com")); @@ -6242,6 +6255,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; let state = ProxyState { secrets: Arc::new(HashMap::new()), @@ -6323,6 +6337,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; let proxy = Proxy::start( HashMap::from([("GITHUB_PAT_TOKEN".to_owned(), "real-token".to_owned())]), @@ -6381,6 +6396,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; let proxy = Proxy::start( HashMap::from([("GH_TOKEN".to_owned(), "real-token".to_owned())]), @@ -6752,6 +6768,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }, None, ) @@ -6797,6 +6814,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }, None, ) @@ -6843,6 +6861,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }, None, ) diff --git a/src/models/agent.rs b/src/models/agent.rs index 55268de8..6d29ffd4 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; @@ -40,6 +40,65 @@ pub struct AgentProfile { /// Capabilities the agent may use through the local run broker. #[serde(default)] pub allow_hooks: Vec, + /// Non-sensitive variables set in the agent's environment. Values are + /// stored in plaintext in the profile; credentials belong in `[secrets]`. + #[serde(default)] + pub config_env: BTreeMap, +} + +/// A `[config_env]` value: a plain scalar, or a table that acknowledges a +/// value the secret-likeness check would otherwise flag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AgentEnvValue { + Plain(AgentEnvScalar), + Detailed(AgentEnvValueDetails), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentEnvValueDetails { + pub value: AgentEnvScalar, + /// Confirms the value is not a credential, silencing the check. + #[serde(default)] + pub not_secret: bool, +} + +/// TOML scalars accepted as an environment value, so `PORT = 3000` and +/// `DEBUG = true` work without quoting. The child always receives a string. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AgentEnvScalar { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), +} + +impl std::fmt::Display for AgentEnvScalar { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(value) => formatter.write_str(value), + Self::Integer(value) => write!(formatter, "{value}"), + // `{:?}` keeps the fractional part, so `1.0` stays "1.0". + Self::Float(value) => write!(formatter, "{value:?}"), + Self::Boolean(value) => write!(formatter, "{value}"), + } + } +} + +impl AgentEnvValue { + /// The string the child receives. + pub fn value(&self) -> String { + match self { + Self::Plain(value) => value.to_string(), + Self::Detailed(details) => details.value.to_string(), + } + } + + pub fn acknowledged(&self) -> bool { + matches!(self, Self::Detailed(details) if details.not_secret) + } } /// Authenticated hooks the run broker serves on the agent's behalf. From bb8df6f48150b10a5e4c0e6e8ea2a8f9299eb1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:37:50 +0200 Subject: [PATCH 02/11] fix(agent): separate the config_env confirm prompt from session output --- src/handlers/agent/env_check.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs index a78f97cb..79ebdee8 100644 --- a/src/handlers/agent/env_check.rs +++ b/src/handlers/agent/env_check.rs @@ -212,6 +212,8 @@ pub fn confirm_config_env(profile: &AgentProfile, silent: bool) -> anyhow::Resul if !proceed { anyhow::bail!("agent run cancelled: [config_env] values were not confirmed"); } + // Separate the answered prompt from the session output that follows. + eprintln!(); Ok(()) } From 084f3d6f8839780770749c039faf191a9d827b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:41:59 +0200 Subject: [PATCH 03/11] fix(agent): limit config_env sizes and reject NUL bytes Each NAME=value may be up to 64 KiB and the whole table up to 256 KiB, well within the spawn limits (128 KiB per string on Linux, ~1 MB for arguments and environment on macOS). NUL bytes can't be held by an environment variable and are rejected with a clear error. Errors name the variable and size, never the value. --- docs/agent-profiles.md | 3 +- src/handlers/agent/env_check.rs | 14 ++++++ src/handlers/agent/validate.rs | 87 ++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index e7527c90..d8b03afa 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -100,7 +100,7 @@ Or per run: `stashbase agent run --profile coding --worktree -- claude` (`--work ## Config Environment Variables -Set non-sensitive configuration for the agent with `[config_env]`. Values are stored in plaintext in the profile, and both sandbox backends apply them. A value can be a string, a number or a boolean (`PORT = 3000` and `DEBUG = true` work without quotes), and the agent always receives it as a string. The Docker backend otherwise starts the container with an empty environment. +Set non-sensitive configuration for the agent with `[config_env]`. Values are stored in plaintext in the profile, and both sandbox backends apply them. The Docker backend otherwise starts the container with an empty environment. A value can be a string, a number or a boolean (`PORT = 3000` and `DEBUG = true` work without quotes), and the agent always receives it as a string. ```toml [config_env] @@ -110,6 +110,7 @@ DATABASE_URL = "postgres://app@localhost:5432/app" ``` - `[config_env]` values take precedence over variables inherited from your shell. They can't replace anything the sandbox sets: proxy and CA variables, `HOME`, `TMPDIR`, `FORCE_COLOR` and anything starting with `STASHBASE_` are rejected. +- Each `NAME=value` can be up to 64 KiB, and the whole table up to 256 KiB. That keeps `[config_env]` well within what the operating system accepts when starting the agent. Values can't contain NUL bytes. Keep larger content in a file in the workspace. - A name that a `[secrets]` or `[personal_credentials]` binding already provides (its `env` name, or a renamed `from` source) is rejected. - Credentials don't belong here. Stashbase checks every value: a credential-like name (`*TOKEN*`, `*PASSWORD*`, `*API_KEY*`, …), a known token format, a URL containing a password, or a long random-looking string triggers a warning in `agent validate`. `agent run` then asks for confirmation before starting, and fails when it can't ask (non-interactive or `--silent`). Values are never printed. - If a flagged value really isn't secret, acknowledge it so the check skips it: diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs index 79ebdee8..bff3d405 100644 --- a/src/handlers/agent/env_check.rs +++ b/src/handlers/agent/env_check.rs @@ -32,6 +32,20 @@ const RESERVED_CHILD_ENV: &[&str] = &[ "CODEX_CA_CERTIFICATE", ]; +/// Largest `NAME=value` entry `[config_env]` accepts. Linux rejects any +/// single environment string over 128 KiB at spawn (`E2BIG`); config is far +/// smaller, and anything this big is a file that belongs in the workspace. +pub(crate) const MAX_CONFIG_ENV_ENTRY_BYTES: usize = 64 * 1024; +/// Largest `[config_env]` in total. Arguments and the whole environment share +/// one spawn limit (about 1 MB on macOS, 2 MB on Linux), and the Docker +/// backend also passes every value on the `docker` command line. +pub(crate) const MAX_CONFIG_ENV_TOTAL_BYTES: usize = 256 * 1024; + +/// Size of the `NAME=value` string the child's environment holds. +pub(crate) fn config_env_entry_bytes(name: &str, value: &str) -> usize { + name.len() + 1 + value.len() +} + /// Whether the sandbox owns `name`, so `[config_env]` must not set it. pub(crate) fn reserved_env_name(name: &str) -> bool { name.to_ascii_uppercase().starts_with("STASHBASE_") diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index f4e29ad5..d487a275 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -738,7 +738,26 @@ fn validate_config_env(profile: &AgentProfile) -> Vec { return Vec::new(); } let mut checks = Vec::new(); - for name in profile.config_env.keys() { + let mut total_bytes = 0; + for (name, value) in &profile.config_env { + let value = value.value(); + let entry_bytes = env_check::config_env_entry_bytes(name, &value); + total_bytes += entry_bytes; + if value.contains('\0') { + checks.push(fail( + "Config env", + format!("[config_env] {name} contains a NUL byte, which an environment variable can't hold."), + )); + } + if entry_bytes > env_check::MAX_CONFIG_ENV_ENTRY_BYTES { + checks.push(fail( + "Config env", + format!( + "[config_env] {name} is {entry_bytes} bytes; the limit is {} bytes per variable. Keep large content in a file in the workspace.", + env_check::MAX_CONFIG_ENV_ENTRY_BYTES + ), + )); + } if !valid_environment_name(name) { checks.push(fail( "Config env", @@ -758,6 +777,15 @@ fn validate_config_env(profile: &AgentProfile) -> Vec { )); } } + if total_bytes > env_check::MAX_CONFIG_ENV_TOTAL_BYTES { + checks.push(fail( + "Config env", + format!( + "[config_env] totals {total_bytes} bytes; the limit is {} bytes.", + env_check::MAX_CONFIG_ENV_TOTAL_BYTES + ), + )); + } for (name, reason) in env_check::flagged_config_env(profile) { checks.push(warn( "Config env", @@ -1824,4 +1852,61 @@ mod tests { assert_eq!(checks[0].0, Status::Ok); assert!(config_env_statuses(&config_env_profile("")).is_empty()); } + + fn config_env_failures(profile: &AgentProfile) -> Vec { + config_env_statuses(profile) + .into_iter() + .filter(|(status, _)| *status == Status::Fail) + .map(|(_, message)| message) + .collect() + } + + #[test] + fn config_env_rejects_nul_bytes() { + let profile = config_env_profile("[config_env]\nGREETING = \"hi\\u0000there\"\n"); + let failures = config_env_failures(&profile); + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("GREETING contains a NUL byte")); + assert!(!failures[0].contains("there")); + } + + #[test] + fn config_env_limits_each_value_size() { + let at_limit = "a".repeat(env_check::MAX_CONFIG_ENV_ENTRY_BYTES - "BLOB=".len()); + let mut profile = config_env_profile(""); + profile.config_env.insert( + "BLOB".to_owned(), + crate::models::agent::AgentEnvValue::Plain( + crate::models::agent::AgentEnvScalar::String(at_limit.clone()), + ), + ); + assert!(config_env_failures(&profile).is_empty()); + + profile.config_env.insert( + "BLOB".to_owned(), + crate::models::agent::AgentEnvValue::Plain( + crate::models::agent::AgentEnvScalar::String(at_limit + "a"), + ), + ); + let failures = config_env_failures(&profile); + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("BLOB is 65537 bytes; the limit is 65536 bytes per variable")); + } + + #[test] + fn config_env_limits_total_size() { + let value = "a".repeat(60 * 1024); + let mut profile = config_env_profile(""); + for index in 0..5 { + profile.config_env.insert( + format!("PART_{index}"), + crate::models::agent::AgentEnvValue::Plain( + crate::models::agent::AgentEnvScalar::String(value.clone()), + ), + ); + } + let failures = config_env_failures(&profile); + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("the limit is 262144 bytes")); + } } From 65c4243c324ca88733bfdea8e985931263b0a731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:44:38 +0200 Subject: [PATCH 04/11] fix(agent): reject PATH, LD_* and DYLD_* in config_env config_env is set on the native launcher (bwrap, systemd-run) before any confinement exists, and the launcher is found through the child's PATH. A repository profile setting PATH or LD_PRELOAD could therefore run its own code outside the sandbox. Reject those names in validation, and skip reserved names when building the child env as a backstop. --- docs/agent-profiles.md | 2 +- src/handlers/agent/env_check.rs | 54 ++++++++++++++++++++++++++++++--- src/handlers/agent/validate.rs | 9 ++---- src/handlers/run/entry.rs | 12 ++++++-- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index d8b03afa..cdbd8284 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -109,7 +109,7 @@ RUST_LOG = "info" DATABASE_URL = "postgres://app@localhost:5432/app" ``` -- `[config_env]` values take precedence over variables inherited from your shell. They can't replace anything the sandbox sets: proxy and CA variables, `HOME`, `TMPDIR`, `FORCE_COLOR` and anything starting with `STASHBASE_` are rejected. +- `[config_env]` values take precedence over variables inherited from your shell. They can't replace anything the sandbox sets: proxy and CA variables, `HOME`, `TMPDIR`, `FORCE_COLOR` and anything starting with `STASHBASE_` are rejected. `PATH`, `LD_*` and `DYLD_*` are rejected too, because they control which programs and libraries start the sandbox. - Each `NAME=value` can be up to 64 KiB, and the whole table up to 256 KiB. That keeps `[config_env]` well within what the operating system accepts when starting the agent. Values can't contain NUL bytes. Keep larger content in a file in the workspace. - A name that a `[secrets]` or `[personal_credentials]` binding already provides (its `env` name, or a renamed `from` source) is rejected. - Credentials don't belong here. Stashbase checks every value: a credential-like name (`*TOKEN*`, `*PASSWORD*`, `*API_KEY*`, …), a known token format, a URL containing a password, or a long random-looking string triggers a warning in `agent validate`. `agent run` then asks for confirmation before starting, and fails when it can't ask (non-interactive or `--silent`). Values are never printed. diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs index bff3d405..3c64c5d8 100644 --- a/src/handlers/agent/env_check.rs +++ b/src/handlers/agent/env_check.rs @@ -46,12 +46,29 @@ pub(crate) fn config_env_entry_bytes(name: &str, value: &str) -> usize { name.len() + 1 + value.len() } -/// Whether the sandbox owns `name`, so `[config_env]` must not set it. -pub(crate) fn reserved_env_name(name: &str) -> bool { - name.to_ascii_uppercase().starts_with("STASHBASE_") +/// Why `[config_env]` must not set `name`, or `None` when it may. +/// +/// Besides names the sandbox owns, this rejects names that change which +/// program or libraries the native launcher loads. `[config_env]` is applied +/// to the launcher process (`bwrap`, `systemd-run`) before any confinement +/// exists, and the launcher is found through the child's `PATH`, so a +/// repository profile setting `PATH` or `LD_PRELOAD` would run its own code +/// outside the sandbox. +pub(crate) fn reserved_env_reason(name: &str) -> Option<&'static str> { + let upper = name.to_ascii_uppercase(); + if upper.starts_with("STASHBASE_") || RESERVED_CHILD_ENV .iter() .any(|reserved| reserved.eq_ignore_ascii_case(name)) + { + return Some("is set by the Stashbase sandbox and can't be overridden"); + } + if upper == "PATH" || upper.starts_with("LD_") || upper.starts_with("DYLD_") { + return Some( + "controls which programs and libraries start the sandbox, so a profile can't set it", + ); + } + None } /// The binding that already provides `name` to the child, as a @@ -302,9 +319,36 @@ mod tests { "STASHBASE_API_KEY", "stashbase_sandbox", ] { - assert!(reserved_env_name(name), "{name}"); + assert_eq!( + reserved_env_reason(name), + Some("is set by the Stashbase sandbox and can't be overridden"), + "{name}" + ); + } + assert_eq!(reserved_env_reason("NODE_ENV"), None); + } + + #[test] + fn reserves_names_that_change_how_the_launcher_starts() { + for name in [ + "PATH", + "path", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "ld_preload", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + ] { + assert!( + reserved_env_reason(name) + .is_some_and(|reason| reason.contains("start the sandbox")), + "{name}" + ); + } + for name in ["PATHS", "MY_PATH", "OLD_PATH", "COLD_START"] { + assert_eq!(reserved_env_reason(name), None, "{name}"); } - assert!(!reserved_env_name("NODE_ENV")); } fn profile_with( diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index d487a275..1b48b5f6 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -763,13 +763,8 @@ fn validate_config_env(profile: &AgentProfile) -> Vec { "Config env", format!("[config_env] {name} is not a valid environment variable name."), )); - } else if env_check::reserved_env_name(name) { - checks.push(fail( - "Config env", - format!( - "[config_env] {name} is set by the Stashbase sandbox and can't be overridden." - ), - )); + } else if let Some(reason) = env_check::reserved_env_reason(name) { + checks.push(fail("Config env", format!("[config_env] {name} {reason}."))); } else if let Some(binding) = env_check::conflicting_binding(profile, name) { checks.push(fail( "Config env", diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 74e60b65..66b319d8 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -1990,13 +1990,17 @@ async fn handle_run( } /// Adds the profile's `[config_env]` to the child environment. Validation -/// already rejects names the sandbox owns; keeping any value the sandbox set -/// is the backstop, so a profile can never replace the proxy or CA settings. +/// already rejects reserved names; skipping them here and keeping any value +/// the sandbox set is the backstop, so a profile can never replace the proxy +/// or CA settings or change how the launcher starts. fn apply_config_env( child_env: &mut HashMap, config_env: &std::collections::BTreeMap, ) { for (name, value) in config_env { + if crate::handlers::agent::env_check::reserved_env_reason(name).is_some() { + continue; + } child_env .entry(name.clone()) .or_insert_with(|| value.clone()); @@ -2121,12 +2125,16 @@ mod tests { let config_env = std::collections::BTreeMap::from([ ("NODE_ENV".to_owned(), "development".to_owned()), ("HTTPS_PROXY".to_owned(), "http://elsewhere".to_owned()), + ("LD_PRELOAD".to_owned(), "./evil.so".to_owned()), + ("PATH".to_owned(), "./evil:/usr/bin".to_owned()), ]); apply_config_env(&mut child_env, &config_env); assert_eq!(child_env["NODE_ENV"], "development"); assert_eq!(child_env["HTTPS_PROXY"], "http://127.0.0.1:1234"); + assert!(!child_env.contains_key("LD_PRELOAD")); + assert!(!child_env.contains_key("PATH")); } #[test] From 9d0deaef9ec47cbf2b9ba9781911b9281c0b2d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:45:47 +0200 Subject: [PATCH 05/11] feat(agent): add --config-env NAME=VALUE to agent run Sets or overrides a [config_env] variable for one run. Values go through the same name, size and reserved-name validation as the profile's; the credential check is skipped since the person running the agent typed them. --- docs/agent-profiles.md | 8 +++++ src/cmd/agent.rs | 65 ++++++++++++++++++++++++++++++++++++++ src/handlers/entry/root.rs | 15 +++++++++ 3 files changed, 88 insertions(+) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index cdbd8284..c370c12a 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -120,6 +120,14 @@ DATABASE_URL = "postgres://app@localhost:5432/app" BUILD_HASH = { value = "9f86d081884c7d659a2feaa0c55ad015", not_secret = true } ``` +To set or override a variable for one run without editing the profile, pass `--config-env NAME=VALUE` (repeatable): + +```bash +stashbase agent run --profile coding --config-env NODE_ENV=test --config-env PORT=4000 -- claude +``` + +Flag values go through the same name and size checks as the profile's. The credential check is skipped for them, because they're typed by the person running the agent rather than committed to a shared profile. + A complete example profile is in [examples/config-env.toml](agent-profiles/examples/config-env.toml). It includes commented-out lines that show the warning and the validation errors. ## Network Access and HTTP Rules diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index aac9938d..c88b6743 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -378,6 +378,13 @@ pub struct AgentRunCommand { #[arg(long, value_name = "PATHS", value_delimiter = ',')] pub docker_isolated_paths: Vec, + /// Set a `[config_env]` variable for this run only, overriding the + /// profile's value. Repeatable: `--config-env NODE_ENV=test --config-env + /// PORT=4000`. Checked like the profile's own values, except that the + /// credential check is skipped because you typed it yourself. + #[arg(long, value_name = "NAME=VALUE", value_parser = parse_config_env_arg)] + pub config_env: Vec<(String, String)>, + /// Store metadata-only proxy audit events locally #[arg( long, @@ -711,6 +718,15 @@ pub enum AgentProfileSource { Auto, } +/// Parses `--config-env NAME=VALUE`, splitting at the first `=` so the value +/// may itself contain `=`. The name is validated with the profile. +fn parse_config_env_arg(arg: &str) -> Result<(String, String), String> { + match arg.split_once('=') { + Some((name, value)) if !name.is_empty() => Ok((name.to_owned(), value.to_owned())), + _ => Err(format!("expected NAME=VALUE, got '{arg}'")), + } +} + #[cfg(test)] mod tests { use clap::ValueEnum; @@ -737,6 +753,55 @@ mod tests { ); } + #[test] + fn config_env_flag_takes_repeatable_name_value_pairs() { + use super::{AgentCommand, AgentSubcommand}; + use crate::cmd::root::{Cli, EntityType}; + use clap::Parser; + + let parse = |args: &[&str]| { + let mut argv = vec!["stashbase", "agent", "run", "--profile", "p"]; + argv.extend_from_slice(args); + match Cli::try_parse_from(argv).expect("parses").entity_type { + EntityType::Agent(AgentCommand { + subcommand: AgentSubcommand::Run(run), + }) => run, + _ => panic!("expected `agent run`"), + } + }; + let env = parse(&[ + "--config-env", + "NODE_ENV=test", + "--config-env", + "QUERY=a=b", + "--config-env", + "EMPTY=", + "claude", + ]); + assert_eq!( + env.config_env, + vec![ + ("NODE_ENV".to_owned(), "test".to_owned()), + ("QUERY".to_owned(), "a=b".to_owned()), + ("EMPTY".to_owned(), String::new()), + ] + ); + assert_eq!(env.command, vec!["claude".to_owned()]); + for bad in ["NOVALUE", "=value"] { + let argv = [ + "stashbase", + "agent", + "run", + "--profile", + "p", + "--config-env", + bad, + "claude", + ]; + assert!(Cli::try_parse_from(argv).is_err(), "{bad}"); + } + } + #[test] fn worktree_flag_is_on_off_or_absent_and_never_swallows_the_command() { use super::{AgentCommand, AgentSubcommand}; diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index a584341b..912cb0e7 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -848,6 +848,21 @@ pub async fn handle_cli(args: Cli) -> Exit { profile.sandbox.isolated_paths.push(path.clone()); } } + // Typed by the person running the agent, not committed to a + // shared profile, so the credential check doesn't apply. + for (name, value) in &agent_run.config_env { + profile.config_env.insert( + name.clone(), + crate::models::agent::AgentEnvValue::Detailed( + crate::models::agent::AgentEnvValueDetails { + value: crate::models::agent::AgentEnvScalar::String( + value.clone(), + ), + not_secret: true, + }, + ), + ); + } crate::handlers::agent::validate::ensure_profile_is_valid_for_run(&profile)?; crate::handlers::agent::env_check::confirm_config_env(&profile, silent)?; // Egress policy is meaningful only when the child cannot opt out of From 82a9ecabdef37fcf1a0c1a0d3e916f2a67cf6010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:45:58 +0200 Subject: [PATCH 06/11] fix(agent): match config_env binding conflicts case-insensitively Windows treats environment variable names case-insensitively, so github_token in [config_env] would shadow a binding exposed as GITHUB_TOKEN. --- src/handlers/agent/env_check.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs index 3c64c5d8..3e3a5f45 100644 --- a/src/handlers/agent/env_check.rs +++ b/src/handlers/agent/env_check.rs @@ -87,7 +87,9 @@ pub(crate) fn conflicting_binding(profile: &AgentProfile, name: &str) -> Option< let binding = &bindings[binding_name]; let env = binding.env.as_deref().unwrap_or(binding_name); let from = binding.from.as_deref().unwrap_or(binding_name); - if env == name || from == name { + // Case-insensitive: Windows treats `github_token` and + // `GITHUB_TOKEN` as the same variable. + if env.eq_ignore_ascii_case(name) || from.eq_ignore_ascii_case(name) { return Some(format!("[{table}.{binding_name}]")); } } @@ -396,6 +398,10 @@ mod tests { conflicting_binding(&profile, "LINEAR_API_KEY").as_deref(), Some("[personal_credentials.LINEAR]") ); + assert_eq!( + conflicting_binding(&profile, "github_token").as_deref(), + Some("[secrets.GITHUB_TOKEN]") + ); assert_eq!(conflicting_binding(&profile, "NODE_ENV"), None); } From 5997a9e179a52aa77b104643bcd9873539570e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:47:41 +0200 Subject: [PATCH 07/11] feat(agent): record config_env names in the session_started audit event Runs that differ only in [config_env] (or --config-env) can now be told apart in the audit log. Only names are recorded, never values. --- docs/agent-profiles.md | 2 ++ src/handlers/entry/root.rs | 7 ++++++- src/handlers/run/proxy.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index c370c12a..b6a25aba 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -128,6 +128,8 @@ stashbase agent run --profile coding --config-env NODE_ENV=test --config-env POR Flag values go through the same name and size checks as the profile's. The credential check is skipped for them, because they're typed by the person running the agent rather than committed to a shared profile. +The audit log's `session_started` event lists the names of the variables set for the run (`config_env_names`), never their values. + A complete example profile is in [examples/config-env.toml](agent-profiles/examples/config-env.toml). It includes commented-out lines that show the warning and the validation errors. ## Network Access and HTTP Rules diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index 912cb0e7..e27df4e4 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -1100,7 +1100,8 @@ pub async fn handle_cli(args: Cli) -> Exit { local_session_id.clone(), policy_fingerprint.clone(), ) - .map(|audit_log| audit_log.with_profile_provenance(profile_provenance.clone())) + .map(|audit_log| audit_log.with_profile_provenance(profile_provenance.clone()) +.with_config_env_names(profile.config_env.keys().cloned().collect())) }) }) .flatten() @@ -1221,6 +1222,7 @@ pub async fn handle_cli(args: Cli) -> Exit { .map(|audit_log| { audit_log .with_profile_provenance(profile_provenance.clone()) +.with_config_env_names(profile.config_env.keys().cloned().collect()) .with_binding_sources(binding_sources) .with_routing(routing.clone()) }) @@ -2842,6 +2844,7 @@ mod tests { profile_source: None, profile_file_modified_at: None, profile_file_sha256: None, + config_env_names: None, routing_mode: None, route: None, action: action.to_owned(), @@ -2894,6 +2897,7 @@ mod tests { profile_source: None, profile_file_modified_at: None, profile_file_sha256: None, + config_env_names: None, routing_mode: None, route: Some("remote".to_owned()), action: "injected".to_owned(), @@ -2918,6 +2922,7 @@ mod tests { profile_source: None, profile_file_modified_at: None, profile_file_sha256: None, + config_env_names: None, routing_mode: None, route: Some("direct".to_owned()), action: "forwarded".to_owned(), diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index a73cd90b..db2dea0f 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -182,6 +182,10 @@ pub struct ProxyAuditLogEvent { /// SHA-256 of the selected profile file at startup. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_file_sha256: Option, + /// Present only on `session_started` when `[config_env]` sets variables: + /// their names, never their values. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_env_names: Option>, pub action: String, /// Present only on `session_started` for remote sessions: `credential` or `full`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -259,6 +263,8 @@ pub struct ProxyAuditLog { profile: String, policy_fingerprint: String, profile_provenance: Option, + /// `[config_env]` variable names (never values), recorded on `session_started`. + config_env_names: Arc>, binding_sources: Arc>, routing: Option>>, path: Arc, @@ -326,6 +332,7 @@ impl ProxyAuditLog { profile: profile.to_owned(), policy_fingerprint, profile_provenance: None, + config_env_names: Arc::new(Vec::new()), binding_sources: Arc::new(HashMap::new()), routing: None, path: Arc::new(path), @@ -369,6 +376,14 @@ impl ProxyAuditLog { self } + /// Records the session's `[config_env]` variable names on + /// `session_started`, so runs that differ only in env can be told apart. + /// Names only: values may be configuration the user considers private. + pub fn with_config_env_names(mut self, names: Vec) -> Self { + self.config_env_names = Arc::new(names); + self + } + /// Adds non-sensitive binding origin metadata for remote audit events. pub fn with_binding_sources(mut self, binding_sources: HashMap) -> Self { self.binding_sources = Arc::new(binding_sources); @@ -456,6 +471,8 @@ impl ProxyAuditLog { .map(|provenance| provenance.sha256.clone()) }) .flatten(), + config_env_names: (action == "session_started" && !self.config_env_names.is_empty()) + .then(|| self.config_env_names.to_vec()), action: action.to_owned(), routing_mode: (action == "session_started") .then(|| { @@ -5086,6 +5103,7 @@ mod tests { profile: "coding".to_owned(), policy_fingerprint: "policy-fingerprint".to_owned(), profile_provenance: None, + config_env_names: Arc::new(Vec::new()), routing: None, binding_sources: Arc::new(HashMap::from([( "EXAMPLE_API_KEY".to_owned(), @@ -5138,6 +5156,7 @@ mod tests { profile: "coding".to_owned(), policy_fingerprint: "policy-fingerprint".to_owned(), profile_provenance: None, + config_env_names: Arc::new(Vec::new()), routing: None, binding_sources: Arc::new(HashMap::new()), path: Arc::new(path.clone()), @@ -5197,6 +5216,7 @@ mod tests { profile: "coding".to_owned(), policy_fingerprint: "policy-fingerprint".to_owned(), profile_provenance: None, + config_env_names: Arc::new(Vec::new()), binding_sources: Arc::new(HashMap::new()), routing: Some(credential_routing(&["api.github.com"])), path: Arc::new(path.clone()), @@ -5248,6 +5268,7 @@ mod tests { modified_at: "2026-08-12T00:00:00+00:00".to_owned(), sha256: "profile-file-sha256".to_owned(), }), + config_env_names: Arc::new(vec!["NODE_ENV".to_owned(), "PORT".to_owned()]), binding_sources: Arc::new(HashMap::new()), routing: None, path: Arc::new(path.clone()), @@ -5286,9 +5307,14 @@ mod tests { events[0].profile_file_sha256.as_deref(), Some("profile-file-sha256") ); + assert_eq!( + events[0].config_env_names, + Some(vec!["NODE_ENV".to_owned(), "PORT".to_owned()]) + ); assert!(events[1].profile_source.is_none()); assert!(events[1].profile_file_modified_at.is_none()); assert!(events[1].profile_file_sha256.is_none()); + assert!(events[1].config_env_names.is_none()); fs::remove_file(path).unwrap(); } @@ -5325,6 +5351,7 @@ mod tests { profile_source: None, profile_file_modified_at: None, profile_file_sha256: None, + config_env_names: None, routing_mode: None, route: None, action: "injected".to_owned(), @@ -6699,6 +6726,7 @@ mod tests { profile: "coding".to_owned(), policy_fingerprint: "policy-fingerprint".to_owned(), profile_provenance: None, + config_env_names: Arc::new(Vec::new()), routing: None, binding_sources: Arc::new(HashMap::new()), path: Arc::new(path.clone()), From cd75feb08bb6cbe043a7a55861780e5d8589604d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:48:32 +0200 Subject: [PATCH 08/11] feat(agent): show a commented config_env example in agent init's template --- src/handlers/agent/init.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/handlers/agent/init.rs b/src/handlers/agent/init.rs index 05998008..ae75557f 100644 --- a/src/handlers/agent/init.rs +++ b/src/handlers/agent/init.rs @@ -26,6 +26,12 @@ egress_hosts = [] # deny_read = ["~/.ssh", "~/.aws", ".env"] # deny_write = ["~/.ssh", "~/.aws", ".git"] +# Optional: non-sensitive config for the agent, stored in plaintext. +# Credentials belong in [secrets]; values that look like one are flagged. +# [config_env] +# NODE_ENV = "development" +# PORT = 3000 + # Replace SECRET_NAME with the Stashbase secret to grant. [secrets.SECRET_NAME] @@ -133,6 +139,18 @@ mod tests { assert!(PROFILE_TEMPLATE.contains("[[secrets.SECRET_NAME.rules]]")); } + #[test] + fn template_config_env_example_is_valid_when_uncommented() { + assert!(PROFILE_TEMPLATE.contains("# [config_env]")); + let uncommented = PROFILE_TEMPLATE + .replace("# [config_env]", "[config_env]") + .replace("# NODE_ENV = ", "NODE_ENV = ") + .replace("# PORT = ", "PORT = "); + let profile: crate::models::agent::AgentProfile = toml::from_str(&uncommented).unwrap(); + assert_eq!(profile.config_env["NODE_ENV"].value(), "development"); + assert_eq!(profile.config_env["PORT"].value(), "3000"); + } + #[test] fn rejects_profile_names_that_could_escape_the_profiles_directory() { assert!(validate_profile_name("codex").is_ok()); From cecf8f02ea717c02342983357fca64fd713db33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:50:42 +0200 Subject: [PATCH 09/11] fix(agent): run the credential check on --config-env values too Flag values reach the agent in plaintext like the profile's, so they get the same confirm-or-fail check. --- docs/agent-profiles.md | 2 +- src/cmd/agent.rs | 4 ++-- src/handlers/entry/root.rs | 13 ++++--------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index b6a25aba..988266f5 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -126,7 +126,7 @@ To set or override a variable for one run without editing the profile, pass `--c stashbase agent run --profile coding --config-env NODE_ENV=test --config-env PORT=4000 -- claude ``` -Flag values go through the same name and size checks as the profile's. The credential check is skipped for them, because they're typed by the person running the agent rather than committed to a shared profile. +Flag values go through the same checks as the profile's, including the credential check: a value that looks like a credential asks for confirmation, or fails the run when it can't ask. The audit log's `session_started` event lists the names of the variables set for the run (`config_env_names`), never their values. diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index c88b6743..2971be96 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -380,8 +380,8 @@ pub struct AgentRunCommand { /// Set a `[config_env]` variable for this run only, overriding the /// profile's value. Repeatable: `--config-env NODE_ENV=test --config-env - /// PORT=4000`. Checked like the profile's own values, except that the - /// credential check is skipped because you typed it yourself. + /// PORT=4000`. Checked like the profile's own values, including the + /// credential check. #[arg(long, value_name = "NAME=VALUE", value_parser = parse_config_env_arg)] pub config_env: Vec<(String, String)>, diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index e27df4e4..1f05d04d 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -848,18 +848,13 @@ pub async fn handle_cli(args: Cli) -> Exit { profile.sandbox.isolated_paths.push(path.clone()); } } - // Typed by the person running the agent, not committed to a - // shared profile, so the credential check doesn't apply. + // Flag values get the same checks as the profile's, credential + // check included: they still reach the agent in plaintext. for (name, value) in &agent_run.config_env { profile.config_env.insert( name.clone(), - crate::models::agent::AgentEnvValue::Detailed( - crate::models::agent::AgentEnvValueDetails { - value: crate::models::agent::AgentEnvScalar::String( - value.clone(), - ), - not_secret: true, - }, + crate::models::agent::AgentEnvValue::Plain( + crate::models::agent::AgentEnvScalar::String(value.clone()), ), ); } From a2f472755392a33a52a26e9ff88cc82a7c6e30d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:54:00 +0200 Subject: [PATCH 10/11] docs: mention --config-env in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 03217fad..85c75354 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ NODE_ENV = "development" RUST_LOG = "info" ``` -Values that look like credentials are flagged: `agent run` asks before starting and fails when it can't ask. Credentials belong in `[secrets]`. See [Config Environment Variables](docs/agent-profiles.md#config-environment-variables) for the rules and an [example profile](docs/agent-profiles/examples/config-env.toml). +Set or override one for a single run with `--config-env NAME=VALUE`. Values that look like credentials are flagged: `agent run` asks before starting and fails when it can't ask. Credentials belong in `[secrets]`. See [Config Environment Variables](docs/agent-profiles.md#config-environment-variables) for the rules and an [example profile](docs/agent-profiles/examples/config-env.toml). ### Filesystem and Network Containment From b1f65b941e511ccf0d71192d715e7fdde4ab8f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Mon, 12 Oct 2026 08:57:23 +0200 Subject: [PATCH 11/11] fix(agent): hide credential-looking config_env values in agent profiles show Flagged values without not_secret = true are shown as in both TOML and JSON output. --- docs/agent-profiles.md | 2 +- src/handlers/agent/env_check.rs | 37 ++++++++++++++++++++++++++++++++- src/handlers/agent/profiles.rs | 3 ++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 988266f5..d1229d4d 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -112,7 +112,7 @@ DATABASE_URL = "postgres://app@localhost:5432/app" - `[config_env]` values take precedence over variables inherited from your shell. They can't replace anything the sandbox sets: proxy and CA variables, `HOME`, `TMPDIR`, `FORCE_COLOR` and anything starting with `STASHBASE_` are rejected. `PATH`, `LD_*` and `DYLD_*` are rejected too, because they control which programs and libraries start the sandbox. - Each `NAME=value` can be up to 64 KiB, and the whole table up to 256 KiB. That keeps `[config_env]` well within what the operating system accepts when starting the agent. Values can't contain NUL bytes. Keep larger content in a file in the workspace. - A name that a `[secrets]` or `[personal_credentials]` binding already provides (its `env` name, or a renamed `from` source) is rejected. -- Credentials don't belong here. Stashbase checks every value: a credential-like name (`*TOKEN*`, `*PASSWORD*`, `*API_KEY*`, …), a known token format, a URL containing a password, or a long random-looking string triggers a warning in `agent validate`. `agent run` then asks for confirmation before starting, and fails when it can't ask (non-interactive or `--silent`). Values are never printed. +- Credentials don't belong here. Stashbase checks every value: a credential-like name (`*TOKEN*`, `*PASSWORD*`, `*API_KEY*`, …), a known token format, a URL containing a password, or a long random-looking string triggers a warning in `agent validate`. `agent run` then asks for confirmation before starting, and fails when it can't ask (non-interactive or `--silent`). Flagged values are never printed: `agent profiles show` displays them as ``. - If a flagged value really isn't secret, acknowledge it so the check skips it: ```toml diff --git a/src/handlers/agent/env_check.rs b/src/handlers/agent/env_check.rs index 3e3a5f45..3820d911 100644 --- a/src/handlers/agent/env_check.rs +++ b/src/handlers/agent/env_check.rs @@ -4,7 +4,7 @@ use std::io::IsTerminal; -use crate::models::agent::AgentProfile; +use crate::models::agent::{AgentEnvScalar, AgentEnvValue, AgentProfile}; /// Variables the sandbox itself sets or strips. Letting a profile set one /// would break the session or weaken its containment (proxy routing, CA @@ -211,6 +211,21 @@ pub(crate) fn flagged_config_env(profile: &AgentProfile) -> Vec<(String, &'stati .collect() } +/// Shown in place of a `[config_env]` value that looks like a credential. +pub(crate) const MASKED_CONFIG_ENV_VALUE: &str = ""; + +/// Replaces each flagged, unacknowledged `[config_env]` value with +/// [`MASKED_CONFIG_ENV_VALUE`], so printing a profile doesn't show what the +/// check exists to keep from being passed around unnoticed. +pub(crate) fn mask_flagged_config_env(profile: &mut AgentProfile) { + for (name, _) in flagged_config_env(profile) { + profile.config_env.insert( + name, + AgentEnvValue::Plain(AgentEnvScalar::String(MASKED_CONFIG_ENV_VALUE.to_owned())), + ); + } +} + pub(crate) const NOT_SECRET_HINT: &str = "Move credentials to [secrets], or confirm a value is safe with `NAME = { value = \"...\", not_secret = true }`."; @@ -462,4 +477,24 @@ mod tests { assert!(toml::from_str::("[config_env]\nLIST = [1, 2]\n").is_err()); } + + #[test] + fn masks_only_flagged_unacknowledged_values() { + let mut profile: AgentProfile = toml::from_str( + "[config_env]\nNODE_ENV = \"development\"\nGH_PAT = \"ghp_abc\"\nBUILD = { value = \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b\", not_secret = true }\n", + ) + .unwrap(); + + mask_flagged_config_env(&mut profile); + + assert_eq!(profile.config_env["NODE_ENV"].value(), "development"); + assert_eq!( + profile.config_env["GH_PAT"].value(), + MASKED_CONFIG_ENV_VALUE + ); + assert_eq!( + profile.config_env["BUILD"].value(), + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b" + ); + } } diff --git a/src/handlers/agent/profiles.rs b/src/handlers/agent/profiles.rs index 7275bcc1..cb6ac284 100644 --- a/src/handlers/agent/profiles.rs +++ b/src/handlers/agent/profiles.rs @@ -121,10 +121,11 @@ fn handle_show( silent, )); }; - let profile = command + let mut profile = command .effective .then(|| effective_profile(profile)) .unwrap_or_else(|| profile.clone()); + crate::handlers::agent::env_check::mask_flagged_config_env(&mut profile); let report = ProfileDetails { name: command.profile, source: source.clone(),