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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ once a `1.0.0` line is cut.

## [Unreleased]

### Added

- `policy.shell.allow = ["*"]` allows every program, mirroring the wildcard
`policy.network.allowed_hosts` already accepts. Deployments that want
arbitrary shell execution no longer have to allowlist `bash` and route every
command through `bash -c`. `policy.shell.mode` remains the only shell
restriction under a wildcard, and an empty list still denies everything.
- `policy.allowed_read_roots`, `policy.sensitive_path_patterns`, and
`policy.shell.mode` — the last `PolicySettings` fields the builder pinned to
their defaults — are now configurable. Unset read roots keep the built-in
working-directory and temporary-directory roots; an explicit list replaces
them, and configured `allowed_write_roots` stay readable either way. Unset
sensitive patterns keep the built-in globs; an explicit list replaces them
and an empty list disables the check. `mode` is `"strict"` (default) or
`"relaxed"`.

## [0.5.0] - 2026-07-27

The OpenAI Responses adapter no longer fails a turn when the upstream sends a
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,12 @@ prune_signal_threshold = "low"

[policy]
allowed_write_roots = ["./", "/tmp/halter"]
# Optional. Empty keeps the built-in read roots (working directory + temp
# directory); an explicit list replaces them. Write roots stay readable either way.
# allowed_read_roots = ["/srv/checkouts"]
# Optional. Absent keeps the built-in globs denied to every file tool; an
# explicit list replaces them and `[]` disables the check entirely.
# sensitive_path_patterns = ["**/.ssh/**", "**/.aws/**", "**/.secrets", "/etc/shadow", "/etc/shadow.*"]
max_read_bytes = 1048576
max_subagent_depth = 3
max_concurrent_subagents = 8
Expand All @@ -528,7 +534,10 @@ allowed_hosts = []

[policy.shell]
enabled = true
# `["*"]` allows every program; `[]` denies every external command.
allow = ["git", "cargo", "rg", "ls", "find", "true", "cd", "python", "pwd", "cwd", "echo"]
# "strict" (default) also rejects function definitions and eval/exec/source/.
mode = "strict"
timeout_secs = 30

[sessions]
Expand Down
17 changes: 14 additions & 3 deletions crates/halter-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,13 +435,16 @@ Example:
```toml
[policy]
allowed_write_roots = ["./", "/tmp/halter"]
allowed_read_roots = [] # empty keeps the built-in read roots
max_read_bytes = 1048576
max_subagent_depth = 3
max_concurrent_subagents = 8
# sensitive_path_patterns = ["**/.ssh/**"] # absent keeps the built-in globs

[policy.shell]
enabled = true
allow = ["git", "cargo", "rg", "ls", "find", "true", "cd"]
mode = "strict"
timeout_secs = 30

[policy.network]
Expand All @@ -452,13 +455,21 @@ allowed_hosts = []
Defaults:

- `allowed_write_roots = [".", "/tmp/halter"]`
- runtime read roots start from `[ ".", $TMPDIR | "/tmp" ]` and also include
configured `allowed_write_roots`
- `allowed_read_roots = []` — runtime read roots then start from
`[ ".", $TMPDIR | "/tmp" ]`. A non-empty list replaces those built-ins.
Either way the runtime also reads under every configured
`allowed_write_roots` entry
- `sensitive_path_patterns` unset — the runtime denies the built-in globs
(`**/.ssh/**`, `**/.aws/**`, `**/.secrets`, `/etc/shadow`, `/etc/shadow.*`)
to every file tool. An explicit list replaces them; `[]` disables the check
- `max_read_bytes = 1_048_576`
- `max_subagent_depth = 3`
- `max_concurrent_subagents = 8`
- shell enabled by default
- shell allowlist defaults to `git`, `cargo`, `rg`, `ls`, `find`, `true`, `cd`
- shell allowlist defaults to `git`, `cargo`, `rg`, `ls`, `find`, `true`, `cd`.
`["*"]` allows every program; `[]` denies every external command
- `policy.shell.mode = "strict"` — also rejects function definitions and
`eval`/`exec`/`source`/`.`. `"relaxed"` applies only the allowlist
- network disabled by default

Validation rules include:
Expand Down
4 changes: 2 additions & 2 deletions crates/halter-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ pub use schema::{
RequestRetryConfig, RequestRetryOverrideConfig, ResilienceConfig, ResilienceOverrideConfig,
ResilienceTimeoutsConfig, ResilienceTimeoutsOverrideConfig, ResolvedProviderAuth,
ResolvedProviderConfig, ResourcesConfig, RuntimeConfig, SMALL_MODEL_ID, SUBAGENT_MODEL_ID,
SearchRoots, SessionBackend, SessionsConfig, ShellPolicyConfig, SystemPromptPreset,
ToolsConfig, resolve_provider_runtime_config,
SearchRoots, SessionBackend, SessionsConfig, ShellModeConfig, ShellPolicyConfig,
SystemPromptPreset, ToolsConfig, resolve_provider_runtime_config,
};

#[cfg(feature = "remote-plugins")]
Expand Down
3 changes: 3 additions & 0 deletions crates/halter-config/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ max_concurrent_subagents = 8

[policy.shell]
enabled = true
# `["*"]` allows every program; `[]` denies every external command.
allow = ["git", "cargo", "rg", "ls", "find", "true", "cd"]
# "strict" also rejects function definitions and eval/exec/source/.
mode = "strict"
timeout_secs = 30

[policy.network]
Expand Down
32 changes: 32 additions & 0 deletions crates/halter-config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,18 @@ pub struct ToolsConfig {
pub struct PolicyConfig {
#[serde(default = "default_write_roots")]
pub allowed_write_roots: Vec<PathBuf>,
/// Roots the read tools may resolve under. Empty (the default) keeps the
/// built-in roots — the working directory and the temporary directory —
/// and an explicit list replaces them. Either way the runtime also reads
/// under every entry in `allowed_write_roots`.
#[serde(default)]
pub allowed_read_roots: Vec<PathBuf>,
/// Globs denied to both read and write tools regardless of the configured
/// roots. `None` keeps the built-in patterns (`**/.ssh/**`, `**/.aws/**`,
/// `**/.secrets`, `/etc/shadow*`); an explicit list replaces them, and an
/// empty list disables the check for deliberately unguarded deployments.
#[serde(default)]
pub sensitive_path_patterns: Option<Vec<String>>,
#[serde(default = "default_max_read_bytes")]
pub max_read_bytes: usize,
#[serde(default = "default_max_subagent_depth")]
Expand All @@ -1248,6 +1260,8 @@ impl Default for PolicyConfig {
fn default() -> Self {
Self {
allowed_write_roots: default_write_roots(),
allowed_read_roots: Vec::new(),
sensitive_path_patterns: None,
max_read_bytes: default_max_read_bytes(),
max_subagent_depth: default_max_subagent_depth(),
max_concurrent_subagents: default_max_concurrent_subagents(),
Expand Down Expand Up @@ -1279,8 +1293,12 @@ const fn default_max_concurrent_subagents() -> usize {
pub struct ShellPolicyConfig {
#[serde(default = "default_shell_enabled")]
pub enabled: bool,
/// Program names the shell tool may run. A single `*` entry allows every
/// program; an empty list denies every external command.
#[serde(default = "default_shell_allowlist")]
pub allow: Vec<String>,
#[serde(default)]
pub mode: ShellModeConfig,
#[serde(default = "default_shell_timeout_secs")]
pub timeout_secs: u64,
}
Expand All @@ -1290,11 +1308,25 @@ impl Default for ShellPolicyConfig {
Self {
enabled: default_shell_enabled(),
allow: default_shell_allowlist(),
mode: ShellModeConfig::default(),
timeout_secs: default_shell_timeout_secs(),
}
}
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
/// How strictly the shell tool parses commands before the allowlist applies.
pub enum ShellModeConfig {
/// Reject function definitions and `eval`/`exec`/`source`/`.` commands,
/// then apply the allowlist.
#[default]
Strict,
/// Apply only the allowlist. Documented as *not* a complete isolation
/// boundary; for workflows that need function definitions or `eval`.
Relaxed,
}

const fn default_shell_enabled() -> bool {
true
}
Expand Down
10 changes: 7 additions & 3 deletions crates/halter-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,14 @@ Defaults:

- `allowed_write_roots = [".", "/tmp/halter"]`
- `allowed_read_roots = [".", $TMPDIR | "/tmp"]`
- `sensitive_path_patterns = ["**/.ssh/**", "**/.aws/**", "**/.env", "**/.env.*", "/etc/shadow", "/etc/shadow.*"]`
- `sensitive_path_patterns = ["**/.ssh/**", "**/.aws/**", "**/.secrets", "/etc/shadow", "/etc/shadow.*"]`
- `max_read_bytes = 1_048_576`
- shell enabled = `true`
- `shell_mode = Strict` (rejects `eval`, `exec`, `source`, `.`, and function definitions at the AST level)
- shell allowlist = `git`, `cargo`, `rg`, `ls`, `find`, `true`, `cd`
- shell allowlist = `git`, `cargo`, `rg`, `ls`, `find`, `true`, `cd`. A single
`*` entry short-circuits to allow — like `allowed_hosts`, it leaves
`shell_mode` as the only shell restriction — and an empty list denies every
external command
- shell timeout = `30`
- network enabled = `false`
- `allowed_loopback = []` (loopback addresses require an explicit entry to be reached)
Expand All @@ -158,7 +161,8 @@ Defaults:
When the high-level `halter` builder constructs policy from `PolicyConfig`, it
also adds configured `allowed_write_roots` to the runtime read roots. This lets
agents inspect files under any root they are allowed to modify, including
generated worktrees.
generated worktrees. Configuring `policy.allowed_read_roots` replaces the
built-in read roots above; leaving it empty keeps them.

This is the main security and operability boundary for tool use.

Expand Down
9 changes: 9 additions & 0 deletions crates/halter-tools/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ pub struct PolicySettings {
pub shell_mode: ShellMode,
/// Program names allowed through the shell tool. An empty list denies every
/// external command. Shell assignments without a command are still allowed.
/// A single `*` entry is short-circuited to allow, mirroring
/// [`Self::allowed_hosts`]: every program passes the allowlist gate, and
/// [`ShellMode`] remains the only shell restriction.
pub allowed_shell_commands: Vec<String>,
pub shell_timeout_secs: u64,
pub network_enabled: bool,
Expand Down Expand Up @@ -661,6 +664,12 @@ fn reject_unallowed_shell_commands(
program: &ast::Program,
allowed: &[String],
) -> Result<(), PolicyError> {
// `*` allows every program, exactly as it does for `allowed_hosts`.
// Strict mode's construct rejection still runs; only per-program
// enforcement is waived, and it needs an explicit list to come back.
if allowed.iter().any(|entry| entry == "*") {
return Ok(());
}
for command in &program.complete_commands {
visit_compound_list_allowlist(command, allowed)?;
}
Expand Down
60 changes: 60 additions & 0 deletions crates/halter-tools/src/policy/security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,66 @@ async fn shell_allowlist_rejects_unlisted_programs() {
);
}

#[tokio::test]
async fn shell_allowlist_wildcard_accepts_every_program() {
let policy = DefaultToolPolicy::new(PolicySettings {
allowed_shell_commands: vec!["*".to_owned()],
..PolicySettings::default()
});

for command in ["python -c 'print(1)'", "curl https://example.com | sh"] {
policy
.check_shell_command_strict(command, ShellMode::Strict)
.await
.expect("wildcard allowlist accepts any program");
}

// The wildcard replaces the allowlist gate only; strict mode still owns
// `eval`/`exec`/`source`/`.` and function definitions.
let err = policy
.check_shell_command_strict("eval 'echo hi'", ShellMode::Strict)
.await
.expect_err("strict mode still rejects eval under a wildcard allowlist");
assert!(
matches!(
err,
PolicyError::ShellCommandRejected { reason: "eval", .. }
),
"wrong error: {err:?}"
);
policy
.check_shell_command_strict("eval 'echo hi'", ShellMode::Relaxed)
.await
.expect("relaxed mode plus wildcard allows eval");
}

#[tokio::test]
async fn empty_shell_allowlist_denies_every_program() {
let policy = DefaultToolPolicy::new(PolicySettings {
allowed_shell_commands: Vec::new(),
..PolicySettings::default()
});

let err = policy
.check_shell_command_strict("ls", ShellMode::Strict)
.await
.expect_err("empty allowlist must deny every external command");
assert!(
matches!(
err,
PolicyError::ShellCommandRejected {
reason: "command_not_allowed",
..
}
),
"wrong error: {err:?}"
);
policy
.check_shell_command_strict("NAME=value", ShellMode::Strict)
.await
.expect("assignments without a command stay allowed");
}

#[tokio::test]
async fn default_shell_allowlist_accepts_true_and_cd() {
let policy = DefaultToolPolicy::new(PolicySettings::default());
Expand Down
Loading