Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/sandbox/allowlist.c
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* ------------------------------------------------------------------ */
/* Built-in blocklist patterns */
Expand Down Expand Up @@ -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) */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/sandbox/allowlist.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions src/sandbox/sandbox.c
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
#ifdef __linux__
#include <sched.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <linux/landlock.h>
#endif

#define DEFAULT_TIMEOUT_MS 10000
Expand Down Expand Up @@ -138,6 +140,137 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms)
/* Child setup before exec */
/* ------------------------------------------------------------------ */

#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 with a stderr warning if Landlock
* is unavailable.
*/
static void landlock_restrict_to_workspace(const char *workspace)
{
__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",
"/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;
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;
/* 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;
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);
Expand All @@ -149,6 +282,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);
Expand Down
14 changes: 9 additions & 5 deletions src/sandbox/sandbox.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions tests/test_allowlist.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
/* ------------------------------------------------------------------ */
Expand All @@ -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;
}
Loading
Loading