Skip to content

Commit 6134fab

Browse files
authored
Merge pull request #90 from itmisx/feat/sandbox-landlock
✨ sandbox-landlock
2 parents f534f54 + 91d8393 commit 6134fab

6 files changed

Lines changed: 138 additions & 19 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
charm.land/lipgloss/v2 v2.0.3
1010
github.com/charmbracelet/x/ansi v0.11.7
1111
github.com/getcharzp/onnxruntime_purego v1.24.0
12+
github.com/landlock-lsm/go-landlock v0.8.1
1213
github.com/odvcencio/gotreesitter v0.19.1
1314
github.com/tiktoken-go/tokenizer v0.7.0
1415
golang.org/x/tools v0.45.0
@@ -45,4 +46,5 @@ require (
4546
golang.org/x/sync v0.20.0 // indirect
4647
golang.org/x/sys v0.44.0 // indirect
4748
golang.org/x/text v0.37.0 // indirect
49+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
4850
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
5757
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
5858
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
5959
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
60+
github.com/landlock-lsm/go-landlock v0.8.1 h1:Krs1co16IzN7bQcFYIdtNF+BKwZem3geRBkVsZtlCKU=
61+
github.com/landlock-lsm/go-landlock v0.8.1/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw=
6062
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
6163
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
6264
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
@@ -99,3 +101,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
99101
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
100102
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
101103
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
104+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
105+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=

main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"deepx/agent"
55
"deepx/config"
6+
"deepx/tools"
67
"deepx/tui"
78
"fmt"
89
"io"
@@ -19,6 +20,10 @@ var (
1920
)
2021

2122
func main() {
23+
// 必须最先调用:若本进程是 Landlock 沙箱跳板(Linux 无 bwrap 时走此路),施加写禁闭后 exec
24+
// 真正的命令、永不返回;否则立即返回,继续正常启动。其它平台为空实现。
25+
tools.RunSandboxTrampolineIfRequested()
26+
2227
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v" || os.Args[1] == "version") {
2328
fmt.Printf("deepx %s (commit %s, built %s)\n", version, commit, date)
2429
return

tools/sandbox_native.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
//
1010
// 思路:native 不再只靠黑名单字符串过滤,而是用各平台的 OS 机制做**文件写禁闭 + 进程隔离**:
1111
// - macOS: sandbox-exec(Seatbelt)——见 sandbox_native_darwin.go
12-
// - Linux: bubblewrap(bwrap)——见 sandbox_native_linux.go
12+
// - Linux: bubblewrap(bwrap)优先,无 bwrap 时退 Landlock(内核 ≥5.13)——见 sandbox_native_linux.go
1313
// - 其它(Windows 等):无 OS 隔离,退回软黑名单——见 sandbox_native_other.go
1414
//
1515
// 网络保持开(否则 go mod / npm / git fetch 全断)。读基本不限,只禁"写到 workspace 外"。

tools/sandbox_native_linux.go

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,51 @@ package tools
44

55
import (
66
"context"
7+
"os"
78
"os/exec"
9+
"path/filepath"
10+
"strings"
811
"sync"
12+
"syscall"
913
"time"
14+
15+
"github.com/landlock-lsm/go-landlock/landlock"
16+
llsys "github.com/landlock-lsm/go-landlock/landlock/syscall"
1017
)
1118

12-
// Linux native 隔离:用 bubblewrap(bwrap)。
13-
// 根挂只读、workspace+临时+缓存挂可写、PID/UTS/IPC namespace 隔进程(看不到也杀不到 host 进程)。
14-
// 网络保持开(不 --unshare-net)。bwrap 不在则退回裸 shell(由调用方决定是否套软黑名单)。
19+
// Linux native 隔离,三级择优:
20+
// 1. bubblewrap(bwrap):根只读 + 可写目录叠加 + PID/UTS/IPC namespace 隔进程。最强。
21+
// 2. Landlock(内核 ≥5.13):纯文件写禁闭(无进程隔离),按路径授权、不改文件标签。无需装任何东西。
22+
// 3. 都没有 → 退软黑名单(由 SandboxCheck 调 nativePolicyCheck)。
23+
// 网络始终开(否则 go mod / npm / git fetch 全断)。读不限,只禁"写到 workspace 外"。
1524
//
16-
// 没装 bwrap 的退路:目前直接退回软策略;landlock(内核 ≥5.13)做纯 FS 限制是后续可选项。
25+
// Landlock 的限制一旦施加于某进程便不可逆,所以不能加在长驻的 deepx 上,只能加在"将要执行命令的那个
26+
// 进程"里。做法是 re-exec 跳板:nativeShellCmd 让命令以「deepx 自身 + 一组 env 标记」启动,启动后的
27+
// deepx 在 main() 最早处(RunSandboxTrampolineIfRequested)识别标记 → 施加 Landlock → exec 真正的
28+
// sh -c <命令>。Landlock 限制随 execve 保留,从而约束到命令本身及其子进程。
29+
30+
const (
31+
sbxTrampolineEnv = "DEEPX_SBX_LANDLOCK" // =1 标记本进程是 Landlock 跳板
32+
sbxWritableEnv = "DEEPX_SBX_WRITABLE" // 可写根列表(PathListSeparator 分隔)
33+
sbxCmdEnv = "DEEPX_SBX_CMD" // 要执行的 shell 命令
34+
sbxCwdEnv = "DEEPX_SBX_CWD" // 工作目录
35+
)
1736

1837
var (
1938
bwrapProbeOnce sync.Once
2039
bwrapProbeOK bool
40+
llProbeOnce sync.Once
41+
llProbeOK bool
2142
)
2243

23-
// nativeIsolationAvailable 报告本机能否做 native OS 隔离。
24-
// 不只查 bwrap 是否存在,而是**实跑一个极简沙箱确认它真能用**——很多发行版禁用了非特权
25-
// user namespace,bwrap 装了也会在运行时报错。探测一次、整会话缓存。
26-
// 探测失败 → 一致退回软策略(裸 shell + 黑名单,状态面板显示"软策略")。
27-
func nativeIsolationAvailable() bool {
44+
// bwrapAvailable 实跑一个极简 bwrap 沙箱确认真能用(很多发行版禁用非特权 userns,装了也运行时报错)。
45+
func bwrapAvailable() bool {
2846
bwrapProbeOnce.Do(func() {
2947
if _, err := exec.LookPath("bwrap"); err != nil {
3048
return
3149
}
3250
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
3351
defer cancel()
34-
// 跑一个最小沙箱(含我们实际会用的 --unshare-pid):userns 被禁则此处失败
3552
err := exec.CommandContext(ctx, "bwrap",
3653
"--ro-bind", "/", "/", "--proc", "/proc", "--unshare-pid",
3754
"sh", "-c", ":").Run()
@@ -40,29 +57,114 @@ func nativeIsolationAvailable() bool {
4057
return bwrapProbeOK
4158
}
4259

43-
// nativeShellCmd 构造在 bwrap 沙箱里跑命令的 *exec.Cmd;bwrap 不可用则退回裸 shell。
60+
// landlockAvailable 仅查询内核 Landlock ABI 版本(纯探测,不施加任何限制)。≥1 即支持。
61+
func landlockAvailable() bool {
62+
llProbeOnce.Do(func() {
63+
if v, err := llsys.LandlockGetABIVersion(); err == nil && v >= 1 {
64+
llProbeOK = true
65+
}
66+
})
67+
return llProbeOK
68+
}
69+
70+
// nativeIsolationAvailable 报告本机能否做 native OS 隔离(bwrap 或 Landlock 任一)。
71+
// 都没有 → false,SandboxCheck 退软黑名单。探测各缓存一次。
72+
func nativeIsolationAvailable() bool {
73+
return bwrapAvailable() || landlockAvailable()
74+
}
75+
76+
// nativeShellCmd 按优先级构造隔离命令:bwrap > Landlock > 裸 shell。
4477
func nativeShellCmd(command, cwd string) *exec.Cmd {
45-
if !nativeIsolationAvailable() {
46-
return plainShellCmd(command, cwd)
78+
if bwrapAvailable() {
79+
return bwrapShellCmd(command, cwd)
80+
}
81+
if landlockAvailable() {
82+
if c := landlockShellCmd(command, cwd); c != nil {
83+
return c
84+
}
4785
}
86+
return plainShellCmd(command, cwd)
87+
}
88+
89+
// bwrapShellCmd 构造在 bwrap 沙箱里跑命令的 *exec.Cmd。
90+
func bwrapShellCmd(command, cwd string) *exec.Cmd {
4891
args := []string{
4992
"--ro-bind", "/", "/", // 整个根只读
5093
"--dev", "/dev", // 干净的 /dev
5194
"--proc", "/proc", // 配合 PID namespace 的新 /proc
5295
"--unshare-pid", "--unshare-uts", "--unshare-ipc", // 进程隔离
5396
"--die-with-parent", // deepx 退出则沙箱进程一起死
5497
}
55-
// 可写目录:在只读根之上叠加可写绑定(workspace + 临时 + 缓存)。
56-
// 用 --bind-try 而非 --bind:候选里含 macOS 专属路径(/private/tmp、~/Library/Caches),
57-
// 这些在 Linux 上不存在,普通 --bind 绑定不存在的 source 会致命报错
58-
// "bwrap: Can't find source path ..." 导致每条命令都挂(见 issue #68)。--bind-try 跳过即可。
98+
// 可写目录:在只读根之上叠加可写绑定。用 --bind-try 而非 --bind:候选含 macOS 专属路径
99+
// (/private/tmp、~/Library/Caches),Linux 上不存在,普通 --bind 绑不存在的 source 会致命报错。
59100
for _, p := range nativeWritableRoots(cwd) {
60101
args = append(args, "--bind-try", p, p)
61102
}
62103
if cwd != "" {
63104
args = append(args, "--chdir", cwd)
64105
}
65106
args = append(args, "sh", "-c", command)
66-
// cwd 通过 --chdir 传入沙箱;不设 c.Dir(宿主侧),避免与 bind 语义冲突
67107
return exec.Command("bwrap", args...)
68108
}
109+
110+
// landlockShellCmd 以 deepx 自身作 re-exec 跳板,带 env 标记启动;跳板进程负责施加 Landlock 再 exec 真命令。
111+
func landlockShellCmd(command, cwd string) *exec.Cmd {
112+
exe, err := os.Executable()
113+
if err != nil {
114+
return nil // 拿不到自身路径就放弃 Landlock,退裸 shell
115+
}
116+
c := exec.Command(exe)
117+
c.Env = append(os.Environ(),
118+
sbxTrampolineEnv+"=1",
119+
sbxWritableEnv+"="+strings.Join(nativeWritableRoots(cwd), string(os.PathListSeparator)),
120+
sbxCmdEnv+"="+command,
121+
sbxCwdEnv+"="+cwd,
122+
)
123+
return c
124+
}
125+
126+
// RunSandboxTrampolineIfRequested 必须在 main() 最早处调用。
127+
// 若本进程带 Landlock 跳板标记:施加"读全局 / 只写可写根"的 Landlock 写禁闭,然后 exec sh -c <命令>,
128+
// 永不返回。否则立即返回,deepx 正常启动。
129+
func RunSandboxTrampolineIfRequested() {
130+
if os.Getenv(sbxTrampolineEnv) != "1" {
131+
return
132+
}
133+
cwd := os.Getenv(sbxCwdEnv)
134+
command := os.Getenv(sbxCmdEnv)
135+
var roots []string
136+
if w := os.Getenv(sbxWritableEnv); w != "" {
137+
roots = filepath.SplitList(w)
138+
}
139+
if cwd != "" {
140+
_ = os.Chdir(cwd)
141+
}
142+
143+
// 读全局(RODirs 含执行权限,能跑二进制),只写可写根。缓存目录可能不存在 → IgnoreIfMissing。
144+
rules := []landlock.Rule{landlock.RODirs("/")}
145+
for _, r := range roots {
146+
if r != "" {
147+
rules = append(rules, landlock.RWDirs(r).IgnoreIfMissing())
148+
}
149+
}
150+
// BestEffort:内核版本不够则尽力而为,绝不因 Landlock 失败而拒跑命令(最坏退化为不隔离)。
151+
_ = landlock.V5.BestEffort().RestrictPaths(rules...)
152+
153+
// 清掉跳板自用的 env,避免泄漏给子命令(否则子命令里再起 deepx 会被误判为跳板)。
154+
env := make([]string, 0, len(os.Environ()))
155+
for _, kv := range os.Environ() {
156+
if strings.HasPrefix(kv, sbxTrampolineEnv+"=") || strings.HasPrefix(kv, sbxWritableEnv+"=") ||
157+
strings.HasPrefix(kv, sbxCmdEnv+"=") || strings.HasPrefix(kv, sbxCwdEnv+"=") {
158+
continue
159+
}
160+
env = append(env, kv)
161+
}
162+
163+
sh, err := exec.LookPath("sh")
164+
if err != nil {
165+
sh = "/bin/sh"
166+
}
167+
// exec 替换当前进程映像;Landlock 域随 execve 保留 → 真正约束 sh 及其后代。
168+
_ = syscall.Exec(sh, []string{"sh", "-c", command}, env)
169+
os.Exit(127) // 只有 exec 失败才会走到这
170+
}

tools/sandbox_trampoline_other.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//go:build !linux
2+
3+
package tools
4+
5+
// 仅 Linux 用 Landlock re-exec 跳板;其它平台无此机制,空实现,main() 调用它即立即返回。
6+
func RunSandboxTrampolineIfRequested() {}

0 commit comments

Comments
 (0)