Skip to content

Commit 3cce945

Browse files
authored
feat: migrate Linux sandbox to bwrap, harden with adversarial escape testing (#7)
## Summary Replaces the `unshare` + shell-script sandbox with Bubblewrap (`bwrap`), then hardens it with adversarial escape testing that found and fixed 3 real bypass vectors. ### Phase 1 — bwrap migration - Replaces `unshare` + shell-script with declarative bwrap bind mounts — no shell escaping, no injection risk, symlinks resolved automatically. - Network-filtered path (`allow_net`) uses bwrap's `--unshare-net` + `--info-fd` with host-side `slirp4netns`, avoiding the EPERM from nested `unshare --net` in bwrap's user namespace. - `aigate doctor` command — checks `bwrap`, `slirp4netns`, user-namespace availability, reports versions/paths. ### Phase 2 — adversarial escape testing & hardening Wrote 24 adversarial escape tests that simulate real attack techniques against the sandbox. Three tests found real bypasses, which were then fixed: | Bypass found | Attack | Fix | |---|---|---| | **Host file write** | `echo evil > ~/.bashrc` from sandbox | `--ro-bind / /` read-only root, `--bind $HOME $HOME` writable home, `--ro-bind` over sensitive dotfiles (.ssh, .bashrc, .gitconfig, .gnupg) | | **Hardlink bypass deny_read** | `ln .env .env-copy && cat .env-copy` | `findHardlinks()` scans workdir for same-inode paths, denies all via bind mounts | | **Workdir deny_exec bypass** | `python3 subprocess.run(["./local-tool"])` | `buildBwrapExecDenyArgs` now also checks `profile.WorkDir` for binaries, not just `$PATH` | ### Mount layering (final architecture) ``` --ro-bind / / ← system dirs read-only (/usr, /etc, /var) --bind $HOME $HOME ← writable home (tools need config/cache dirs) --ro-bind ~/.ssh ← SSH keys protected --ro-bind ~/.gnupg ← GPG keys protected --ro-bind ~/.bashrc ← shell startup injection blocked --ro-bind ~/.gitconfig ← git hook injection blocked --tmpfs ~/.aigate ← config hidden completely --tmpfs /tmp ← isolated from host /tmp --bind workdir workdir ← only if outside $HOME --dev /dev ← minimal (no /dev/mem, /dev/kmem) --proc /proc ← fresh PID namespace --unshare-pid ← PID isolation --unshare-user ← user namespace ``` ### Escape test coverage (24 tests, 3 skipped known limitations) **PID namespace (3 tests):** host PIDs invisible, `kill` can't reach host processes (verified both command exit code AND victim survival), PID 1 is sandbox init not systemd **Filesystem write protection (6 tests):** can't write to `~/.bashrc`, `~/.ssh/authorized_keys`, `~/.gitconfig`, `/etc`, or paths outside `$HOME`. Workdir IS writable (sanity check). Host `/tmp` isolated. **deny_read bypass attempts (5 tests):** symlink, hardlink, `/proc/self/root`, python `open()`/`os.open()`/`mmap()`, config dir via `cat`/`find`/`python`/`/proc/self/root` **deny_exec bypass attempts (1 test + 3 known limitations):** python `subprocess.run()` now blocked. Known limitations (documented as `t.Skip`): copy to new path, interpreter bypass, copy to workdir — inherent to path-based blocking, mitigate by pairing `deny_exec` with `deny_read`. **Device/privilege (5 tests):** `/dev/mem`+`/dev/kmem`+`/dev/port` absent, `mknod` blocked, `mount` blocked, `chroot` blocked **Kernel/namespace (3 tests):** `sysrq-trigger` not writable, `nsenter` blocked, nested `unshare` can't recover hidden config **Information leak (1 test):** no config file descriptors leaked via `/proc/self/fd` ### Other changes - CI: added `govulncheck` job and pre-push hook - Go upgraded to 1.25.8 (fixes GO-2026-4602 stdlib vuln) - Fixed pre-existing lint issues (errcheck, staticcheck) across sandbox files ## Test plan - [x] `go test ./... -count=1` — 197 tests pass (24 escape, 10 integration, 163 unit) - [x] `go test -short ./...` — 162 pass (escape/integration skipped) - [x] `aigate run -- echo "sandbox works"` — runs successfully - [x] `aigate run -- claude` — Claude Code starts inside sandbox - [x] `aigate run -- ls` — lists workdir contents - [x] `aigate run -- curl ifconfig.me` — blocked by deny_exec - [ ] `aigate run -- sh -c 'python3 -c "import urllib.request; urllib.request.urlopen(\"https://api.github.com\")"'` — allowed host - [ ] `aigate run -- sh -c 'python3 -c "import urllib.request; urllib.request.urlopen(\"https://example.com\")"'` — blocked host
1 parent 1773e73 commit 3cce945

16 files changed

Lines changed: 2999 additions & 93 deletions

.github/workflows/test.yml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
- name: Set up Go
1313
uses: actions/setup-go@v5
1414
with:
15-
go-version: '1.24'
15+
go-version: '1.25'
1616
cache: true
1717

1818
- name: Download dependencies
@@ -24,6 +24,23 @@ jobs:
2424
- name: Build binary
2525
run: make build-local
2626

27+
vuln:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- name: Set up Go
33+
uses: actions/setup-go@v5
34+
with:
35+
go-version: '1.25'
36+
cache: true
37+
38+
- name: Install govulncheck
39+
run: go install golang.org/x/vuln/cmd/govulncheck@latest
40+
41+
- name: Run govulncheck
42+
run: govulncheck ./...
43+
2744
lint:
2845
runs-on: ubuntu-latest
2946
steps:
@@ -32,11 +49,11 @@ jobs:
3249
- name: Set up Go
3350
uses: actions/setup-go@v5
3451
with:
35-
go-version: '1.24'
52+
go-version: '1.25'
3653
cache: true
3754

55+
- name: Install golangci-lint
56+
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
57+
3858
- name: Run golangci-lint
39-
uses: golangci/golangci-lint-action@v6
40-
with:
41-
version: latest
42-
args: --timeout=5m
59+
run: golangci-lint run --timeout=5m

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
curl -L https://github.com/AxeForging/aigate/releases/latest/download/aigate-linux-amd64.tar.gz | tar xz
1919
sudo mv aigate-linux-amd64 /usr/local/bin/aigate
2020

21+
# Install sandbox dependencies (Linux)
22+
sudo dnf install bubblewrap slirp4netns # Fedora / RHEL
23+
# sudo apt install bubblewrap slirp4netns # Ubuntu / Debian
24+
2125
# Set up sandbox
2226
sudo aigate setup # One-time: create OS group/user for ACLs
2327
aigate init # Create default config
28+
aigate doctor # Verify prerequisites
2429

2530
# Add restrictions
2631
aigate deny read .env secrets/ *.pem
@@ -44,8 +49,8 @@ AI coding tools rely on application-level permission systems that can be bypasse
4449
## Features
4550

4651
- **File isolation** - POSIX ACLs (Linux) / macOS ACLs deny read access to secrets
47-
- **Process isolation** - Mount namespaces overmount sensitive directories (Linux)
48-
- **Network isolation** - Network namespaces restrict egress to allowed domains (Linux)
52+
- **Process isolation** - Bubblewrap (`bwrap`) + mount namespaces isolate the sandbox declaratively (Linux); Seatbelt on macOS
53+
- **Network isolation** - `bwrap --unshare-net` + `slirp4netns` + `iptables` restrict egress to allowed domains (Linux)
4954
- **Command blocking** - Deny execution of dangerous commands (curl, wget, ssh)
5055
- **Output masking** - Redact secrets (API keys, tokens) from stdout/stderr before they reach the terminal
5156
- **Resource limits** - cgroups v2 enforce memory, CPU, PID limits (Linux)
@@ -67,6 +72,7 @@ AI coding tools rely on application-level permission systems that can be bypasse
6772
```sh
6873
sudo aigate setup # Create OS group/user (one-time)
6974
aigate init # Create default config
75+
aigate doctor # Check prerequisites and isolation mode
7076
aigate deny read .env secrets/ *.pem # Block file access
7177
aigate deny exec curl wget ssh # Block commands
7278
aigate deny net --except api.anthropic.com # Restrict network

actions/doctor.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package actions
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"runtime"
7+
"strings"
8+
9+
"github.com/urfave/cli"
10+
)
11+
12+
type DoctorAction struct{}
13+
14+
func NewDoctorAction() *DoctorAction {
15+
return &DoctorAction{}
16+
}
17+
18+
func (a *DoctorAction) Execute(c *cli.Context) error {
19+
fmt.Printf("aigate doctor — runtime checks (%s/%s)\n\n", runtime.GOOS, runtime.GOARCH)
20+
21+
switch runtime.GOOS {
22+
case "linux":
23+
runLinuxChecks()
24+
case "darwin":
25+
runDarwinChecks()
26+
default:
27+
fmt.Println(" No platform-specific checks for this OS.")
28+
}
29+
30+
return nil
31+
}
32+
33+
// ── Linux ────────────────────────────────────────────────────────────────────
34+
35+
func runLinuxChecks() {
36+
bwrapOK := printCheck("bwrap",
37+
"sandbox isolation (mount / pid / user namespaces)",
38+
"sudo dnf install bubblewrap OR sudo apt install bubblewrap")
39+
40+
slirpOK := printCheck("slirp4netns",
41+
"network filtering — required for allow_net rules",
42+
"sudo dnf install slirp4netns OR sudo apt install slirp4netns")
43+
44+
printCheck("setfacl",
45+
"persistent ACLs — deny_read enforced on disk between sessions",
46+
"sudo dnf install acl OR sudo apt install acl")
47+
48+
unshareOK := checkUserNamespaces()
49+
50+
fmt.Println()
51+
printLinuxIsolationMode(bwrapOK, slirpOK, unshareOK)
52+
}
53+
54+
// printCheck looks up tool by name, prints a status line, and returns true if found.
55+
// installHint is printed on a second line when the tool is missing.
56+
func printCheck(name, desc, installHint string) bool {
57+
path, err := exec.LookPath(name)
58+
if err != nil {
59+
fmt.Printf(" WARN %-16s not found\n", name)
60+
fmt.Printf(" %s\n", desc)
61+
if installHint != "" {
62+
fmt.Printf(" Install: %s\n", installHint)
63+
}
64+
return false
65+
}
66+
67+
ver := toolVersion(name)
68+
if ver != "" {
69+
fmt.Printf(" ok %-16s %s (%s)\n", name, ver, path)
70+
} else {
71+
fmt.Printf(" ok %-16s %s\n", name, path)
72+
}
73+
fmt.Printf(" %s\n", desc)
74+
return true
75+
}
76+
77+
// toolVersion runs `name --version` and returns the first meaningful token.
78+
func toolVersion(name string) string {
79+
out, err := exec.Command(name, "--version").CombinedOutput() //nolint:gosec
80+
if err != nil {
81+
return ""
82+
}
83+
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
84+
// Keep only the version token (e.g. "bubblewrap 0.10.0" → "v0.10.0")
85+
parts := strings.Fields(line)
86+
for _, p := range parts {
87+
if len(p) > 0 && (p[0] >= '0' && p[0] <= '9') {
88+
return "v" + p
89+
}
90+
}
91+
return ""
92+
}
93+
94+
// checkUserNamespaces verifies that unprivileged user namespaces are enabled.
95+
func checkUserNamespaces() bool {
96+
// Attempt a trivial unshare; if it fails the kernel has them disabled.
97+
err := exec.Command("unshare", "--user", "--", "true").Run()
98+
if err != nil {
99+
fmt.Printf(" WARN %-16s disabled\n", "user namespaces")
100+
fmt.Printf(" Required for all sandbox modes.\n")
101+
fmt.Printf(" Enable: echo 1 | sudo tee /proc/sys/kernel/unprivileged_userns_clone\n")
102+
return false
103+
}
104+
fmt.Printf(" ok %-16s enabled\n", "user namespaces")
105+
fmt.Printf(" Required for all sandbox modes.\n")
106+
return true
107+
}
108+
109+
// printLinuxIsolationMode describes which sandbox path will be taken based on
110+
// available tools, mirroring the dispatch logic in RunSandboxed.
111+
func printLinuxIsolationMode(bwrap, slirp, unshare bool) {
112+
fmt.Println("Isolation mode:")
113+
114+
switch {
115+
case bwrap && slirp:
116+
fmt.Println(" bwrap + slirp4netns (full isolation)")
117+
fmt.Println()
118+
fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run")
119+
fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run")
120+
fmt.Println(" allow_net bwrap --unshare-net network namespace via bwrap")
121+
fmt.Println(" slirp4netns + iptables egress filtered to allowed hosts")
122+
fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent")
123+
case bwrap && !slirp:
124+
fmt.Println(" bwrap (no network filtering — slirp4netns missing)")
125+
fmt.Println()
126+
fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run")
127+
fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run")
128+
fmt.Println(" allow_net INACTIVE install slirp4netns to enable")
129+
fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent")
130+
case !bwrap && slirp && unshare:
131+
fmt.Println(" unshare + slirp4netns (fallback — install bwrap for stronger isolation)")
132+
fmt.Println()
133+
fmt.Println(" deny_read mount namespace overrides shell-script-based")
134+
fmt.Println(" deny_exec mount namespace overrides shell-script-based")
135+
fmt.Println(" allow_net unshare --net + slirp4netns egress filtered to allowed hosts")
136+
fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent")
137+
case !bwrap && !slirp && unshare:
138+
fmt.Println(" unshare (fallback — install bwrap for stronger isolation)")
139+
fmt.Println()
140+
fmt.Println(" deny_read mount namespace overrides shell-script-based")
141+
fmt.Println(" deny_exec mount namespace overrides shell-script-based")
142+
fmt.Println(" allow_net INACTIVE install slirp4netns to enable")
143+
fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent")
144+
default:
145+
fmt.Println(" NONE — user namespaces are disabled; sandbox cannot run")
146+
}
147+
}
148+
149+
// ── macOS ────────────────────────────────────────────────────────────────────
150+
151+
func runDarwinChecks() {
152+
printCheck("sandbox-exec",
153+
"macOS Seatbelt sandbox (deny_read, deny_exec, allow_net)",
154+
"Built into macOS — should always be present")
155+
156+
fmt.Println()
157+
fmt.Println("Isolation mode: sandbox-exec (Seatbelt)")
158+
fmt.Println()
159+
fmt.Println(" deny_read Seatbelt file-read* deny rules kernel-enforced")
160+
fmt.Println(" deny_exec Seatbelt process-exec deny kernel-enforced")
161+
fmt.Println(" allow_net Seatbelt network-outbound rules kernel-enforced")
162+
}

docs/AI/README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ domain/ Pure data structures
1414
types.go Rule, Config, SandboxProfile, ResourceLimits
1515
1616
services/ Core business logic
17-
platform.go Platform interface + Executor interface + resolvePatterns
18-
platform_linux.go Linux: setfacl, groupadd/useradd, unshare (namespaces)
19-
platform_darwin.go macOS: chmod +a, dscl, sandbox-exec
20-
config_service.go Config load/save/merge (global + project)
21-
rule_service.go Rule CRUD (add/remove/list deny rules)
22-
runner_service.go Sandboxed process launcher
17+
platform.go Platform interface + Executor interface + resolvePatterns
18+
platform_linux.go Linux: setfacl, groupadd/useradd, RunSandboxed dispatch
19+
platform_linux_bwrap.go Linux bwrap path: buildBwrapArgs, runWithBwrap, runWithBwrapNetFilter
20+
platform_darwin.go macOS: chmod +a, dscl, sandbox-exec
21+
config_service.go Config load/save/merge (global + project)
22+
rule_service.go Rule CRUD (add/remove/list deny rules)
23+
runner_service.go Sandboxed process launcher
2324
2425
actions/ CLI command handlers
2526
init.go Create group, user, default config
@@ -28,6 +29,7 @@ actions/ CLI command handlers
2829
run.go Run command inside sandbox
2930
status.go Show current sandbox state
3031
reset.go Remove group, user, config
32+
doctor.go Check prerequisites and active isolation mode
3133
3234
helpers/ Logging and error types
3335
logger.go zerolog console logger
@@ -40,8 +42,9 @@ integration/ End-to-end CLI tests
4042
## Key Design Decisions
4143

4244
- **Platform interface**: Linux and macOS use completely different OS mechanisms. The `Platform` interface abstracts this with `newPlatform()` factory via build tags.
43-
- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root.
44-
- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod).
45+
- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root. Exception: `runWithBwrapNetFilter` uses `exec.Command` directly because it needs `cmd.Start()` + `ExtraFiles` for the info-fd pipe, which the Executor interface does not expose.
46+
- **bwrap-first on Linux**: `RunSandboxed` prefers bwrap when available; falls back to `unshare`-based shell scripts. bwrap uses declarative bind mounts (no shell injection risk), resolves symlinks for bind destinations, and handles capabilities via `--uid 0 --cap-add` for the network path.
47+
- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod, bwrap, slirp4netns).
4548
- **Config merging**: Global config (`~/.aigate/config.yaml`) + project config (`.aigate.yaml`) merge with project extending global.
4649

4750
## Testing

docs/user/README.md

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,28 @@ Unlike application-level restrictions that can be bypassed, aigate uses kernel-e
1414

1515
| | Linux | macOS |
1616
|---|---|---|
17-
| **Required** | `setfacl` (usually pre-installed) | None (uses built-in sandbox-exec) |
17+
| **Recommended** | `bwrap` (Bubblewrap) | None (uses built-in sandbox-exec) |
1818
| **For network filtering** | `slirp4netns` | None (uses built-in Seatbelt) |
19+
| **For persistent ACLs** | `setfacl` (usually pre-installed) | None |
1920

20-
Install `slirp4netns` on Linux if you use `allow_net`:
21+
### Install Bubblewrap (recommended, Linux)
22+
23+
`bwrap` provides stronger isolation than the fallback `unshare` path. When present, aigate uses it for all sandbox modes.
24+
25+
```sh
26+
# Fedora / RHEL
27+
sudo dnf install bubblewrap
28+
29+
# Ubuntu / Debian
30+
sudo apt install bubblewrap
31+
32+
# Arch
33+
sudo pacman -S bubblewrap
34+
```
35+
36+
Without `bwrap`, aigate falls back to `unshare`-based namespaces (still functional, but shell-script-based overrides instead of declarative bind mounts).
37+
38+
### Install slirp4netns (required for `allow_net`, Linux)
2139

2240
```sh
2341
# Fedora / RHEL
@@ -32,6 +50,14 @@ sudo pacman -S slirp4netns
3250

3351
If `slirp4netns` is not installed, aigate logs a warning and runs without network filtering.
3452

53+
### Verify your setup
54+
55+
```sh
56+
aigate doctor
57+
```
58+
59+
Shows which tools are available and the isolation mode that will be used.
60+
3561
## Install
3662

3763
### Linux/macOS (AMD64)
@@ -144,6 +170,24 @@ Show current sandbox configuration:
144170
aigate status
145171
```
146172

173+
### doctor
174+
175+
Check sandbox prerequisites and show which isolation mode will be active:
176+
177+
```sh
178+
aigate doctor
179+
```
180+
181+
Example output:
182+
```
183+
ok bwrap v0.10.0 — sandbox isolation (mount/pid/user namespaces)
184+
ok slirp4netns v1.3.1 — network filtering (allow_net rules)
185+
ok setfacl v2.3.2 — persistent ACLs
186+
ok user namespaces enabled
187+
188+
Isolation mode: bwrap + slirp4netns (full isolation)
189+
```
190+
147191
### reset
148192

149193
Remove everything (group, user, config):
@@ -295,7 +339,8 @@ Two layers working together for defense-in-depth:
295339

296340
Restricts outbound connections to domains listed in `allow_net`:
297341

298-
- **Linux**: User namespace + network namespace + `slirp4netns` for user-mode networking + `iptables` OUTPUT rules. Hostnames are resolved inside the namespace so iptables IPs match what the sandboxed process sees. Requires `slirp4netns` (falls back to unrestricted if not installed). No root needed.
342+
- **Linux (bwrap path)**: bwrap creates a network namespace via `--unshare-net`. Go reads bwrap's `--info-fd` to get the child PID, then launches `slirp4netns --configure` from host-side to attach user-mode networking. Inside the sandbox, `iptables` OUTPUT rules resolve each `allow_net` hostname and restrict egress. No root needed.
343+
- **Linux (unshare fallback)**: Two-layer `unshare` — outer creates user namespace, inner creates network namespace. `slirp4netns` runs inside the user namespace. Same `iptables` filtering.
299344
- **macOS**: `sandbox-exec` Seatbelt profiles with `(deny network-outbound)` and per-host `(allow network-outbound (remote ip ...))` rules. Kernel-enforced via Sandbox.kext.
300345

301346
**Linux**:
@@ -308,9 +353,13 @@ Restricts outbound connections to domains listed in `allow_net`:
308353

309354
### Process isolation (Linux)
310355

311-
- **User namespace**: Maps calling user to UID 0 inside the namespace, giving capabilities for mount/net operations without real root
312-
- **PID namespace**: Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted to match
313-
- **Mount namespace**: Enables filesystem overrides without affecting the host
356+
When `bwrap` is installed (recommended):
357+
358+
- **User namespace** (`--unshare-user`): Maps calling user to a root-equivalent UID inside the namespace. Required for mount/net operations without real root.
359+
- **PID namespace** (`--unshare-pid`): Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted fresh.
360+
- **Mount namespace**: bwrap declaratively applies deny_read bind mounts, config-dir hiding, and deny_exec stubs before exec — no shell-based overrides.
361+
362+
Without `bwrap`, aigate falls back to `unshare --user --map-root-user` + shell scripts for the same effects.
314363

315364
![Linux Process Isolation](../diagrams/linux-process.png)
316365

@@ -342,7 +391,10 @@ Run `sudo aigate setup` to create the sandbox group and user, then `aigate init`
342391
Install `slirp4netns` for network filtering on Linux (see [Prerequisites](#prerequisites)). Without it, `allow_net` rules are ignored and the sandboxed process has unrestricted network access.
343392

344393
### Allowed hosts still blocked
345-
If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run with `AIGATE_LOG_LEVEL=debug` for detailed output.
394+
If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run `aigate doctor` to verify your setup, or use `AIGATE_LOG_LEVEL=debug` for detailed output.
395+
396+
### bwrap not found
397+
Install `bubblewrap` for stronger isolation (see [Prerequisites](#prerequisites)). Without it, aigate falls back to the `unshare`-based sandbox which is still functional but uses shell-script-based mount overrides.
346398

347399
## Exit Codes
348400

0 commit comments

Comments
 (0)