diff --git a/AGENTS.md b/AGENTS.md index 666c4416..05c34fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ src/ │ └── wizard.rs # CLI setup flow └── acp/ ├── protocol.rs # JSON-RPC types + ACP event classification - ├── connection.rs # Spawn CLI, stdio JSON-RPC, env_clear whitelist + ├── connection.rs # Spawn CLI, stdio JSON-RPC, [agent.clear_env] policy └── pool.rs # Session key → AcpConnection map + lifecycle ``` @@ -65,13 +65,24 @@ The definitive rules — do NOT reinvent this: ### 3. Security — Child Process Environment -Agent subprocesses start with `env_clear()`. The baseline env passed to the child is: +Agent subprocesses start with `env_clear()`. The baseline env always passed to the child is: - **All platforms:** `HOME`, `PATH` - **Unix only:** `USER` - **Windows only:** `USERPROFILE`, `USERNAME`, `SystemRoot`, `SystemDrive` - Plus any explicit `[agent].env` keys (logged with a prompt-injection warning) -Never leak `DISCORD_BOT_TOKEN` or other OAB credentials to the agent. +Inheritance beyond the baseline is governed by `[agent.clear_env]` (see `docs/config-reference.md`): + +```text +if enabled (default true): + if allow_list non-empty: pass only those keys from process env + elif deny_list non-empty: pass all process env EXCEPT deny_list + else: pass nothing (pure secure default) +else: + pass all process env (escape hatch — both lists ignored) +``` + +Never leak `DISCORD_BOT_TOKEN` or other OAB credentials to the agent. The default (`enabled = true` with empty lists) inherits nothing — when widening, prefer `allow_list` for known-safe keys; reach for `deny_list` only when the inherited set is large and dynamically injected (e.g. AWS-IRSA pods). ### 4. Dockerfile Discipline diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 78316b59..3e979242 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -106,8 +106,23 @@ data: {{- if $cfg.env }} env = { {{ $first := true }}{{ range $k, $v := $cfg.env }}{{ if not $first }}, {{ end }}{{ $k }} = {{ $v | toJson }}{{ $first = false }}{{ end }} } {{- end }} - {{- if $cfg.inheritEnv }} - inherit_env = {{ $cfg.inheritEnv | toJson }} + {{- if hasKey $cfg "inheritEnv" }} + {{- fail (printf "agents.%s.inheritEnv was removed -- use agents.%s.clearEnv.allowList instead (BREAKING in beta; see CHANGELOG)" $name $name) }} + {{- end }} + {{- $clearEnv := $cfg.clearEnv | default dict }} + {{- $clearEnvDisabled := and (hasKey $clearEnv "enabled") (not $clearEnv.enabled) }} + {{- if or $clearEnvDisabled $clearEnv.allowList $clearEnv.denyList }} + + [agent.clear_env] + {{- if $clearEnvDisabled }} + enabled = false + {{- end }} + {{- if $clearEnv.allowList }} + allow_list = {{ $clearEnv.allowList | toJson }} + {{- end }} + {{- if $clearEnv.denyList }} + deny_list = {{ $clearEnv.denyList | toJson }} + {{- end }} {{- end }} [pool] diff --git a/charts/openab/tests/configmap_test.yaml b/charts/openab/tests/configmap_test.yaml index 31002809..b465dea6 100644 --- a/charts/openab/tests/configmap_test.yaml +++ b/charts/openab/tests/configmap_test.yaml @@ -143,18 +143,45 @@ tests: path: data["config.toml"] pattern: 'max_bot_turns = 30' - - it: renders inherit_env as TOML array + - it: renders clear_env.allow_list as TOML array set: - agents.kiro.inheritEnv: + agents.kiro.clearEnv.allowList: - "API_BASE_URL" - "MODEL_NAME" asserts: - matchRegex: path: data["config.toml"] - pattern: 'inherit_env = \["API_BASE_URL","MODEL_NAME"\]' + pattern: '\[agent\.clear_env\]' + - matchRegex: + path: data["config.toml"] + pattern: 'allow_list = \["API_BASE_URL","MODEL_NAME"\]' + + - it: renders clear_env.enabled = false when disabled + set: + agents.kiro.clearEnv.enabled: false + agents.kiro.clearEnv.denyList: + - "DISCORD_BOT_TOKEN" + asserts: + - matchRegex: + path: data["config.toml"] + pattern: '\[agent\.clear_env\]' + - matchRegex: + path: data["config.toml"] + pattern: 'enabled = false' + - matchRegex: + path: data["config.toml"] + pattern: 'deny_list = \["DISCORD_BOT_TOKEN"\]' - - it: does not render inherit_env when unset + - it: does not render clear_env section when unset asserts: - notMatchRegex: path: data["config.toml"] - pattern: 'inherit_env' + pattern: '\[agent\.clear_env\]' + + - it: fails with helpful message when legacy inheritEnv is used + set: + agents.kiro.inheritEnv: + - "API_BASE_URL" + asserts: + - failedTemplate: + errorPattern: 'inheritEnv was removed' diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 4f12e315..13f5e669 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -42,6 +42,10 @@ agents: # nameOverride: "" # env: {} # envFrom: [] + # clearEnv: + # enabled: true # default; set false to disable filtering entirely (escape hatch) + # allowList: [] # only-these mode (deny ignored when set) + # denyList: [] # all-except-these mode (active only when allowList is empty) # pool: # maxSessions: 10 # sessionTtlHours: 24 @@ -76,6 +80,10 @@ agents: # workingDir: /home/node # env: {} # envFrom: [] + # clearEnv: + # enabled: true # default; set false to disable filtering entirely (escape hatch) + # allowList: [] # only-these mode (deny ignored when set) + # denyList: [] # all-except-these mode (active only when allowList is empty) # pool: # maxSessions: 10 # sessionTtlHours: 24 @@ -107,6 +115,10 @@ agents: # workingDir: /home/agent # env: {} # envFrom: [] + # clearEnv: + # enabled: true # default; set false to disable filtering entirely (escape hatch) + # allowList: [] # only-these mode (deny ignored when set) + # denyList: [] # all-except-these mode (active only when allowList is empty) # pool: # maxSessions: 10 # sessionTtlHours: 24 diff --git a/config.toml.example b/config.toml.example index d1044c00..afda4b49 100644 --- a/config.toml.example +++ b/config.toml.example @@ -60,13 +60,39 @@ working_dir = "/home/agent" # Note: env vars here can override baseline vars (HOME, PATH, USER) if needed. # env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" } # -# By default, the agent subprocess only inherits these baseline vars: -# Linux/macOS: HOME, PATH, USER -# Windows: USERPROFILE, USERNAME, PATH, SystemRoot, SystemDrive +# Env inheritance from the OAB process is controlled by the [agent.clear_env] +# table. The default is secure: the subprocess starts with env_clear() and +# only receives the baseline (HOME/PATH/USER on Linux/macOS, +# USERPROFILE/USERNAME/PATH/SystemRoot/SystemDrive on Windows) plus +# everything in [agent].env above. # -# To pass additional env vars from the OAB process (e.g. vars injected via K8s envFrom), -# list them in inherit_env. Keys in [agent].env take precedence over inherited ones. -# inherit_env = ["API_BASE_URL", "MODEL_NAME"] +# Decision tree (when [agent.clear_env] is set): +# if enabled (default true): +# if allow_list non-empty → only those keys pass through from process env +# elif deny_list non-empty → all process env passes through EXCEPT deny_list +# else → nothing inherited (pure secure default) +# else (enabled = false): +# full process env inherited; both lists ignored (pure escape hatch) +# +# allow_list takes priority over deny_list when both are set under enabled=true. +# [agent].env always wins on key conflict (highest precedence). +# +# Example: allow-list mode — explicitly pass a small set of keys. +# [agent.clear_env] +# allow_list = ["API_BASE_URL", "MODEL_NAME"] +# +# Example: deny-list mode for AWS-IRSA / web-identity workloads. K8s auto- +# injects many AWS_* env vars (AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE, ...); +# listing every benign one in allow_list is brittle. Inherit everything and +# strip known secrets: +# [agent.clear_env] +# deny_list = ["DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "ANTHROPIC_API_KEY"] +# +# Example: pure escape hatch — disable filtering entirely (NOT recommended, +# every secret in the OAB env is exposed to the agent and exfil-able via +# prompt injection). +# [agent.clear_env] +# enabled = false # [agent] # command = "codex" diff --git a/docs/config-reference.md b/docs/config-reference.md index 9049466e..7cd11dbb 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -90,9 +90,37 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP. | `args` | string[] | `[]` | CLI arguments passed to the agent. | | `working_dir` | string | `"/tmp"` | Working directory for the agent process. | | `env` | map | `{}` | Extra environment variables (e.g. `{ ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }`). | -| `inherit_env` | string[] | `[]` | Env var names to inherit from the OAB process (e.g. vars injected via K8s `envFrom`). Keys in `env` take precedence. | +| `clear_env` | table | *(see below)* | Controls how the subprocess inherits env vars from the OAB process. Default is secure (env_clear + baseline + `env`). | -> **Default inherited vars:** After `env_clear()`, the agent always receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). Use `inherit_env` to pass additional vars beyond this baseline. +#### `[agent.clear_env]` — environment inheritance policy + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | bool | `true` | When `true`, `env_clear()` runs and inheritance follows the decision tree below. When `false`, the subprocess inherits the FULL OAB process env and both lists are ignored (escape hatch). | +| `allow_list` | string[] | `[]` | When non-empty under `enabled = true`: only these keys pass through from the OAB process env (deny_list ignored). **Baseline (`HOME`, `PATH`, `USER`, etc.) is always added separately** — `allow_list = ["FOO"]` yields `{baseline + [agent].env + FOO}`, not `{FOO}` alone. | +| `deny_list` | string[] | `[]` | When non-empty under `enabled = true` AND `allow_list` is empty: all process env passes through EXCEPT these keys. **Baseline keys are added unconditionally and cannot be denied** — `deny_list = ["PATH"]` does NOT remove `PATH` from the subprocess. | + +**Decision tree** (always after `env_clear()` + baseline + `[agent].env`): + +```text +if enabled: + if allow_list non-empty: pass only those keys from process env + elif deny_list non-empty: pass all process env EXCEPT deny_list + else: pass nothing (pure secure default) +else: + pass all process env (escape hatch — both lists ignored) +``` + +`allow_list` takes priority over `deny_list` when both are set under `enabled = true` (the deny-list branch is only reached when allow-list is empty). `[agent].env` always wins on key conflict (highest precedence). + +> **Baseline always set:** Regardless of `clear_env` mode, the subprocess receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). The baseline ensures agents can locate OAuth/auth files (`~/.codex`, `~/.claude`, `~/.config/gh`). + +**Use case — AWS-IRSA / web-identity workloads:** Kubernetes auto-injects many `AWS_*` env vars (`AWS_ROLE_ARN`, `AWS_WEB_IDENTITY_TOKEN_FILE`, ...). Listing every benign one in `allow_list` is brittle. Use `deny_list` to inherit everything but strip known secrets: + +```toml +[agent.clear_env] +deny_list = ["DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "ANTHROPIC_API_KEY"] +``` ### Agent examples diff --git a/src/acp/connection.rs b/src/acp/connection.rs index f49c0f50..7a611f21 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -122,25 +122,66 @@ pub struct AcpConnection { _reader_handle: JoinHandle<()>, } -/// Build the final set of env vars for the agent subprocess. -/// `explicit` ([agent].env) takes precedence over `inherit` ([agent].inherit_env). +/// Build the inherited portion of env vars for the agent subprocess. +/// +/// Decision tree: +/// ```text +/// if enabled: +/// if allow_list non-empty: # allow-list mode: only those keys +/// pass allow_list keys from process env +/// elif deny_list non-empty: # deny-list mode: all except those +/// pass all process env minus deny_list +/// else: # pure clear: nothing inherited +/// pass nothing +/// else: # escape hatch: full inherit +/// pass all process env, ignoring both lists +/// ``` +/// +/// `explicit` ([agent].env) always wins via highest precedence. /// Returns (merged env map, list of keys that were inherited from the process). fn build_agent_env( explicit: &std::collections::HashMap, - inherit_keys: &[String], + clear_env: &crate::config::ClearEnvConfig, ) -> (std::collections::HashMap, Vec) { let mut result: std::collections::HashMap = std::collections::HashMap::new(); let mut inherited: Vec = Vec::new(); + // 1. Explicit [agent].env always wins. for (k, v) in explicit { result.insert(k.clone(), expand_env(v)); } - for key in inherit_keys { - if !result.contains_key(key) { - if let Ok(v) = std::env::var(key) { - result.insert(key.clone(), v); - inherited.push(key.clone()); + // 2. Inherit from process env per the decision tree above. + if clear_env.enabled { + if !clear_env.allow_list.is_empty() { + for key in &clear_env.allow_list { + if !result.contains_key(key) { + if let Ok(v) = std::env::var(key) { + result.insert(key.clone(), v); + inherited.push(key.clone()); + } + } + } + } else if !clear_env.deny_list.is_empty() { + let deny: std::collections::HashSet<&str> = + clear_env.deny_list.iter().map(String::as_str).collect(); + for (k, v) in std::env::vars() { + if deny.contains(k.as_str()) { + continue; + } + if !result.contains_key(&k) { + result.insert(k.clone(), v); + inherited.push(k); + } + } + } + // else: pure clear — nothing inherited beyond explicit + baseline. + } else { + // Escape hatch: full inherit, both lists ignored. + for (k, v) in std::env::vars() { + if !result.contains_key(&k) { + result.insert(k.clone(), v); + inherited.push(k); } } } @@ -154,7 +195,7 @@ impl AcpConnection { args: &[String], working_dir: &str, env: &std::collections::HashMap, - inherit_env: &[String], + clear_env: &crate::config::ClearEnvConfig, ) -> Result { info!(cmd = command, ?args, cwd = working_dir, "spawning agent"); @@ -181,12 +222,13 @@ impl AcpConnection { { cmd.creation_flags(0x00000200); // CREATE_NEW_PROCESS_GROUP } - // Clear inherited env to prevent credential leakage (e.g. DISCORD_BOT_TOKEN). - // Only [agent].env values + essential baseline vars are passed through. + // Always env_clear() for determinism; build_agent_env returns the + // exact set of vars to add back per the configured policy. cmd.env_clear(); - // Preserve the real HOME so agents can find OAuth/auth files (~/.codex, - // ~/.claude, ~/.config/gh, etc.). working_dir is already set via - // current_dir() above and is not necessarily the user's home directory. + // Baseline: preserve real HOME so agents can find OAuth/auth files + // (~/.codex, ~/.claude, ~/.config/gh, etc.). working_dir is already + // set via current_dir() above and is not necessarily the user's home + // directory. PATH is required for the agent binary to find tools. cmd.env("HOME", std::env::var("HOME").unwrap_or_else(|_| working_dir.into())); cmd.env("PATH", std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into())); #[cfg(unix)] @@ -202,22 +244,22 @@ impl AcpConnection { if let Ok(v) = std::env::var("SystemRoot") { cmd.env("SystemRoot", v); } if let Ok(v) = std::env::var("SystemDrive") { cmd.env("SystemDrive", v); } } - for (k, v) in env { - cmd.env(k, expand_env(v)); - } - // Inherit selected env vars from the OAB process (e.g. vars injected - // via Kubernetes envFrom). Keys already in [agent].env are skipped — - // explicit values take precedence. - let (agent_env, inherited_keys) = build_agent_env(env, inherit_env); + // Build inherited set per [agent.clear_env] policy. + let (agent_env, inherited_keys) = build_agent_env(env, clear_env); for (k, v) in &agent_env { cmd.env(k, v); } - if !agent_env.is_empty() { + if !clear_env.enabled { + tracing::warn!( + inherited_count = inherited_keys.len(), + "[agent].clear_env.enabled = false -- the agent subprocess inherits the FULL OAB process environment. All inherited values are accessible to the agent and could be exfiltrated via prompt injection. Prefer enabled = true with allow_list or deny_list when possible." + ); + } else if !agent_env.is_empty() { let explicit_keys: Vec<&String> = env.keys().collect(); tracing::warn!( ?explicit_keys, - ?inherited_keys, - "[agent].env/inherit_env is set -- these values are accessible to the agent and could be exfiltrated via prompt injection" + inherited_count = inherited_keys.len(), + "[agent].env / clear_env is set -- these values are accessible to the agent and could be exfiltrated via prompt injection" ); } let mut proc = cmd @@ -688,15 +730,23 @@ mod tests { ); } + fn make_clear_env(enabled: bool, allow: Vec<&str>, deny: Vec<&str>) -> crate::config::ClearEnvConfig { + crate::config::ClearEnvConfig { + enabled, + allow_list: allow.into_iter().map(String::from).collect(), + deny_list: deny.into_iter().map(String::from).collect(), + } + } + #[test] - fn explicit_env_takes_precedence_over_inherit_env() { + fn explicit_env_takes_precedence_over_allow_list() { let key = "OAB_TEST_PRECEDENCE"; std::env::set_var(key, "from_process"); let mut explicit = std::collections::HashMap::new(); explicit.insert(key.to_string(), "from_config".to_string()); - let inherit = vec![key.to_string()]; + let clear_env = make_clear_env(true, vec![key], vec![]); - let (result, inherited) = build_agent_env(&explicit, &inherit); + let (result, inherited) = build_agent_env(&explicit, &clear_env); assert_eq!(result.get(key).unwrap(), "from_config"); assert!(!inherited.contains(&key.to_string())); @@ -704,13 +754,13 @@ mod tests { } #[test] - fn inherit_env_copies_from_process() { + fn allow_list_copies_from_process() { let key = "OAB_TEST_INHERIT"; std::env::set_var(key, "process_value"); let explicit = std::collections::HashMap::new(); - let inherit = vec![key.to_string()]; + let clear_env = make_clear_env(true, vec![key], vec![]); - let (result, inherited) = build_agent_env(&explicit, &inherit); + let (result, inherited) = build_agent_env(&explicit, &clear_env); assert_eq!(result.get(key).unwrap(), "process_value"); assert!(inherited.contains(&key.to_string())); @@ -718,13 +768,94 @@ mod tests { } #[test] - fn inherit_env_skips_missing_vars() { + fn allow_list_skips_missing_vars() { let explicit = std::collections::HashMap::new(); - let inherit = vec!["OAB_TEST_NONEXISTENT_VAR_12345".to_string()]; + let clear_env = make_clear_env(true, vec!["OAB_TEST_NONEXISTENT_VAR_12345"], vec![]); - let (result, inherited) = build_agent_env(&explicit, &inherit); + let (result, inherited) = build_agent_env(&explicit, &clear_env); assert!(!result.contains_key("OAB_TEST_NONEXISTENT_VAR_12345")); assert!(inherited.is_empty()); } + + #[test] + fn enabled_true_with_empty_lists_inherits_nothing() { + // Pure clear mode: enabled=true with both lists empty inherits nothing. + let key = "OAB_TEST_PURE_CLEAR"; + std::env::set_var(key, "should_not_appear"); + let explicit = std::collections::HashMap::new(); + let clear_env = make_clear_env(true, vec![], vec![]); + + let (result, _inherited) = build_agent_env(&explicit, &clear_env); + + assert!(!result.contains_key(key)); + std::env::remove_var(key); + } + + #[test] + fn enabled_false_inherits_full_env_ignoring_lists() { + // Escape hatch: full inherit, both lists ignored. + let inherited_key = "OAB_TEST_FULL_INHERIT"; + let allow_only_key = "OAB_TEST_ALLOW_IGNORED"; + let deny_target_key = "OAB_TEST_DENY_IGNORED"; + std::env::set_var(inherited_key, "process_value"); + std::env::set_var(allow_only_key, "value_a"); + std::env::set_var(deny_target_key, "value_d"); + let explicit = std::collections::HashMap::new(); + let clear_env = make_clear_env(false, vec![allow_only_key], vec![deny_target_key]); + + let (result, inherited) = build_agent_env(&explicit, &clear_env); + + // All three are inherited because lists are ignored under enabled=false. + assert_eq!(result.get(inherited_key).unwrap(), "process_value"); + assert_eq!(result.get(allow_only_key).unwrap(), "value_a"); + assert_eq!(result.get(deny_target_key).unwrap(), "value_d"); + assert!(inherited.contains(&inherited_key.to_string())); + std::env::remove_var(inherited_key); + std::env::remove_var(allow_only_key); + std::env::remove_var(deny_target_key); + } + + #[test] + fn deny_list_strips_keys_when_enabled() { + // deny_list mode: enabled=true, allow_list empty, deny_list non-empty + // → inherit all process env minus deny_list keys. + let kept = "OAB_TEST_KEPT"; + let stripped = "OAB_TEST_STRIPPED"; + std::env::set_var(kept, "kept_value"); + std::env::set_var(stripped, "should_be_stripped"); + let explicit = std::collections::HashMap::new(); + let clear_env = make_clear_env(true, vec![], vec![stripped]); + + let (result, _inherited) = build_agent_env(&explicit, &clear_env); + + assert_eq!(result.get(kept).unwrap(), "kept_value"); + assert!(!result.contains_key(stripped)); + std::env::remove_var(kept); + std::env::remove_var(stripped); + } + + #[test] + fn allow_list_takes_priority_over_deny_list_when_both_set() { + // When allow_list is non-empty under enabled=true, deny_list is + // ignored entirely (allow-list-only mode). + let allowed = "OAB_TEST_ALLOWED"; + let other = "OAB_TEST_NOT_ALLOWED"; + let listed_in_deny = "OAB_TEST_LISTED_IN_DENY"; + std::env::set_var(allowed, "allowed_value"); + std::env::set_var(other, "other_value"); + std::env::set_var(listed_in_deny, "deny_value"); + let explicit = std::collections::HashMap::new(); + let clear_env = make_clear_env(true, vec![allowed], vec![listed_in_deny]); + + let (result, _inherited) = build_agent_env(&explicit, &clear_env); + + // Only the allow_list key passes; deny_list and other keys are absent. + assert_eq!(result.get(allowed).unwrap(), "allowed_value"); + assert!(!result.contains_key(other)); + assert!(!result.contains_key(listed_in_deny)); + std::env::remove_var(allowed); + std::env::remove_var(other); + std::env::remove_var(listed_in_deny); + } } diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a146abb0..5e055b27 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -171,7 +171,7 @@ impl SessionPool { &self.config.args, &self.config.working_dir, &self.config.env, - &self.config.inherit_env, + &self.config.clear_env, ) .await?; diff --git a/src/config.rs b/src/config.rs index 3bd99504..75227b0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -221,7 +221,64 @@ pub struct AgentConfig { #[serde(default)] pub env: HashMap, #[serde(default)] - pub inherit_env: Vec, + pub clear_env: ClearEnvConfig, +} + +/// Controls how the agent subprocess inherits environment variables from the +/// OAB process. The default is secure (`enabled = true`, both lists empty): +/// the subprocess starts with `env_clear()` and only receives baseline vars +/// (HOME/PATH/USER/SystemRoot) plus `[agent].env`. +/// +/// Decision tree (always after `env_clear()` + baseline + `[agent].env`): +/// +/// ```text +/// if enabled: +/// if allow_list non-empty: pass only these keys from process env +/// elif deny_list non-empty: pass all process env EXCEPT deny_list +/// else: pass nothing (pure secure default) +/// else: +/// pass all process env (both lists ignored — escape hatch) +/// ``` +/// +/// `allow_list` takes priority over `deny_list` when both are set under +/// `enabled = true` (the deny_list branch is only reached when allow_list is +/// empty). `[agent].env` always wins on key conflict. +#[derive(Debug, Deserialize)] +pub struct ClearEnvConfig { + /// When true (default), env_clear() runs and inheritance is governed by + /// `allow_list` / `deny_list` per the decision tree. When false, the + /// subprocess inherits the FULL OAB process env and both lists are + /// ignored. + #[serde(default = "default_true")] + pub enabled: bool, + /// Allow-list mode: when non-empty under `enabled = true`, only these + /// keys pass through from the OAB process env (deny_list is ignored). + /// No effect under `enabled = false`. + /// + /// Baseline vars (HOME, PATH, USER on Unix; USERPROFILE, USERNAME, PATH, + /// SystemRoot, SystemDrive on Windows) are always added separately and + /// are NOT subject to this filter — `allow_list = ["FOO"]` yields + /// `{baseline + [agent].env + FOO}`, not `{FOO}` alone. + #[serde(default)] + pub allow_list: Vec, + /// Deny-list mode: when non-empty under `enabled = true` AND `allow_list` + /// is empty, all process env passes through EXCEPT these keys. No effect + /// under `enabled = false` or when `allow_list` is non-empty. + /// + /// Baseline vars are added unconditionally and CANNOT be denied — + /// `deny_list = ["PATH"]` does NOT remove PATH from the subprocess. + #[serde(default)] + pub deny_list: Vec, +} + +impl Default for ClearEnvConfig { + fn default() -> Self { + Self { + enabled: true, + allow_list: Vec::new(), + deny_list: Vec::new(), + } + } } #[derive(Debug, Deserialize)]