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
66 changes: 58 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ execute hostile projects or hostile instructions safely.
write destinations (common mutating commands and output redirects) must stay
under the canonical workspace root, including through existing symlinks;
newly created workspace symlinks may not target paths outside that root.
Lexical inspection cannot prove every indirect effect of an arbitrary
program, so when an OS write sandbox is available it also confines the writes
performed inside a command or script (see [OS Write Sandbox](#os-write-sandbox)).
Bash is not a complete sandbox: shell commands and invoked programs still run
as the current OS user, and static inspection cannot prove every indirect
effect of an arbitrary program.
as the current OS user, reads and network are not confined in this stage, and
the sandbox is absent when the backend is unavailable.

## `--allow` and `--yes`

Expand All @@ -25,13 +28,16 @@ Read/Glob/Grep, `write` admits and auto-approves Write/Edit, and `bash:verify`
admits and auto-approves only Bash commands accepted by the shared verifier
policy; recognized direct filesystem-mutating Bash commands remain excluded.
Repeat the option or comma-separate selectors. Once an explicit list is used,
omitted classes are blocked before execution.
omitted classes are blocked before execution. `bash:verify` admits a command by
its classification only: the contents of any script the command invokes are
constrained only by the OS write sandbox, when it is applied, and not by the
classification.

`--yes` is the backward-compatible all-tools alias and also skips resume
confirmation. Neither option bypasses the recognized Bash write-destination
guard. Use `--yes` only in a trusted workspace after checking the goal and
current branch: it does not turn Bash into an OS sandbox and does not auto-kill
unrelated local processes.
guard or the OS write sandbox. Use `--yes` only in a trusted workspace after
checking the goal and current branch: it does not turn Bash into a complete OS
sandbox and does not auto-kill unrelated local processes.

At run startup CommandAgent warns about a non-Git workspace or existing Git
changes. At exit it reports the final tracked/staged diff stat and untracked
Expand All @@ -40,7 +46,46 @@ predated the run.

`--offline` blocks runtime dependency setup plus Bash command families named in
`--help`. It does not block provider/API requests or arbitrary network-capable
programs and therefore is not a network sandbox.
programs and therefore is not a network sandbox. Network isolation is a later
stage of #502 and is independent of the write sandbox below.

## OS Write Sandbox

`--sandbox auto|required|off` (default `auto`) adds an OS-level write sandbox at
the two `sh -c` launch points: the model Bash tool (the `bash:verify` interval
reuses the same function) and the verification/acceptance commands. The sandbox
is defense in depth; the lexical checks still run first and can refuse a command
before the shell starts.

- What the OS closes (when the backend is available): a write performed inside a
command or script that the lexical guard cannot see — `sh -c 'cp ...'`,
`eval`, `xargs`, `find -exec`, an interpreter's `open(..., 'w')`, `dd of=`,
`sed -i` — and creating a hardlink to an inode outside the workspace. The only
writable locations are the workspace, the run's temp directory, the known
dependency caches, and device files such as `/dev/null`.
- What remains lexical-only: reads, network access, and every command run while
the backend is unavailable. `--allow bash:verify` and `--offline` are lexical
policies, so a command they admit can still start a script; only the sandbox
(when applied) constrains that script's writes. Reads and network are not
confined in this stage.
- Backend availability is probed once at run start. On macOS the backend is
Seatbelt through `sandbox-exec`; under a nested sandbox it reports `rc=71` and
is unavailable. Other platforms have no backend in this stage.
- `auto` continues with lexical checks only when the backend is unavailable,
warns once at startup, and records `bash_sandbox_status` with `applied=false`
and a reason. `required` instead fails Bash and verifier commands with
`bash_sandbox_unavailable`. `off` keeps the previous behavior. None of them
reports "sandboxed" when the sandbox was not applied.
- The dependency caches (cargo registry/git/package-cache, npm, pip, Playwright
browsers) stay writable so builds and tests work; they remain a contamination
path outside the workspace. pnpm and yarn stores are not allowed in this
stage.

macOS `mktemp` ignores `TMPDIR` and targets the per-user temp directory, which
the sandbox denies. A `mktemp` inside the run temp directory (`mktemp -p
"$TMPDIR"` or a template under `$TMPDIR`) succeeds; a bare `mktemp` does not.
The applied-probe record is under
`tests/corpus/apps/issue502-indirect-write/fixtures/macos-probe-results.md`.

## Child Process Environment

Expand Down Expand Up @@ -115,6 +160,11 @@ template files instead.

## Backlog

- OS write sandbox, remaining stages of #502: Linux write confinement (landlock),
read confinement, and network/dependency-install isolation. Stage 1a (macOS
Seatbelt write confinement and the shared leaf module) is implemented here;
the same leaf module is the seam the later stages extend.
- Container-sandboxed execution track: run Bash/verifier commands inside a
per-workspace container or equivalent OS sandbox with a mounted workspace,
minimal environment, and explicit network policy.
minimal environment, and explicit network policy. Superseded in part by the
#502 staging above; kept as the alternative with a fully mounted root.
30 changes: 30 additions & 0 deletions docs/guide/en/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ internal integration surface and is intentionally not a public user flag.
| `--footer` | `<on\|off>` | `on` | Control the fixed TUI footer; off keeps scrollback breadcrumbs. | [Footer problems](troubleshooting.md#footer-rendering-problems) |
| `--no-footer` | none | off | Disable the fixed TUI footer. Equivalent in effect to `--footer off`. | [Footer problems](troubleshooting.md#footer-rendering-problems) |

## OS write sandbox

The hidden `--sandbox auto|required|off` flag (default `auto`) controls an
OS-level write sandbox for the model Bash tool and for verification/acceptance
commands. The lexical checks still run first; the sandbox adds confinement for
writes the lexical guard cannot see.

- **What the OS closes (when the backend is available):** writes performed inside
a command or script — `sh -c 'cp ...'`, `eval`, `xargs`, `find -exec`, an
interpreter's `open(..., 'w')`, `dd of=`, `sed -i` — and hardlinks to an inode
outside the workspace. Only the workspace, the run's temp directory, the known
dependency caches, and device files such as `/dev/null` are writable.
- **What stays lexical-only:** reads, network access, and every command run when
the backend is unavailable. `--yes`, `--allow bash:verify`, and `--offline` do
not close an indirect write by themselves; only the sandbox, when applied,
does. `--allow bash:verify` admits a command by its classification only, so the
contents of any script it invokes are limited by the sandbox alone.
- **Modes:** `auto` uses the backend when available and otherwise warns once and
records `bash_sandbox_status` with `applied=false` and a reason, continuing
with lexical checks only; `required` fails Bash and verifier commands with
`bash_sandbox_unavailable`; `off` keeps the previous behavior. The backend is
unavailable under a nested sandbox (`rc=71`) and on a platform without one
(macOS only in this stage).
- Dependency caches (cargo, npm, pip, Playwright browsers) stay writable so
builds and tests work; they remain a contamination path outside the workspace.

macOS `mktemp` ignores `TMPDIR`; use `mktemp -p "$TMPDIR"` or a template under
`$TMPDIR`. The applied-probe record is under
`tests/corpus/apps/issue502-indirect-write/fixtures/macos-probe-results.md`.

## Interactive REPL controls

Within one TUI session, `/model <id>` and `/provider <name>` change the
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/ja/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ Clap が生成する `-h`/`--help` と `-V`/`--version` は、以下のアプリ
| `--footer` | `<on\|off>` | `on` | 固定 TUI フッターを制御します。off でもスクロールバックの breadcrumb は残ります。 | [フッターの問題](troubleshooting.md#フッター描画の問題) |
| `--no-footer` | なし | オフ | 固定 TUI フッターを無効にします。効果は `--footer off` と同じです。 | [フッターの問題](troubleshooting.md#フッター描画の問題) |

## OS 書き込みサンドボックス

隠しフラグ `--sandbox auto|required|off`(既定 `auto`)は、モデルの Bash ツールと検証・受け入れコマンドに対する OS レベルの書き込みサンドボックスを制御します。字句検査は先に働き、サンドボックスは字句検査から見えない書き込みを閉じ込めます。

- **OS が閉じるもの(backend が使えるとき):** コマンドやスクリプトの中の書き込み — `sh -c 'cp ...'`、`eval`、`xargs`、`find -exec`、インタープリタの `open(..., 'w')`、`dd of=`、`sed -i` — と、workspace の外の inode への hardlink です。書き込めるのは、workspace・run の一時ディレクトリ・既知の依存キャッシュ・`/dev/null` などのデバイスだけです。
- **字句検査だけのもの:** 読み取り、ネットワーク、backend が使えないときの全コマンドです。`--yes`・`--allow bash:verify`・`--offline` は、それ自体では間接的な書き込みを閉じません。適用されたサンドボックスだけが閉じます。`--allow bash:verify` はコマンドを分類で通すだけなので、そのコマンドが呼ぶスクリプトの中身を制限するのはサンドボックスだけです。
- **モード:** `auto` は backend が使えれば適用し、使えなければ起動時に一度警告し、`applied=false` と理由つきの `bash_sandbox_status` を記録して字句検査だけで続けます。`required` は Bash と検証コマンドを `bash_sandbox_unavailable` で失敗させます。`off` は今までの挙動を保ちます。入れ子の sandbox(`rc=71`)と backend の無いプラットフォーム(この段階は macOS のみ)では未適用です。
- 依存キャッシュ(cargo・npm・pip・Playwright のブラウザ)はビルドとテストのために書き込み可能なままにします。workspace の外の汚染経路として残ります。

macOS の `mktemp` は `TMPDIR` を見ません。`mktemp -p "$TMPDIR"` か `$TMPDIR` 配下のテンプレートを使ってください。適用の試験の記録は `tests/corpus/apps/issue502-indirect-write/fixtures/macos-probe-results.md` にあります。

## 対話型 REPL の操作

1 回の TUI セッション内で、`/model <id>` と `/provider <name>` は executor の
Expand Down
7 changes: 6 additions & 1 deletion src/bounded_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ fn child_env_allowed(key: &std::ffi::OsStr, source: EnvSource) -> bool {
|| (source == EnvSource::Explicit
&& (matches!(
key,
"NODE_ENV" | "NODE_OPTIONS" | "NODE_PATH" | "NEXT_TELEMETRY_DISABLED" | "PORT"
"NODE_ENV"
| "NODE_OPTIONS"
| "NODE_PATH"
| "NEXT_TELEMETRY_DISABLED"
| "PORT"
| "TMPDIR"
) || [
"COMMANDAGENT_FOREIGN_TOOLCHAIN_ROOT",
"COMMANDAGENT_FOREIGN_TOOLCHAIN_EVENTS",
Expand Down
10 changes: 10 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,16 @@ pub struct Cli {
help = crate::tools::offline_policy::CLI_HELP
)]
pub offline: bool,
#[arg(
long,
hide = true,
value_enum,
value_name = "auto|required|off",
default_value = "auto",
help_heading = "Workspace and State",
help = "Select the OS write sandbox for Bash and verifier commands. `auto` applies it when the backend is available and otherwise continues with lexical checks only; `required` fails those commands when the backend is unavailable; `off` keeps the previous behavior."
)]
pub sandbox: crate::tools::os_sandbox::SandboxMode,
#[arg(
long,
action = ArgAction::SetTrue,
Expand Down
17 changes: 17 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,13 @@ pub fn run_with_provider_options(
}
let pack_cli = cli.clone();
let summary_json = cli.summary_json;
let sandbox_mode = cli.sandbox;
let config = Config::from_cli_with_provider_options(cli, provider_options)
.map_err(cli_argument_error)?;
tools::os_sandbox::install(sandbox_mode, &config.workspace_root, &config.state_dir);
if let Some(warning) = tools::os_sandbox::startup_warning() {
eprintln!("{warning}");
}
for profile in planner::extension_profiles::registered() {
for warning in &profile.warnings {
eprintln!("warning: {warning}");
Expand Down Expand Up @@ -895,6 +900,18 @@ fn emit_run_start(config: &Config) {
"build_timestamp": build_info::TIMESTAMP,
}),
);
if let Some(sandbox) = tools::os_sandbox::status() {
eval_events::emit(
config.eval_events_path.as_deref(),
json!({
"event": "bash_sandbox_status",
"mode": sandbox.mode.as_str(),
"backend": sandbox.backend,
"applied": sandbox.applied,
"reason": sandbox.reason.unwrap_or_default(),
}),
);
}
eval_events::emit(
config.eval_events_path.as_deref(),
json!({
Expand Down
13 changes: 13 additions & 0 deletions src/minimal_loop/verifier_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@ fn run_structured(

let mut process = normalized_command_at_root("sh", root);
process.arg("-c").arg(command).current_dir(root);
let mut process = match crate::tools::os_sandbox::wrap_command(process) {
Ok(process) => process,
Err(refusal) => {
return Ok(BashOutcome {
kind: BashOutcomeKind::Blocked,
status: None,
stdout: String::new(),
stderr: String::new(),
elapsed_ms: started.elapsed().as_millis(),
summary: refusal.summary(),
});
}
};
process.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = bounded_process::run_with_timeout(&mut process, timeout)
.with_context(|| format!("failed to spawn verifier command: {command}"))?;
Expand Down
2 changes: 2 additions & 0 deletions src/sensitive_data/fixed_identifiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub const FIXED_IDENTIFIERS: &[&str] = &[
"anchor_stagnation_interlock",
"artifact_stagnation_feedback",
"bash_path_confinement_rejected",
"bash_sandbox_status",
"bash_sandbox_unavailable",
"blocked",
"blocked_by_dependency_setup",
"browser_interaction_probe",
Expand Down
13 changes: 13 additions & 0 deletions src/tools/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,19 @@ where

let mut process = Command::new("sh");
process.arg("-c").arg(&command).current_dir(root);
let mut process = match super::os_sandbox::wrap_command(process) {
Ok(process) => process,
Err(refusal) => {
return Ok(BashOutcome {
kind: BashOutcomeKind::Blocked,
status: None,
stdout: String::new(),
stderr: String::new(),
elapsed_ms: started.elapsed().as_millis(),
summary: refusal.summary(),
});
}
};
process.stdout(Stdio::piped()).stderr(Stdio::piped());
remove_shell_cdpath(&mut process);
let output = bounded_process::run_with_timeout_cancel_and_force(
Expand Down
1 change: 1 addition & 0 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod grep;
pub mod hidden_path;
pub mod mcp;
pub mod offline_policy;
pub mod os_sandbox;
pub mod path_guard;
pub(crate) mod placeholder_path;
pub mod read;
Expand Down
Loading
Loading