# Vulnerability Verification Report

## 1. Summary
- **Vulnerability Type**: Remote Command Execution / command injection (CWE-78)
- **Flagged Location**: src/security/shell.rs:214
- **Vulnerability Description**: The shell security validator uses regex/literal blocklists and a first-token allowlist before executing user-influenced shell commands. Static tracing confirms command separators, extra arguments, and shell glob patterns can bypass these checks and still reach `sh -c` execution.

## 2. Analysis Logic

### Step 1: Examining the flagged validator at src/security/shell.rs:17-66 and src/security/shell.rs:170-244
The shell security module defines a regex blocklist, a literal substring blocklist, and an allowlist check inside `validate_command()`:

```rust
src/security/shell.rs:17:const REGEX_BLOCKED_PATTERNS: &[&str] = &[
src/security/shell.rs:41:    r"base64\s+(-d|--decode)",
src/security/shell.rs:42:    r"python[23]?\s+-c\s+",
src/security/shell.rs:43:    r"perl\s+-e\s+",
src/security/shell.rs:44:    r"ruby\s+-e\s+",
src/security/shell.rs:45:    r"node\s+-e\s+",
...
src/security/shell.rs:55:const LITERAL_BLOCKED_PATTERNS: &[&str] = &[
src/security/shell.rs:56:    "/etc/shadow",
src/security/shell.rs:57:    "/etc/passwd",
src/security/shell.rs:58:    "~/.ssh/",
```

```rust
src/security/shell.rs:170:pub fn validate_command(&self, command: &str) -> Result<()> {
src/security/shell.rs:175:    let command_lower = command.to_lowercase();
src/security/shell.rs:178:    for pattern in &self.compiled_patterns {
src/security/shell.rs:179:        if pattern.is_match(command) {
...
src/security/shell.rs:198:    for literal in &self.literal_patterns {
src/security/shell.rs:199:        if command_lower.contains(literal) {
...
src/security/shell.rs:214:    // Allowlist check (runs after blocklist)
src/security/shell.rs:215:    if self.allowlist_mode != ShellAllowlistMode::Off && !self.allowlist.is_empty() {
src/security/shell.rs:216:        let first_token = command
src/security/shell.rs:217:            .split_whitespace()
src/security/shell.rs:218:            .next()
src/security/shell.rs:219:            .unwrap_or("")
src/security/shell.rs:220:            .to_lowercase();
src/security/shell.rs:222:        let executable = first_token.rsplit('/').next().unwrap_or(&first_token);
src/security/shell.rs:223:        if !self.allowlist.iter().any(|a| a == executable) {
...
src/security/shell.rs:244:    Ok(())
```

Analysis: the validator does not parse shell syntax. It checks raw substrings/regexes and then only the first whitespace-delimited token. A command beginning with `/usr/bin/git` can pass a strict allowlist containing `git`, while a later `; python3 -P -c ...` remains unchecked by the allowlist. The blocklist regex at src/security/shell.rs:42 only matches `python3 -c`, not `python3 -P -c`, and the literal check at src/security/shell.rs:199 only tests raw substrings, so `/etc/pass[w]d` does not contain the literal `/etc/passwd` before shell glob expansion.

### Step 2: Confirming shell metacharacters and globbing reach a real process execution sink
`ShellTool` accepts a `command` string from tool arguments, validates it, then passes the same string to the configured runtime:

```rust
src/tools/shell.rs:126:fn parameters(&self) -> Value {
src/tools/shell.rs:127:    json!({
src/tools/shell.rs:130:        "command": {
src/tools/shell.rs:131:            "type": "string",
src/tools/shell.rs:132:            "description": "The shell command to execute"
src/tools/shell.rs:139:        "required": ["command"]
src/tools/shell.rs:143:async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolOutput> {
src/tools/shell.rs:144:    let command = args
src/tools/shell.rs:145:        .get("command")
src/tools/shell.rs:146:        .and_then(|v| v.as_str())
src/tools/shell.rs:147:        .ok_or_else(|| ZeptoError::Tool("Missing 'command' argument".into()))?;
src/tools/shell.rs:149:    // Security check
src/tools/shell.rs:150:    self.security_config.validate_command(command)?;
src/tools/shell.rs:166:    let output = self
src/tools/shell.rs:167:        .runtime
src/tools/shell.rs:168:        .execute(command, &container_config)
```

The default native runtime executes the validated command through a shell:

```rust
src/runtime/native.rs:34:async fn execute(
src/runtime/native.rs:36:    command: &str,
src/runtime/native.rs:39:    let mut cmd = Command::new("sh");
src/runtime/native.rs:40:    cmd.arg("-c").arg(command);
```

The Docker runtime also executes the command via `sh -c` inside the container:

```rust
src/runtime/docker.rs:174:    // Add image and command
src/runtime/docker.rs:175:    args.push(self.image.clone());
src/runtime/docker.rs:176:    args.push("sh".to_string());
src/runtime/docker.rs:177:    args.push("-c".to_string());
src/runtime/docker.rs:178:    args.push(command.to_string());
```

Analysis: this is not safe array-form execution of a fixed executable plus separate arguments. The validated string is interpreted by `sh -c`, so `;`, `$()`, pipes, and glob patterns are evaluated after validation.

### Step 3: Tracing external message content to tool-call arguments
Discord gateway messages are parsed from user-controlled `MESSAGE_CREATE` content and published to the inbound bus:

```rust
src/channels/discord.rs:84:/// The `d` field of a MESSAGE_CREATE dispatch event.
src/channels/discord.rs:86:struct MessageCreateData {
src/channels/discord.rs:87:    /// The message text content.
src/channels/discord.rs:89:    content: String,
...
src/channels/discord.rs:265:let content = msg.content.trim().to_string();
src/channels/discord.rs:294:let inbound = InboundMessage::new("discord", &sender_id, &channel_id, &content)
src/channels/discord.rs:295:    .with_metadata("discord_message_id", &msg.id);
```

```rust
src/channels/discord.rs:733:if let Some(event_name) = payload.t.as_deref() {
src/channels/discord.rs:734:    if event_name == "MESSAGE_CREATE" {
src/channels/discord.rs:735:        if let Some(ref data) = payload.d {
src/channels/discord.rs:736:            if let Some(inbound) =
src/channels/discord.rs:737:                Self::parse_message_create(data, &allowlist, deny_by_default)
src/channels/discord.rs:740:                    bus.publish_inbound(inbound).await
```

The message bus preserves this content in `InboundMessage.content`:

```rust
src/bus/message.rs:10:pub struct InboundMessage {
src/bus/message.rs:17:    /// The text content of the message
src/bus/message.rs:18:    pub content: String,
...
src/bus/message.rs:87:pub fn new(channel: &str, sender_id: &str, chat_id: &str, content: &str) -> Self {
src/bus/message.rs:92:        content: content.to_string(),
```

The agent includes `msg.content` in the LLM prompt and exposes tool definitions, then parses LLM-returned tool arguments and executes the selected tool:

```rust
src/agent/loop.rs:493:let messages = self
src/agent/loop.rs:494:    .context_builder
src/agent/loop.rs:495:    .build_messages(&session.messages, &msg.content);
src/agent/loop.rs:549:// Call LLM -- provider lock is NOT held during this await
src/agent/loop.rs:550:let mut response = provider
src/agent/loop.rs:551:    .chat(messages, tool_definitions, model, options.clone())
src/agent/loop.rs:552:    .await?;
...
src/agent/loop.rs:655:let args: serde_json::Value = match serde_json::from_str(&raw_args) {
src/agent/loop.rs:728:match tools_guard.execute_with_context(&name, args, &ctx).await {
```

The registry then dispatches those arguments to the concrete tool implementation:

```rust
src/tools/registry.rs:155:pub async fn execute_with_context(
src/tools/registry.rs:157:    name: &str,
src/tools/registry.rs:158:    args: Value,
src/tools/registry.rs:161:    let tool = match self.tools.get(name) {
src/tools/registry.rs:170:    match tool.execute(args, ctx).await {
```

Analysis: the shell command string is not hardcoded or server-generated. It is an LLM tool-call argument influenced by external user messages. The chain is: Discord message content → `InboundMessage.content` → LLM prompt/tool selection → parsed tool-call JSON arguments → `ShellTool.execute()` → `validate_command()` → runtime `sh -c`.

### Step 4: Checking tool registration, runtime defaults, and approval/mode protections
The shell tool is registered in normal agent construction when the tool filter allows it:

```rust
src/cli/common.rs:453:// Resolve tool profile: config default > template override > all tools
src/cli/common.rs:454:let profile_tools: Option<HashSet<String>> =
src/cli/common.rs:468:} else {
src/cli/common.rs:469:    None
src/cli/common.rs:472:let tool_enabled = |name: &str| {
src/cli/common.rs:475:    if let Some(ref profile) = profile_tools {
src/cli/common.rs:480:    // Template allowed filter
src/cli/common.rs:486:    !blocked_tools.contains(&key)
...
src/cli/common.rs:618:if tool_enabled("shell") {
src/cli/common.rs:620:        .register_tool(Box::new(ShellTool::with_runtime(runtime)))
```

`ShellTool::with_runtime()` uses the default security config:

```rust
src/tools/shell.rs:66:/// Create a shell tool with default security and custom runtime.
src/tools/shell.rs:67:pub fn with_runtime(runtime: Arc<dyn ContainerRuntime>) -> Self {
src/tools/shell.rs:69:        security_config: ShellSecurityConfig::new(),
src/tools/shell.rs:70:        runtime,
```

Default runtime and default approval/mode settings do not force a safe API or approval:

```rust
src/config/types.rs:1622:impl Default for RuntimeConfig {
src/config/types.rs:1625:        runtime_type: RuntimeType::Native,
src/config/types.rs:1626:        allow_fallback_to_native: false,
```

```rust
src/tools/approval.rs:94:/// # Defaults
src/tools/approval.rs:96:/// - `enabled`: `false`
src/tools/approval.rs:97:/// - `policy`: `AlwaysAllow`
src/tools/approval.rs:123:impl Default for ApprovalConfig {
src/tools/approval.rs:126:        enabled: false,
src/tools/approval.rs:127:        policy: ApprovalPolicyConfig::AlwaysAllow,
```

```rust
src/security/agent_mode.rs:16:/// Agent execution mode controlling tool permissions.
src/security/agent_mode.rs:19:/// blocked during an agent session. The default mode is `Autonomous` for
src/security/agent_mode.rs:21:#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
src/security/agent_mode.rs:28:    /// Full access. All tool categories are allowed without restriction.
src/security/agent_mode.rs:29:    #[default]
src/security/agent_mode.rs:30:    Autonomous,
src/security/agent_mode.rs:90:pub fn check(&self, category: ToolCategory) -> CategoryPermission {
src/security/agent_mode.rs:92:    AgentMode::Autonomous => CategoryPermission::Allowed,
```

Analysis: by default, the shell tool is available, uses native `sh -c`, the approval gate is disabled, and autonomous mode allows shell tools. Container runtimes are defense-in-depth only; Docker still runs `sh -c` at src/runtime/docker.rs:176-178, so the validator bypass remains even when command execution is containerized.

### Step 5: Checking code context and production reachability
The flagged module is part of the production security module and is re-exported, not only a test helper:

```rust
src/security/mod.rs:5:pub mod agent_mode;
src/security/mod.rs:10:pub mod shell;
src/security/mod.rs:17:pub use shell::{ShellAllowlistMode, ShellSecurityConfig};
```

The same file contains tests showing the intended allowlist semantics, including strict mode allowing listed first-token commands and blocking unlisted first-token commands:

```rust
src/security/shell.rs:497:#[test]
src/security/shell.rs:498:fn test_allowlist_strict_blocks_unlisted_command() {
src/security/shell.rs:499:    let config = ShellSecurityConfig::new()
src/security/shell.rs:500:        .with_allowlist(vec!["git", "cargo"], ShellAllowlistMode::Strict);
src/security/shell.rs:501:    assert!(config.validate_command("git status").is_ok());
src/security/shell.rs:503:    assert!(config.validate_command("ls -la").is_err());
```

Analysis: the vulnerable logic is in production code (`src/security/shell.rs:170-244`) and is used by the production shell tool (`src/tools/shell.rs:149-168`). The tests demonstrate the first-token allowlist contract but do not cover shell command separators after an allowed executable.

### Analysis Process
- Q1: Does user-controllable input reach a command/code execution sink? → YES. Discord `MESSAGE_CREATE` content is copied to `InboundMessage.content` at src/channels/discord.rs:265-295, included in the LLM prompt at src/agent/loop.rs:493-495, returned tool-call arguments are parsed at src/agent/loop.rs:655, dispatched at src/agent/loop.rs:728, and the shell command argument is executed at src/tools/shell.rs:166-168 and src/runtime/native.rs:39-40.
- Q2: Is it code evaluation (`eval`/`exec`) with user data? → NO. The sink is process execution through `sh -c`, shown at src/runtime/native.rs:39-40 and src/runtime/docker.rs:176-178.
- Q3: Is the command executed in array form without shell? → NO. Both native and Docker runtimes explicitly invoke `sh -c` with the entire validated command string at src/runtime/native.rs:39-40 and src/runtime/docker.rs:176-178.
- Q4: Is the user input effectively sanitized? → NO. The regex blocklist misses argument-injected `python3 -P -c` because the pattern is `python[23]?\s+-c\s+` at src/security/shell.rs:42; the literal blocklist misses shell-globbed `/etc/pass[w]d` because it only checks `command_lower.contains(literal)` at src/security/shell.rs:198-199 against literal `/etc/passwd` at src/security/shell.rs:57; the allowlist only checks the first whitespace-delimited token at src/security/shell.rs:216-223.
- Q5: Is the code in test/demo/dead/generated context? → NO. The shell security module is exported from production code at src/security/mod.rs:10 and src/security/mod.rs:17, and the production shell tool calls `validate_command()` before runtime execution at src/tools/shell.rs:149-168.
- → Reached leaf: TRUE_POSITIVE

## 3. Verdict
**TRUE_POSITIVE**

**Key Evidence:**
- The validator only allowlists the first token (`split_whitespace().next()` then `rsplit('/')`) at src/security/shell.rs:216-223, so an allowed `/usr/bin/git` prefix does not constrain later shell commands after `;`.
- Blocklist sanitization is incomplete: `python[23]?\s+-c\s+` at src/security/shell.rs:42 misses `python3 -P -c`, and literal matching at src/security/shell.rs:198-199 misses `/etc/pass[w]d` before shell glob expansion.
- The post-validation string is executed by a shell (`Command::new("sh"); cmd.arg("-c").arg(command)`) at src/runtime/native.rs:39-40, enabling command separators and glob expansion.

## 4. Remediation Suggestions
- Replace string-based shell execution with a parsed, allowlisted command model: fixed executable plus explicitly validated argument vector, then run without `sh -c` where possible.
- If shell syntax must be supported, parse commands into an AST and reject command separators, command substitutions, pipes, redirections, glob metacharacters, and unexpected flags before execution.
- Make strict allowlist mode apply to every command segment, not only the first token, and treat an empty strict allowlist as deny-all.
- Prefer positive allowlists for permitted subcommands/flags (for example, specific `git` subcommands) over regex/literal blocklists, then re-check bypass cases such as `;`, `$()`, `python3 -P -c`, and `/etc/pass[w]d` after the fix.