diff --git a/README.md b/README.md index 598a5f06..85c75354 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" +``` + +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 #### Read and write restrictions diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 13bda690..d1229d4d 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -98,6 +98,40 @@ 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. 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] +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. `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`). 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 + [config_env] + 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 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. + +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/cmd/agent.rs b/src/cmd/agent.rs index aac9938d..2971be96 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, including the + /// credential check. + #[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/agent/env_check.rs b/src/handlers/agent/env_check.rs new file mode 100644 index 00000000..3820d911 --- /dev/null +++ b/src/handlers/agent/env_check.rs @@ -0,0 +1,500 @@ +//! 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::{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 +/// 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", +]; + +/// 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() +} + +/// 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 +/// `[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); + // 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}]")); + } + } + } + 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() +} + +/// 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 }`."; + +/// 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"); + } + // Separate the answered prompt from the session output that follows. + eprintln!(); + 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_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}"); + } + } + + 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, "github_token").as_deref(), + Some("[secrets.GITHUB_TOKEN]") + ); + 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()); + } + + #[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/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()); 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..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(), @@ -371,6 +372,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 +413,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 +445,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..1b48b5f6 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,84 @@ 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(); + 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", + format!("[config_env] {name} is not a valid environment variable name."), + )); + } 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", + format!("[config_env] {name} is already provided by {binding}; remove it from [config_env]."), + )); + } + } + 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", + 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 +1210,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 +1237,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 +1268,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 +1298,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 +1337,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 +1364,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 +1420,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 +1537,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 +1574,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 +1597,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 +1631,7 @@ mod tests { )]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; assert!(!validate_profile(&profile) @@ -1594,6 +1686,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 +1723,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 +1790,118 @@ 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()); + } + + 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")); + } } diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index ad976c27..1f05d04d 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -848,7 +848,18 @@ pub async fn handle_cli(args: Cli) -> Exit { profile.sandbox.isolated_paths.push(path.clone()); } } + // 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::Plain( + crate::models::agent::AgentEnvScalar::String(value.clone()), + ), + ); + } 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 +1051,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 @@ -1079,7 +1095,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() @@ -1200,6 +1217,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()) }) @@ -2669,6 +2687,7 @@ mod tests { )]), policy_tests: Vec::new(), allow_hooks: Vec::new(), + config_env: Default::default(), }; let bindings = remote_bindings(&profile); @@ -2820,6 +2839,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(), @@ -2872,6 +2892,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(), @@ -2896,6 +2917,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/entry.rs b/src/handlers/run/entry.rs index 897bd124..66b319d8 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,24 @@ async fn handle_run( Ok(()) } +/// Adds the profile's `[config_env]` to the child environment. Validation +/// 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()); + } +} + fn apply_secret_bindings( secrets: &mut Vec, bindings: &HashMap, @@ -2079,10 +2104,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 +2118,25 @@ 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()), + ("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] 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..db2dea0f 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, @@ -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(|| { @@ -809,6 +826,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 +974,7 @@ impl ProxyPolicy { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -4089,6 +4111,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -4479,6 +4502,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 +4638,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -5078,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(), @@ -5130,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()), @@ -5189,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()), @@ -5240,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()), @@ -5278,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(); } @@ -5317,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(), @@ -5377,6 +5412,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 +5462,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), } } @@ -5621,6 +5658,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }; assert!(secret_allows_request( &policy, @@ -6160,6 +6198,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 +6247,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 +6282,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 +6364,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 +6423,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())]), @@ -6683,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()), @@ -6752,6 +6796,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }, None, ) @@ -6797,6 +6842,7 @@ mod tests { worktree: false, worktree_resume: None, sandbox_isolated_paths: Vec::new(), + config_env: Default::default(), }, None, ) @@ -6843,6 +6889,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.