Add more tools to claude profile's allowedTools
This commit is contained in:
Notes:
Kristóf Tóth
2026-05-13 23:18:10 +02:00
# Persistent state in the sandbox
Two independent opt-in features. Implement them in order: `persistent-tmp` first (smaller scope), `persistent-overlays` later.
---
# Part 1: `persistent-tmp`
Replace the per-session `/tmp` and `/var/tmp` with stable host-side directories so writes survive across invocations.
## Behavior
When enabled, `/tmp` and `/var/tmp` inside the sandbox are bind-mounted from:
- `/tmp/agent-sandbox-<key>`
- `/var/tmp/agent-sandbox-<key>`
These directories are created on the host on demand (mode `0700`, owned by the invoking user). They inherit the host's cleanup policy and storage backend (typical: `/tmp` is tmpfs cleared on reboot, `/var/tmp` survives reboot per `systemd-tmpfiles`). Off by default.
If a directory already exists at one of those paths and is **not owned by the invoking euid**, abort with a clear error. Reason: `/tmp` is world-writable with the sticky bit, the key is derivable from non-secret inputs (profile name, cwd), so on a multi-user host another user could pre-create the directory and steal subsequent writes.
### TOCTOU mitigation
A naive `stat → bind` sequence is racy: between the ownership check and bwrap's mount, an attacker on the same host could swap the directory for a symlink. The implementation must:
1. `mkdir` the path (or accept `EEXIST`).
2. `open(path, O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC_OFF)` into a `File` kept alive for the lifetime of the bwrap process. The `O_NOFOLLOW` prevents following a symlink substituted at the path; the `O_DIRECTORY` rejects non-directories. Keep `FD_CLOEXEC` cleared so the fd survives across bwrap's `exec`.
3. `fstat` the fd. Reject unless `st_uid == geteuid()` and `S_ISDIR(st_mode)`.
4. Pass `/proc/<parent-pid>/fd/<N>` (where `<parent-pid>` is *our* pid, not bwrap's) as the bwrap bind source instead of the original path. The fd refers to a fixed inode that cannot be substituted, regardless of what happens on the filesystem.
5. Keep the `File` alive until `Command::status()` returns.
`--dry-run` prints the bwrap command and does not create the host directories.
## Session key
`<key>` = `blake3(profile_name + "\0" + mode + "\0" + canonical_cwd)` truncated to 12 hex characters.
- `profile_name`: the leaf profile selected by the user (`--profile foo` → `"foo"`, even if `foo` extends `bar`). Falls back to the literal `"<default>"` when no profile is selected.
- `mode`: `"blacklist"` or `"whitelist"`. Switching modes is a real security boundary, so different modes get different state.
- `canonical_cwd`: the resolved `chdir` after `config.rs::resolve_chdir`.
`std::hash::DefaultHasher` is unsuitable: its output is explicitly not stable across Rust versions, so on-disk identifiers would silently break on a toolchain upgrade. Add `blake3` to `Cargo.toml` (single crate, no transitive bloat).
Optional per-profile escape hatch: `persistent-key = "label"` in `Options` overrides the derived hash. Useful when the user wants to share state across profiles or move a project directory without orphaning state.
## Code changes
- **`Cargo.toml`**: add `blake3 = "1"`.
- **`src/session_key.rs`** (new): `pub fn derive(config: &SandboxConfig) -> String`. Uses `SandboxConfig::persistent_key` if set, otherwise hashes the three inputs.
- **`src/lib.rs`** (`SandboxConfig`): add `pub persistent_tmp: bool`, `pub persistent_key: Option<String>`, and `pub profile_name: Option<String>`. The profile name currently isn't propagated past `FileConfig::resolve_profile` — plumb it through from the `Args::profile` / `FileConfig::options.profile` resolution.
- **`src/cli.rs`** (`Args`): add `--persistent-tmp` / `--no-persistent-tmp` pair (matches existing `merge_flag_pair` convention), and `--persistent-key=LABEL` for one-off overrides.
- **`src/config.rs`** (`Options`): add `persistent_tmp: Option<bool>` and `persistent_key: Option<String>` (allowed at both global and per-profile levels — user's responsibility if they choose to share state across profiles).
- **`src/config.rs`** (`build`): merge `persistent_tmp` with `merge_flag` (default false); pass through `persistent_key` and the resolved leaf profile name into `SandboxConfig`.
- **`src/sandbox.rs`** (`add_blacklist_mode`, lines 109–110): when `persistent_tmp`, replace the two `--bind /tmp /tmp` / `--bind /var/tmp /var/tmp` calls with binds from the per-session host paths.
- **`src/sandbox.rs`** (`add_whitelist_mode`, lines 181–182): same substitution, replacing the two `--tmpfs` calls.
- **`src/sandbox.rs`** (new helper): `fn open_persistent_dir(path: &Path) -> Result<OwnedFd, SandboxError>` — `mkdir` if missing (mode `0700`), open with `O_DIRECTORY | O_NOFOLLOW`, clear `FD_CLOEXEC`, `fstat` the fd, reject unless `st_uid == geteuid()` and the inode is a directory. Returns the owned fd; the caller holds it for the duration of the bwrap process.
- **`src/sandbox.rs`** (`build_command` signature): return type changes to carry the live fds alongside `Command`, so callers (`main.rs`) keep them alive until the child exits.
- **`src/errors.rs`**: new variants `PersistentTmpDirCreation(PathBuf, io::Error)`, `PersistentTmpDirOpen(PathBuf, io::Error)`, `PersistentTmpDirNotOwned(PathBuf, u32 /* actual uid */)`, `PersistentTmpPathNotDirectory(PathBuf)`.
The host-side directory creation must happen *before* `bwrap` is invoked (bwrap won't create source paths for binds) and is skipped on `--dry-run`.
## Tests (write first, observe failing)
- `persistent_tmp_creates_host_dirs_on_first_use`
- `persistent_tmp_reuses_existing_dirs_on_second_use`
- `persistent_tmp_separate_keys_for_different_modes` — same profile + cwd in blacklist vs whitelist → different host dirs.
- `persistent_tmp_command_uses_session_paths` — golden-arg check on the bwrap command line.
- `persistent_tmp_off_by_default` — regression: existing arg sequence preserved when the flag is unset.
- `persistent_tmp_refuses_dir_owned_by_other_user` — pre-create the dir under a different uid (or simulate via stat mock), expect error.
- `persistent_tmp_refuses_when_path_is_file` — a regular file at the session path → error, not silent overwrite.
- `persistent_tmp_refuses_when_path_is_symlink` — `O_NOFOLLOW` rejects the open, the bind never happens.
- `persistent_tmp_bind_source_is_proc_fd` — golden-arg check confirms the bwrap source is `/proc/<pid>/fd/<n>`, not the literal `/tmp/agent-sandbox-<key>` path.
- `persistent_tmp_dry_run_does_not_create_dirs`.
- `persistent_key_override_replaces_hash`.
- `persistent_tmp_uses_leaf_profile_name` — `--profile foo` where `foo` extends `bar` hashes `"foo"`, not `"bar"` or `"foo:bar"`.
- `persistent_tmp_uses_default_when_no_profile`.
## Documentation
- `config-example.toml`: commented example of `persistent-tmp = true` and `persistent-key = "..."`.
- `README.md`: short "Persistent state" section explaining the host directory layout and the ownership check.
## Out of scope
- Cleanup of stale `/tmp/agent-sandbox-*` and `/var/tmp/agent-sandbox-*` directories. User's responsibility.
- Migration of existing on-disk state when the key derivation changes (renamed leaf profile, moved project). User re-creates manually or sets `persistent-key`.
---
# Part 2: `persistent-overlays` + `ephemeral-overlays`
Mount a writable overlayfs upper layer over chosen subtrees. With `persistent-overlays` the upperdir lives on the host and survives sandbox exit. With `ephemeral-overlays` writes go to bwrap's internal tmpfs and are discarded on exit (useful for "let writes succeed silently this run, but don't keep them"). Both off by default.
## Behavior
For each path `P` in `persistent-overlays`, bwrap receives:
```
--overlay-src <canonicalized P> --overlay <upper> <work> P
```
For each path `P` in `ephemeral-overlays`:
```
--overlay-src <canonicalized P> --tmp-overlay P
```
The host upperdir + workdir for persistent overlays live at:
```
$XDG_DATA_HOME/agent-sandbox/overlays/<key>/<P-as-nested-dirs>/{upper,work}
```
Fallback when `XDG_DATA_HOME` is unset or empty: `$HOME/.local/share/agent-sandbox/overlays/...`. Hard error if the resolved data root is not writable. The check is a probe-write (create-then-unlink a `.probe-<pid>` file), not `access(W_OK)` — `access` lies on read-only bind mounts and quota-exhausted filesystems, and we're about to write substantially anyway.
`<P-as-nested-dirs>` mirrors the absolute path as nested directories. `/home/superuser` → `home/superuser/`. Human-readable, collision-free, easy to inspect from the host.
`<key>` uses the same derivation as Part 1: `blake3(leaf_profile_name + "\0" + mode + "\0" + canonical_cwd)`, with the same `persistent-key` override honored.
## Bwrap argument ordering
Per `AGENTS.md`: later bwrap args override earlier ones for the same path. The placement inside `sandbox.rs::build_command` is:
```
base mode (add_blacklist_mode / add_whitelist_mode)
→ agents::agent_rw_paths
→ extra_ro
→ overlays (persistent + ephemeral, both kinds in one block)
→ chdir rw-bind
→ extra_rw
```
Rationale:
- After `extra_ro`: a user `--ro /host/etc:/etc` doesn't replace the overlay (which would leave writes failing on a read-only mount).
- Before `chdir` and `extra_rw`: the user's project directory and explicit `--rw` punch through. Writes to a `--rw`-bound project dir hit the real host filesystem instead of being silently buried in an overlay upperdir, so `git status` from outside the sandbox still sees them.
- Mode setup still comes first (so overlays replace whatever the base mode put there).
- `--remount-ro /` and seccomp still come last.
## Pre-flight checks
All hard errors that abort the invocation with a clear message before bwrap is launched:
1. **Overlayfs available**: `/proc/filesystems` contains a line ending in `overlay`. Avoids bwrap's raw EINVAL on kernels without overlayfs or in setuid bwrap builds where overlay flags are unavailable.
2. **Subtree exists**: each listed path resolves to an existing directory on the host.
3. **No submounts inside subtree**: parse `/proc/self/mountinfo`; reject if any mountpoint is a strict descendant of any listed subtree's canonical path. Overlayfs returns EINVAL with a submounted lowerdir, and the bare bwrap error is opaque. (This is the `/etc` landmine on hosts already running agent-sandbox in blacklist mode, since blacklist tmpfs-mounts `/etc/ssh`, `/etc/sudoers.d`, etc.)
4. **No listed path is an ancestor of another**: per the bwrap manpage, "no host directory given via `--overlay-src` or `--overlay` may be an ancestor of another, after resolving symlinks — undefined behavior otherwise". Compare canonical paths across both `persistent-overlays` and `ephemeral-overlays`.
5. **No path appears in both lists**: `persistent-overlays` and `ephemeral-overlays` are mutually exclusive intents (keep vs discard). Hard error if a path is in both.
## Concurrency
Overlayfs requires each mount's `workdir` to be exclusive to that mount. Two concurrent agent-sandbox processes with the same session key would point at the same `workdir/` and the second mount would fail with EBUSY (or silently corrupt state, depending on kernel).
Mitigation: take an exclusive `flock` on a lockfile inside each per-subtree overlay directory (e.g. `$XDG_DATA_HOME/agent-sandbox/overlays/<key>/<subtree>/.lock`) **before** the bwrap process starts and **hold it** for the duration of bwrap. Hard error on contention (`flock(LOCK_EX | LOCK_NB)` returning `EWOULDBLOCK`) with a message naming the conflicting lockfile.
Ephemeral overlays don't have this problem (workdir is on bwrap's internal tmpfs, unique per invocation) — no locking needed.
Persistent-tmp also doesn't need locking — bind-mounting the same `/tmp/agent-sandbox-<key>` from multiple sandboxes is fine, that's how host `/tmp` already works.
## Symlink handling
Match the existing `--rw` / `--ro` convention (`sandbox.rs::resolve_bind_source`, line 240): canonicalize the host source via `fs::canonicalize` for `--overlay-src`; the sandbox-side destination stays literal (the path the user listed, unresolved).
## No conflict checks against `mask` / `extra_rw` / `extra_ro`
Same stance as the rest of the codebase: later bwrap args win, user is responsible. If you list `/etc` in `persistent-overlays` while it's also blacklist-tmpfs'd, the overlay wins (and exposes the host's `/etc`); that's a configuration the user can already construct via `--rw /etc:/etc`. Document the precedence in `--help`; don't refuse the config.
## Code changes
- **`src/cli.rs`** (`Args`): add repeating flags `--persistent-overlay <PATH>` and `--ephemeral-overlay <PATH>` (singular, repeatable, matching existing `--rw` / `--ro` / `--mask` convention).
- **`src/config.rs`** (`Options`): add `persistent_overlays: Vec<PathBuf>` and `ephemeral_overlays: Vec<PathBuf>` (both `#[serde(default)]`).
- **`src/config.rs`** (`SandboxConfig`): add same fields. Merge with `merge_vecs` (globals → profile → CLI, append).
- **`src/config.rs`** (`Options::validate_paths`): canonicalize and require absolute paths for both new lists.
- **`src/overlays.rs`** (new module):
- `fn data_root() -> Result<PathBuf, SandboxError>` — `$XDG_DATA_HOME` with `$HOME/.local/share` fallback; verifies writability by probe-writing and unlinking a `.probe-<pid>` file.
- `fn nested_subtree(parent: &Path, subtree: &Path) -> PathBuf` — strips the leading `/` and joins, producing `<parent>/home/superuser` for subtree `/home/superuser`.
- `fn prepare_persistent_overlay(key: &str, subtree: &Path) -> Result<OverlayHandle, SandboxError>` — creates `upper/` and `work/` (mode `0700`); takes an exclusive non-blocking `flock` on `.lock` in the per-subtree directory; returns a handle owning the lockfile fd (dropping releases the lock). Skipped on `--dry-run`. The handle must outlive the bwrap child.
- `fn check_overlayfs_available() -> Result<(), SandboxError>` — reads `/proc/filesystems`.
- `fn check_no_submounts(subtree: &Path) -> Result<(), SandboxError>` — parses `/proc/self/mountinfo`.
- `fn check_no_nesting(paths: &[PathBuf]) -> Result<(), SandboxError>` — pairwise ancestor check on canonical paths across both overlay lists.
- `fn check_no_overlap(persistent: &[PathBuf], ephemeral: &[PathBuf]) -> Result<(), SandboxError>`.
- **`src/sandbox.rs`** (`build_command`): emit overlay args at the position described in the ordering diagram above, between `extra_ro` and `add_rw_bind_path(chdir)`. Run all pre-flight checks before emitting any `--overlay-src` / `--overlay` / `--tmp-overlay`.
- **`src/errors.rs`**: new variants `OverlayfsUnavailable(String /* reason */)`, `OverlaySubtreeMissing(PathBuf)`, `OverlaySubmount(PathBuf /* subtree */, PathBuf /* offending submount */)`, `OverlayNestedPaths(PathBuf, PathBuf)`, `OverlayPersistentEphemeralOverlap(PathBuf)`, `OverlayDataRootUnwritable(PathBuf, io::Error)`, `OverlayWorkdirCreation(PathBuf, io::Error)`, `OverlayLockContended(PathBuf /* lockfile path */)`.
No ownership check on overlay storage dirs: the data root lives under `$HOME`, only the user can create entries there.
## Tests (write first, observe failing)
- `persistent_overlay_creates_upper_and_work_dirs`.
- `persistent_overlay_emits_correct_bwrap_args` — golden-arg check, including ordering relative to `extra_ro` / chdir / `extra_rw`.
- `ephemeral_overlay_uses_tmp_overlay_flag`.
- `overlay_canonicalizes_source_keeps_literal_target` — symlinked subtree → `--overlay-src` is the canonical path, destination is the literal user-provided path.
- `overlay_rejects_subtree_with_submount` — mount a tmpfs inside a fixture dir, expect `OverlaySubmount`.
- `overlay_rejects_when_overlayfs_unavailable` — mock `/proc/filesystems`.
- `overlay_rejects_missing_subtree`.
- `overlay_rejects_nested_paths` — `/home` and `/home/me` both listed → error.
- `overlay_rejects_path_in_both_lists`.
- `overlay_rejects_unwritable_data_root`.
- `overlay_rejects_concurrent_invocation_with_same_key` — second invocation while a first is still running fails with `OverlayLockContended`.
- `overlay_separate_keys_for_different_modes`.
- `overlay_off_by_default`.
- `overlay_dry_run_does_not_create_dirs`.
- E2E (in `tests/e2e/`): persistent overlay actually persists across two sandbox invocations on `/usr/share` (known-good lowerdir per the bwrap probing done during design).
- E2E: ephemeral overlay does not persist across two invocations.
## Documentation
- `config-example.toml`: commented examples for both keys with a note about the submount caveat.
- `README.md`: extend the "Persistent state" section with the overlay layout, the subtree constraints (no submounts, no nesting), and the kernel/bwrap requirements (kernel ≥ 5.11, non-setuid bwrap).
## Out of scope
- Cleanup of stale `$XDG_DATA_HOME/agent-sandbox/overlays/...` directories. User's responsibility.
- Auto-detection of "good" subtrees to ship as defaults. The user populates the lists in `config-example.toml` if they want; we don't ship a built-in set.
# Sandbox Path Coverage — Research Notes
Findings from investigating which host paths the sandbox should expose
by default, prompted by `run0` failing inside a whitelist profile.
## TL;DR
- Default `--tmpfs /run/systemd/system` in **blacklist mode only** so
`sd_booted()` returns true and well-mannered systemd-aware tools stop
bailing out before reaching the bus (which blacklist already exposes).
Whitelist does not bind the bus, so adding the sentinel there only
turns graceful no-systemd fallback into noisier "connection refused"
failures with nothing to gain.
- Add a small set of read-only `/run` whitelist entries for blacklist
mode (active-login database).
- Add a curated set of read-only `/etc` paths for whitelist mode
(distro identification, locale, shell init, readline, man-db,
Kerberos).
- Use `--ro-bind-try` for everything distro-specific so the sandbox
does not fail on hosts where the file isn't present.
## Background: why this came up
`run0 ls` failed in the current whitelist sandbox with:
```
System has not been booted with systemd as init system (PID 1). Can't operate.
Failed to connect to system scope bus via local transport: Host is down
```
The first message is `sd_booted()` returning false. Per `sd_booted(3)`:
> Internally, this function checks whether the directory
> `/run/systemd/system/` exists.
In whitelist mode `/run` is replaced with a tmpfs (`sandbox.rs:185`)
and `/run/systemd/system` is never restored. In blacklist mode `/run`
is also tmpfs'd (`sandbox.rs:120`) with a small selective whitelist
(`:124–132`) that does not include this path.
`run0` is fundamentally a thin client over the systemd D-Bus API.
Fixing the `sd_booted` check alone does not make `run0` functional in
whitelist mode (the system bus is intentionally not bound there), but
it does eliminate the misleading "not booted with systemd" failure mode
in blacklist where the bus *is* bound and tools have something to talk
to.
## The `/run/systemd/system` sentinel
### Why tmpfs over `--ro-bind`
Both satisfy `sd_booted()`. The difference:
- `--ro-bind /run/systemd/system /run/systemd/system` — exposes the
host directory's contents (transient unit files generated by
`systemd-run`, generators, etc.) read-only. Information leak is minor
but unnecessary.
- `--tmpfs /run/systemd/system` — fresh empty directory inside the
sandbox. Sandbox can write to it but the writes don't reach host
systemd, so they're functionally inert. No host content visible.
Tmpfs is strictly preferable for this purpose.
### Blacklist only
Blacklist already binds `/run/dbus/system_bus_socket`
(`sandbox.rs:125`), so the bus is reachable today. Without the
sentinel, `sd_booted()`-gated tools (`systemctl`, `loginctl`,
`systemd-run`, `run0`, `pam_systemd`, libsystemd helpers like
`sd_pid_get_unit`) silently bail out. Tools that go directly through
the bus (`busctl`, `dbus-send`, `gdbus`, raw libdbus / sd-bus code)
work today regardless. The sentinel therefore only matters for the
polite/CLI subset.
This is security-by-obstacle for naive callers; the escape ceiling
(polkit-authenticated transient unit on host = root execution outside
the sandbox) is the same with or without it. Defaulting it on is
consistent with the fact that the bus is already exposed.
### Why not whitelist
Whitelist deliberately does not bind the system bus. Adding the
sentinel there means programs think systemd is up, try to connect, and
get connection refused — a worse failure mode than the current
graceful fallback (where `sd_booted()=false` causes libsystemd-using
tools to take their non-systemd path quietly). No tools start
*working* by adding the sentinel without also adding the bus, which
is its own much larger discussion.
## Path catalogue
Each entry below was verified against the indicated documentation
source on the host where this research was performed (Arch, systemd
260.1).
### Blacklist `/run` whitelist additions
Blacklist binds `/` read-only at the start, so `/etc` paths come
through automatically. The only place additions matter is the
selective `/run` whitelist after the `/run` tmpfs.
| Path | Purpose | Citation | Default |
| ---- | ------- | -------- | ------- |
| `/run/systemd/system` | sd_booted sentinel; lets `systemctl`, `loginctl`, `systemd-run`, `run0`, `pam_systemd`, libsystemd helpers proceed past the boot check | `sd_booted(3)` | **tmpfs, on** |
| `/run/utmp` | active-login database read by `who(1)`, `w(1)`, `last(1)`, libc `getutent(3)` | `utmp(5)`, `getutent(3)` | **ro-bind-try, on** — minor host info disclosure (login records); cheap usability win for interactive shells |
Deliberately not added by default:
- `/run/systemd/journal/{socket,stdout,dev-log}` — would let sandboxed
processes log into the host journal. Log-injection vector with no
meaningful upside for typical sandbox use.
- `/run/systemd/private`, `/run/systemd/notify`,
`/run/systemd/io.systemd.*` (Hostname, Resolve, Network, Userdb,
Machine), `/run/systemd/{inhibit,sessions,seats,users}/` — each
crosses the sandbox boundary into a host service.
### Whitelist `/etc` additions
These only matter in whitelist (in blacklist they come through with
`--ro-bind / /`). Use `--ro-bind-try` throughout — several are
distro-specific.
| Path | Purpose | Citation | Default |
| ---- | ------- | -------- | ------- |
| `/etc/os-release` | OS identification, read by package tools, language runtimes, build scripts | `os-release(5)` | **on** |
| `/etc/locale.conf` | system locale; libc and systemd | `locale.conf(5)` | **on** |
| `/etc/environment` | PAM-installed env vars | `pam_env(8)` (FILES section: "Default environment file") | **on** |
| `/etc/profile` + `/etc/profile.d` | bash login-shell init; sets PATH and distro defaults; `/etc/profile` sources `/etc/profile.d/*.sh` | `bash(1)` FILES section | **on** (but see security note) |
| `/etc/zsh` | zsh system-wide init: `zshenv`, `zprofile`, `zshrc`, `zlogin`, `zlogout` | `zsh(1)` STARTUP/SHUTDOWN FILES | **on** (but see security note) |
| `/etc/bash.bashrc` | bash non-login interactive init *as patched by Debian/Arch*; not in upstream `bash(1)` | distro convention | **on** (try) |
| `/etc/bashrc` | Red Hat/Fedora variant of the above | distro convention | **on** (try) |
| `/etc/inputrc` | readline default keymap | `readline(3)` | **on** |
| `/etc/shells` | list of valid login shells; `getusershell(3)`, `chsh(1)` | `getusershell(3)` | **on** |
| `/etc/man_db.conf` | `manpath` search configuration | `manpath(1)` | **on** |
| `/etc/pki` | Fedora/RHEL trust roots and shared NSS DBs (existing `/etc/ssl` + `/etc/ca-certificates` covers Debian/Arch) | distro convention | **on** (try) |
| `/etc/krb5.conf` | Kerberos config | `krb5.conf(5)` | **on** (try) |
| `/etc/timezone` | Debian-style timezone marker (existing `/etc/localtime` covers most) | Debian convention | **on** (try) |
| `/etc/lsb-release` | LSB / Debian distro info | LSB convention | **on** (try) |
Deliberately not added by default:
- `/etc/login.defs`, `/etc/securetty` — used by `login(1)`,
`passwd(1)`, PAM auth flows; the sandbox is not a login session
host.
- `/etc/security/` — PAM module configs; same reason.
- `/etc/dbus-1/` — only meaningful if running a dbus-daemon inside
the sandbox.
- `/etc/vconsole.conf` — VT settings, not relevant inside a sandbox.
- `/etc/mime.types`, `/etc/mailcap` — rarely consulted; add if a real
consumer surfaces.
## Security analysis: shell init files
Of all the candidates, only the shell init paths (`/etc/profile`,
`/etc/profile.d`, `/etc/bash.bashrc`, `/etc/bashrc`, `/etc/zsh/*`)
contain executable code. Worth being explicit about the threat model:
### Why it's mostly fine
- All bound read-only — sandboxed processes cannot modify them and
cannot use them to inject persistence onto the host.
- Code runs as the sandbox UID with `PR_SET_NO_NEW_PRIVS` set. No
privilege change.
- Authored by the host admin / distro packager, not attacker-controlled.
The same code runs on every normal login on this host.
### Real risks, ranked
1. **Inherited environment from host config (mild).** `/etc/profile`,
`/etc/profile.d/*.sh`, `/etc/bash.bashrc`, `/etc/zsh/zshenv`
execute and set env vars — including, occasionally, things like
proxy URLs or site-internal tokens that an admin baked in. If the
goal of an agent sandbox is a "clean room" environment, this widens
it. If the goal is "behave like a normal shell on this host", it's
the right thing.
2. **`/etc/profile.d` is open-ended.** Distros and admins drop
fragments freely (rvm, nvm, fzf, gnome-keyring, ssh-agent
launchers, etc.). Each runs on every login shell. They cannot
escalate, but they can start helper processes inside the sandbox,
source user-controlled files via `$HOME`, or fail noisily when
referencing host paths that aren't bound. Failure is more likely
than misuse.
3. **`/etc/zsh/zshenv` runs for every zsh invocation.** `zsh(1)`:
"Commands are first read from `/etc/zsh/zshenv`; this cannot be
overridden." Including non-interactive `zsh -c '…'` invocations.
Same threat model as #1, more invocations.
4. **`/etc/inputrc`** (negligible). Pure readline config, no code
execution. Worst case: weird key behavior.
### Not a risk
- No path to escape the sandbox via these. Anything they `exec`
inherits the same namespaces and bind mounts.
- `LD_PRELOAD` / `LD_LIBRARY_PATH` set by host config affects only
library lookup within the sandbox; cannot reach files that aren't
bound.
### If "clean room" matters
Split the additions into two groups behind a per-profile setting like
`inherit-shell-init = true`:
- Default-on regardless: `/etc/inputrc`, `/etc/shells`,
`/etc/os-release`, `/etc/locale.conf`, `/etc/environment` (passive
data — `pam_env` only matters for PAM-driven sessions; in-sandbox
shells don't run it).
- Default-on but gated for paranoid profiles: `/etc/profile`,
`/etc/profile.d`, `/etc/bash.bashrc`, `/etc/bashrc`, `/etc/zsh`.
## References
- `sd_booted(3)` — sentinel directory check
- `os-release(5)` — `/etc/os-release` and `/usr/lib/os-release`
- `locale.conf(5)` — system-wide locale
- `pam_env(8)` — `/etc/environment` semantics
- `bash(1)` — FILES section, login-shell init
- `zsh(1)` — STARTUP/SHUTDOWN FILES section
- `readline(3)` — `/etc/inputrc` fallback
- `manpath(1)` — `/etc/man_db.conf` search config
- `krb5.conf(5)` — Kerberos config
- `utmp(5)`, `getutent(3)` — active-login database
- `file-hierarchy(7)` — systemd file system layout
- `sandbox.rs:103–141` — current blacklist setup
- `sandbox.rs:143–189` — current whitelist setup
- `blacklist.rs:97–292` — `SENSITIVE_PATHS` list
# Host approval bridge
A way for the agent inside the sandbox to ask the host user to approve and run
individual commands, without dbus, polkit, setuid binaries, or other escalation
machinery.
## Idea
When `agent-sandbox` is about to `exec` bwrap:
1. It opens a `socketpair(AF_UNIX, SOCK_STREAM)` on the host.
2. It forks a small helper process that keeps one end and runs as the normal
user (no privilege escalation needed — the helper inherits the user's own
privileges, which is all the agent should ever need).
3. The other end is inherited by bwrap and exposed inside the sandbox via
`--setenv AGENT_ASK_FD=<n>`.
4. A tiny static shim binary (`/run/agent-sandbox/ask`, bind-mounted in and on
PATH) reads the fd from the env var and speaks the protocol.
The agent runs `ask <argv...>`, the host helper pops a confirmation dialog,
the user approves or denies, and the helper either runs the command as the
user and streams the result back, or returns a denial (with an optional
reason the agent can read).
If the approved command itself needs root, the helper just invokes
`sudo`/`pkexec` like a normal user would — that's the only place polkit
shows up, and only when the user opts into it.
## Why a socketpair, not a pipe
- **Bidirectional.** Agent needs stdout/stderr/exit code back. A pipe is
one-way; two pipes lose ordering across concurrent requests.
- **Full duplex over one fd.** Simpler to pass and to lifetime-manage.
- **`SCM_RIGHTS` available later.** If we ever want to forward a pty for
interactive prompts (sudo password, `pacman` y/n), we can pass an fd
across the socket without changing the wire format.
## Framing
Length-prefixed frames (4-byte big-endian length + JSON body) in both
directions. Required regardless of pipe vs socket: raw pipe writes are only
atomic up to `PIPE_BUF` (4096 bytes), and a long argv or environment block
will interleave catastrophically if two shim invocations run concurrently.
Request shape (sketch):
```json
{ "id": 17, "kind": "exec", "argv": ["pacman", "-S", "ripgrep"],
"cwd": "/home/user/proj", "env_allowlist": ["TERM", "LANG"] }
```
Response shape (sketch):
```json
{ "id": 17, "kind": "result", "exit": 0,
"stdout_chunks": [...], "stderr_chunks": [...] }
```
or:
```json
{ "id": 17, "kind": "denied", "reason": "no network installs during release freeze" }
```
The denial reason matters: it lets the agent adapt instead of retrying blindly.
## Lifetime
Don't use EOF on the channel as the "sandbox exited" signal. Each `ask`
invocation opens and closes its own fd, so the helper would see EOF on every
call. Instead, the helper `waitpid`s (or pidfd-polls) the bwrap child and
exits when bwrap does. That cleanly decouples "this request is done" from
"the sandbox is gone."
## Shim binary
- Static musl build, single file, bind-mounted at `/run/agent-sandbox/ask`.
- `/run/agent-sandbox` prepended to PATH inside the sandbox.
- Static avoids glibc mismatch if the sandbox image ever uses a different
libc than the host.
- The shim reads `AGENT_ASK_FD`, frames its argv/cwd/env into a request,
writes it to the fd, reads the framed response, replays
stdout/stderr/exit to its own caller.
## Approval UX
The agent fully controls the request payload, so the dialog is the entire
trust boundary. It needs to:
- Render argv unambiguously: strip/escape control chars, mark RTL overrides,
show cwd, show which env vars will be forwarded.
- Offer graded approvals: **once / for this session / for this exact argv
forever / deny / deny with reason**.
- Rate-limit and/or batch to avoid prompt fatigue — if the user habitually
clicks "yes" the whole scheme is worse than no sandbox at all.
Implementation-wise the dialog can be zenity/kdialog/yad for GUI sessions
or a plain TTY prompt when the host is headless; the protocol doesn't care.
## What this is not
- **Not polkit.** Polkit answers "is this user allowed to do this privileged
thing." This bridge answers "does the user, right now, want the agent to
do this *unprivileged* thing on their behalf." Different question.
- **Not a privileged daemon.** The helper runs as the user. Nothing on this
path is setuid.
- **Not a generic RPC.** Scope is "run a command on the host with consent."
Resist feature creep — every new request kind is new attack surface.
## Open questions
- Should the helper persist an allowlist across sandbox runs, or is
per-session state enough?
- How to display long-running command output back to the agent — stream
chunks, or buffer and return on exit?
- Do we want a `kind: "read_file"` / `kind: "write_file"` shortcut, or keep
the protocol to `exec` only and let the user write small shell wrappers?
+1
-1
@@ -50,7 +50,7 @@ profile = "claude"
|
||||
[profiles.claude]
|
||||
ro = ["~/.local/share/claude-code"]
|
||||
rw = ["~/.config/claude"]
|
||||
entrypoint = ["claude", "--allowedTools", "Bash(*)", "WebSearch", "WebFetch(*)", "mcp__brightdata__*"]
|
||||
entrypoint = ["claude", "--allowedTools", "Bash(*)", "Read", "Glob", "Grep", "WebSearch", "WebFetch(*)", "mcp__brightdata__*"]
|
||||
|
||||
[profiles.claude-yolo]
|
||||
profile = "claude"
|
||||
|
||||
Reference in New Issue
Block a user