From 280a78a60ce7412c825643a27343ec4a2e130ad3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:13:41 +0000 Subject: [PATCH 1/2] fix(sandbox): block workspace FS escapes via Landlock and relative tokens Allowlist only flagged tokens starting with / ~ ., so relative symlink indirection (python os.sep symlink + cat leak) and interpreter path obfuscation bypassed workspace_only. Namespaces alone do not hide the host FS. Apply Landlock to the configured workspace in sandbox_exec and resolve bare relative tokens against the workspace in the allowlist. Co-authored-by: esadrianno --- src/sandbox/allowlist.c | 44 ++++++++++++++++++++ src/sandbox/allowlist.h | 5 ++- src/sandbox/sandbox.c | 92 +++++++++++++++++++++++++++++++++++++++++ src/sandbox/sandbox.h | 14 ++++--- tests/test_allowlist.c | 38 +++++++++++++++++ tests/test_sandbox.c | 67 ++++++++++++++++++++++++++++++ 6 files changed, 253 insertions(+), 7 deletions(-) diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index c3033ce..1f09018 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -11,6 +11,7 @@ #include #include #include +#include /* ------------------------------------------------------------------ */ /* Built-in blocklist patterns */ @@ -77,6 +78,46 @@ static int has_path_chars(const char *tok) return tok[0] == '/' || tok[0] == '~' || tok[0] == '.'; } +/** Return 1 if @p tok looks like a shell option flag (-x / --long), not a path. */ +static int is_option_token(const char *tok) +{ + if (!tok || tok[0] != '-') return 0; + /* Negative numbers and lone "-" are not options we skip for path checks. */ + if (tok[1] == '\0') return 0; + if (tok[1] >= '0' && tok[1] <= '9') return 0; + return 1; +} + +/** + * Resolve a bare relative filename against the workspace and reject symlink + * (or hard-link) escapes. Tokens that do not exist yet are left alone. + * @return 1 if blocked, 0 if allowed / not applicable. + */ +static int block_if_relative_token_escapes(const char *tok, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char joined[PATH_MAX]; + int n; + if (!tok || !tok[0] || !workspace_root || !workspace_root[0]) return 0; + if (has_path_chars(tok) || is_option_token(tok)) return 0; + /* Skip tokens that already contain a slash mid-string without leading path char + * (e.g. "src/foo") — those are relative multi-component paths; join + resolve. */ + n = snprintf(joined, sizeof(joined), "%s/%s", workspace_root, tok); + if (n < 0 || (size_t)n >= sizeof(joined)) { + set_reason(reason_buf, reason_cap, "command blocked: path too long: ", tok); + return 1; + } + /* Only enforce when the name already exists (symlink / file / dir). */ + if (access(joined, F_OK) != 0) return 0; + if (!allowlist_path_is_under_workspace(joined, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", joined); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", joined); + return 1; + } + return 0; +} + /* ------------------------------------------------------------------ */ /* Public: path-under-workspace check (5.4) */ /* ------------------------------------------------------------------ */ @@ -184,6 +225,9 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg free(cmd_copy); return 1; } + } else if (block_if_relative_token_escapes(tok, workspace_root, reason_buf, reason_cap)) { + free(cmd_copy); + return 1; } tok = strtok_r(NULL, " \t\n;|&><", &saveptr); } diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index 76a4e4f..d6cfc13 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -12,8 +12,9 @@ * when they escape the declared workspace root. * * Both checks are intentionally conservative and may produce false positives. - * They are a best-effort defence-in-depth layer; real isolation is provided by - * sandbox_exec() via kernel namespaces. + * They are a best-effort defence-in-depth layer; filesystem isolation for the + * shell tool is enforced by sandbox_exec() (Landlock on Linux when a workspace + * path is configured, plus namespaces/cgroups). */ #ifndef SHELLCLAW_ALLOWLIST_H #define SHELLCLAW_ALLOWLIST_H diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index b3d3262..4fd6942 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -30,6 +30,8 @@ #ifdef __linux__ #include #include +#include +#include #endif #define DEFAULT_TIMEOUT_MS 10000 @@ -138,6 +140,93 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) /* Child setup before exec */ /* ------------------------------------------------------------------ */ +#ifdef __linux__ + +/** + * Best-effort Landlock FS policy: full access under @p workspace, read/exec + * for common system paths needed by /bin/sh and interpreters. Without this, + * unshare(CLONE_NEWNS) alone still exposes the host filesystem (symlinks, + * python -c, etc. can read /etc). Degrades silently if Landlock is unavailable. + */ +static void landlock_restrict_to_workspace(const char *workspace) +{ + __u64 handled = + LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_REFER | + LANDLOCK_ACCESS_FS_TRUNCATE; + __u64 workspace_access = + LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_TRUNCATE; + __u64 ro_exec = + LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR; + /* System prefixes required to run /bin/sh and typical interpreters. */ + static const char *const RO_PATHS[] = { + "/bin", "/usr", "/lib", "/lib64", "/lib32", + "/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d", + "/etc/ssl", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", + "/dev/null", "/dev/zero", "/dev/urandom", "/dev/tty", + NULL + }; + struct landlock_ruleset_attr attr; + int ruleset_fd; + int ws_fd; + size_t i; + if (!workspace || !workspace[0]) return; + memset(&attr, 0, sizeof(attr)); + attr.handled_access_fs = handled; + ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0); + if (ruleset_fd < 0) return; + ws_fd = open(workspace, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (ws_fd >= 0) { + struct landlock_path_beneath_attr pb; + memset(&pb, 0, sizeof(pb)); + pb.allowed_access = workspace_access; + pb.parent_fd = ws_fd; + (void)syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(ws_fd); + } + for (i = 0; RO_PATHS[i]; i++) { + int pfd = open(RO_PATHS[i], O_PATH | O_CLOEXEC); + struct landlock_path_beneath_attr pb; + if (pfd < 0) continue; + memset(&pb, 0, sizeof(pb)); + pb.allowed_access = ro_exec; + pb.parent_fd = pfd; + (void)syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(pfd); + } + /* PR_SET_NO_NEW_PRIVS is required before restrict_self; already set by caller. */ + (void)syscall(__NR_landlock_restrict_self, ruleset_fd, 0); + close(ruleset_fd); +} + +#endif /* __linux__ */ + static void setup_child_process(int pipe_wr, const char *workspace) { close(STDIN_FILENO); @@ -149,6 +238,9 @@ static void setup_child_process(int pipe_wr, const char *workspace) /* Namespace isolation: mount + network + PID (children of this process). */ unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + /* Host FS remains visible after unshare(NEWNS) alone; Landlock enforces + * workspace containment (blocks symlink / interpreter path escapes). */ + landlock_restrict_to_workspace(workspace); #endif if (workspace && workspace[0]) if (chdir(workspace) != 0) _exit(124); diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 5d255d7..c1cad15 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -2,9 +2,11 @@ * @file sandbox.h * @brief Process sandbox API: isolated execution with namespaces, timeout, and cgroups v2. * - * On Linux, sandbox_exec uses clone(2) with PID/mount/network namespace isolation, - * optional cgroups v2 resource limits, and a hard timeout with SIGKILL. - * On other platforms (macOS, BSDs) it falls back to a plain fork+exec and logs a warning. + * On Linux, sandbox_exec uses clone(2)/fork with PID/mount/network namespace + * isolation, Landlock filesystem restrictions to the configured workspace + * (when set), optional cgroups v2 resource limits, and a hard timeout with + * SIGKILL. On other platforms (macOS, BSDs) it falls back to a plain fork+exec + * and logs a warning. */ #ifndef SHELLCLAW_SANDBOX_H #define SHELLCLAW_SANDBOX_H @@ -42,8 +44,10 @@ typedef struct sandbox_config { /** * Execute @p cmd inside an isolated child process and capture output. * - * On Linux, clones with CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET. - * Applies cgroups v2 limits when available; degrades gracefully if not. + * On Linux, forks then unshare(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET) and, + * when @p cfg->workspace_path is set, applies a Landlock ruleset that denies + * host filesystem reads/writes outside the workspace (blocking symlink and + * interpreter path escapes). Applies cgroups v2 limits when available. * Kills the child with SIGKILL if @p timeout_ms elapses before exit. * * On non-Linux platforms the function executes the command via fork()+exec() diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 5d2ac74..e575ab8 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -184,6 +184,43 @@ static int test_symlink_escape(void) #endif } +/** + * Relative symlink indirection: has_path_chars misses bare names, so + * "cat leak" must still be blocked when leak -> /etc/passwd under workspace. + */ +static int test_relative_symlink_indirection(void) +{ +#ifdef __linux__ + char workspace[] = "/tmp/sc_al_rel_XXXXXX"; + char leak_path[256]; + char *ws; + allowlist_config_t cfg; + char reason[256]; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_relative_symlink_indirection: mkdtemp failed, skipping\n"); + return 0; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_relative_symlink_indirection: symlink failed, skipping\n"); + return 0; + } + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat leak", &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "escapes") != NULL || strstr(reason, "workspace") != NULL); + unlink(leak_path); + rmdir(ws); + return 0; +#else + fprintf(stderr, "test_relative_symlink_indirection: skipped (Linux-specific)\n"); + return 0; +#endif +} + /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ @@ -206,6 +243,7 @@ int main(void) RUN(test_workspace_only_blocks_outside_path()); RUN(test_workspace_only_allows_inside_path()); RUN(test_symlink_escape()); + RUN(test_relative_symlink_indirection()); printf("test_allowlist: all tests passed\n"); return 0; } diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 4c653c3..c9676ba 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -116,6 +116,71 @@ static int test_shadow_not_accessible(void) ASSERT(strlen(out) > 0); return 0; } + +/** + * With a workspace configured, Landlock must deny host reads even via a + * relative symlink (allowlist may miss bare names; sandbox is the FS gate). + */ +static int test_workspace_landlock_blocks_symlink_escape(void) +{ + char workspace[] = "/tmp/sc_sb_ws_XXXXXX"; + char leak_path[256]; + char out[4096]; + sandbox_config_t cfg; + char *ws; + int rc; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: mkdtemp failed, skipping\n"); + return 0; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: symlink failed, skipping\n"); + return 0; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("cat leak 2>&1; echo EXIT:$?", out, sizeof(out), 5000, &cfg); + ASSERT(rc == 0); + /* Must not dump passwd contents (root:x: or similar). */ + ASSERT(strstr(out, "root:x:") == NULL); + ASSERT(strstr(out, "Permission denied") != NULL || + strstr(out, "No such file") != NULL || + strstr(out, "EXIT:1") != NULL || + strstr(out, "EXIT:2") != NULL); + unlink(leak_path); + rmdir(ws); + return 0; +} + +static int test_workspace_landlock_blocks_abs_etc(void) +{ + char workspace[] = "/tmp/sc_sb_ws2_XXXXXX"; + char out[4096]; + char outp[256]; + sandbox_config_t cfg; + char *ws; + int rc; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_abs_etc: mkdtemp failed, skipping\n"); + return 0; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec( + "python3 -c 'open(\"out\",\"w\").write(open(chr(47)+\"etc\"+chr(47)+\"passwd\").read())' 2>&1; " + "echo EXIT:$?", + out, sizeof(out), 8000, &cfg); + ASSERT(rc == 0); + ASSERT(strstr(out, "root:x:") == NULL); + snprintf(outp, sizeof(outp), "%s/out", ws); + unlink(outp); + rmdir(ws); + return 0; +} #endif /* ------------------------------------------------------------------ */ @@ -182,6 +247,8 @@ int main(void) RUN(test_timeout_kills_process()); #ifdef __linux__ RUN(test_shadow_not_accessible()); + RUN(test_workspace_landlock_blocks_symlink_escape()); + RUN(test_workspace_landlock_blocks_abs_etc()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif From 545508a7fb74f80d3eab8fcd7976b818b0e1df88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:20:50 +0000 Subject: [PATCH 2/2] fix(sandbox): probe Landlock ABI so rulesets apply on older kernels Passing REFER/TRUNCATE in handled_access_fs makes create_ruleset fail on ABI 1, skipping the whole FS policy. Probe the ABI, mask unknown bits, and pass the ABI-1 attr size to avoid E2BIG. Co-authored-by: esadrianno --- src/sandbox/sandbox.c | 114 +++++++++++++++++++++++++++++------------- tests/test_sandbox.c | 31 ++++++++++++ 2 files changed, 110 insertions(+), 35 deletions(-) diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 4fd6942..6b85a64 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -142,46 +142,62 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) #ifdef __linux__ +/** ABI 1 filesystem rights. Extra bits (REFER/TRUNCATE) are OR'd when supported. */ +static __u64 landlock_abi1_fs_rights(void) +{ + return LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; +} + +/** + * Probe Landlock ABI and mask handled FS rights the running kernel understands. + * Passing REFER (ABI 2) or TRUNCATE (ABI 3) on ABI 1 makes create_ruleset fail + * and would skip the whole policy (fail-open). + */ +static int landlock_handled_fs(__u64 *handled_out) +{ + int abi; + __u64 handled; + if (!handled_out) return -1; + abi = (int)syscall(__NR_landlock_create_ruleset, NULL, 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 1) return -1; + handled = landlock_abi1_fs_rights(); +#ifdef LANDLOCK_ACCESS_FS_REFER + if (abi >= 2) + handled |= LANDLOCK_ACCESS_FS_REFER; +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + if (abi >= 3) + handled |= LANDLOCK_ACCESS_FS_TRUNCATE; +#endif + *handled_out = handled; + return 0; +} + /** * Best-effort Landlock FS policy: full access under @p workspace, read/exec * for common system paths needed by /bin/sh and interpreters. Without this, * unshare(CLONE_NEWNS) alone still exposes the host filesystem (symlinks, - * python -c, etc. can read /etc). Degrades silently if Landlock is unavailable. + * python -c, etc. can read /etc). Degrades with a stderr warning if Landlock + * is unavailable. */ static void landlock_restrict_to_workspace(const char *workspace) { - __u64 handled = - LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_WRITE_FILE | - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR | - LANDLOCK_ACCESS_FS_REMOVE_DIR | - LANDLOCK_ACCESS_FS_REMOVE_FILE | - LANDLOCK_ACCESS_FS_MAKE_CHAR | - LANDLOCK_ACCESS_FS_MAKE_DIR | - LANDLOCK_ACCESS_FS_MAKE_REG | - LANDLOCK_ACCESS_FS_MAKE_SOCK | - LANDLOCK_ACCESS_FS_MAKE_FIFO | - LANDLOCK_ACCESS_FS_MAKE_BLOCK | - LANDLOCK_ACCESS_FS_MAKE_SYM | - LANDLOCK_ACCESS_FS_REFER | - LANDLOCK_ACCESS_FS_TRUNCATE; - __u64 workspace_access = - LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_WRITE_FILE | - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR | - LANDLOCK_ACCESS_FS_REMOVE_DIR | - LANDLOCK_ACCESS_FS_REMOVE_FILE | - LANDLOCK_ACCESS_FS_MAKE_DIR | - LANDLOCK_ACCESS_FS_MAKE_REG | - LANDLOCK_ACCESS_FS_MAKE_SYM | - LANDLOCK_ACCESS_FS_MAKE_FIFO | - LANDLOCK_ACCESS_FS_TRUNCATE; - __u64 ro_exec = - LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR; + __u64 handled; + __u64 workspace_access; + __u64 ro_exec; /* System prefixes required to run /bin/sh and typical interpreters. */ static const char *const RO_PATHS[] = { "/bin", "/usr", "/lib", "/lib64", "/lib32", @@ -195,10 +211,38 @@ static void landlock_restrict_to_workspace(const char *workspace) int ws_fd; size_t i; if (!workspace || !workspace[0]) return; + if (landlock_handled_fs(&handled) != 0) { + fprintf(stderr, + "sandbox: Landlock unavailable; host filesystem still visible\n"); + return; + } + workspace_access = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_MAKE_FIFO) & handled; +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + workspace_access |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + ro_exec = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR) & handled; memset(&attr, 0, sizeof(attr)); attr.handled_access_fs = handled; - ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0); - if (ruleset_fd < 0) return; + /* Pass only the ABI-1 field size so older kernels do not return E2BIG. */ + ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, + sizeof(attr.handled_access_fs), 0); + if (ruleset_fd < 0) { + fprintf(stderr, + "sandbox: Landlock ruleset create failed (errno=%d); host filesystem still visible\n", + errno); + return; + } ws_fd = open(workspace, O_PATH | O_DIRECTORY | O_CLOEXEC); if (ws_fd >= 0) { struct landlock_path_beneath_attr pb; diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index c9676ba..93885a8 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -181,6 +181,36 @@ static int test_workspace_landlock_blocks_abs_etc(void) rmdir(ws); return 0; } + +static int test_workspace_landlock_allows_workspace_write(void) +{ + char workspace[] = "/tmp/sc_sb_wr_XXXXXX"; + char out[4096]; + char wrote[256]; + char buf[64]; + sandbox_config_t cfg; + char *ws; + FILE *f; + int rc; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_allows_workspace_write: mkdtemp failed, skipping\n"); + return 0; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("echo landlock_ok > wrote.txt", out, sizeof(out), 5000, &cfg); + ASSERT(rc == 0); + snprintf(wrote, sizeof(wrote), "%s/wrote.txt", ws); + f = fopen(wrote, "r"); + ASSERT(f != NULL); + ASSERT(fgets(buf, sizeof(buf), f) != NULL); + fclose(f); + ASSERT(strstr(buf, "landlock_ok") != NULL); + unlink(wrote); + rmdir(ws); + return 0; +} #endif /* ------------------------------------------------------------------ */ @@ -249,6 +279,7 @@ int main(void) RUN(test_shadow_not_accessible()); RUN(test_workspace_landlock_blocks_symlink_escape()); RUN(test_workspace_landlock_blocks_abs_etc()); + RUN(test_workspace_landlock_allows_workspace_write()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif