mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
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:
committed by
GitHub
parent
17db18393e
commit
8ac73e6ba9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user