Skip to content

Sandbox hardening: dynamic mode, env sanitization, leak detection#259

Merged
jamiepine merged 9 commits intomainfrom
sandbox-hardening
Mar 1, 2026
Merged

Sandbox hardening: dynamic mode, env sanitization, leak detection#259
jamiepine merged 9 commits intomainfrom
sandbox-hardening

Conversation

@jamiepine
Copy link
Member

Summary

Implements all 5 phases from docs/design-docs/sandbox-hardening.md. Fixes the hot-reload bug where toggling sandbox mode via the UI silently reverts, adds environment sanitization to prevent secret leakage through env vars, and extends leak detection to cover OpenCode workers.

Changes

Phase 1 — Dynamic Sandbox Mode (Hot-Reload Fix)

  • RuntimeConfig.sandbox changed from ArcSwap<SandboxConfig> to Arc<ArcSwap<SandboxConfig>> so it can be shared with the Sandbox struct
  • reload_config() now stores sandbox config updates (removed the skip comment)
  • Sandbox struct reads mode dynamically from ArcSwap on every wrap() call — toggling via API takes effect immediately
  • Backend detection always runs regardless of initial mode, so switching Disabled→Enabled works without restart
  • Both Sandbox::new() call sites (main.rs, api/agents.rs) pass the shared Arc<ArcSwap<SandboxConfig>>

Phase 2 — Environment Sanitization

  • Added passthrough_env: Vec<String> to SandboxConfig for self-hosted users who set env vars in Docker/systemd
  • All sandbox modes now call --clearenv (bubblewrap) or env_clear() (sandbox-exec, passthrough)
  • Re-injects only: PATH (with tools/bin prepended), safe vars (HOME, USER, LANG, TERM, TMPDIR), and passthrough_env entries
  • Workers can no longer run printenv ANTHROPIC_API_KEY to extract system secrets
  • API config handler updated to read/write passthrough_env

Phase 3 — Durable Binary Instruction

  • Worker prompt now includes the persistent tools/bin directory location with explanation of ephemeral vs durable storage
  • New GET /agents/tools endpoint lists installed binaries (name, size, modified timestamp) for dashboard observability

Phase 4 — OpenCode Output Protection

  • Created src/secrets/scrub.rs with StreamScrubber (exact-match redaction with cross-chunk boundary handling) and shared scan_for_leaks() / match_leak_patterns() functions
  • SpacebotHook now delegates to shared functions — eliminated code duplication
  • OpenCode worker scans both Part::Text content and Part::Tool completed output for known secret patterns, terminating with error on detection
  • 6 unit tests for scrubber and leak detection

Phase 5 — send_file Hardening

  • Added symlink traversal rejection to send_file's workspace validation, matching the file tool's TOCTOU protection pattern

Files Changed

File Change
src/sandbox.rs Dynamic mode via Arc<ArcSwap<SandboxConfig>>, env sanitization in all wrapping modes, passthrough_env support
src/config.rs RuntimeConfig.sandbox type change, reload_config() stores sandbox, Arc::new() in constructor
src/main.rs Pass runtime_config.sandbox.clone() to Sandbox::new()
src/api/agents.rs Same Sandbox::new() signature update
src/api/config.rs passthrough_env in SandboxSection, SandboxUpdate, and TOML serializer
src/api/server.rs Add /agents/tools route
src/api/tools.rs New: GET /agents/tools directory listing endpoint
src/api.rs Add tools module
src/secrets.rs Add scrub module
src/secrets/scrub.rs New: StreamScrubber, scan_for_leaks(), match_leak_patterns(), 6 tests
src/hooks/spacebot.rs Delegate to shared secrets::scrub::scan_for_leaks()
src/opencode/worker.rs Leak detection on text and tool output
src/tools/shell.rs env_clear() for Windows path
src/tools/send_file.rs Symlink traversal rejection
prompts/en/worker.md.j2 Durable binary location instruction

Verification

  • cargo check — clean
  • cargo clippy --lib — no warnings
  • cargo test --lib — 280 tests pass
  • cargo fmt --all -- --check — clean

…ion, durable binaries

Implement all 5 phases from docs/design-docs/sandbox-hardening.md:

Phase 1 — Dynamic sandbox mode: Sandbox reads mode from shared
ArcSwap on every wrap() call. RuntimeConfig.sandbox is now
Arc<ArcSwap<SandboxConfig>> and reload_config() stores new values.
Toggling sandbox via API takes effect immediately without restart.

Phase 2 — Environment sanitization: All sandbox modes (bubblewrap,
sandbox-exec, passthrough) now clear inherited env vars and re-inject
only PATH + safe vars (HOME, USER, LANG, TERM, TMPDIR) + user-
configured passthrough_env. Prevents system secrets from leaking to
worker subprocesses.

Phase 3 — Durable binary instruction: Worker prompt now includes the
persistent tools/bin directory location. New GET /agents/tools endpoint
lists installed binaries for dashboard observability.

Phase 4 — OpenCode output protection: Extracted leak detection from
SpacebotHook into shared secrets::scrub module. OpenCode worker now
scans text and tool output for known secret patterns, terminating on
detection. Added StreamScrubber for exact-match redaction with cross-
chunk boundary handling.

Phase 5 — send_file hardening: Added symlink traversal rejection to
match the file tool's TOCTOU protection.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a durable tools/bin listing API; makes SandboxConfig dynamically reloadable via Arc<ArcSwap<SandboxConfig>> with passthrough_env and enforced environment sanitization; introduces a streaming secrets scrubber and leak detection; hardens path/tool handling and updates prompts, docs, and tests.

Changes

Cohort / File(s) Summary
Tools docs & API
prompts/en/worker.md.j2, src/api.rs, src/api/server.rs, src/api/tools.rs
Documents durable {{ instance_dir }}/tools/bin and adds GET /agents/tools -> tools::list_tools, returning binaries (name, size, modified).
Runtime & Sandbox config
src/config.rs, src/api/config.rs, src/main.rs, src/api/agents.rs, src/sandbox.rs
Wraps runtime sandbox in Arc<ArcSwap<SandboxConfig>>, adds passthrough_env to configs and API, switches agent initialization to use runtime_config.sandbox.clone(), makes Sandbox read dynamic config, enforce env sanitization, and use writable_paths at runtime.
Secrets scrubber & integration
src/secrets.rs, src/secrets/scrub.rs, src/hooks/spacebot.rs
Adds scrub module with pattern and encoded-segment leak detection and StreamScrubber; replaces local leak-scanning in hooks with calls to scrub::scan_for_leaks.
Worker leak detection & tool hardening
src/opencode/worker.rs, src/tools/send_file.rs, src/tools/shell.rs
Adds leak scanning to worker/text and tool outputs that aborts on detection; send_file rejects symlink path components to avoid TOCTOU; Windows shell invocations go through sandbox wrapper with sanitized env.
Prompts & prompt templates
src/agent/cortex.rs, src/prompts/engine.rs, prompts/en/channel.md.j2, prompts/en/worker.md.j2
Propagates sandbox state into worker/channel prompt rendering and inserts sandbox-aware template text for builtin workers.
Documentation updates
docs/content/docs/(configuration)/sandbox.mdx, docs/.../workers.mdx, docs/.../tools.mdx, docs/.../config.mdx, docs/.../permissions.mdx
Adds comprehensive Sandbox docs, documents passthrough_env and env sanitization, durable tools/bin, leak-detection behavior, and updates agent/config examples and design text.
Tests adjustments
tests/bulletin.rs, tests/context_dump.rs
Update tests to pass ArcSwap-wrapped sandbox config into Sandbox::new.
Misc small changes
src/tools/..., src/api/..., src/main.rs, src/opencode/worker.rs
Minor imports and wiring (mod tools), API route registration, and small ownership/initialization changes to use runtime-config sandbox clone.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main changes: dynamic sandbox mode switching, environment sanitization, and leak detection across the codebase.
Description check ✅ Passed The description is detailed and directly related to the changeset, explaining each of the 5 phases, file modifications, and verification steps.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sandbox-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/sandbox.rs (1)

182-199: Minor inconsistency in config passing to wrap methods.

wrap_bubblewrap and wrap_sandbox_exec receive &config as a parameter (lines 193, 196), but wrap_passthrough loads the config internally (line 337). This results in double config loading when sandbox is disabled (line 167 + line 337).

While ArcSwap::load() is cheap (atomic load), consider passing the already-loaded config to wrap_passthrough for consistency:

♻️ Optional: Pass config to wrap_passthrough for consistency
-    fn wrap_passthrough(
-        &self,
-        program: &str,
-        args: &[&str],
-        working_dir: &Path,
-        path_env: &str,
-    ) -> Command {
-        let config = self.config.load();
+    fn wrap_passthrough(
+        &self,
+        program: &str,
+        args: &[&str],
+        working_dir: &Path,
+        path_env: &str,
+        config: &SandboxConfig,
+    ) -> Command {

And update call sites:

-            return self.wrap_passthrough(program, args, working_dir, &path_env);
+            return self.wrap_passthrough(program, args, working_dir, &path_env, &config);
-            SandboxBackend::None => self.wrap_passthrough(program, args, working_dir, &path_env),
+            SandboxBackend::None => self.wrap_passthrough(program, args, working_dir, &path_env, &config),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 182 - 199, The current call sites pass &config
into wrap_bubblewrap and wrap_sandbox_exec but not wrap_passthrough, causing an
extra internal config load; update wrap_passthrough to accept the already-loaded
config reference (same type as the local config variable), change its signature
(wrap_passthrough(..., config: &ConfigType)) and remove its internal
ArcSwap::load() usage, and then update all call sites (including the
early-return branch and any other callers) to pass &config consistently; keep
wrap_bubblewrap and wrap_sandbox_exec calls unchanged except for using the same
config type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/tools.rs`:
- Around line 33-51: Replace blocking std::fs calls in the async handler
list_tools with tokio async equivalents: call
tokio::fs::read_dir(&tools_bin).await (instead of std::fs::read_dir) and iterate
with the async directory stream, and call entry.metadata().await (instead of
entry.metadata()). On errors, map_err to StatusCode::INTERNAL_SERVER_ERROR when
read_dir fails and log metadata errors instead of silently continuing (use
tracing::warn or tracing::debug with the entry path and the error) so failures
are observable; update references to tools_bin, list_tools, and entry.metadata()
accordingly.

In `@src/secrets/scrub.rs`:
- Around line 158-161: The current split uses byte index `split_at` on
`scrubbed`, which can panic on multi-byte UTF-8; change the logic in the `if
self.max_secret_len > 0 && scrubbed.len() > self.max_secret_len` block to find a
safe character boundary at or before `split_at` (e.g., check
`scrubbed.is_char_boundary(split_at)` and if false walk back using
`char_indices()` to the last valid boundary), then set `self.tail` to the
substring after that boundary and return the substring before that boundary;
reference `self.max_secret_len`, `scrubbed`, `split_at`, and `self.tail` when
making the change.

In `@src/tools/send_file.rs`:
- Line 69: The match arm that currently silently discards errors from the
symlink_metadata call (the `_ => {}` arm) must not ignore I/O errors; modify the
code around the symlink_metadata call so that errors are handled or propagated
instead of dropped — for example, replace the match with use of the ? operator
or change the `_ => {}` arm to capture the error (e) and return Err(e) or log
the error (e.g., tracing::error!) and then return a suitable Err; locate the
symlink_metadata invocation and the `_ => {}` match arm in send_file.rs and
implement one of these error-handling behaviors (propagate with ?, return
Err(e), or log+return) to satisfy the guideline.
- Around line 54-71: The symlink check currently iterates components of the
already-resolved canonical path (`canonical` / `relative`) so it never sees
user-supplied symlinks; change the loop to iterate the original `path`'s
components instead (e.g. compute a `relative_original` from
`path.strip_prefix(&workspace_canonical)` or fallback to `&path`) and push those
components onto `walk` (still starting from `workspace_canonical`) and call
`walk.symlink_metadata()` to detect symlinks and return the same SendFileError;
keep the existing error behavior but replace `relative.components()` with the
original path components to reject symlink traversal prior to reading the file.

In `@src/tools/shell.rs`:
- Around line 128-136: After env_clear() in the Windows branch where you build
Command (variable c in the block using Command::new("cmd") /C &args.command and
c.current_dir(&working_dir)), re-inject the same "safe" environment variables as
Unix: check std::env::var for PATH, HOME, USER, LANG, TERM, TMPDIR and call
c.env(key, value) for each that exists; also iterate the module's
passthrough_env entries and re-inject any of those vars the caller requested
(using c.env(name, value) when std::env::var(name) is Ok). This mirrors
sandbox.wrap() behavior and makes Windows sanitization consistent with Unix.

---

Nitpick comments:
In `@src/sandbox.rs`:
- Around line 182-199: The current call sites pass &config into wrap_bubblewrap
and wrap_sandbox_exec but not wrap_passthrough, causing an extra internal config
load; update wrap_passthrough to accept the already-loaded config reference
(same type as the local config variable), change its signature
(wrap_passthrough(..., config: &ConfigType)) and remove its internal
ArcSwap::load() usage, and then update all call sites (including the
early-return branch and any other callers) to pass &config consistently; keep
wrap_bubblewrap and wrap_sandbox_exec calls unchanged except for using the same
config type.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6bd61b3 and 3d84a1b.

📒 Files selected for processing (15)
  • prompts/en/worker.md.j2
  • src/api.rs
  • src/api/agents.rs
  • src/api/config.rs
  • src/api/server.rs
  • src/api/tools.rs
  • src/config.rs
  • src/hooks/spacebot.rs
  • src/main.rs
  • src/opencode/worker.rs
  • src/sandbox.rs
  • src/secrets.rs
  • src/secrets/scrub.rs
  • src/tools/send_file.rs
  • src/tools/shell.rs

Add standalone sandbox.mdx covering containment, env sanitization, leak
detection, durable binaries, and dynamic mode switching. Update config,
workers, tools, and permissions docs to reference the new page, add
passthrough_env to the config table, and reflect env sanitization.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
docs/content/docs/(features)/tools.mdx (1)

188-192: Minor redundancy between "Status reporting" and "Fire-and-forget sends" sections.

Line 188 already states that status updates use try_send (non-blocking). The "Fire-and-forget sends" section at lines 190-192 repeats this information. Consider consolidating into a single description under "Status reporting" or removing the separate section.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(features)/tools.mdx around lines 188 - 192, Remove the
redundant "Fire-and-forget sends" subsection and consolidate its content into
the existing "Status reporting" paragraph: mention that workers call set_status
and that status updates use try_send (non-blocking) so updates are dropped when
the channel is full rather than blocking the worker, and delete the duplicate
lines under "Fire-and-forget sends" to avoid repetition.
docs/content/docs/(configuration)/config.mdx (1)

558-559: Add a least-privilege warning for passthrough_env.

The current text may encourage forwarding broad credentials. Add a short caution to forward only strictly required vars.

✏️ Suggested wording tweak
-Regardless of mode, all worker subprocesses start with a clean environment. Only `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), and any `passthrough_env` entries are injected. Use `passthrough_env` to forward credentials set in Docker Compose or systemd to workers.
+Regardless of mode, all worker subprocesses start with a clean environment. Only `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), and any `passthrough_env` entries are injected. Use `passthrough_env` with least privilege: only forward variables required by worker tools (for example, credentials set in Docker Compose or systemd).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(configuration)/config.mdx around lines 558 - 559, Add a
short least-privilege caution to the paragraph describing passthrough_env:
mention that forwarding environment variables via passthrough_env should be
limited to only the strictly required variables (e.g., specific credential or
token names) to minimize exposure, and recommend avoiding broad or sensitive
creds; reference the existing terms PATH, safe variables (HOME, USER, LANG,
TERM, TMPDIR) and passthrough_env so reviewers can place the warning inline with
that text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/content/docs/`(configuration)/permissions.mdx:
- Line 12: Update the unconditional sandbox guarantees in the Permissions doc by
qualifying them to reflect mode/platform dependence: find the sentences
containing "OS-level containment is handled by the [Sandbox]..." and the other
instance that claims unconditional sandbox guarantees, and change wording to
something like "When sandboxing is enabled, the sandbox enforces..." and add a
note such as "capabilities vary by backend/platform" (or similar phrasing) so
both occurrences clearly state sandbox behavior is conditional and
backend-dependent.

In `@docs/content/docs/`(features)/workers.mdx:
- Around line 156-160: Update the docs paragraph to avoid absolutes about secret
visibility and to document memory-path write guards: change the sentence that
claims workers "never" see parent environment to state that workers only inherit
PATH, safe vars (HOME, USER, LANG, TERM, TMPDIR) and any explicitly configured
passthrough_env entries (so secrets forwarded via passthrough_env are visible if
configured), and add a sentence that the file tool rejects writes to identity
files (SOUL.md, IDENTITY.md, USER.md) and memory-paths with an error instructing
the LLM to use the proper tool; also mention that the exec tool blocks dangerous
env vars (LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.) as before and link to the
Sandbox page for full containment and leak-detection details (update the
paragraph containing "Worker subprocesses also start with a clean environment"
and the following sentences accordingly).

---

Nitpick comments:
In `@docs/content/docs/`(configuration)/config.mdx:
- Around line 558-559: Add a short least-privilege caution to the paragraph
describing passthrough_env: mention that forwarding environment variables via
passthrough_env should be limited to only the strictly required variables (e.g.,
specific credential or token names) to minimize exposure, and recommend avoiding
broad or sensitive creds; reference the existing terms PATH, safe variables
(HOME, USER, LANG, TERM, TMPDIR) and passthrough_env so reviewers can place the
warning inline with that text.

In `@docs/content/docs/`(features)/tools.mdx:
- Around line 188-192: Remove the redundant "Fire-and-forget sends" subsection
and consolidate its content into the existing "Status reporting" paragraph:
mention that workers call set_status and that status updates use try_send
(non-blocking) so updates are dropped when the channel is full rather than
blocking the worker, and delete the duplicate lines under "Fire-and-forget
sends" to avoid repetition.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d84a1b and 26ac9f5.

⛔ Files ignored due to path filters (1)
  • docs/content/docs/(configuration)/meta.json is excluded by !**/*.json
📒 Files selected for processing (5)
  • docs/content/docs/(configuration)/config.mdx
  • docs/content/docs/(configuration)/permissions.mdx
  • docs/content/docs/(configuration)/sandbox.mdx
  • docs/content/docs/(features)/tools.mdx
  • docs/content/docs/(features)/workers.mdx
✅ Files skipped from review due to trivial changes (1)
  • docs/content/docs/(configuration)/sandbox.mdx

Tighten async filesystem handling, UTF-8-safe scrubbing, symlink validation, and Windows env sanitization parity while updating docs to reflect mode-dependent sandbox guarantees.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
docs/content/docs/(features)/workers.mdx (1)

148-155: Qualify the kernel-enforcement claim to avoid over-promising.

Line 154 reads unconditional, but sandboxing is mode/backend dependent. A small qualifier keeps this precise.

✏️ Suggested wording
-Worker shell and exec commands run inside an OS-level sandbox (bubblewrap on Linux, sandbox-exec on macOS). The entire host filesystem is mounted read-only except:
+When sandbox mode is enabled and a backend is available, worker shell and exec commands run inside an OS-level sandbox (bubblewrap on Linux, sandbox-exec on macOS). The entire host filesystem is mounted read-only except:
@@
-The agent's data directory (databases, config files) is explicitly re-mounted read-only. This is enforced at the kernel level -- no amount of command creativity can bypass it.
+In that mode, the agent's data directory (databases, config files) is explicitly re-mounted read-only and enforced at the kernel level.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(features)/workers.mdx around lines 148 - 155, The
statement that the agent's data directory is "enforced at the kernel level"
overstates guarantees; update the paragraph containing "Worker shell and exec
commands..." and the sentence "The agent's data directory (databases, config
files) is explicitly re-mounted read-only. This is enforced at the kernel level
-- no amount of command creativity can bypass it." to add a qualification that
enforcement depends on the sandbox backend and kernel support (e.g., bubblewrap
on Linux, sandbox-exec on macOS) and may vary across platforms and
configurations, e.g., replace the absolute claim with language like "where
supported by the sandbox backend and kernel" or "depending on the sandbox
backend and underlying OS/kernel guarantees."
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sandbox.rs`:
- Around line 275-280: The passthrough loop re-applies user env entries from
config.passthrough_env after the hardened defaults and can overwrite
sanitized/reserved vars (e.g., PATH), weakening isolation; update each
passthrough application (the loops that iterate config.passthrough_env which
call cmd.arg("--setenv").arg(var_name).arg(value)) to skip any variable in the
reserved/sanitized set (e.g., PATH, LD_LIBRARY_PATH, PYTHONPATH, any keys you
populate earlier) or if that variable was already set by the hardened/default
logic — i.e., check var_name against a small ReservedSanitizedVars set (or
against the map of already-set env keys) and only apply the passthrough entry
when it is not in that set; make the same change for the other two identical
blocks that handle config.passthrough_env so user-provided entries cannot
override the hardened defaults.

In `@src/secrets/scrub.rs`:
- Around line 121-126: The current replacement logic can leak suffixes when a
secret is a prefix of another because replacements run in input order; in pub fn
new(secrets: Vec<(String, String)>) and the other replacement sites (around the
code using the secrets vector at the regions corresponding to lines 150-153 and
184-187) sort the secrets by descending value length before storing/iterating
them so longer secrets are replaced first; update SecretScrub::new (or the
constructor that receives secrets) to sort the incoming Vec<(String,String)> by
value.len() in descending order (or build an internal ordered list) and ensure
all places that iterate over the secrets use that sorted list to perform
replacements.

---

Nitpick comments:
In `@docs/content/docs/`(features)/workers.mdx:
- Around line 148-155: The statement that the agent's data directory is
"enforced at the kernel level" overstates guarantees; update the paragraph
containing "Worker shell and exec commands..." and the sentence "The agent's
data directory (databases, config files) is explicitly re-mounted read-only.
This is enforced at the kernel level -- no amount of command creativity can
bypass it." to add a qualification that enforcement depends on the sandbox
backend and kernel support (e.g., bubblewrap on Linux, sandbox-exec on macOS)
and may vary across platforms and configurations, e.g., replace the absolute
claim with language like "where supported by the sandbox backend and kernel" or
"depending on the sandbox backend and underlying OS/kernel guarantees."

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8fe1b89 and 1c100e6.

📒 Files selected for processing (10)
  • docs/content/docs/(configuration)/config.mdx
  • docs/content/docs/(configuration)/permissions.mdx
  • docs/content/docs/(features)/tools.mdx
  • docs/content/docs/(features)/workers.mdx
  • src/api/tools.rs
  • src/opencode/worker.rs
  • src/sandbox.rs
  • src/secrets/scrub.rs
  • src/tools/send_file.rs
  • src/tools/shell.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/tools/shell.rs
  • src/tools/send_file.rs
  • src/api/tools.rs
  • docs/content/docs/(features)/tools.mdx
  • src/opencode/worker.rs

…ntainment visibility

Replace the full-filesystem-read-only sandbox model with a minimal read-only
allowlist (system runtime roots only). The agent data directory is now masked
with an empty tmpfs (bubblewrap) or denied for both reads and writes (macOS)
instead of being re-mounted read-only. HOME and TMPDIR are set deterministically
to workspace and /tmp respectively. Worker and channel prompts now receive live
sandbox state so the LLM knows exactly which paths are readable and writable.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
src/sandbox.rs (1)

420-423: ⚠️ Potential issue | 🟠 Major

Block reserved env keys in passthrough_env to preserve sanitization guarantees.

passthrough_env is applied after hardened vars, so reserved keys (PATH, HOME, TMPDIR, and safe vars) can be overridden and weaken containment behavior.

Suggested fix
 const SAFE_ENV_VARS: &[&str] = &["USER", "LANG", "TERM"];
+
+fn is_reserved_env_var(variable_name: &str) -> bool {
+    matches!(variable_name, "PATH" | "HOME" | "TMPDIR")
+        || SAFE_ENV_VARS.contains(&variable_name)
+}
@@
-        for var_name in &config.passthrough_env {
-            if let Ok(value) = std::env::var(var_name) {
-                cmd.arg("--setenv").arg(var_name).arg(value);
+        for variable_name in &config.passthrough_env {
+            let variable_name = variable_name.as_str();
+            if is_reserved_env_var(variable_name) {
+                tracing::debug!(%variable_name, "skipping reserved passthrough_env variable");
+                continue;
+            }
+            if let Ok(value) = std::env::var(variable_name) {
+                cmd.arg("--setenv").arg(variable_name).arg(value);
             }
         }
@@
-        for var_name in &config.passthrough_env {
-            if let Ok(value) = std::env::var(var_name) {
-                cmd.env(var_name, value);
+        for variable_name in &config.passthrough_env {
+            let variable_name = variable_name.as_str();
+            if is_reserved_env_var(variable_name) {
+                tracing::debug!(%variable_name, "skipping reserved passthrough_env variable");
+                continue;
+            }
+            if let Ok(value) = std::env::var(variable_name) {
+                cmd.env(variable_name, value);
             }
         }
@@
-        for var_name in &config.passthrough_env {
-            if let Ok(value) = std::env::var(var_name) {
-                cmd.env(var_name, value);
+        for variable_name in &config.passthrough_env {
+            let variable_name = variable_name.as_str();
+            if is_reserved_env_var(variable_name) {
+                tracing::debug!(%variable_name, "skipping reserved passthrough_env variable");
+                continue;
+            }
+            if let Ok(value) = std::env::var(variable_name) {
+                cmd.env(variable_name, value);
             }
         }

Also applies to: 465-468, 503-506

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 420 - 423, The loop that applies
config.passthrough_env directly to the command (the code iterating over
config.passthrough_env and calling cmd.arg("--setenv").arg(var_name).arg(value))
must skip reserved/sanitized environment keys so hardened vars like PATH, HOME,
TMPDIR and other “safe” keys cannot be overridden; change the logic to filter
out these reserved keys (e.g., maintain a const RESERVED_ENV = {
"PATH","HOME","TMPDIR", ...safe_keys } and only call cmd.arg("--setenv") for
var_name not in that set), and apply the same filtering in the other identical
passthrough_env sites noted (the other loops at the indicated locations).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/content/docs/`(configuration)/sandbox.mdx:
- Line 8: Fix the minor typo in the sentence containing "OS-level containment
for builtin worker subprocesses (`shell` and `exec`)." — change "builtin" to the
hyphenated form "built-in" so the text reads "OS-level containment for built-in
worker subprocesses (`shell` and `exec`)."
- Around line 166-167: Replace the sentence "If a leak is detected, the process
is terminated immediately with an error. The leaked value is logged for
debugging but not returned to the LLM." with wording that explicitly forbids
logging raw secret values and describes safe handling instead (e.g., terminate
the process on leak, record only a redacted value or a non-reversible
fingerprint/hash and minimal contextual metadata for debugging, and never store
or display the raw leaked secret), ensuring the doc now states use of
redaction/fingerprinting rather than logging raw leaked values.

In `@src/prompts/engine.rs`:
- Around line 245-253: Several public prompt API signatures changed and callers
must be updated: for each call to render_worker_prompt add the four new
arguments (sandbox_enabled: bool, sandbox_containment_active: bool,
sandbox_read_allowlist: Vec<String>, sandbox_write_allowlist: Vec<String>) in
the same order as the signature; for each call to
render_channel_prompt_with_links add the new sandbox_enabled: bool parameter at
the correct position. Locate usages of render_worker_prompt and
render_channel_prompt_with_links in the codebase and tests, update their
call-sites to pass the appropriate sandbox flags and allowlist vectors (or empty
Vec::new() / false defaults where appropriate), and ensure the types match the
signature so the project compiles.

---

Duplicate comments:
In `@src/sandbox.rs`:
- Around line 420-423: The loop that applies config.passthrough_env directly to
the command (the code iterating over config.passthrough_env and calling
cmd.arg("--setenv").arg(var_name).arg(value)) must skip reserved/sanitized
environment keys so hardened vars like PATH, HOME, TMPDIR and other “safe” keys
cannot be overridden; change the logic to filter out these reserved keys (e.g.,
maintain a const RESERVED_ENV = { "PATH","HOME","TMPDIR", ...safe_keys } and
only call cmd.arg("--setenv") for var_name not in that set), and apply the same
filtering in the other identical passthrough_env sites noted (the other loops at
the indicated locations).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1c100e6 and 0e4e287.

📒 Files selected for processing (6)
  • docs/content/docs/(configuration)/sandbox.mdx
  • prompts/en/channel.md.j2
  • prompts/en/worker.md.j2
  • src/agent/cortex.rs
  • src/prompts/engine.rs
  • src/sandbox.rs
✅ Files skipped from review due to trivial changes (1)
  • prompts/en/channel.md.j2
🚧 Files skipped from review as they are similar to previous changes (1)
  • prompts/en/worker.md.j2

jamiepine and others added 4 commits February 28, 2026 18:53
passthrough_env entries were applied after the hardened defaults (PATH,
HOME, TMPDIR, SAFE_ENV_VARS), allowing user config to silently override
them — weakening env sanitization and potentially dropping tools/bin
from PATH precedence.

Add is_reserved_env_var() guard to all three passthrough loops
(bubblewrap, sandbox-exec, passthrough) so reserved variables are
skipped with a debug log.
- tests/context_dump.rs: pass new sandbox args to render_worker_prompt
  (2 sites) and sandbox_enabled bool to render_channel_prompt so the
  test compiles again after the prompt API signature changes.

- interface: rename 'Writable Paths' to 'Extra Allowed Paths' and
  update description since the sandbox now controls read access too,
  not just writes.

- docs/sandbox.mdx: replace claim that leaked values are logged with
  language specifying only redacted fingerprints and metadata are
  recorded — raw secrets are never logged or returned.
Add sandbox_enabled to both render_channel_prompt_with_links calls and
the four sandbox args (enabled, containment_active, read/write
allowlists) to the render_worker_prompt call in spawn_worker_from_state.
These were missed when the prompt API signatures were updated.
@jamiepine jamiepine merged commit ba4d2a4 into main Mar 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant