diff --git a/README.md b/README.md index 40a5461..38d9f07 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ | `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/.jsonl`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | -| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off` | 审批模式 / 关闭沙箱(回落到只读白名单规则) | +| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 | | `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox | ## Run @@ -269,9 +269,14 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 - **沙箱**(C 实现,`src/sandbox_exec.c`): - 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 + (界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给 + agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。 + 读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。 + 比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。 - macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。 - - Android / Termux、Windows、老内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 - - `CST_SANDBOX=off` 强制关闭沙箱。 + - Windows、更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 + - `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` 只用指定的那种。 - 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问 “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。 - **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree` diff --git a/docs/compat.md b/docs/compat.md index a5222cf..27073bf 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -46,17 +46,35 @@ write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`. | Platform | Sandbox | Notes | |---|---|---| | Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind | +| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below | | macOS | `sandbox_init` profile | also denies network | -| Android / Termux | none | falls back to the read-only rules | -| WSL 2 | Landlock when the WSL kernel enables it | otherwise rules | | Older kernels, Windows | none | falls back to the read-only rules | -The status bar and sidebar show `auto · landlock`, `auto · seatbelt` or -`auto · rules`. `CST_SANDBOX=off` disables the sandbox. When a sandboxed -command looks blocked, the TUI asks whether to rerun it outside the sandbox. -The ruleset is built in the parent; the forked child only calls `prctl` and -`landlock_restrict_self` (async-signal-safe), so the agent itself is never -confined. +The status bar and sidebar show `auto · landlock`, `auto · seccomp`, +`auto · seatbelt` or `auto · rules`. `CST_SANDBOX=off` disables the +sandbox; `CST_SANDBOX=landlock` or `CST_SANDBOX=seccomp` allows only that +kind. When a sandboxed command looks blocked, the TUI asks whether to rerun +it outside the sandbox. The policy is built in the parent; the forked child +only makes a few async-signal-safe syscalls before `exec`, so the agent +itself is never confined. + +**seccomp.** Used when Landlock is missing, e.g. Android GKI kernels. The +child installs a seccomp filter with a user-notify listener and passes the +listener to a supervisor thread in the agent. Opens with write flags and +every create / remove / rename / link / chmod / chown / utimes / xattr call +stop until the supervisor has read the path from the child's memory, +resolved it (cwd, dirfd, `..`, symlinks, `/proc/self`, `/dev/stdout`) and +answered: continue when it lands in the allowed places, `EACCES` otherwise. +Reads, exec and writes to already-open files are not checked. Background +jobs that outlive their shell are no longer the agent's descendants: with +Yama `ptrace_scope` ≥ 1 (common on desktop distros, absent on Android) the +supervisor cannot read their memory and their checked writes fail with +`EACCES`; once the run has finished they get `ENOSYS`. This keeps +mistakes out of the rest of the disk but is not a security boundary: the +kernel reads the path again after the check, so a hostile multi-threaded +program can swap it in between. The availability check runs in a +throwaway child, so a platform seccomp policy that kills the caller cannot +take the agent down. ## Terminal notes @@ -106,7 +124,7 @@ TUI: - [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette - [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked - [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the - sandbox?" (Linux 5.13+/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks + sandbox?" (Linux/Termux/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks - [ ] Resize the terminal during streaming: layout recovers - [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens diff --git a/docs/env_openai.md b/docs/env_openai.md index 2ffc6a2..d3d1c0a 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -78,7 +78,7 @@ export OPENAI_MODEL='your-model' | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | | `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(= `--approval yolo`;headless 默认开启) | | `CST_APPROVAL` | 审批模式 `ask` / `auto`(默认)/ `yolo` | -| `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / macOS sandbox),`auto` 回落到只读白名单 | +| `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / seccomp / macOS sandbox),`auto` 回落到只读白名单;`landlock` / `seccomp` 只允许该种 | | `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) | | `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) | diff --git a/include/cst_sandbox.h b/include/cst_sandbox.h index 12fac80..555d63f 100644 --- a/include/cst_sandbox.h +++ b/include/cst_sandbox.h @@ -6,6 +6,11 @@ * are limited to the workspace, the temp dir, /dev/null and /dev/tty, and * the network is cut where the OS can do it. * Linux Landlock (network needs ABI 4, kernel 6.7+) + * Linux seccomp user-notify when Landlock is missing (Android/Termux, + * older kernels): a supervisor thread checks the path of every + * write-type syscall and IPv4/IPv6 sockets are refused. Guards + * against mistakes, not hostile code (the path is checked before + * the kernel reads it again). * macOS sandbox_init (Seatbelt profile) * other none: callers fall back to rule-based approval */ @@ -21,12 +26,17 @@ extern "C" { typedef enum { CST_SANDBOX_NONE = 0, CST_SANDBOX_LANDLOCK, - CST_SANDBOX_SEATBELT + CST_SANDBOX_SEATBELT, + CST_SANDBOX_SECCOMP } cst_sandbox_kind_t; -/* What this process can use; cached. CST_SANDBOX=0 forces NONE. */ +/* What this process can use; cached. CST_SANDBOX=off|0|none forces NONE, + * CST_SANDBOX=landlock|seccomp allows only that kind. */ int cst_sandbox_probe(void); -const char *cst_sandbox_name(int kind); /* "landlock" | "seatbelt" | "none" */ +/* Drops the cache and probes again (tests that change CST_SANDBOX). */ +int cst_sandbox_reprobe(void); +/* "landlock" | "seatbelt" | "seccomp" | "none" */ +const char *cst_sandbox_name(int kind); /* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */ int cst_sandbox_landlock_abi(void); @@ -34,13 +44,14 @@ typedef struct { int kind; int ruleset_fd; /* Landlock, built in the parent */ char *profile; /* Seatbelt */ + void *sc; /* seccomp: filter program and supervisor thread */ } cst_sandbox_plan_t; /* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */ int cst_sandbox_prepare(const char *workspace, const char *tmpdir, cst_sandbox_plan_t *plan); /* Child side, after fork and before exec (os_run_capture_ex hook): - * async-signal-safe for Landlock. Returns 0 when confined. */ + * async-signal-safe. Returns 0 when confined. */ int cst_sandbox_enter(void *plan); void cst_sandbox_release(cst_sandbox_plan_t *plan); diff --git a/src/sandbox_exec.c b/src/sandbox_exec.c index 4107415..9a3d750 100644 --- a/src/sandbox_exec.c +++ b/src/sandbox_exec.c @@ -21,6 +21,30 @@ #include #endif +#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(SECCOMP_RET_USER_NOTIF) && defined(SECCOMP_IOCTL_NOTIF_RECV) && \ + defined(SECCOMP_FILTER_FLAG_NEW_LISTENER) +#define CST_HAVE_SECCOMP 1 +#endif +#endif + static int g_kind = -1; static int g_abi; @@ -174,26 +198,712 @@ static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) { #endif /* CST_HAVE_SEATBELT */ +/* --- Linux: seccomp user-notify ------------------------------------------- */ +#ifdef CST_HAVE_SECCOMP + +#ifndef __NR_seccomp +#if defined(__x86_64__) +#define __NR_seccomp 317 +#else +#define __NR_seccomp 277 +#endif +#endif +#ifndef __NR_openat2 +#define __NR_openat2 437 +#endif +#ifndef __NR_fchmodat2 +#define __NR_fchmodat2 452 +#endif +#ifndef __NR_io_uring_setup +#define __NR_io_uring_setup 425 +#endif +#ifndef SECCOMP_SET_MODE_FILTER +#define SECCOMP_SET_MODE_FILTER 1 +#endif +#ifndef SECCOMP_USER_NOTIF_FLAG_CONTINUE +#define SECCOMP_USER_NOTIF_FLAG_CONTINUE (1UL << 0) +#endif +#ifndef PR_SET_NO_NEW_PRIVS +#define PR_SET_NO_NEW_PRIVS 38 +#endif +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif + +#if defined(__x86_64__) +#define SC_ARCH AUDIT_ARCH_X86_64 +#else +#define SC_ARCH AUDIT_ARCH_AARCH64 +#endif + +#define SC_WRITE_FLAGS (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC) +#define SC_PROG_MAX 160 + +typedef struct { + struct sock_filter f[SC_PROG_MAX]; + unsigned short n; +} sc_prog_t; + +typedef struct { + sc_prog_t prog; + int sock[2]; /* the child sends its listener fd on [1] */ + int stop[2]; /* release wakes the supervisor through [1] */ + pthread_t th; + int started; + char ws[OS_PATH_MAX]; + char tmp[OS_PATH_MAX]; +} sc_state_t; + +static void sc_emit(sc_prog_t *p, uint16_t code, uint8_t jt, uint8_t jf, + uint32_t k) { + if (p->n < SC_PROG_MAX) { + struct sock_filter ins = {code, jt, jf, k}; + p->f[p->n++] = ins; + } +} + +#define SC_LD(off) sc_emit(p, BPF_LD | BPF_W | BPF_ABS, 0, 0, (uint32_t)(off)) +#define SC_RET(k) sc_emit(p, BPF_RET | BPF_K, 0, 0, (uint32_t)(k)) +#define SC_JEQ(k, jt, jf) \ + sc_emit(p, BPF_JMP | BPF_JEQ | BPF_K, jt, jf, (uint32_t)(k)) +#define SC_JSET(k, jt, jf) \ + sc_emit(p, BPF_JMP | BPF_JSET | BPF_K, jt, jf, (uint32_t)(k)) +#define SC_ARG(i) (offsetof(struct seccomp_data, args) + (i) * sizeof(uint64_t)) + +/* Syscalls whose path arguments the supervisor always checks. */ +static const int SC_NOTIFY[] = { + __NR_openat2, __NR_mkdirat, __NR_mknodat, __NR_unlinkat, + __NR_symlinkat, __NR_linkat, __NR_renameat2, __NR_truncate, + __NR_fchmodat, __NR_fchmodat2, __NR_fchownat, __NR_utimensat, + __NR_setxattr, __NR_lsetxattr, __NR_removexattr, __NR_lremovexattr, +#ifdef __NR_renameat + __NR_renameat, +#endif +#if defined(__x86_64__) + __NR_creat, __NR_mkdir, __NR_mknod, __NR_rmdir, + __NR_unlink, __NR_rename, __NR_link, __NR_symlink, + __NR_chmod, __NR_chown, __NR_lchown, __NR_utime, + __NR_utimes, __NR_futimesat, +#endif +}; + +/* Foreign ABIs are refused, IPv4/IPv6 sockets fail with EACCES, opens + * without write flags pass untouched, write-type calls go to the + * supervisor. */ +static void sc_build(sc_prog_t *p) { + p->n = 0; + SC_LD(offsetof(struct seccomp_data, arch)); + SC_JEQ(SC_ARCH, 1, 0); + SC_RET(SECCOMP_RET_ERRNO | EPERM); + SC_LD(offsetof(struct seccomp_data, nr)); +#if defined(__x86_64__) + sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */ + SC_RET(SECCOMP_RET_ERRNO | EPERM); +#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_RET(SECCOMP_RET_ERRNO | EPERM); + SC_JEQ(__NR_openat, 0, 4); + SC_LD(SC_ARG(2)); + SC_JSET(SC_WRITE_FLAGS, 1, 0); + SC_RET(SECCOMP_RET_ALLOW); + SC_RET(SECCOMP_RET_USER_NOTIF); +#if defined(__x86_64__) + SC_JEQ(__NR_open, 0, 4); + SC_LD(SC_ARG(1)); + SC_JSET(SC_WRITE_FLAGS, 1, 0); + SC_RET(SECCOMP_RET_ALLOW); + SC_RET(SECCOMP_RET_USER_NOTIF); +#endif + size_t n = sizeof(SC_NOTIFY) / sizeof(SC_NOTIFY[0]); + for (size_t i = 0; i < n; i++) + SC_JEQ(SC_NOTIFY[i], (uint8_t)(n - i), 0); + SC_RET(SECCOMP_RET_ALLOW); + SC_RET(SECCOMP_RET_USER_NOTIF); +} + +static int sc_send_fd(int s, int fd) { + char c = 0; + union { + char buf[CMSG_SPACE(sizeof(int))]; + struct cmsghdr align; + } u; + struct iovec io = {&c, 1}; + struct msghdr m; + memset(&m, 0, sizeof(m)); + memset(&u, 0, sizeof(u)); + m.msg_iov = &io; + m.msg_iovlen = 1; + m.msg_control = u.buf; + m.msg_controllen = sizeof(u.buf); + struct cmsghdr *h = CMSG_FIRSTHDR(&m); + h->cmsg_level = SOL_SOCKET; + h->cmsg_type = SCM_RIGHTS; + h->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(h), &fd, sizeof(int)); + return sendmsg(s, &m, MSG_NOSIGNAL) == 1 ? 0 : -1; +} + +static int sc_recv_fd(int s) { + char c; + union { + char buf[CMSG_SPACE(sizeof(int))]; + struct cmsghdr align; + } u; + struct iovec io = {&c, 1}; + struct msghdr m; + memset(&m, 0, sizeof(m)); + m.msg_iov = &io; + m.msg_iovlen = 1; + m.msg_control = u.buf; + m.msg_controllen = sizeof(u.buf); + if (recvmsg(s, &m, MSG_CMSG_CLOEXEC) != 1) + return -1; + struct cmsghdr *h = CMSG_FIRSTHDR(&m); + if (!h || h->cmsg_level != SOL_SOCKET || h->cmsg_type != SCM_RIGHTS) + return -1; + int fd; + memcpy(&fd, CMSG_DATA(h), sizeof(int)); + return fd; +} + +/* Reads child memory one page at a time (a range may end at a hole). */ +static ssize_t sc_peek(int pid, uint64_t addr, void *out, size_t n) { + struct iovec l = {out, n}, r = {(void *)(uintptr_t)addr, n}; + ssize_t k = process_vm_readv(pid, &l, 1, &r, 1, 0); + if (k > 0) + return k; + char mp[64]; + snprintf(mp, sizeof(mp), "/proc/%d/mem", pid); + int fd = open(mp, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + k = pread(fd, out, n, (off_t)addr); + close(fd); + return k; +} + +static int sc_read_mem(int pid, uint64_t addr, void *out, size_t n) { + size_t got = 0; + while (got < n) { + uint64_t a = addr + got; + size_t chunk = 4096 - (size_t)(a & 4095); + if (chunk > n - got) + chunk = n - got; + ssize_t k = sc_peek(pid, a, (char *)out + got, chunk); + if (k <= 0) + return -1; + got += (size_t)k; + } + return 0; +} + +static int sc_read_str(int pid, uint64_t addr, char *out, size_t n) { + size_t got = 0; + while (got + 1 < n) { + uint64_t a = addr + got; + size_t chunk = 4096 - (size_t)(a & 4095); + if (chunk > n - 1 - got) + chunk = n - 1 - got; + ssize_t k = sc_peek(pid, a, out + got, chunk); + if (k <= 0) + return -1; + if (memchr(out + got, 0, (size_t)k)) + return 0; + got += (size_t)k; + } + return -1; +} + +static int sc_readlink(const char *link, char *out, size_t n) { + ssize_t k = readlink(link, out, n - 1); + if (k < 0) + return -1; + out[k] = 0; + return 0; +} + +/* strict: the root itself does not count (it may not be removed). */ +static int sc_under(const char *root, const char *p, int strict) { + size_t n = strlen(root); + if (n == 0) + return 0; + if (n == 1 && root[0] == '/') + return p[0] == '/' && (!strict || p[1]); + if (strncmp(p, root, n) != 0) + return 0; + if (p[n] == 0) + return !strict; + return p[n] == '/'; +} + +static int sc_inside(const sc_state_t *s, const char *p, int strict) { + static const char *const DEV[] = {"/dev/null", "/dev/zero", "/dev/full", + "/dev/tty", "/dev/ptmx", "/dev/random", + "/dev/urandom"}; + if (sc_under(s->ws, p, strict) || sc_under(s->tmp, p, strict)) + return 1; + for (size_t i = 0; i < sizeof(DEV) / sizeof(DEV[0]); i++) + if (strcmp(p, DEV[i]) == 0) + return 1; + return strncmp(p, "/dev/pts/", 9) == 0; +} + +/* The file behind fd (or the cwd for AT_FDCWD). Pipes, sockets and other + * anonymous files are fine; unreadable links are refused. */ +static int sc_fd_ok(const sc_state_t *s, int pid, int fd, int strict) { + char link[64], tgt[OS_PATH_MAX]; + if (fd == AT_FDCWD) + snprintf(link, sizeof(link), "/proc/%d/cwd", pid); + else + snprintf(link, sizeof(link), "/proc/%d/fd/%d", pid, fd); + if (sc_readlink(link, tgt, sizeof(tgt)) != 0) + return 0; + return tgt[0] != '/' || sc_inside(s, tgt, strict); +} + +static int sc_digits(const char **q) { + const char *p = *q; + while (**q >= '0' && **q <= '9') + (*q)++; + return *q > p; +} + +/* /proc/N/fd/M or /proc/N/task/T/fd/M */ +static int sc_is_fd_link(const char *p) { + if (strncmp(p, "/proc/", 6) != 0) + return 0; + p += 6; + if (!sc_digits(&p)) + return 0; + if (strncmp(p, "/task/", 6) == 0) { + p += 6; + if (!sc_digits(&p)) + return 0; + } + if (strncmp(p, "/fd/", 4) != 0) + return 0; + p += 4; + return sc_digits(&p) && *p == 0; +} + +/* /proc/self, /dev/fd and /dev/std* name the supervisor when it resolves + * them, so they are rewritten to the task first. */ +static void sc_rewrite(int pid, const char *in, char *out, size_t n) { + static const char *const STD[] = {"/dev/stdin", "/dev/stdout", + "/dev/stderr"}; + for (int i = 0; i < 3; i++) + if (strcmp(in, STD[i]) == 0) { + snprintf(out, n, "/proc/%d/fd/%d", pid, i); + return; + } + if (strncmp(in, "/dev/fd/", 8) == 0) { + snprintf(out, n, "/proc/%d/fd/%s", pid, in + 8); + return; + } + static const char *const SELF[] = {"/proc/self", "/proc/thread-self"}; + for (int i = 0; i < 2; i++) { + size_t k = strlen(SELF[i]); + if (strncmp(in, SELF[i], k) == 0 && (in[k] == 0 || in[k] == '/')) { + snprintf(out, n, "/proc/%d%s", pid, in + k); + return; + } + } + snprintf(out, n, "%s", in); +} + +/* full: absolute path as the task sees it. follow: the call follows a + * symlink in the last component. */ +static int sc_abs_ok(const sc_state_t *s, const char *full, int follow, + int strict) { + char path[OS_PATH_MAX], real[OS_PATH_MAX], loc[2 * OS_PATH_MAX + 2]; + if (strlen(full) >= sizeof(path)) + return 0; + snprintf(path, sizeof(path), "%s", full); + size_t n = strlen(path); + while (n > 1 && path[n - 1] == '/') + path[--n] = 0; + char *slash = strrchr(path, '/'); + if (!slash) + return 0; + const char *base = slash + 1; + if (!*base || strcmp(base, ".") == 0 || strcmp(base, "..") == 0) { + if (!realpath(path, real)) + return errno != ENAMETOOLONG; + return sc_inside(s, real, strict); + } + if (slash == path) { + real[0] = 0; + } else { + *slash = 0; + /* a missing or unreadable parent fails the same way in the kernel */ + if (!realpath(path, real)) + return errno == ENOENT || errno == ENOTDIR || errno == EACCES || + errno == ELOOP; + if (strcmp(real, "/") == 0) + real[0] = 0; + } + snprintf(loc, sizeof(loc), "%s/%s", real, base); + if (strlen(loc) >= OS_PATH_MAX) + return 0; + if (sc_is_fd_link(loc)) { + char tgt[OS_PATH_MAX]; + if (sc_readlink(loc, tgt, sizeof(tgt)) != 0) + return 0; + return tgt[0] != '/' || sc_inside(s, tgt, 0); + } + if (follow) { + struct stat st; + if (lstat(loc, &st) == 0 && S_ISLNK(st.st_mode)) { + if (!realpath(loc, real)) + return 0; /* dangling: following it would create the target */ + return sc_inside(s, real, strict); + } + } + return sc_inside(s, loc, strict); +} + +/* One (dirfd, path) argument pair. addr 0 (or "" with AT_EMPTY_PATH) + * means the call acts on dirfd itself. */ +static int sc_path_ok(const sc_state_t *s, int pid, int dirfd, uint64_t addr, + int follow, int empty_ok, int strict) { + char rel[OS_PATH_MAX], base[OS_PATH_MAX], full[2 * OS_PATH_MAX]; + if (addr == 0) + return sc_fd_ok(s, pid, dirfd, strict); + if (sc_read_str(pid, addr, rel, sizeof(rel)) != 0) + return 0; + if (!rel[0]) + return empty_ok ? sc_fd_ok(s, pid, dirfd, strict) : 1; + if (rel[0] == '/') { + sc_rewrite(pid, rel, full, sizeof(full)); + } else { + char link[64]; + if (dirfd == AT_FDCWD) + snprintf(link, sizeof(link), "/proc/%d/cwd", pid); + else + snprintf(link, sizeof(link), "/proc/%d/fd/%d", pid, dirfd); + if (sc_readlink(link, base, sizeof(base)) != 0 || base[0] != '/') + return 0; + snprintf(full, sizeof(full), "%s/%s", base, rel); + } + return sc_abs_ok(s, full, follow, strict); +} + +/* 1: let the call run, 0: fail it with EACCES. */ +static int sc_decide(const sc_state_t *s, const struct seccomp_notif *req) { + const uint64_t *a = (const uint64_t *)req->data.args; + int pid = (int)req->pid; +#define SC_P(d, p, fl, em, st) \ + sc_path_ok(s, pid, (int)(d), (p), (fl), (em), (st)) + switch (req->data.nr) { + case __NR_openat: { + int fl = (int)a[2]; + return !(fl & SC_WRITE_FLAGS) || SC_P(a[0], a[1], !(fl & O_NOFOLLOW), 0, 0); + } + case __NR_openat2: { + struct { + uint64_t flags, mode, resolve; + } how; + memset(&how, 0, sizeof(how)); + size_t sz = a[3] < sizeof(how) ? (size_t)a[3] : sizeof(how); + if (sz < sizeof(uint64_t) || sc_read_mem(pid, a[2], &how, sz) != 0) + return 0; + if (!(how.flags & SC_WRITE_FLAGS)) + return 1; + return SC_P(a[0], a[1], + !(how.flags & O_NOFOLLOW) && !(how.resolve & 0x04 /* NO_SYMLINKS */), + 0, 0); + } + case __NR_mkdirat: + case __NR_mknodat: + return SC_P(a[0], a[1], 0, 0, 0); + case __NR_unlinkat: + return SC_P(a[0], a[1], 0, 0, 1); + case __NR_symlinkat: + return SC_P(a[1], a[2], 0, 0, 0); + case __NR_linkat: + return SC_P(a[0], a[1], (a[4] & AT_SYMLINK_FOLLOW) != 0, + (a[4] & AT_EMPTY_PATH) != 0, 0) && + SC_P(a[2], a[3], 0, 0, 0); +#ifdef __NR_renameat + case __NR_renameat: +#endif + case __NR_renameat2: + return SC_P(a[0], a[1], 0, 0, 1) && SC_P(a[2], a[3], 0, 0, 1); + case __NR_truncate: + return SC_P(AT_FDCWD, a[0], 1, 0, 0); + case __NR_fchmodat: + return SC_P(a[0], a[1], 1, 0, 0); + case __NR_fchmodat2: + return SC_P(a[0], a[1], !(a[3] & AT_SYMLINK_NOFOLLOW), + (a[3] & AT_EMPTY_PATH) != 0, 0); + case __NR_fchownat: + return SC_P(a[0], a[1], !(a[4] & AT_SYMLINK_NOFOLLOW), + (a[4] & AT_EMPTY_PATH) != 0, 0); + case __NR_utimensat: + return SC_P(a[0], a[1], !(a[3] & AT_SYMLINK_NOFOLLOW), 0, 0); + case __NR_setxattr: + case __NR_removexattr: + return SC_P(AT_FDCWD, a[0], 1, 0, 0); + case __NR_lsetxattr: + case __NR_lremovexattr: + return SC_P(AT_FDCWD, a[0], 0, 0, 0); +#if defined(__x86_64__) + case __NR_open: { + int fl = (int)a[1]; + return !(fl & SC_WRITE_FLAGS) || + SC_P(AT_FDCWD, a[0], !(fl & O_NOFOLLOW), 0, 0); + } + case __NR_creat: + case __NR_chmod: + case __NR_chown: + case __NR_utime: + case __NR_utimes: + return SC_P(AT_FDCWD, a[0], 1, 0, 0); + case __NR_mkdir: + case __NR_mknod: + case __NR_lchown: + return SC_P(AT_FDCWD, a[0], 0, 0, 0); + case __NR_rmdir: + case __NR_unlink: + return SC_P(AT_FDCWD, a[0], 0, 0, 1); + case __NR_rename: + return SC_P(AT_FDCWD, a[0], 0, 0, 1) && SC_P(AT_FDCWD, a[1], 0, 0, 1); + case __NR_link: + return SC_P(AT_FDCWD, a[0], 0, 0, 0) && SC_P(AT_FDCWD, a[1], 0, 0, 0); + case __NR_symlink: + return SC_P(AT_FDCWD, a[1], 0, 0, 0); + case __NR_futimesat: + return SC_P(a[0], a[1], 1, 0, 0); +#endif + default: + return 0; + } +#undef SC_P +} + +static void sc_handle(const sc_state_t *s, int lfd) { + struct seccomp_notif req; + struct seccomp_notif_resp resp; + memset(&req, 0, sizeof(req)); + if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req) != 0) + return; /* the task died meanwhile */ + int allow = sc_decide(s, &req); + /* the pid read above still names the task that made the call */ + if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_ID_VALID, &req.id) != 0) + return; + memset(&resp, 0, sizeof(resp)); + resp.id = req.id; + if (allow) + resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; + else + resp.error = -EACCES; + ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp); +} + +/* Waits for the listener fd, then answers until no task uses the filter + * or release() asks to stop. Tasks left over after that get ENOSYS from + * the kernel for checked calls. */ +static void *sc_main(void *arg) { + sc_state_t *s = arg; + int lfd = -1; + for (;;) { + struct pollfd pf[2] = {{s->sock[0], POLLIN, 0}, {s->stop[0], POLLIN, 0}}; + if (poll(pf, 2, -1) < 0) { + if (errno == EINTR) + continue; + return NULL; + } + if (pf[1].revents) + return NULL; + if (pf[0].revents) { + lfd = sc_recv_fd(s->sock[0]); + break; + } + } + if (lfd < 0) + return NULL; + for (;;) { + struct pollfd pf[2] = {{lfd, POLLIN, 0}, {s->stop[0], POLLIN, 0}}; + if (poll(pf, 2, -1) < 0) { + if (errno == EINTR) + continue; + break; + } + if (pf[0].revents & POLLIN) { + sc_handle(s, lfd); + continue; + } + if (pf[0].revents || pf[1].revents) + break; + } + close(lfd); + return NULL; +} + +static void sc_free(sc_state_t *s) { + if (!s) + return; + if (s->started) { + char c = 1; + ssize_t w = write(s->stop[1], &c, 1); + (void)w; + pthread_join(s->th, NULL); + } + for (int i = 0; i < 2; i++) { + if (s->sock[i] >= 0) + close(s->sock[i]); + if (s->stop[i] >= 0) + close(s->stop[i]); + } + free(s); +} + +static int sc_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) { + sc_state_t *s = calloc(1, sizeof(*s)); + if (!s) + return -1; + s->sock[0] = s->sock[1] = s->stop[0] = s->stop[1] = -1; + if (!realpath(ws, s->ws)) + goto fail; + if (!tmp || !tmp[0] || !realpath(tmp, s->tmp)) + s->tmp[0] = 0; + sc_build(&s->prog); + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, s->sock) != 0 || + pipe2(s->stop, O_CLOEXEC) != 0) + goto fail; + if (pthread_create(&s->th, NULL, sc_main, s) != 0) + goto fail; + s->started = 1; + p->sc = s; + return 0; +fail: + sc_free(s); + return -1; +} + +static int sc_install(const sc_prog_t *prog, int sock) { + struct sock_fprog fp; + fp.len = prog->n; + fp.filter = (struct sock_filter *)prog->f; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + return -1; + int lfd = (int)syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, + SECCOMP_FILTER_FLAG_NEW_LISTENER, &fp); + if (lfd < 0) + return -1; + int rc = sc_send_fd(sock, lfd); + close(lfd); + return rc; +} + +/* The whole round trip in a throwaway child: install a listener filter on + * getppid, answer it with CONTINUE (kernel 5.5+). A child killed by the + * platform's own seccomp policy only costs that child. */ +static int sc_probe(void) { + sc_prog_t prog; + sc_prog_t *p = &prog; + p->n = 0; + SC_LD(offsetof(struct seccomp_data, arch)); + SC_JEQ(SC_ARCH, 1, 0); + SC_RET(SECCOMP_RET_ALLOW); + SC_LD(offsetof(struct seccomp_data, nr)); + SC_JEQ(__NR_getppid, 0, 1); + SC_RET(SECCOMP_RET_USER_NOTIF); + SC_RET(SECCOMP_RET_ALLOW); + + int sp[2]; + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sp) != 0) + return 0; + pid_t self = getpid(); + pid_t pid = fork(); + if (pid == 0) { + close(sp[0]); + if (sc_install(&prog, sp[1]) != 0) + _exit(3); + _exit(syscall(__NR_getppid) == self ? 0 : 4); + } + close(sp[1]); + if (pid < 0) { + close(sp[0]); + return 0; + } + int ok = 0; + struct pollfd pf = {sp[0], POLLIN, 0}; + if (poll(&pf, 1, 3000) > 0) { + int lfd = sc_recv_fd(sp[0]); + if (lfd >= 0) { + struct pollfd pl = {lfd, POLLIN, 0}; + if (poll(&pl, 1, 3000) > 0 && (pl.revents & POLLIN)) { + struct seccomp_notif req; + struct seccomp_notif_resp resp; + memset(&req, 0, sizeof(req)); + if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req) == 0) { + memset(&resp, 0, sizeof(resp)); + resp.id = req.id; + resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; + ok = ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp) == 0; + } + } + close(lfd); + } + } + close(sp[0]); + if (!ok) + kill(pid, SIGKILL); + int st = 0; + while (waitpid(pid, &st, 0) < 0 && errno == EINTR) + ; + return ok && WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +#endif /* CST_HAVE_SECCOMP */ + /* --- common --------------------------------------------------------------- */ int cst_sandbox_probe(void) { if (g_kind >= 0) return g_kind; - g_kind = CST_SANDBOX_NONE; + int kind = CST_SANDBOX_NONE; const char *e = getenv("CST_SANDBOX"); if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 || - strcmp(e, "none") == 0)) + strcmp(e, "none") == 0)) { + g_kind = kind; return g_kind; + } + int want_ll = !e || strcmp(e, "seccomp") != 0; + int want_sc = !e || strcmp(e, "landlock") != 0; #ifdef CST_HAVE_LANDLOCK - g_abi = ll_abi(); - if (g_abi > 0) - g_kind = CST_SANDBOX_LANDLOCK; -#elif defined(CST_HAVE_SEATBELT) - g_kind = CST_SANDBOX_SEATBELT; + if (want_ll) { + g_abi = ll_abi(); + if (g_abi > 0) + kind = CST_SANDBOX_LANDLOCK; + } #endif +#ifdef CST_HAVE_SECCOMP + if (kind == CST_SANDBOX_NONE && want_sc && sc_probe()) + kind = CST_SANDBOX_SECCOMP; +#endif +#ifdef CST_HAVE_SEATBELT + kind = CST_SANDBOX_SEATBELT; +#endif + (void)want_ll; + (void)want_sc; + g_kind = kind; return g_kind; } +int cst_sandbox_reprobe(void) { + g_kind = -1; + g_abi = 0; + return cst_sandbox_probe(); +} + int cst_sandbox_landlock_abi(void) { cst_sandbox_probe(); return g_abi; @@ -205,6 +915,8 @@ const char *cst_sandbox_name(int kind) { return "landlock"; case CST_SANDBOX_SEATBELT: return "seatbelt"; + case CST_SANDBOX_SECCOMP: + return "seccomp"; default: return "none"; } @@ -223,6 +935,10 @@ int cst_sandbox_prepare(const char *workspace, const char *tmpdir, #ifdef CST_HAVE_SEATBELT if (kind == CST_SANDBOX_SEATBELT) rc = sb_prepare(workspace, tmpdir, plan); +#endif +#ifdef CST_HAVE_SECCOMP + if (kind == CST_SANDBOX_SECCOMP) + rc = sc_prepare(workspace, tmpdir, plan); #endif (void)workspace; (void)tmpdir; @@ -252,6 +968,12 @@ int cst_sandbox_enter(void *arg) { } return 0; } +#endif +#ifdef CST_HAVE_SECCOMP + if (p->kind == CST_SANDBOX_SECCOMP && p->sc) { + const sc_state_t *s = p->sc; + return sc_install(&s->prog, s->sock[1]); + } #endif return -1; } @@ -263,6 +985,10 @@ void cst_sandbox_release(cst_sandbox_plan_t *plan) { if (plan->ruleset_fd >= 0) close(plan->ruleset_fd); #endif +#ifdef CST_HAVE_SECCOMP + sc_free(plan->sc); +#endif + plan->sc = NULL; free(plan->profile); plan->profile = NULL; plan->ruleset_fd = -1; @@ -307,6 +1033,8 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) { "Permission denied", "Read-only file system", "Operation not permitted", "EACCES", "EPERM", "EROFS", "[cst: sandbox", "Network is unreachable", + /* curl when the socket itself was refused (seccomp) */ + "Could not connect to server", "Failed to connect to", /* localized strerror(EACCES/EPERM/EROFS) */ "权限不够", "不允许的操作", "只读文件系统", "權限不足", "拒絕不符權限的操作", "許可がありません", "Keine Berechtigung", diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index c4e9bdb..c61130f 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -70,6 +70,9 @@ static void test_blocked(void) { CHECK(!cst_sandbox_blocked(b, strlen(b), 1)); const char *c = "touch: cannot touch 'x': Read-only file system\n"; CHECK(cst_sandbox_blocked(c, strlen(c), 1)); + const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 " + "ms: Could not connect to server\n"; + CHECK(cst_sandbox_blocked(d, strlen(d), 7)); } #if !defined(_WIN32) @@ -112,7 +115,10 @@ static void test_network(const char *ws) { if (pid == 0) { if (cst_sandbox_enter(&plan) != 0) _exit(3); + /* Landlock refuses the connect, seccomp already the socket */ int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) + _exit(errno == EACCES ? 0 : 2); int rc = connect(s, (struct sockaddr *)&a, sizeof(a)); _exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2); } @@ -123,7 +129,123 @@ static void test_network(const char *ws) { CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0); } -static void test_landlock(void) { +static int blocked(const os_run_result_t *r) { + if (!cst_sandbox_blocked(r->out, r->out_len, r->exit_code)) { + fprintf(stderr, "expected a sandbox refusal (exit %d): %s\n", r->exit_code, + r->out ? r->out : ""); + return 0; + } + return 1; +} + +static char *slurp(const char *path) { + FILE *fp = fopen(path, "r"); + if (!fp) + return NULL; + static char buf[256]; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + buf[n] = 0; + fclose(fp); + return buf; +} + +/* Path tricks the seccomp supervisor has to resolve itself. */ +static void test_seccomp_paths(const char *ws, const char *out) { + char cmd[3 * OS_PATH_MAX], p[OS_PATH_MAX + 64], q[OS_PATH_MAX + 64]; + char readme[OS_PATH_MAX + 64]; + os_run_result_t r; + snprintf(readme, sizeof(readme), "%s/readme.txt", out); + + /* ../ out of the workspace */ + const char *outname = strrchr(out, '/') + 1; + snprintf(cmd, sizeof(cmd), "echo x > '../%s/e1'", outname); + snprintf(p, sizeof(p), "%s/e1", out); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p)); + os_run_result_free(&r); + + /* a symlink inside may be created, but not written through */ + snprintf(cmd, sizeof(cmd), "ln -s '%s' link && echo x > link/e2", out); + snprintf(p, sizeof(p), "%s/e2", out); + snprintf(q, sizeof(q), "%s/link", ws); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p)); + struct stat st; + CHECK(lstat(q, &st) == 0 && S_ISLNK(st.st_mode)); + os_run_result_free(&r); + snprintf(cmd, sizeof(cmd), "ln -s '%s' rl && echo x >> rl", readme); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r)); + CHECK(strcmp(slurp(readme), "outside-content\n") == 0); + os_run_result_free(&r); + + /* moving, hard-linking or chmod-ing an outside file */ + snprintf(cmd, sizeof(cmd), "mv '%s' ./moved", readme); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && exists(readme)); + os_run_result_free(&r); + snprintf(cmd, sizeof(cmd), "ln '%s' hard", readme); + snprintf(p, sizeof(p), "%s/hard", ws); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p)); + os_run_result_free(&r); + snprintf(cmd, sizeof(cmd), "chmod 600 '%s'", readme); + CHECK(run(ws, cmd, &r) != 0 && blocked(&r)); + os_run_result_free(&r); + + /* the command's own stdout/stderr */ + CHECK(run(ws, "echo hi > /dev/stdout && echo e > /dev/stderr && " + "echo fd > /proc/self/fd/1", + &r) == 0); + CHECK(strstr(r.out, "hi") && strstr(r.out, "fd")); + os_run_result_free(&r); + + /* ordinary tree work inside */ + CHECK(run(ws, "mkdir -p a/b/c && touch a/b/c/f && mv a/b/c/f a/g && " + "ln -s g a/s && chmod 600 a/g && rm -r a", + &r) == 0); + os_run_result_free(&r); + + /* a missing parent stays ENOENT, not a sandbox refusal */ + CHECK(run(ws, "touch nodir/x", &r) != 0); + CHECK(!cst_sandbox_blocked(r.out, r.out_len, r.exit_code)); + os_run_result_free(&r); + + /* many checked writes */ + double t0 = os_now_ms(); + CHECK(run(ws, "i=0; while [ $i -lt 300 ]; do echo $i > f$i; " + "i=$((i+1)); done; ls | grep -c '^f[0-9]'", + &r) == 0); + CHECK(strstr(r.out, "300") != NULL); + printf("test_sandbox: 300 checked writes in %.0f ms\n", os_now_ms() - t0); + os_run_result_free(&r); + + /* background jobs: one still holding the pipe is served (unless Yama + * hides the orphaned job's memory from the supervisor), one detached + * does not hang the run */ + snprintf(p, sizeof(p), "%s/bg.txt", ws); + CHECK(run(ws, "(sleep 0.2; echo late > bg.txt) & echo started", &r) == 0); + FILE *yf = fopen("/proc/sys/kernel/yama/ptrace_scope", "r"); + int yama = 0; + if (yf) { + if (fscanf(yf, "%d", &yama) != 1) + yama = 0; + fclose(yf); + } + CHECK(yama > 0 || exists(p)); + os_run_result_free(&r); + t0 = os_now_ms(); + CHECK(run(ws, "(sleep 0.3; echo late > bg2.txt) >/dev/null 2>&1 &", &r) == + 0); + CHECK(os_now_ms() - t0 < 3000); + os_run_result_free(&r); + + /* the workspace root itself cannot be removed */ + char cwd[OS_PATH_MAX], ws2[OS_PATH_MAX + 32]; + CHECK(getcwd(cwd, sizeof(cwd)) != NULL); + snprintf(ws2, sizeof(ws2), "%s/tests/sbx_ws2_XXXXXX", cwd); + CHECK(mkdtemp(ws2)); + CHECK(run(ws2, "rmdir \"$PWD\"", &r) != 0 && blocked(&r) && exists(ws2)); + os_run_result_free(&r); + CHECK(rmdir(ws2) == 0); +} + +static void test_confined(int kind) { char cwd[OS_PATH_MAX], ws[OS_PATH_MAX + 32], out[OS_PATH_MAX + 32]; char cmd[3 * OS_PATH_MAX]; CHECK(getcwd(cwd, sizeof(cwd)) != NULL); @@ -170,12 +292,15 @@ static void test_landlock(void) { CHECK(run(ws, cmd, &r) == 0); os_run_result_free(&r); + if (kind == CST_SANDBOX_SECCOMP) + test_seccomp_paths(ws, out); + /* the parent itself stays unconfined */ fp = fopen(outfile, "w"); CHECK(fp); fclose(fp); - if (cst_sandbox_landlock_abi() >= 4) + if (kind == CST_SANDBOX_SECCOMP || cst_sandbox_landlock_abi() >= 4) test_network(ws); else printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n", @@ -192,8 +317,26 @@ int main(void) { int kind = cst_sandbox_probe(); printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind)); #if !defined(_WIN32) - if (kind == CST_SANDBOX_LANDLOCK) - test_landlock(); + if (kind == CST_SANDBOX_LANDLOCK || kind == CST_SANDBOX_SECCOMP) + test_confined(kind); + if (kind != CST_SANDBOX_SECCOMP) { + const char *was = getenv("CST_SANDBOX"); + char saved[32] = ""; + if (was) + snprintf(saved, sizeof(saved), "%s", was); + setenv("CST_SANDBOX", "seccomp", 1); + if (cst_sandbox_reprobe() == CST_SANDBOX_SECCOMP) { + printf("test_sandbox: forced seccomp\n"); + test_confined(CST_SANDBOX_SECCOMP); + } else { + printf("test_sandbox: seccomp not available here\n"); + } + if (was) + setenv("CST_SANDBOX", saved, 1); + else + unsetenv("CST_SANDBOX"); + CHECK(cst_sandbox_reprobe() == kind); + } #endif puts("test_sandbox: ok"); return 0;