feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531)

* feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths

Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds
accept entries of the form { src, dst } in addition to the existing string
form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp
gets wiped between Bash tool calls because of --die-with-parent, and the
old string-only API (--bind p p) had no way to map a host directory under
$HOME onto /tmp inside the sandbox without exposing the host /tmp itself.

Validation:
- src: same checks as the string form (absolute, not in
  FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW)
- dst: absolute and non-forbidden only — the $HOME constraint is
  intentionally skipped since the whole point of the form is to map
  outside $HOME (e.g. /tmp)
- malformed objects are filtered out with a warning, matching the
  existing string-validation behavior

Doctor (--doctor) renders the object form as "src -> dst" in both the
Python and Node parser branches.

100% backwards compatible: the string form is preserved unchanged. The 36
existing tests pass; 13 new tests cover accept/reject paths, mixed
string+object configs, the persistent-/tmp recipe end-to-end, and the
doctor rendering (58/58 total).

Closes #530

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>

* docs(configuration): document {src, dst} mount form

Refs #530

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>

* chore(bwrap): address PR #531 review feedback

- doctor: warn when an additional mount's dst lands on a default RO
  mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap
  honors the later mount, so the user's bind silently replaces the
  default — a config footgun, not an escape, but worth surfacing
  (RayCharlizard issue 1)
- docs(configuration): note the shadowing implication under
  "Distinct host/sandbox paths" (RayCharlizard issue 2)
- test(bwrap-config): pin the reject contract for dst under a
  forbidden path (e.g. /proc/self), beyond the existing exact-match
  case (RayCharlizard issue 3)
- bwrap-config: harmonize the rejected-mount warning text — the
  string-form path now reads "rejected mount" like the object-form
  variants (RayCharlizard issue 4)

Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow
positive/negative).

Refs #530

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Charles Bonnissent
2026-04-30 16:34:20 +02:00
committed by GitHub
parent 17db18393e
commit 8ac73e6ba9
4 changed files with 487 additions and 22 deletions

View File

@@ -69,15 +69,51 @@ the sandbox mount points via `~/.config/Claude/claude_desktop_linux_config.json`
| Key | Type | Description |
|-----|------|-------------|
| `additionalROBinds` | `string[]` | Extra paths mounted read-only inside the sandbox. Accepts any absolute path except `/`, `/proc`, `/dev`, `/sys`. |
| `additionalBinds` | `string[]` | Extra paths mounted read-write inside the sandbox. **Restricted to paths under `$HOME`** for security. |
| `additionalROBinds` | `(string \| {src, dst})[]` | Extra paths mounted read-only inside the sandbox. Accepts any absolute path except `/`, `/proc`, `/dev`, `/sys`. |
| `additionalBinds` | `(string \| {src, dst})[]` | Extra paths mounted read-write inside the sandbox. **`src` is restricted to paths under `$HOME`** for security; `dst` is unconstrained. |
| `disabledDefaultBinds` | `string[]` | Default mounts to skip. Cannot disable critical mounts (`/`, `/dev`, `/proc`). Use with caution: disabling `/usr` or `/etc` may break tools inside the sandbox. |
### Distinct host/sandbox paths (`{src, dst}` form)
By default a string entry like `"/opt/tools"` mounts the host path at the
*same* path inside the sandbox. To map a host directory to a different path
inside the sandbox, use the object form `{ "src": "...", "dst": "..." }`.
The most common use case is making `/tmp` persistent across Bash tool calls.
Each Bash invocation spawns a fresh `bwrap` with `--tmpfs /tmp` and
`--die-with-parent`, so the default `/tmp` is wiped between calls. Mapping a
host cache directory onto `/tmp` keeps state across calls without exposing the
host's real `/tmp`:
```json
{
"preferences": {
"coworkBwrapMounts": {
"additionalBinds": [
{ "src": "/home/user/.cache/claude-tmp", "dst": "/tmp" }
],
"disabledDefaultBinds": ["/tmp"]
}
}
}
```
`disabledDefaultBinds: ["/tmp"]` is required to remove the default
`--tmpfs /tmp` so the bind takes effect.
The string and object forms can be mixed freely in the same array.
> **Caution:** Mapping `dst` onto a default RO mount (`/usr`, `/etc`, `/bin`,
> `/sbin`, `/lib`, `/lib64`) silently replaces it inside the sandbox; you
> almost never want this, and `--doctor` will warn if you do.
### Security notes
- Paths `/`, `/proc`, `/dev`, `/sys` (and their subpaths) are always rejected
- Read-write mounts (`additionalBinds`) are restricted to paths under your home
directory
for both `src` and `dst`
- For read-write mounts (`additionalBinds`), `src` must be under your home
directory. `dst` has no `$HOME` constraint — that is the entire purpose of
the object form (e.g. mapping onto `/tmp`)
- The core sandbox structure (`--tmpfs /`, `--unshare-pid`, `--die-with-parent`,
`--new-session`) cannot be modified
- Mount order is enforced: user mounts cannot override security-critical

View File

@@ -763,21 +763,41 @@ function loadBwrapMountsConfig(configPath, logFn) {
return empty;
}
function filterPaths(arr, readWrite) {
function filterMounts(arr, readWrite) {
if (!Array.isArray(arr)) return [];
return arr.filter(p => {
if (typeof p !== 'string') return false;
const result = validateMountPath(p, { readWrite });
if (!result.valid) {
warn(`BwrapConfig: rejected path "${p}": ${result.reason}`);
// String form: "/path" → bind /path /path
if (typeof p === 'string') {
const r = validateMountPath(p, { readWrite });
if (!r.valid) {
warn(`BwrapConfig: rejected mount "${p}": ${r.reason}`);
}
return r.valid;
}
return result.valid;
// Object form: { src, dst } → bind src dst (different paths)
if (p && typeof p === 'object'
&& typeof p.src === 'string' && typeof p.dst === 'string') {
const srcRes = validateMountPath(p.src, { readWrite });
if (!srcRes.valid) {
warn(`BwrapConfig: rejected mount src "${p.src}": ${srcRes.reason}`);
return false;
}
// dst is the in-sandbox path — skip the $HOME constraint
// (the whole point of {src,dst} is to map outside it).
const dstRes = validateMountPath(p.dst, { readWrite: false });
if (!dstRes.valid) {
warn(`BwrapConfig: rejected mount dst "${p.dst}": ${dstRes.reason}`);
return false;
}
return true;
}
return false;
});
}
return {
additionalROBinds: filterPaths(mounts.additionalROBinds, false),
additionalBinds: filterPaths(mounts.additionalBinds, true),
additionalROBinds: filterMounts(mounts.additionalROBinds, false),
additionalBinds: filterMounts(mounts.additionalBinds, true),
disabledDefaultBinds: Array.isArray(mounts.disabledDefaultBinds)
? mounts.disabledDefaultBinds
.filter(p => {
@@ -844,11 +864,19 @@ function mergeBwrapArgs(defaultArgs, config) {
}
}
for (const p of config.additionalROBinds) {
result.push('--ro-bind', p, p);
for (const m of config.additionalROBinds) {
if (typeof m === 'string') {
result.push('--ro-bind', m, m);
} else {
result.push('--ro-bind', m.src, m.dst);
}
}
for (const p of config.additionalBinds) {
result.push('--bind', p, p);
for (const m of config.additionalBinds) {
if (typeof m === 'string') {
result.push('--bind', m, m);
} else {
result.push('--bind', m.src, m.dst);
}
}
return result;

View File

@@ -189,25 +189,53 @@ JSEOF
if [[ $parser == 'python3' ]]; then
parsed_output=$(python3 - "$mounts_json" 2>/dev/null <<'PYEOF'
import json, sys
def fmt(p):
if isinstance(p, str):
return p
if isinstance(p, dict) and isinstance(p.get('src'), str) \
and isinstance(p.get('dst'), str):
return p['src'] + ' -> ' + p['dst']
return None
m = json.loads(sys.argv[1])
for p in m.get('additionalROBinds', []):
print(p)
s = fmt(p)
if s is not None:
print(s)
print('---')
for p in m.get('additionalBinds', []):
print(p)
s = fmt(p)
if s is not None:
print(s)
print('---')
for p in m.get('disabledDefaultBinds', []):
print(p)
if isinstance(p, str):
print(p)
PYEOF
)
else
parsed_output=$(node - "$mounts_json" 2>/dev/null <<'JSEOF'
function fmt(p) {
if (typeof p === 'string') return p;
if (p && typeof p === 'object'
&& typeof p.src === 'string' && typeof p.dst === 'string') {
return p.src + ' -> ' + p.dst;
}
return null;
}
const m = JSON.parse(process.argv[1]);
(m.additionalROBinds || []).forEach(p => console.log(p));
(m.additionalROBinds || []).forEach(p => {
const s = fmt(p);
if (s !== null) console.log(s);
});
console.log('---');
(m.additionalBinds || []).forEach(p => console.log(p));
(m.additionalBinds || []).forEach(p => {
const s = fmt(p);
if (s !== null) console.log(s);
});
console.log('---');
(m.disabledDefaultBinds || []).forEach(p => console.log(p));
(m.disabledDefaultBinds || []).forEach(p => {
if (typeof p === 'string') console.log(p);
});
JSEOF
)
fi
@@ -243,6 +271,31 @@ JSEOF
done <<< "$rw_binds"
fi
# Warn when an additional mount's dst lands on a default RO mount.
# bwrap honors the later mount, so this silently replaces a system
# path inside the sandbox. Only the {src, dst} form can trigger this
# (string form mounts src=dst, and additionalBinds requires src under
# $HOME, which never overlaps the default RO set).
local shadow_input=''
[[ -n $ro_binds ]] && shadow_input+="${ro_binds}"$'\n'
[[ -n $rw_binds ]] && shadow_input+="${rw_binds}"$'\n'
shadow_input=${shadow_input%$'\n'}
local shadow_line shadow_dst
if [[ -n $shadow_input ]]; then
while IFS= read -r shadow_line; do
[[ $shadow_line == *' -> '* ]] || continue
shadow_dst=${shadow_line##* -> }
# Long alternation pattern (STYLEGUIDE 80-col exception)
case $shadow_dst in
/usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*)
_warn \
"Mount dst '${shadow_dst}' shadows a default sandbox mount" \
'(may break system tools inside the sandbox)'
;;
esac
done <<< "$shadow_input"
fi
local critical_warned=false
if [[ -n $disabled_binds ]]; then
while IFS= read -r bind_path; do

View File

@@ -609,6 +609,279 @@ assert(warnings[1].includes('/outside/home'), 'warns about rw outside home');
[[ "$status" -eq 0 ]]
}
# =============================================================================
# {src, dst} mount form — distinct host/sandbox paths
# =============================================================================
@test "loadBwrapMountsConfig: accepts RO {src,dst} with src outside HOME" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [{ src: '/opt/tools', dst: '/sandbox/tools' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds,
[{ src: '/opt/tools', dst: '/sandbox/tools' }],
'object form preserved');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: accepts RW {src,dst} with src under HOME and dst outside HOME" {
# This is the /tmp persistence use case: src under \$HOME (passes RW
# constraint) but dst can be anywhere (e.g. /tmp inside the sandbox).
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalBinds: [{ src: home + '/persistent-tmp', dst: '/tmp' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalBinds,
[{ src: home + '/persistent-tmp', dst: '/tmp' }],
'persistent /tmp mapping accepted');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects RW {src,dst} with src outside HOME" {
run node -e "${NODE_PREAMBLE}
const warnings = [];
function logWarn() { warnings.push(Array.from(arguments).join(' ')); }
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalBinds: [{ src: '/opt/anywhere', dst: '/sandbox/x' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath, logWarn);
assertDeepEqual(result.additionalBinds, [], 'rejected');
assertEqual(warnings.length, 1, 'one warning');
assert(warnings[0].includes('/opt/anywhere'), 'warns about src');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects {src,dst} with forbidden src" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [{ src: '/proc', dst: '/sandbox/p' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [], 'forbidden src rejected');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects {src,dst} with forbidden dst" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [{ src: '/opt/tools', dst: '/proc' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [], 'forbidden dst rejected');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects {src,dst} with dst under forbidden path" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [{ src: '/opt/tools', dst: '/proc/self' }]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [], 'dst under /proc rejected');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects {src,dst} with non-absolute paths" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [
{ src: 'rel/src', dst: '/abs/dst' },
{ src: '/abs/src', dst: 'rel/dst' }
]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [], 'both rejected');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects malformed mount objects" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [
{ src: '/opt/a' },
{ dst: '/sandbox/b' },
{ src: 42, dst: '/sandbox/c' },
{},
null
]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [], 'all malformed rejected');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: accepts mix of string and {src,dst} forms" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: [
'/opt/tools',
{ src: '/nix/store', dst: '/sandbox/nix' }
],
additionalBinds: [
home + '/data',
{ src: home + '/cache', dst: '/tmp' }
]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, [
'/opt/tools',
{ src: '/nix/store', dst: '/sandbox/nix' }
], 'ro mix preserved');
assertDeepEqual(result.additionalBinds, [
home + '/data',
{ src: home + '/cache', dst: '/tmp' }
], 'rw mix preserved');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: emits --ro-bind src dst for object form" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [{ src: '/opt/tools', dst: '/sandbox/tools' }],
additionalBinds: [],
disabledDefaultBinds: []
});
assertDeepEqual(result, [
'--tmpfs', '/',
'--ro-bind', '/opt/tools', '/sandbox/tools'
], 'ro object form');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: emits --bind src dst for object form" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const defaults = ['--tmpfs', '/'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [],
additionalBinds: [{ src: home + '/persistent', dst: '/tmp' }],
disabledDefaultBinds: []
});
assertDeepEqual(result, [
'--tmpfs', '/',
'--bind', home + '/persistent', '/tmp'
], 'rw object form');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: mixes string and object forms in same config" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const defaults = ['--tmpfs', '/'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [
'/opt/tools',
{ src: '/nix/store', dst: '/sandbox/nix' }
],
additionalBinds: [
home + '/data',
{ src: home + '/cache', dst: '/tmp' }
],
disabledDefaultBinds: []
});
assertDeepEqual(result, [
'--tmpfs', '/',
'--ro-bind', '/opt/tools', '/opt/tools',
'--ro-bind', '/nix/store', '/sandbox/nix',
'--bind', home + '/data', home + '/data',
'--bind', home + '/cache', '/tmp'
], 'mixed forms');
"
[[ "$status" -eq 0 ]]
}
@test "buildBwrapArgsWithConfig: persistent /tmp via {src,dst} and disabled default" {
# The full /tmp persistence recipe: disable the default --tmpfs /tmp,
# then bind a host path under \$HOME onto /tmp inside the sandbox.
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalBinds: [{ src: home + '/claude-tmp', dst: '/tmp' }],
disabledDefaultBinds: ['/tmp']
}
}
}));
const config = loadBwrapMountsConfig(configPath);
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
const result = mergeBwrapArgs(defaults, config);
assert(!result.includes('/tmp')
|| result.indexOf('--tmpfs') !== result.lastIndexOf('--tmpfs')
|| result[result.indexOf('--tmpfs') + 1] !== '/tmp',
'default --tmpfs /tmp must be removed');
const bindIdx = result.indexOf('--bind');
assertEqual(result[bindIdx + 1], home + '/claude-tmp', 'bind src');
assertEqual(result[bindIdx + 2], '/tmp', 'bind dst');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# --doctor integration (bash)
# =============================================================================
@@ -660,6 +933,81 @@ assert(warnings[1].includes('/outside/home'), 'warns about rw outside home');
[[ "$output" == *"/usr"* ]]
}
@test "doctor: renders {src,dst} mounts as 'src -> dst'" {
mkdir -p "${TEST_TMP}/.config/Claude"
local home_tmp="${TEST_TMP}"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
cat > "$config_file" <<-ENDJSON
{
"preferences": {
"coworkBwrapMounts": {
"additionalROBinds": [
{ "src": "/opt/tools", "dst": "/sandbox/tools" }
],
"additionalBinds": [
{ "src": "${home_tmp}/persistent-tmp", "dst": "/tmp" }
]
}
}
}
ENDJSON
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
[[ "$output" == *"/opt/tools -> /sandbox/tools"* ]]
[[ "$output" == *"persistent-tmp -> /tmp"* ]]
}
@test "doctor: warns when {src,dst} dst shadows a default RO mount" {
mkdir -p "${TEST_TMP}/.config/Claude"
local home_tmp="${TEST_TMP}"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
cat > "$config_file" <<-ENDJSON
{
"preferences": {
"coworkBwrapMounts": {
"additionalBinds": [
{ "src": "${home_tmp}/fake-etc", "dst": "/etc/foo" }
]
}
}
}
ENDJSON
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
[[ "$output" == *"WARN"* ]]
[[ "$output" == *"/etc/foo"* ]]
[[ "$output" == *"shadows a default sandbox mount"* ]]
}
@test "doctor: does not warn when {src,dst} dst is safe (e.g. /tmp, /sandbox/...)" {
mkdir -p "${TEST_TMP}/.config/Claude"
local home_tmp="${TEST_TMP}"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
cat > "$config_file" <<-ENDJSON
{
"preferences": {
"coworkBwrapMounts": {
"additionalBinds": [
{ "src": "${home_tmp}/cache", "dst": "/tmp" }
],
"additionalROBinds": [
{ "src": "/opt/tools", "dst": "/sandbox/tools" }
]
}
}
}
ENDJSON
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
[[ "$output" != *"shadows a default sandbox mount"* ]]
}
@test "doctor: no output when no custom mounts configured" {
mkdir -p "${TEST_TMP}/.config/Claude"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"