Skip to content
Merged
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
72 changes: 72 additions & 0 deletions scripts/landlock-probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Landlock availability probe — run INSIDE the prod zero-server container.

Landlock is the one filesystem-sandbox primitive that fits zero-server's
constraints: it needs no Linux capabilities and no user namespace (the two
things the capless-root + apparmor-userns-restricted OCD host denies), only
PR_SET_NO_NEW_PRIVS, which the hardened container already sets. See the
project memory `project_bash_sandbox_infeasible` for why bubblewrap can't run.

This probe answers the single gating question: can a process in this exact
container (same kernel, seccomp profile, and caps as the agent's bash) use
Landlock at all? It calls landlock_create_ruleset(NULL, 0, VERSION), which
returns the supported ABI version without changing anything.

Run it where bash actually executes:

ocd ssh server-2 --server # real root on the host
docker exec <zero-server-container> python3 /app/scripts/landlock-probe.py

Interpreting the result:
- "Landlock ABI vN" -> WORKS. Implement the Landlock bash wrapper.
ABI>=1 covers read/write FS containment (enough
to block cross-project access); ABI>=3 adds
truncate, ABI>=4 adds TCP rules (not needed here).
- errno ENOSYS (38) -> kernel too old / Landlock not compiled in.
- errno EOPNOTSUPP (95) -> Landlock present but disabled at boot
(lsm= line / CONFIG). Fixable host-side.
- errno EPERM/EACCES (1/13)-> a seccomp filter is blocking landlock_* syscalls.
Needs the OCD seccomp profile to allow them.
"""

import ctypes, ctypes.util, errno, os, sys

# Generic syscall number, identical on x86_64 and arm64.
SYS_landlock_create_ruleset = 444
LANDLOCK_CREATE_RULESET_VERSION = 1 # query-ABI flag

libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True)

# Also report whether the LSM is even listed, for a clearer diagnosis.
try:
with open("/sys/kernel/security/lsm") as f:
lsms = f.read().strip()
print(f"active LSMs: {lsms}")
print(f"landlock listed in LSMs: {'landlock' in lsms.split(',')}")
except OSError as e:
print(f"could not read /sys/kernel/security/lsm: {e}")

try:
with open("/proc/version") as f:
print("kernel:", f.read().split(' (')[0].strip())
except OSError:
pass

ctypes.set_errno(0)
abi = libc.syscall(SYS_landlock_create_ruleset, None, ctypes.c_size_t(0),
ctypes.c_uint(LANDLOCK_CREATE_RULESET_VERSION))
err = ctypes.get_errno()

if abi >= 1:
print(f"\nRESULT: Landlock ABI v{abi} — USABLE in this container. Proceed.")
sys.exit(0)

name = errno.errorcode.get(err, str(err))
print(f"\nRESULT: landlock_create_ruleset failed (errno {err} {name}) — NOT usable.")
if err == errno.ENOSYS:
print(" -> Kernel has no Landlock. Need a newer host kernel (>=5.13).")
elif err == errno.EOPNOTSUPP:
print(" -> Landlock compiled but disabled. Enable via host lsm= boot param.")
elif err in (errno.EPERM, errno.EACCES):
print(" -> Likely seccomp-blocked. OCD seccomp profile must allow landlock_*.")
sys.exit(1)
9 changes: 9 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ RUN bun --filter zero build
# Copy server source
COPY server/ ./server/

# Compile the Landlock bash-sandbox helper (server/landlock-exec). Pure C,
# UAPI structs defined inline so it needs no kernel headers; links only libc.
# bash on Linux is routed through this by project-sandbox so a prompt-injected
# command can't read/write sibling projects. `/var/empty` is the GIT_TEMPLATE_DIR
# the bash ops point git at; it must exist and be readable inside the ruleset.
RUN g++ -O2 -x c server/landlock-exec/zero-landlock.c -o /usr/local/bin/zero-landlock \
&& chmod 755 /usr/local/bin/zero-landlock \
&& mkdir -p /var/empty

# Build frontend (build.ts uses `bun x vite build`)
COPY web/ ./web/
COPY build.ts ./build.ts
Expand Down
199 changes: 199 additions & 0 deletions server/landlock-exec/zero-landlock.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// zero-landlock — apply a Landlock filesystem ruleset, then exec a command.
//
// Why this exists: zero-server runs as capless root (CapEff=0) on a shared,
// hardened OCD host where unprivileged user namespaces are blocked, so
// bubblewrap (the sandbox-runtime Linux backend) cannot engage and bash
// falls back to unsandboxed — letting a prompt-injected command read/write
// sibling projects under the projects root. Landlock is the one FS-sandbox
// primitive that works here: it needs no capabilities and no user namespace,
// only PR_SET_NO_NEW_PRIVS (already set on the container). Verified usable at
// ABI v4 on the prod kernel (6.8).
//
// Model: Landlock is deny-by-default allowlist. We grant rw on the project
// dir + /tmp, ro on system dirs + the zero package/agent roots, rw on a few
// /dev nodes. The projects ROOT is never granted, so sibling projects are
// denied automatically — no explicit deny needed.
//
// Usage:
// zero-landlock --check # exit 0 if Landlock usable
// zero-landlock [--rw DIR]... [--ro DIR]... [--rwfile FILE]... -- CMD [ARG]...
//
// Fail-closed: if a ruleset cannot be created/applied, we exit non-zero
// rather than exec unsandboxed. Missing allow paths are skipped (a dir that
// doesn't exist yet is simply not granted), which is not a failure.

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>

#ifndef __NR_landlock_create_ruleset
#define __NR_landlock_create_ruleset 444
#endif
#ifndef __NR_landlock_add_rule
#define __NR_landlock_add_rule 445
#endif
#ifndef __NR_landlock_restrict_self
#define __NR_landlock_restrict_self 446
#endif

#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
#define LANDLOCK_RULE_PATH_BENEATH 1

// Filesystem access-right bits (stable UAPI).
#define A_EXECUTE (1ULL << 0)
#define A_WRITE_FILE (1ULL << 1)
#define A_READ_FILE (1ULL << 2)
#define A_READ_DIR (1ULL << 3)
#define A_REMOVE_DIR (1ULL << 4)
#define A_REMOVE_FILE (1ULL << 5)
#define A_MAKE_CHAR (1ULL << 6)
#define A_MAKE_DIR (1ULL << 7)
#define A_MAKE_REG (1ULL << 8)
#define A_MAKE_SOCK (1ULL << 9)
#define A_MAKE_FIFO (1ULL << 10)
#define A_MAKE_BLOCK (1ULL << 11)
#define A_MAKE_SYM (1ULL << 12)
#define A_REFER (1ULL << 13) // ABI >= 2
#define A_TRUNCATE (1ULL << 14) // ABI >= 3
#define A_IOCTL_DEV (1ULL << 15) // ABI >= 5

struct landlock_ruleset_attr {
uint64_t handled_access_fs;
uint64_t handled_access_net;
};

struct landlock_path_beneath_attr {
uint64_t allowed_access;
int32_t parent_fd;
} __attribute__((packed));

static int abi_version(void) {
return (int)syscall(__NR_landlock_create_ruleset, NULL, (size_t)0,
LANDLOCK_CREATE_RULESET_VERSION);
}

// Full FS mask the kernel handles at the given ABI. handled_access_fs must
// not include bits the running kernel doesn't know, or create_ruleset fails.
static uint64_t handled_fs_for_abi(int abi) {
uint64_t m = A_EXECUTE | A_WRITE_FILE | A_READ_FILE | A_READ_DIR |
A_REMOVE_DIR | A_REMOVE_FILE | A_MAKE_CHAR | A_MAKE_DIR |
A_MAKE_REG | A_MAKE_SOCK | A_MAKE_FIFO | A_MAKE_BLOCK |
A_MAKE_SYM; // ABI 1
if (abi >= 2) m |= A_REFER;
if (abi >= 3) m |= A_TRUNCATE;
if (abi >= 5) m |= A_IOCTL_DEV;
return m;
}

static int add_path(int ruleset_fd, const char *path, uint64_t access) {
int pfd = open(path, O_PATH | O_CLOEXEC);
if (pfd < 0) {
// Missing path: skip silently (allowlist entry that doesn't exist yet).
if (errno == ENOENT) return 0;
fprintf(stderr, "zero-landlock: open %s: %s\n", path, strerror(errno));
return -1;
}
struct landlock_path_beneath_attr pb = {.allowed_access = access,
.parent_fd = pfd};
int rc = (int)syscall(__NR_landlock_add_rule, ruleset_fd,
LANDLOCK_RULE_PATH_BENEATH, &pb, 0U);
int saved = errno;
close(pfd);
if (rc != 0) {
fprintf(stderr, "zero-landlock: add_rule %s: %s\n", path, strerror(saved));
return -1;
}
return 0;
}

int main(int argc, char **argv) {
int abi = abi_version();
if (argc == 2 && strcmp(argv[1], "--check") == 0) {
if (abi >= 1) {
printf("landlock-abi=%d\n", abi);
return 0;
}
fprintf(stderr, "zero-landlock: unavailable (abi=%d errno=%d)\n", abi, errno);
return 1;
}

// Collect allow-lists and find the "--" separator.
const char **rw = calloc(argc, sizeof(char *));
const char **ro = calloc(argc, sizeof(char *));
const char **rwf = calloc(argc, sizeof(char *));
int nrw = 0, nro = 0, nrwf = 0;
int cmd_start = -1;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) {
cmd_start = i + 1;
break;
} else if (strcmp(argv[i], "--rw") == 0 && i + 1 < argc) {
rw[nrw++] = argv[++i];
} else if (strcmp(argv[i], "--ro") == 0 && i + 1 < argc) {
ro[nro++] = argv[++i];
} else if (strcmp(argv[i], "--rwfile") == 0 && i + 1 < argc) {
rwf[nrwf++] = argv[++i];
} else {
fprintf(stderr, "zero-landlock: unknown arg: %s\n", argv[i]);
return 2;
}
}
if (cmd_start < 0 || cmd_start >= argc) {
fprintf(stderr, "zero-landlock: no command after --\n");
return 2;
}

if (abi < 1) {
// Fail closed: caller only routes through us when it believes Landlock
// works, so a surprise here means the security control is absent.
fprintf(stderr, "zero-landlock: Landlock unavailable (abi=%d) — refusing "
"to run unsandboxed\n", abi);
return 126;
}

uint64_t handled = handled_fs_for_abi(abi);
struct landlock_ruleset_attr rsattr = {.handled_access_fs = handled,
.handled_access_net = 0};
int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &rsattr,
sizeof(rsattr), 0U);
if (ruleset_fd < 0) {
fprintf(stderr, "zero-landlock: create_ruleset: %s\n", strerror(errno));
return 126;
}

// RW dirs get the full handled mask; RO dirs get read+traverse+execute;
// RW files get read/write (+truncate/ioctl where the ABI handles them).
uint64_t ro_access = A_READ_FILE | A_READ_DIR | A_EXECUTE;
uint64_t rwfile_access = A_READ_FILE | A_WRITE_FILE;
if (abi >= 3) rwfile_access |= A_TRUNCATE;
if (abi >= 5) rwfile_access |= A_IOCTL_DEV;

for (int i = 0; i < nrw; i++)
if (add_path(ruleset_fd, rw[i], handled) != 0) return 126;
for (int i = 0; i < nro; i++)
if (add_path(ruleset_fd, ro[i], ro_access) != 0) return 126;
for (int i = 0; i < nrwf; i++)
if (add_path(ruleset_fd, rwf[i], rwfile_access) != 0) return 126;

if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
fprintf(stderr, "zero-landlock: prctl(NO_NEW_PRIVS): %s\n", strerror(errno));
return 126;
}
if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0U) != 0) {
fprintf(stderr, "zero-landlock: restrict_self: %s\n", strerror(errno));
return 126;
}
close(ruleset_fd);

execvp(argv[cmd_start], &argv[cmd_start]);
fprintf(stderr, "zero-landlock: exec %s: %s\n", argv[cmd_start],
strerror(errno));
return 127;
}
42 changes: 36 additions & 6 deletions server/lib/browser/host-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ import type {
CDPSession,
} from "playwright";
import { log } from "@/lib/utils/logger.ts";
import { projectDirFor } from "@/lib/pi/run-turn.ts";
import {
chromeStateFileFor,
legacyChromeStateFileFor,
} from "@/lib/pi/run-turn.ts";
import { getCompanionRegistry } from "@/lib/companion/registry.ts";
import type { BrowserAction as ProtocolBrowserAction } from "@/lib/browser/protocol.ts";

Expand All @@ -45,13 +48,39 @@ import type { BrowserAction as ProtocolBrowserAction } from "@/lib/browser/proto
* without paying the RAM cost of a persistent Chromium per project.
*
* Written on context close / idle eviction; reloaded on next context create.
* Sensitive (auth tokens) — sandbox denies the agent both reading and
* writing this file, and snapshot-service excludes it from git snapshots.
* Sensitive (auth tokens), so it lives OUTSIDE the project dir
* (`chromeStateFileFor`): the agent's bash is Landlock-confined to the project
* dir but Landlock can't deny a single file within a granted tree, so the only
* reliable protection is keeping this file out of that tree. In-process tools
* are project-dir-scoped and can't reach it either.
*/
const STATE_FILENAME = ".chrome-state.json";

function stateFileFor(projectId: string): string {
return join(projectDirFor(projectId), STATE_FILENAME);
return chromeStateFileFor(projectId);
}

/**
* One-time migration: older builds stored the file at
* `<projectDir>/.chrome-state.json`. Move any such file to the new
* out-of-tree location on next open so existing sessions aren't logged out
* and the secret no longer sits in the Landlock-readable project dir.
*/
async function migrateLegacyState(projectId: string): Promise<void> {
const legacy = legacyChromeStateFileFor(projectId);
const exists = await stat(legacy)
.then((s) => s.isFile())
.catch(() => false);
if (!exists) return;
const target = stateFileFor(projectId);
try {
await mkdir(join(target, ".."), { recursive: true });
await rename(legacy, target);
browserLog.info("migrated legacy chrome-state out of project dir", { projectId });
} catch (err) {
browserLog.warn("legacy chrome-state migration failed", {
projectId,
err: err instanceof Error ? err.message : String(err),
});
}
}

// rebrowser-playwright is a drop-in fork that patches the Runtime.Enable CDP
Expand Down Expand Up @@ -191,6 +220,7 @@ class HostBrowserPool extends EventEmitter {

const p = (async () => {
const browser = await this.ensureBrowser();
await migrateLegacyState(projectId);
const statePath = stateFileFor(projectId);
const hasState = await stat(statePath)
.then((s) => s.isFile())
Expand Down
Loading
Loading