Skip to content

Commit b287495

Browse files
committed
Merge pull request 'sandbox: leak-free placement for the .cliguard exec symlink' (#227) from issue-185-sandbox-leakfree into main
Reviewed-on: https://forgejo.coilysiren.me/coilyco-flight-deck/cli-guard/pulls/227
2 parents af3306b + 97a01cc commit b287495

5 files changed

Lines changed: 224 additions & 45 deletions

File tree

.forgejo/workflows/ci.yml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ jobs:
2525
- uses: actions/checkout@v6
2626
with:
2727
fetch-depth: 0
28+
- name: Configure Go scratch
29+
run: |
30+
set -euo pipefail
31+
scratch="${RUNNER_TEMP:-/tmp}"
32+
mkdir -p \
33+
"$scratch/go-tmp" \
34+
"$scratch/go-build" \
35+
"$scratch/go-mod" \
36+
"$scratch/xdg-cache"
37+
{
38+
echo "GOTMPDIR=$scratch/go-tmp"
39+
echo "GOCACHE=$scratch/go-build"
40+
echo "GOMODCACHE=$scratch/go-mod"
41+
echo "XDG_CACHE_HOME=$scratch/xdg-cache"
42+
} >> "$GITHUB_ENV"
2843
- name: go vet
2944
run: go vet ./...
3045
- name: go build
@@ -104,8 +119,24 @@ jobs:
104119
container: forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:latest
105120
steps:
106121
- uses: actions/checkout@v6
122+
- name: Configure Go scratch
123+
run: |
124+
set -euo pipefail
125+
scratch="${RUNNER_TEMP:-/tmp}"
126+
mkdir -p \
127+
"$scratch/go-tmp" \
128+
"$scratch/go-build" \
129+
"$scratch/go-mod" \
130+
"$scratch/xdg-cache"
131+
{
132+
echo "GOTMPDIR=$scratch/go-tmp"
133+
echo "TMPDIR=$scratch/go-tmp"
134+
echo "GOCACHE=$scratch/go-build"
135+
echo "GOMODCACHE=$scratch/go-mod"
136+
echo "XDG_CACHE_HOME=$scratch/xdg-cache"
137+
} >> "$GITHUB_ENV"
107138
- name: golangci-lint
108-
run: golangci-lint run --timeout=5m ./...
139+
run: golangci-lint run --timeout=5m --concurrency=1 ./...
109140

110141
- name: Alert Telegram on main failure
111142
if: ${{ failure() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release') }}

.github/workflows/ci.yml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ jobs:
1414
container: forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:v0.195.0
1515
steps:
1616
- uses: actions/checkout@v6
17+
- name: Configure Go scratch
18+
run: |
19+
set -euo pipefail
20+
scratch="${RUNNER_TEMP:-/tmp}"
21+
mkdir -p \
22+
"$scratch/go-tmp" \
23+
"$scratch/go-build" \
24+
"$scratch/go-mod" \
25+
"$scratch/xdg-cache"
26+
{
27+
echo "GOTMPDIR=$scratch/go-tmp"
28+
echo "TMPDIR=$scratch/go-tmp"
29+
echo "GOCACHE=$scratch/go-build"
30+
echo "GOMODCACHE=$scratch/go-mod"
31+
echo "XDG_CACHE_HOME=$scratch/xdg-cache"
32+
} >> "$GITHUB_ENV"
1733
- name: go vet
1834
run: go vet ./...
1935
- name: go build
@@ -88,8 +104,24 @@ jobs:
88104
container: forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:v0.195.0
89105
steps:
90106
- uses: actions/checkout@v6
107+
- name: Configure Go scratch
108+
run: |
109+
set -euo pipefail
110+
scratch="${RUNNER_TEMP:-/tmp}"
111+
mkdir -p \
112+
"$scratch/go-tmp" \
113+
"$scratch/go-build" \
114+
"$scratch/go-mod" \
115+
"$scratch/xdg-cache"
116+
{
117+
echo "GOTMPDIR=$scratch/go-tmp"
118+
echo "TMPDIR=$scratch/go-tmp"
119+
echo "GOCACHE=$scratch/go-build"
120+
echo "GOMODCACHE=$scratch/go-mod"
121+
echo "XDG_CACHE_HOME=$scratch/xdg-cache"
122+
} >> "$GITHUB_ENV"
91123
- name: golangci-lint
92-
run: golangci-lint run --timeout=5m ./...
124+
run: golangci-lint run --timeout=5m --concurrency=1 ./...
93125

94126
- name: Alert Telegram on main failure
95127
if: ${{ failure() && github.ref == 'refs/heads/main' }}
@@ -156,6 +188,22 @@ jobs:
156188
container: forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:v0.195.0
157189
steps:
158190
- uses: actions/checkout@v6
191+
- name: Configure Go scratch
192+
run: |
193+
set -euo pipefail
194+
scratch="${RUNNER_TEMP:-/tmp}"
195+
mkdir -p \
196+
"$scratch/go-tmp" \
197+
"$scratch/go-build" \
198+
"$scratch/go-mod" \
199+
"$scratch/xdg-cache"
200+
{
201+
echo "GOTMPDIR=$scratch/go-tmp"
202+
echo "TMPDIR=$scratch/go-tmp"
203+
echo "GOCACHE=$scratch/go-build"
204+
echo "GOMODCACHE=$scratch/go-mod"
205+
echo "XDG_CACHE_HOME=$scratch/xdg-cache"
206+
} >> "$GITHUB_ENV"
159207
- name: govulncheck
160208
run: |
161209
go install golang.org/x/vuln/cmd/govulncheck@latest

cli/sandbox/sandbox_linux.go

Lines changed: 136 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
// jailArgv0 is the internal subcommand the consumer's main routes to RunJail
1919
// before any normal CLI parsing.
2020
const jailArgv0 = "__jail"
21+
const jailStashPath = "/tmp/.cliguard-jail"
2122

2223
// Wrap rewrites cmd to re-exec the consumer binary as the jail helper in a fresh
2324
// user+mount namespace. No-op when jailed, opted out (EnvNoSandbox), or spec nil.
@@ -95,45 +96,132 @@ func RunJail(args []string) error {
9596
if execTool == "" {
9697
return fmt.Errorf("sandbox: jail missing --exec target")
9798
}
99+
resolvedTools := resolveTools(tools)
100+
if err := prepareJailMounts(resolvedTools); err != nil {
101+
return err
102+
}
103+
if err := installToolShims(resolvedTools, spec().SelfExe); err != nil {
104+
return err
105+
}
106+
if err := setJailEnvironment(); err != nil {
107+
return err
108+
}
109+
if err := clearAmbientCaps(); err != nil {
110+
return err
111+
}
112+
if err := lockdownSyscalls(); err != nil {
113+
return err
114+
}
115+
return execJailTarget(execTool, rest)
116+
}
117+
118+
type resolvedTool struct {
119+
tool string
120+
canonical string
121+
realPath string
122+
dir string
123+
needsLink bool
124+
}
125+
126+
// resolveTools snapshots the wrapped tools' PATH entries before any mount
127+
// rewrite can hide them.
128+
func resolveTools(tools []string) []resolvedTool {
129+
resolved := make([]resolvedTool, 0, len(tools))
130+
for _, tool := range tools {
131+
rt, ok := resolveTool(tool)
132+
if ok {
133+
resolved = append(resolved, rt)
134+
}
135+
}
136+
return resolved
137+
}
98138

139+
// resolveTool records one wrapped tool's current PATH entry. Missing tools stay
140+
// a no-op, matching the pre-refactor behavior.
141+
func resolveTool(tool string) (resolvedTool, bool) {
142+
canonical, err := exec.LookPath(tool)
143+
if err != nil {
144+
// Tool not present on this host: nothing to shim.
145+
return resolvedTool{}, false
146+
}
147+
realPath := canonical
148+
if resolved, err := filepath.EvalSymlinks(canonical); err == nil {
149+
realPath = resolved
150+
}
151+
return resolvedTool{
152+
tool: tool,
153+
canonical: canonical,
154+
realPath: realPath,
155+
dir: filepath.Dir(canonical),
156+
needsLink: realPath != canonical,
157+
}, true
158+
}
159+
160+
// prepareJailMounts makes the mount namespace private and stages the real tools
161+
// inside a private tmpfs before canonical dirs are masked.
162+
func prepareJailMounts(resolvedTools []resolvedTool) error {
99163
// Make our mount view private so binds never propagate back to the host.
100164
if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil {
101165
return fmt.Errorf("sandbox: make-rprivate: %w", err)
102166
}
103167

104168
// A private tmpfs stashes real binaries for the compiled-tool case, where
105169
// masking the canonical path would also hide the file the gate must exec.
106-
stash := "/tmp/.cliguard-jail"
170+
stash := jailStashPath
107171
if err := os.MkdirAll(stash, 0o700); err != nil {
108172
return fmt.Errorf("sandbox: mkdir stash: %w", err)
109173
}
110174
if err := unix.Mount("tmpfs", stash, "tmpfs", 0, "mode=0700"); err != nil {
111175
return fmt.Errorf("sandbox: mount stash tmpfs: %w", err)
112176
}
113177

114-
shim := spec().SelfExe
115-
for _, t := range tools {
116-
if err := installToolShim(t, stash, shim); err != nil {
178+
mountedDirs := map[string]struct{}{}
179+
for _, tool := range resolvedTools {
180+
if err := stashRealBinary(tool.realPath, filepath.Join(stash, tool.tool)); err != nil {
181+
return err
182+
}
183+
if !tool.needsLink {
184+
continue
185+
}
186+
if _, seen := mountedDirs[tool.dir]; seen {
187+
continue
188+
}
189+
if err := mountPrivateToolDir(tool.dir); err != nil {
190+
return err
191+
}
192+
mountedDirs[tool.dir] = struct{}{}
193+
}
194+
return nil
195+
}
196+
197+
// installToolShims binds the consumer shim into each wrapped tool's canonical
198+
// location after the private staging area is ready.
199+
func installToolShims(resolvedTools []resolvedTool, shim string) error {
200+
for _, tool := range resolvedTools {
201+
if err := installToolShim(tool, jailStashPath, shim); err != nil {
117202
return err
118203
}
119204
}
205+
return nil
206+
}
120207

208+
func setJailEnvironment() error {
121209
if err := os.Setenv(EnvJailed, "1"); err != nil {
122210
return fmt.Errorf("sandbox: set jailed env: %w", err)
123211
}
212+
return nil
213+
}
124214

215+
func clearAmbientCaps() error {
125216
// All bind-mounts are done; the tool itself must not keep CAP_SYS_ADMIN.
126217
// Clear the ambient set so the cap does not leak into the exec'd tool.
127218
if err := unix.Prctl(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0); err != nil {
128219
return fmt.Errorf("sandbox: clear ambient caps: %w", err)
129220
}
221+
return nil
222+
}
130223

131-
// Drop the ability to gain privileges, then install the syscall denylist.
132-
// Order matters: all bind-mounts are done above, so mount() can be denied.
133-
if err := lockdownSyscalls(); err != nil {
134-
return err
135-
}
136-
224+
func execJailTarget(execTool string, rest []string) error {
137225
target := os.Getenv(RealBinEnv(execTool))
138226
if target == "" {
139227
return fmt.Errorf("sandbox: exec target %q was not stashed (not on PATH?)", execTool)
@@ -145,46 +233,51 @@ func RunJail(args []string) error {
145233

146234
// installToolShim records one wrapped tool's real target for the gate to exec,
147235
// then masks its canonical PATH entry with the consumer shim.
148-
func installToolShim(tool, stash, shim string) error {
149-
canonical, err := exec.LookPath(tool)
150-
if err != nil {
151-
// Tool not present on this host: nothing to shim.
152-
return nil //nolint:nilerr // absence is not an error; just skip
153-
}
154-
realPath := canonical
155-
if resolved, err := filepath.EvalSymlinks(canonical); err == nil {
156-
realPath = resolved
157-
}
158-
159-
realTarget := realPath
160-
if realPath == canonical {
161-
// Compiled binary at its own path: stash a bind before masking. argv0/$0
162-
// are irrelevant for an ELF, so exec'ing the stash copy is fine.
163-
stashed := filepath.Join(stash, tool)
164-
if err := touch(stashed); err != nil {
165-
return fmt.Errorf("sandbox: stash placeholder %s: %w", tool, err)
166-
}
167-
if err := unix.Mount(realPath, stashed, "", unix.MS_BIND, ""); err != nil {
168-
return fmt.Errorf("sandbox: stash bind %s: %w", tool, err)
169-
}
170-
realTarget = stashed
171-
} else {
172-
// Symlinked $0-sensitive tool (brew derives HOMEBREW_PREFIX from $0's
173-
// grandparent): exec a canonical-dir symlink so the prefix resolves right.
174-
link := filepath.Join(filepath.Dir(canonical), "."+tool+".cliguard")
236+
func installToolShim(tool resolvedTool, stash, shim string) error {
237+
stashed := filepath.Join(stash, tool.tool)
238+
realTarget := stashed
239+
if tool.needsLink {
240+
// Symlinked $0-sensitive tools need argv[0] to stay beside the canonical
241+
// entry, but the exec symlink itself must stay off the host filesystem.
242+
link := filepath.Join(tool.dir, "."+tool.tool+".cliguard")
175243
_ = os.Remove(link)
176-
if err := os.Symlink(realPath, link); err == nil {
177-
realTarget = link
244+
if err := os.Symlink(stashed, link); err != nil {
245+
return fmt.Errorf("sandbox: symlink exec target %s: %w", tool.tool, err)
178246
}
247+
realTarget = link
179248
}
180-
if err := os.Setenv(RealBinEnv(tool), realTarget); err != nil {
181-
return fmt.Errorf("sandbox: set realbin env %s: %w", tool, err)
249+
if err := os.Setenv(RealBinEnv(tool.tool), realTarget); err != nil {
250+
return fmt.Errorf("sandbox: set realbin env %s: %w", tool.tool, err)
182251
}
183252

253+
if err := touch(tool.canonical); err != nil {
254+
return fmt.Errorf("sandbox: ensure canonical entry %s: %w", tool.tool, err)
255+
}
184256
// Mask the canonical PATH/symlink entry with the shim (O_NOFOLLOW so the
185257
// bind lands on the symlink itself, leaving realTarget reachable).
186-
if err := maskWithShim(canonical, shim); err != nil {
187-
return fmt.Errorf("sandbox: shim mask %s: %w", tool, err)
258+
if err := maskWithShim(tool.canonical, shim); err != nil {
259+
return fmt.Errorf("sandbox: shim mask %s: %w", tool.tool, err)
260+
}
261+
return nil
262+
}
263+
264+
// stashRealBinary copies the resolved tool into the private tmpfs stash so the
265+
// jail can exec it after the canonical path is masked.
266+
func stashRealBinary(realPath, stashed string) error {
267+
if err := touch(stashed); err != nil {
268+
return fmt.Errorf("sandbox: stash placeholder %s: %w", filepath.Base(stashed), err)
269+
}
270+
if err := unix.Mount(realPath, stashed, "", unix.MS_BIND, ""); err != nil {
271+
return fmt.Errorf("sandbox: stash bind %s: %w", filepath.Base(stashed), err)
272+
}
273+
return nil
274+
}
275+
276+
// mountPrivateToolDir overlays a canonical tool directory with a private tmpfs
277+
// so sandbox-only exec symlinks never land on the host filesystem.
278+
func mountPrivateToolDir(dir string) error {
279+
if err := unix.Mount("tmpfs", dir, "tmpfs", 0, "mode=0700"); err != nil {
280+
return fmt.Errorf("sandbox: mount tool dir tmpfs %s: %w", dir, err)
188281
}
189282
return nil
190283
}

cli/sandbox/sandbox_linux_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ fi
130130
if !strings.Contains(got, "JAILED=1") {
131131
t.Skipf("sandbox did not engage (opted out or namespace jail unavailable); reroute not enforceable; log:\n%s", got)
132132
}
133+
link := filepath.Join(binDir, "."+fakeTool+".cliguard")
134+
if _, err := os.Lstat(link); !os.IsNotExist(err) {
135+
t.Fatalf("exec symlink leaked onto host filesystem at %s: %v", link, err)
136+
}
133137
if !strings.Contains(got, "SHIM byname") {
134138
t.Errorf("name-based grandchild call was not rerouted to the gate; log:\n%s", got)
135139
}

docs/sandbox.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ the tool and its descendants re-enter the gate (they can't reach a wrapped binar
55
without hitting the shim) and a seccomp denylist blocks escape syscalls. It is a
66
no-op off Linux. The jail is applied by `shell.Runner.Exec` when the runner
77
carries a non-nil `sandbox.Spec`; read-only `Capture` calls are never jailed.
8+
When a wrapped tool is `$0`-sensitive, the jail places its `.<tool>.cliguard`
9+
exec symlink in a private tmpfs view of the canonical directory so the host
10+
filesystem stays clean.
811

912
## When the environment can't jail
1013

0 commit comments

Comments
 (0)