Skip to content

Commit ac9931d

Browse files
committed
fix(security): scrub credential env vars from ShellExecutor subprocess environment (#2449)
Zeph's ShellExecutor inherited the full parent process environment when spawning bash subcommands, exposing any credentials present in the process env (e.g. vars set by the user's shell profile) to arbitrary shell commands executed by the agent. Add `env_blocklist: Vec<String>` to `ShellConfig` (default-on, covers ZEPH_*, AWS_*, AZURE_*, GCP_*, GOOGLE_*, OPENAI_*, ANTHROPIC_*, HF_*, HUGGING*). In `execute_bash`, iterate `std::env::vars()` and call `cmd.env_remove()` for any key matching a blocklist prefix before spawning. Skill `extra_env` vars are injected after scrubbing and are unaffected. Configurable via `[tools.shell] env_blocklist`. Closes #2449
1 parent e1b1ad3 commit ac9931d

File tree

4 files changed

+192
-11
lines changed

4 files changed

+192
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3535
- fix(acp): discovery endpoint already reflects `ProtocolVersion::LATEST` — confirmed fixed in PR #2423; no code change required (closes #2412)
3636
- fix(security): extend MCP env var blocklist — `PATH`, `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `BASH_ENV`, `ENV`, `PYTHONPATH`, `NODE_PATH`, `RUBYLIB` are now stripped from ACP-provided env vars for MCP stdio child processes (closes #2437)
3737
- fix(tools): `AuditLogger::log` now emits `tracing::error!` when `serde_json` serialization fails instead of silently dropping the audit entry (closes #2438)
38+
- fix(security): scrub credential env vars (`ZEPH_*`, `AWS_*`, `ANTHROPIC_*`, `OPENAI_*`, `AZURE_*`, `GCP_*`, `GOOGLE_*`, `HF_*`, `HUGGING*`) from `ShellExecutor` subprocess environment to prevent exfiltration via shell commands; configurable via `[tools.shell] env_blocklist` (closes #2449)
3839

3940
### Added (tests)
4041

crates/zeph-tools/src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ pub struct ShellConfig {
337337
pub allow_network: bool,
338338
#[serde(default = "default_confirm_patterns")]
339339
pub confirm_patterns: Vec<String>,
340+
/// Environment variable name prefixes to strip from subprocess environment.
341+
/// Variables whose names start with any of these prefixes are removed before
342+
/// spawning shell commands. Default covers common credential naming conventions.
343+
#[serde(default = "ShellConfig::default_env_blocklist")]
344+
pub env_blocklist: Vec<String>,
345+
}
346+
347+
impl ShellConfig {
348+
#[must_use]
349+
pub fn default_env_blocklist() -> Vec<String> {
350+
vec![
351+
"ZEPH_".into(),
352+
"AWS_".into(),
353+
"AZURE_".into(),
354+
"GCP_".into(),
355+
"GOOGLE_".into(),
356+
"OPENAI_".into(),
357+
"ANTHROPIC_".into(),
358+
"HF_".into(),
359+
"HUGGING".into(),
360+
]
361+
}
340362
}
341363

342364
/// Configuration for audit logging of tool executions.
@@ -379,6 +401,7 @@ impl Default for ShellConfig {
379401
allowed_paths: Vec::new(),
380402
allow_network: true,
381403
confirm_patterns: default_confirm_patterns(),
404+
env_blocklist: Self::default_env_blocklist(),
382405
}
383406
}
384407
}

crates/zeph-tools/src/shell/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub struct ShellExecutor {
9898
blocked_commands: Vec<String>,
9999
allowed_paths: Vec<PathBuf>,
100100
confirm_patterns: Vec<String>,
101+
env_blocklist: Vec<String>,
101102
audit_logger: Option<Arc<AuditLogger>>,
102103
tool_event_tx: Option<ToolEventTx>,
103104
permission_policy: Option<PermissionPolicy>,
@@ -145,6 +146,7 @@ impl ShellExecutor {
145146
blocked_commands: blocked,
146147
allowed_paths,
147148
confirm_patterns: config.confirm_patterns.clone(),
149+
env_blocklist: config.env_blocklist.clone(),
148150
audit_logger: None,
149151
tool_event_tx: None,
150152
permission_policy: None,
@@ -278,6 +280,7 @@ impl ShellExecutor {
278280
self.tool_event_tx.as_ref(),
279281
self.cancel_token.as_ref(),
280282
skill_env_snapshot.as_ref(),
283+
&self.env_blocklist,
281284
)
282285
.await;
283286
if exit_code == 130
@@ -932,6 +935,7 @@ async fn execute_bash(
932935
event_tx: Option<&ToolEventTx>,
933936
cancel_token: Option<&CancellationToken>,
934937
extra_env: Option<&std::collections::HashMap<String, String>>,
938+
env_blocklist: &[String],
935939
) -> (String, i32) {
936940
use std::process::Stdio;
937941
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -943,6 +947,16 @@ async fn execute_bash(
943947
.arg(code)
944948
.stdout(Stdio::piped())
945949
.stderr(Stdio::piped());
950+
951+
for (key, _) in std::env::vars() {
952+
if env_blocklist
953+
.iter()
954+
.any(|prefix| key.starts_with(prefix.as_str()))
955+
{
956+
cmd.env_remove(&key);
957+
}
958+
}
959+
946960
if let Some(env) = extra_env {
947961
cmd.envs(env);
948962
}

crates/zeph-tools/src/shell/tests.rs

Lines changed: 154 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ fn default_config() -> ShellConfig {
1111
allowed_paths: Vec::new(),
1212
allow_network: true,
1313
confirm_patterns: Vec::new(),
14+
env_blocklist: ShellConfig::default_env_blocklist(),
1415
}
1516
}
1617

@@ -60,15 +61,23 @@ fn unclosed_block_ignored() {
6061
#[cfg(not(target_os = "windows"))]
6162
async fn execute_simple_command() {
6263
let (result, code) =
63-
execute_bash("echo hello", Duration::from_secs(30), None, None, None).await;
64+
execute_bash("echo hello", Duration::from_secs(30), None, None, None, &[]).await;
6465
assert!(result.contains("hello"));
6566
assert_eq!(code, 0);
6667
}
6768

6869
#[tokio::test]
6970
#[cfg(not(target_os = "windows"))]
7071
async fn execute_stderr_output() {
71-
let (result, _) = execute_bash("echo err >&2", Duration::from_secs(30), None, None, None).await;
72+
let (result, _) = execute_bash(
73+
"echo err >&2",
74+
Duration::from_secs(30),
75+
None,
76+
None,
77+
None,
78+
&[],
79+
)
80+
.await;
7281
assert!(result.contains("[stderr]"));
7382
assert!(result.contains("err"));
7483
}
@@ -82,6 +91,7 @@ async fn execute_stdout_and_stderr_combined() {
8291
None,
8392
None,
8493
None,
94+
&[],
8595
)
8696
.await;
8797
assert!(result.contains("out"));
@@ -93,7 +103,7 @@ async fn execute_stdout_and_stderr_combined() {
93103
#[tokio::test]
94104
#[cfg(not(target_os = "windows"))]
95105
async fn execute_empty_output() {
96-
let (result, code) = execute_bash("true", Duration::from_secs(30), None, None, None).await;
106+
let (result, code) = execute_bash("true", Duration::from_secs(30), None, None, None, &[]).await;
97107
assert_eq!(result, "(no output)");
98108
assert_eq!(code, 0);
99109
}
@@ -899,6 +909,7 @@ async fn execute_bash_injects_extra_env() {
899909
None,
900910
None,
901911
Some(&env),
912+
&[],
902913
)
903914
.await;
904915
assert_eq!(code, 0);
@@ -912,11 +923,8 @@ async fn shell_executor_set_skill_env_injects_vars() {
912923

913924
let config = ShellConfig {
914925
timeout: 5,
915-
allowed_commands: vec![],
916-
blocked_commands: vec![],
917-
allowed_paths: vec![],
918-
confirm_patterns: vec![],
919926
allow_network: false,
927+
..default_config()
920928
};
921929

922930
let executor = ShellExecutor::new(&config);
@@ -935,7 +943,7 @@ async fn shell_executor_set_skill_env_injects_vars() {
935943
#[cfg(unix)]
936944
#[tokio::test]
937945
async fn execute_bash_error_handling() {
938-
let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None).await;
946+
let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None, &[]).await;
939947
assert_eq!(result, "(no output)");
940948
assert_eq!(code, 1);
941949
}
@@ -949,6 +957,7 @@ async fn execute_bash_command_not_found() {
949957
None,
950958
None,
951959
None,
960+
&[],
952961
)
953962
.await;
954963
assert!(result.contains("[stderr]") || result.contains("[error]"));
@@ -1056,6 +1065,7 @@ async fn cancel_token_kills_child_process() {
10561065
None,
10571066
Some(&token),
10581067
None,
1068+
&[],
10591069
)
10601070
.await;
10611071
assert_eq!(code, 130);
@@ -1065,7 +1075,8 @@ async fn cancel_token_kills_child_process() {
10651075
#[tokio::test]
10661076
#[cfg(not(target_os = "windows"))]
10671077
async fn cancel_token_none_does_not_cancel() {
1068-
let (result, code) = execute_bash("echo ok", Duration::from_secs(5), None, None, None).await;
1078+
let (result, code) =
1079+
execute_bash("echo ok", Duration::from_secs(5), None, None, None, &[]).await;
10691080
assert_eq!(code, 0);
10701081
assert!(result.contains("ok"));
10711082
}
@@ -1082,8 +1093,15 @@ async fn cancel_kills_child_process_group() {
10821093
tokio::time::sleep(Duration::from_millis(200)).await;
10831094
token_clone.cancel();
10841095
});
1085-
let (result, code) =
1086-
execute_bash(&script, Duration::from_secs(30), None, Some(&token), None).await;
1096+
let (result, code) = execute_bash(
1097+
&script,
1098+
Duration::from_secs(30),
1099+
None,
1100+
Some(&token),
1101+
None,
1102+
&[],
1103+
)
1104+
.await;
10871105
assert_eq!(code, 130);
10881106
assert!(result.contains("[cancelled]"));
10891107
// Wait briefly, then verify the subprocess did NOT create the marker file
@@ -1550,3 +1568,128 @@ fn classify_exit_0_returns_none() {
15501568
fn classify_exit_1_generic_returns_none() {
15511569
assert_eq!(classify_shell_exit(1, "some other error"), None);
15521570
}
1571+
1572+
// --- env_blocklist / scrubbing tests ---
1573+
1574+
#[cfg(unix)]
1575+
#[allow(unsafe_code)]
1576+
#[tokio::test]
1577+
async fn env_blocklist_strips_sensitive_vars() {
1578+
// Set a fake sensitive env var in the current process
1579+
unsafe { std::env::set_var("ZEPH_SECRET_TEST_VAR", "should-not-leak") };
1580+
let blocklist = vec!["ZEPH_".to_owned()];
1581+
let (result, code) = execute_bash(
1582+
"echo ${ZEPH_SECRET_TEST_VAR:-absent}",
1583+
Duration::from_secs(5),
1584+
None,
1585+
None,
1586+
None,
1587+
&blocklist,
1588+
)
1589+
.await;
1590+
unsafe { std::env::remove_var("ZEPH_SECRET_TEST_VAR") };
1591+
assert_eq!(code, 0);
1592+
assert!(
1593+
result.contains("absent"),
1594+
"ZEPH_ var should have been stripped, got: {result}"
1595+
);
1596+
}
1597+
1598+
#[cfg(unix)]
1599+
#[tokio::test]
1600+
async fn env_blocklist_preserves_safe_vars() {
1601+
let blocklist = vec!["ZEPH_".to_owned()];
1602+
// PATH and HOME are always set in the test environment; verify they are inherited.
1603+
let (result, code) = execute_bash(
1604+
"echo ${PATH:+present}",
1605+
Duration::from_secs(5),
1606+
None,
1607+
None,
1608+
None,
1609+
&blocklist,
1610+
)
1611+
.await;
1612+
assert_eq!(code, 0);
1613+
assert!(
1614+
result.contains("present"),
1615+
"PATH should be preserved, got: {result}"
1616+
);
1617+
}
1618+
1619+
#[cfg(unix)]
1620+
#[tokio::test]
1621+
async fn env_blocklist_extra_env_still_injected() {
1622+
// Even with a blocklist active, skill-provided extra_env vars must be passed through.
1623+
let blocklist = vec!["ZEPH_".to_owned()];
1624+
let mut extra = std::collections::HashMap::new();
1625+
extra.insert("SKILL_TEST_VAR".to_owned(), "skill-value".to_owned());
1626+
let (result, code) = execute_bash(
1627+
"echo $SKILL_TEST_VAR",
1628+
Duration::from_secs(5),
1629+
None,
1630+
None,
1631+
Some(&extra),
1632+
&blocklist,
1633+
)
1634+
.await;
1635+
assert_eq!(code, 0);
1636+
assert!(
1637+
result.contains("skill-value"),
1638+
"skill extra_env should be injected, got: {result}"
1639+
);
1640+
}
1641+
1642+
#[cfg(unix)]
1643+
#[allow(unsafe_code)]
1644+
#[tokio::test]
1645+
async fn env_blocklist_multiple_prefixes() {
1646+
unsafe {
1647+
std::env::set_var("AWS_SECRET_ACCESS_KEY", "aws-secret");
1648+
std::env::set_var("OPENAI_API_KEY", "openai-secret");
1649+
}
1650+
let blocklist = vec!["AWS_".to_owned(), "OPENAI_".to_owned()];
1651+
let (result, code) = execute_bash(
1652+
"echo ${AWS_SECRET_ACCESS_KEY:-absent1} ${OPENAI_API_KEY:-absent2}",
1653+
Duration::from_secs(5),
1654+
None,
1655+
None,
1656+
None,
1657+
&blocklist,
1658+
)
1659+
.await;
1660+
unsafe {
1661+
std::env::remove_var("AWS_SECRET_ACCESS_KEY");
1662+
std::env::remove_var("OPENAI_API_KEY");
1663+
}
1664+
assert_eq!(code, 0);
1665+
assert!(
1666+
result.contains("absent1"),
1667+
"AWS_ var should be stripped, got: {result}"
1668+
);
1669+
assert!(
1670+
result.contains("absent2"),
1671+
"OPENAI_ var should be stripped, got: {result}"
1672+
);
1673+
}
1674+
1675+
#[cfg(unix)]
1676+
#[allow(unsafe_code)]
1677+
#[tokio::test]
1678+
async fn empty_env_blocklist_passes_all_vars() {
1679+
unsafe { std::env::set_var("ZEPH_EMPTY_BLOCKLIST_TEST", "visible") };
1680+
let (result, code) = execute_bash(
1681+
"echo ${ZEPH_EMPTY_BLOCKLIST_TEST:-absent}",
1682+
Duration::from_secs(5),
1683+
None,
1684+
None,
1685+
None,
1686+
&[],
1687+
)
1688+
.await;
1689+
unsafe { std::env::remove_var("ZEPH_EMPTY_BLOCKLIST_TEST") };
1690+
assert_eq!(code, 0);
1691+
assert!(
1692+
result.contains("visible"),
1693+
"empty blocklist should pass all vars, got: {result}"
1694+
);
1695+
}

0 commit comments

Comments
 (0)