1 Commits
Author SHA1 Message Date
WHC 32b4afd772 v0.7.1: allow network inside the run_shell sandbox
ci / linux (clang) (push) Successful in 4m44s
ci / linux (gcc) (push) Successful in 5m36s
ci / musl (push) Failing after 29s
ci / windows-cross (push) Successful in 31m37s
Landlock no longer handles TCP, the Seatbelt profile drops (deny network*),
and the seccomp filter stops refusing IPv4/IPv6 sockets. Sandboxed commands
run without asking in auto mode on every backend (Low-IL no longer asks for
network commands), and connection errors are not treated as sandbox blocks.
2026-10-08 21:05:14 +08:00
8 changed files with 36 additions and 62 deletions
+4 -4
View File
@@ -277,16 +277,16 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。 `ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
- **沙箱**(C 实现,`src/sandbox_exec.c`): - **沙箱**(C 实现,`src/sandbox_exec.c`):
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`; - Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。 读、执行和联网不受限。
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify - Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给 (界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。 agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。 读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。 探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。 比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。 - macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时 - Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网,所以联网命令仍会询问。 给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。 `/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 - 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。 - `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
+2 -3
View File
@@ -111,9 +111,8 @@ windows-x86_64/
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行, 以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
界面会问是否在沙箱外重跑。请在项目目录里启动。 界面会问是否在沙箱外重跑。请在项目目录里启动。
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。 - `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**。所以像联网的命令 - 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**:`curl`、`git pull`、
(`curl`、`wget`、`ssh`、`git clone/fetch/pull/push`、`npm install`、`pip install`、 `npm install` 等联网命令在 `auto` 模式下也直接在沙箱里运行。
`winget install`、`Invoke-WebRequest` 等)在 `auto` 模式下仍然会弹窗确认。
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问 - 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。 “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化 - MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
+6 -5
View File
@@ -42,13 +42,14 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
when one is available. Commands may read and execute anything but may only when one is available. Commands may read and execute anything but may only
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`. write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
The network is not restricted.
| Platform | Sandbox | Notes | | Platform | Sandbox | Notes |
|---|---|---| |---|---|---|
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind | | Linux 5.13+ | Landlock (raw syscalls, no extra library) | |
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below | | Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | see below |
| macOS | `sandbox_init` profile | also denies network | | macOS | `sandbox_init` profile | |
| Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); network is not blocked, so network commands still ask; see `README-windows.md` | | Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); see `README-windows.md` |
| Older kernels | none | falls back to the read-only rules | | Older kernels | none | falls back to the read-only rules |
The status bar and sidebar show `auto · landlock`, `auto · seccomp`, The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
@@ -165,7 +166,7 @@ Chinese VMs as Administrator, a standard user and a Chinese user name):
keep CRLF and BOM; `run_shell` output in Chinese is readable on a keep CRLF and BOM; `run_shell` output in Chinese is readable on a
936 system 936 system
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside` - [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
asks to rerun; `curl ...` asks before running; `/sandbox reset-labels` asks to rerun; `curl ...` runs without asking; `/sandbox reset-labels`
restores the label restores the label
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no - [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
`busybox.exe` behind `busybox.exe` behind
+7 -8
View File
@@ -2,20 +2,19 @@
#define CST_SANDBOX_H #define CST_SANDBOX_H
/* /*
* OS sandbox for run_shell: reads and exec stay allowed everywhere, writes * OS sandbox for run_shell: reads, exec and the network stay allowed;
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and * writes are limited to the workspace, the temp dir, /dev/null and /dev/tty.
* the network is cut where the OS can do it. * Linux Landlock
* Linux Landlock (network needs ABI 4, kernel 6.7+)
* Linux seccomp user-notify when Landlock is missing (Android/Termux, * Linux seccomp user-notify when Landlock is missing (Android/Termux,
* older kernels): a supervisor thread checks the path of every * older kernels): a supervisor thread checks the path of every
* write-type syscall and IPv4/IPv6 sockets are refused. Guards * write-type syscall. Guards
* against mistakes, not hostile code (the path is checked before * against mistakes, not hostile code (the path is checked before
* the kernel reads it again). * the kernel reads it again).
* macOS sandbox_init (Seatbelt profile) * macOS sandbox_init (Seatbelt profile)
* Windows Low integrity level: a restricted token (no admin privileges, * Windows Low integrity level: a restricted token (no admin privileges,
* Low IL) can only write objects labeled Low, so the workspace and * Low IL) can only write objects labeled Low, so the workspace and
* a temp dir get that label; the credentials file is labeled * a temp dir get that label; the credentials file is labeled
* no-read-up. The network stays open (callers ask first). * no-read-up.
* other none: callers fall back to rule-based approval * other none: callers fall back to rule-based approval
*/ */
@@ -42,7 +41,7 @@ int cst_sandbox_probe(void);
int cst_sandbox_reprobe(void); int cst_sandbox_reprobe(void);
/* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */ /* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */
const char *cst_sandbox_name(int kind); const char *cst_sandbox_name(int kind);
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */ /* Landlock ABI version (0 when unavailable). */
int cst_sandbox_landlock_abi(void); int cst_sandbox_landlock_abi(void);
typedef struct { typedef struct {
@@ -69,7 +68,7 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
size_t max_out, os_run_result_t *r); size_t max_out, os_run_result_t *r);
/* Output of a failed sandboxed run that looks like the sandbox got in the /* Output of a failed sandboxed run that looks like the sandbox got in the
* way (EACCES / EPERM / EROFS / blocked network). */ * way (EACCES / EPERM / EROFS). */
int cst_sandbox_blocked(const char *out, size_t n, int exit_code); int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
/* lowil: directories this user's cst-agent labeled Low (recorded in the /* lowil: directories this user's cst-agent labeled Low (recorded in the
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 7 #define CST_VERSION_MINOR 7
#define CST_VERSION_PATCH 0 #define CST_VERSION_PATCH 1
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+5 -11
View File
@@ -259,7 +259,7 @@ static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
"the task requires it. Never print secrets or API keys.\n"); "the task requires it. Never print secrets or API keys.\n");
if (tool_on(cfg, "run_shell")) if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the " cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the "
"workspace and temp dir, no network). If a command is " "workspace and temp dir; the network works). If a command is "
"blocked the user is asked whether to rerun it outside; " "blocked the user is asked whether to rerun it outside; "
"do not try to work around the sandbox.\n"); "do not try to work around the sandbox.\n");
} }
@@ -827,13 +827,9 @@ static void sandbox_escalate(turn_t *t, cst_tool_ctx_t *ctx,
} }
if (ans == CST_PERM_DENY) { if (ans == CST_PERM_DENY) {
const char *note = const char *note =
cst_sandbox_probe() == CST_SANDBOX_LOWIL "\n[cst: blocked by the sandbox (writes are limited to the "
? "\n[cst: blocked by the sandbox (writes are limited to the "
"workspace and temp dir); the user did not allow running it " "workspace and temp dir); the user did not allow running it "
"outside]\n" "outside]\n";
: "\n[cst: blocked by the sandbox (writes are limited to "
"the workspace and temp dir, no network); the user did "
"not allow running it outside]\n";
cst_buf_t b = {0}; cst_buf_t b = {0};
if (tr->out) if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len); cst_buf_append(&b, tr->out, tr->out_len);
@@ -989,11 +985,9 @@ static void exec_begin(turn_t *t, pcall_t *pc, exec_t *x) {
if (def->perm == CST_TP_WRITE) { if (def->perm == CST_TP_WRITE) {
ask = 0; ask = 0;
} else if (def->perm == CST_TP_SHELL) { } else if (def->perm == CST_TP_SHELL) {
int sb = cst_sandbox_probe(); if (cst_sandbox_probe() != CST_SANDBOX_NONE) {
if (sb != CST_SANDBOX_NONE) {
ctx->sandbox = 1; ctx->sandbox = 1;
/* the Low-IL sandbox does not stop network access */ ask = 0;
ask = sb == CST_SANDBOX_LOWIL && cmd && cst_shell_uses_network(cmd);
} else if (cmd && cst_shell_is_safe(cmd)) { } else if (cmd && cst_shell_is_safe(cmd)) {
ask = 0; ask = 0;
} }
+5 -19
View File
@@ -86,8 +86,6 @@ static int g_abi;
#define LL_FS_MAKE_SYM (1ULL << 12) #define LL_FS_MAKE_SYM (1ULL << 12)
#define LL_FS_REFER (1ULL << 13) /* ABI 2 */ #define LL_FS_REFER (1ULL << 13) /* ABI 2 */
#define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */ #define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */
#define LL_NET_BIND_TCP (1ULL << 0) /* ABI 4 */
#define LL_NET_CONNECT_TCP (1ULL << 1) /* ABI 4 */
struct ll_ruleset_attr { struct ll_ruleset_attr {
uint64_t handled_access_fs; uint64_t handled_access_fs;
@@ -137,9 +135,7 @@ static int ll_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
int abi = g_abi; int abi = g_abi;
struct ll_ruleset_attr attr; struct ll_ruleset_attr attr;
memset(&attr, 0, sizeof(attr)); memset(&attr, 0, sizeof(attr));
attr.handled_access_fs = ll_write_rights(abi); attr.handled_access_fs = ll_write_rights(abi); /* network stays open */
if (abi >= 4)
attr.handled_access_net = LL_NET_BIND_TCP | LL_NET_CONNECT_TCP;
int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0); int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
if (fd < 0) if (fd < 0)
return -1; return -1;
@@ -193,8 +189,7 @@ static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
"(version 1)(allow default)(deny file-write*)" "(version 1)(allow default)(deny file-write*)"
"(allow file-write* (subpath \"%s\") (subpath \"%s\") " "(allow file-write* (subpath \"%s\") (subpath \"%s\") "
"(literal \"/dev/null\") (literal \"/dev/tty\") " "(literal \"/dev/null\") (literal \"/dev/tty\") "
"(literal \"/dev/dtracehelper\"))" "(literal \"/dev/dtracehelper\"))",
"(deny network*)",
qws, qtmp); qws, qtmp);
return 0; return 0;
} }
@@ -290,9 +285,8 @@ static const int SC_NOTIFY[] = {
#endif #endif
}; };
/* Foreign ABIs are refused, IPv4/IPv6 sockets fail with EACCES, opens /* Foreign ABIs are refused, opens without write flags pass untouched,
* without write flags pass untouched, write-type calls go to the * write-type calls go to the supervisor. */
* supervisor. */
static void sc_build(sc_prog_t *p) { static void sc_build(sc_prog_t *p) {
p->n = 0; p->n = 0;
SC_LD(offsetof(struct seccomp_data, arch)); SC_LD(offsetof(struct seccomp_data, arch));
@@ -303,12 +297,6 @@ static void sc_build(sc_prog_t *p) {
sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */ sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */
SC_RET(SECCOMP_RET_ERRNO | EPERM); SC_RET(SECCOMP_RET_ERRNO | EPERM);
#endif #endif
SC_JEQ(__NR_socket, 0, 5);
SC_LD(SC_ARG(0));
SC_JEQ(AF_INET, 2, 0);
SC_JEQ(AF_INET6, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_ERRNO | EACCES);
SC_JEQ(__NR_io_uring_setup, 0, 1); SC_JEQ(__NR_io_uring_setup, 0, 1);
SC_RET(SECCOMP_RET_ERRNO | EPERM); SC_RET(SECCOMP_RET_ERRNO | EPERM);
SC_JEQ(__NR_openat, 0, 4); SC_JEQ(__NR_openat, 0, 4);
@@ -1446,9 +1434,7 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
static const char *const SIGNS[] = { static const char *const SIGNS[] = {
"Permission denied", "Read-only file system", "Operation not permitted", "Permission denied", "Read-only file system", "Operation not permitted",
"EACCES", "EPERM", "EROFS", "EACCES", "EPERM", "EROFS",
"[cst: sandbox", "Network is unreachable", "[cst: sandbox",
/* curl when the socket itself was refused (seccomp) */
"Could not connect to server", "Failed to connect to",
/* localized strerror(EACCES/EPERM/EROFS) */ /* localized strerror(EACCES/EPERM/EROFS) */
"权限不够", "不允许的操作", "只读文件系统", "權限不足", "权限不够", "不允许的操作", "只读文件系统", "權限不足",
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung", "拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
+4 -9
View File
@@ -72,7 +72,7 @@ static void test_blocked(void) {
CHECK(cst_sandbox_blocked(c, strlen(c), 1)); CHECK(cst_sandbox_blocked(c, strlen(c), 1));
const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 " const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 "
"ms: Could not connect to server\n"; "ms: Could not connect to server\n";
CHECK(cst_sandbox_blocked(d, strlen(d), 7)); CHECK(!cst_sandbox_blocked(d, strlen(d), 7)); /* network is not sandboxed */
const char *e = "Access is denied.\r\n"; const char *e = "Access is denied.\r\n";
CHECK(cst_sandbox_blocked(e, strlen(e), 1)); CHECK(cst_sandbox_blocked(e, strlen(e), 1));
const char *f = "拒绝访问。\r\n"; const char *f = "拒绝访问。\r\n";
@@ -132,7 +132,7 @@ static int run(const char *ws, const char *cmd, os_run_result_t *r) {
return r->exit_code; return r->exit_code;
} }
/* Child: enter the sandbox, then try a TCP connect to a local listener. */ /* Child: enter the sandbox, then a TCP connect to a local listener works. */
static void test_network(const char *ws) { static void test_network(const char *ws) {
int ls = socket(AF_INET, SOCK_STREAM, 0); int ls = socket(AF_INET, SOCK_STREAM, 0);
CHECK(ls >= 0); CHECK(ls >= 0);
@@ -152,12 +152,11 @@ static void test_network(const char *ws) {
if (pid == 0) { if (pid == 0) {
if (cst_sandbox_enter(&plan) != 0) if (cst_sandbox_enter(&plan) != 0)
_exit(3); _exit(3);
/* Landlock refuses the connect, seccomp already the socket */
int s = socket(AF_INET, SOCK_STREAM, 0); int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) if (s < 0)
_exit(errno == EACCES ? 0 : 2); _exit(errno == EACCES ? 1 : 2);
int rc = connect(s, (struct sockaddr *)&a, sizeof(a)); int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
_exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2); _exit(rc == 0 ? 0 : errno == EACCES ? 1 : 2);
} }
int st = 0; int st = 0;
waitpid(pid, &st, 0); waitpid(pid, &st, 0);
@@ -337,11 +336,7 @@ static void test_confined(int kind) {
CHECK(fp); CHECK(fp);
fclose(fp); fclose(fp);
if (kind == CST_SANDBOX_SECCOMP || cst_sandbox_landlock_abi() >= 4)
test_network(ws); test_network(ws);
else
printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n",
cst_sandbox_landlock_abi());
snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out); snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out);
CHECK(system(cmd) == 0); CHECK(system(cmd) == 0);