Skip to content

feat(sandbox): bubblewrap-based local execution sandbox + path-grant flow#15

Merged
juacker merged 1 commit into
mainfrom
feat/linux-bash-sandbox
May 22, 2026
Merged

feat(sandbox): bubblewrap-based local execution sandbox + path-grant flow#15
juacker merged 1 commit into
mainfrom
feat/linux-bash-sandbox

Conversation

@juacker
Copy link
Copy Markdown
Owner

@juacker juacker commented May 22, 2026

Summary

Implements docs/LOCAL_EXECUTION_SANDBOX_DESIGN.md. On Linux, every CLAI-controlled local tool execution (bash_exec and friends) now runs inside a bubblewrap sandbox with per-agent filesystem grants, network policy, and session-bus policy. macOS / Windows continue running through an unsandboxed fallback labeled in the UI as "host shell — sandbox not yet available on this platform."

  • Sandbox is mandatory on Linux: if bwrap is missing or the kernel refuses unprivileged namespaces, bash_exec fails closed with RunNoticeKind::SandboxUnavailable rather than silently degrading.
  • New fs_request_grant tool gives agents a discoverable way to extend their filesystem grants at runtime, with a user-approval modal that parallels the existing command-approval flow.
  • $HOME shows up by default as a read-only entry in new agents' Additional Path Grants — visible, removable, no magic in sandbox_profile().
  • D-Bus session bus is bound by default so gh, git-credential-libsecret, and secret-tool can reach the host keyring.

What's in the sandbox

  • workspace_root: read-write bind, persistent.
  • system baseline: /usr, /etc (with /etc/ssh overlaid by a tmpfs to avoid the unprivileged-userns OpenSSH "Bad owner or permissions" failure mode), /bin, /sbin, /lib*, /sys all RO; /proc via --proc; /dev via --dev; /tmp via private tmpfs.
  • Runtime symlink resolution: /etc/resolv.conf and /etc/localtime are resolved at build time. Targets that point into /run (e.g. systemd-resolved stub) get private parent dirs + a RO bind of the resolved file. /run is never bind-mounted wholesale — keeps ssh-agent, dbus, docker, gpg-agent and other host sockets out.
  • Mount-ordering invariant: binds are emitted shallowest-first so a configured ancestor grant (\$HOME RO) never overlays the deeper workspace RW bind. Regression test covers this against real bwrap.
  • Env filter: small allowlist (PATH, LANG, LC_*, TZ, TERM) + denylist for socket / display vars. HOME points at the user's real \$HOME.

Path-grant approval flow

  • New fs_request_grant({path, access, reason}) tool. The agent calls it before attempting work that needs an out-of-grant path (e.g. gh pr create needing ~/.config/gh).
  • Frontend modal (InlinePathGrantCard) mirrors InlineApprovalCard. Validation: modal can narrow paths (descendant only) and downgrade access (RW→RO only) — never widen or upgrade.
  • "Always allow" grants persist to the agent's execution.filesystem.extra_paths in the workspace_agents DB row, tagged with GrantOrigin::Approval{reason, granted_at_unix_ms}. "Allow once" applies only to the current run via a session_grants Arc on ToolExecutionContext.
  • Grant chips in agent settings now show provenance (Manual / Approved , with the original reason on hover).

Per-agent execution config

New fields under execution.sandbox.* (camelCase on the wire):

  • network: \"enabled\" | \"disabled\" (default enabled). Maps to --share-net / --unshare-net.
  • sessionBus: \"allow\" | \"deny\" (default allow). When Allow, binds the host's D-Bus session bus socket (parsed from DBUS_SESSION_BUS_ADDRESS with fallback to \$XDG_RUNTIME_DIR/bus) and passes DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR through the env filter. Required for libsecret-based auth. Other socket env vars (SSH_AUTH_SOCK, DOCKER_HOST, DISPLAY, etc.) stay denied.

$HOME visibility

New agents' extra_paths defaults to one entry: the host's \$HOME as read-only. Frontend resolves the path at form-mount via Tauri's homeDir(). Visible and removable in agent settings like any other grant — remove it for a fully-isolated agent. Existing agents are not auto-migrated.

Bwrap failure classification → run notice

"Sandboxed shell is unavailable" errors (bwrap missing, kernel refused namespaces) surface as RunNoticeKind::SandboxUnavailable on the run, not just a generic tool failure. Detection requires exit non-zero + empty stdout + any stderr line starting with bwrap:, so an inner command that happens to print bwrap: ... to its own stderr isn't misclassified.

Packaging

tauri.conf.json declares bubblewrap (>= 0.4.0) as a hard dependency in the .deb / .rpm bundle configs. Flatpak still needs bwrap bundled inside the runtime — flagged in the design doc as a known gap (the design's §Flatpak section spells out the bundled-bwrap approach).

System prompt

  • "Filesystem boundary" section rewritten: read access matches the user's; writes confined to the workspace; fs_request_grant is the escape valve for everything else.
  • Per-agent capability listing now reports network status, session-bus status, and sandbox availability.
  • New "Git and SSH conventions" guard: never rewrite commit authorship to bypass GH007 (GitHub email-privacy block); escalate via workspace_requestUserInput instead.

Frontend changes

  • InlinePathGrantCard.jsx + .module.css: approval card mirroring InlineApprovalCard. Mounted alongside it in Workspace and Fleet pages.
  • AgentFormModal: removed Credentials Preset toggle (redundant with the \$HOME default); added Desktop integration (D-Bus) toggle; new agents pre-fill \$HOME RO into extraPathGrants via homeDir().
  • pathGrantsClient.js: thin invoke() wrapper for submit_path_grant_decision + list_pending_path_grant_requests.

Test plan

  • cargo test --lib — 367 passing / 1 ignored locally on Linux with bwrap 0.11.2 installed (the real-bwrap integration tests actually exercise namespaces).
  • cargo fmt --check clean.
  • cargo clippy -- -D warnings (lib-only) clean.
  • npm run lint — 0 errors, 47 pre-existing warnings.
  • npm run build — vite bundle clean.
  • Manual end-to-end: agent creates a workspace, requests grant for ~/.ssh, user approves "Always", agent pushes branch via SSH, opens PR via gh. Path-grant persistence verified in workspace_agents.execution.filesystem.extra_paths with provenance metadata.
  • CI run on push (relies on the GitHub workflow).
  • Verify install on a clean Ubuntu/Fedora machine that bubblewrap is pulled in by the .deb / .rpm.

Known follow-ups (not in this PR)

  • Flatpak build needs bwrap bundled inside the runtime (extraction-from-deb workflow currently doesn't include it).
  • db::canonicalize_legacy_tool_names: SQL path uses REPLACE (replaces-all) vs Rust JSON-blob path uses replacen (first-only); would diverge on multi-dot tool names. Captured in an agent's SOW for a dedicated bug-fix PR.
  • macOS / Windows backends: design doc has the shapes; implementation is a separate effort per platform.

…flow

Implements docs/LOCAL_EXECUTION_SANDBOX_DESIGN.md. Every CLAI-controlled
local tool execution on Linux now runs inside a bwrap sandbox with
per-agent filesystem grants, network policy, and session-bus policy.
macOS / Windows continue running through an unsandboxed fallback labeled
as "host shell — sandbox not yet available on this platform".

Sandbox layout

- workspace_root: read-write bind, persistent.
- system baseline: /usr, /etc (with /etc/ssh overlaid by a tmpfs to avoid
  the unprivileged-userns OpenSSH "Bad owner or permissions" failure mode),
  /bin, /sbin, /lib*, /sys all read-only; /proc via --proc; /dev via --dev;
  /tmp via private tmpfs.
- Runtime symlink resolution: /etc/resolv.conf and /etc/localtime are
  resolved at sandbox-build time. Targets that point into /run (e.g.
  systemd-resolved stub) get private parent dirs + a ro-bind of the
  resolved file. /run is never bind-mounted wholesale, so ssh-agent,
  dbus, docker, gpg-agent and other host sockets stay out.
- Mount-ordering invariant: binds emitted shallowest-first so a
  configured ancestor grant (e.g. $HOME RO) never overlays the deeper
  workspace RW bind.
- Env filter: small allowlist (PATH, LANG, LC_*, TZ, TERM) + explicit
  denylist for socket / display vars (SSH_AUTH_SOCK, DBUS_*, DOCKER_HOST,
  DISPLAY, WAYLAND_DISPLAY, XAUTHORITY, etc.). HOME points at the user's
  real $HOME so ~/.foo resolves the same way as in the user's shell.

Path-grant approval flow

- `fs_request_grant({path, access, reason})` tool. Approval modal
  (InlinePathGrantCard) parallels the existing command-approval flow.
  Validation: modal can narrow paths (descendant only) and downgrade
  access (RW→RO only) but never widen or upgrade.
- "Always allow" grants persist to the agent's
  `execution.filesystem.extra_paths` in workspace_agents, tagged with
  GrantOrigin::Approval{reason, granted_at_unix_ms}. "Allow once"
  applies to the current run via a session_grants Arc on
  ToolExecutionContext.
- New RunNoticeKind variants: PathGranted, PathGrantDenied,
  SandboxUnavailable.

Per-agent execution config

All under execution.sandbox.* (camelCase on the wire):

- network: enabled | disabled (default enabled). Maps to bwrap's
  --share-net / --unshare-net.
- session_bus: allow | deny (default allow). When Allow, binds the
  host's D-Bus session bus socket (parsed from DBUS_SESSION_BUS_ADDRESS
  with fallback to $XDG_RUNTIME_DIR/bus) and passes
  DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR through the env filter.
  Required for gh / git-credential-libsecret / secret-tool. Other
  socket env vars stay denied.

$HOME visibility

New agents' extra_paths defaults to one entry: the host's $HOME as
read-only. Frontend resolves the path at form-mount via Tauri's
homeDir(). Visible and removable in agent settings like any other
grant — remove it for a fully-isolated agent. Existing agents are not
auto-migrated.

Bwrap failure classification

"Sandboxed shell is unavailable" errors (bwrap missing, kernel refused
namespaces) surface as RunNoticeKind::SandboxUnavailable on the run.
Detection requires exit non-zero + empty stdout + any stderr line
starting with `bwrap:`, so an inner command that happens to print
`bwrap: ...` to its own stderr isn't misclassified as a setup failure.

Packaging

tauri.conf.json declares `bubblewrap (>= 0.4.0)` as a hard dependency
in the .deb / .rpm bundle configs so install pulls it transitively.
Flatpak still needs bwrap bundled inside the Flatpak runtime — flagged
in the design doc as a known gap.

System prompt updates

- "Filesystem boundary" section rewritten: read access matches the
  user's, writes confined to the workspace, fs_request_grant is the
  escape valve for everything else.
- Per-agent capability listing now reports network status, session-bus
  status, and sandbox availability.
- "Git and SSH conventions" guard: never rewrite commit authorship to
  bypass GH007; escalate via workspace_requestUserInput instead.

Frontend

- InlinePathGrantCard.jsx + .module.css: approval card mirroring
  InlineApprovalCard. Mounted alongside it in Workspace and Fleet
  pages.
- AgentFormModal: removed Credentials Preset toggle (redundant with the
  $HOME default); added Desktop integration (D-Bus) toggle; new agents
  pre-fill $HOME RO into extraPathGrants via homeDir(); grant chips
  show provenance (Manual / Approved <date> with reason on hover).
- pathGrantsClient.js: thin invoke() wrapper for
  submit_path_grant_decision + list_pending_path_grant_requests.

Tests

367 lib tests passing (337 baseline + 30 new) across:
- sandbox::profile (env filter + session-bus passthrough)
- sandbox::linux_bwrap (mount ordering, /etc/ssh overlay, bwrap failure
  classification, D-Bus address resolution, workspace-writable-under-RO-
  ancestor regression — exercised against real bwrap on Linux)
- commands::path_grants (registry round-trip, narrow / downgrade
  validation)
- assistant::tools::local (path-already-covered short-circuit,
  ~ expansion)

Env-mutating tests serialize on a module-local Mutex to avoid races
with the Rust test runner's thread pool.
@juacker juacker merged commit 2572e20 into main May 22, 2026
1 check 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