Skip to content
Open
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
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ Secrets passed as env vars via `env.sh` in the workdir. Only visible inside the

- Bash builtins (`echo`, `test`, `[`, `eval`, `source`) bypass PATH wrappers and the DEBUG trap. They are not subject to `deny.cli` and won't be tracked for insist enforcement.
- DNS-based iptables rules resolve hostnames at execution time. CDN IP rotation mid-request may cause connection failure.
- IPv6 traffic is not filtered. Only IPv4 iptables rules are applied — AAAA-resolvable hosts reachable via IPv6 are not subject to `allow.net`/`deny.net`.
- Network restrictions are enforced with `iptables` and `ip6tables`; DNS-based rules resolve both A and AAAA records at execution time.
- Symlinks inside `allow.read`/`allow.write` directories are followed by the kernel. A symlink at an allowed path that points outside the allowed tree lets the script read or write the target. Avoid mounting directories that contain attacker-controlled symlinks.
- `/proc` and `/dev` are mounted inside the sandbox. Scripts can read `/proc/self/*` and other process metadata visible to the unprivileged `band-runner` UID.
72 changes: 48 additions & 24 deletions packages/runtime/src/band-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,24 @@ function writeFile(path: string, content: string): void {

// ── Firewall ──────────────────────────────────────────────────────────

const FIREWALL_TABLES = [
{ table: "iptables", resolver: "ahostsv4" },
{ table: "ip6tables", resolver: "ahostsv6" },
];

function addResolvedFirewallRule(
cmds: string[],
chainName: string,
host: string,
target: "ACCEPT" | "REJECT"
): void {
for (const { table, resolver } of FIREWALL_TABLES) {
cmds.push(
`for ip in $(getent ${resolver} "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do ${table} -A ${chainName} -d "$ip" -j ${target}; done`
);
}
}

function setupFirewall(
chainName: string,
allowNet: string[],
Expand All @@ -173,62 +191,68 @@ function setupFirewall(
if (allowNet.length === 0 && denyNet.length === 0) return;
if (allowNet.includes("*") && denyNet.length === 0) return;

// Check if iptables is available and BAND-DEFAULT chain exists.
// Check if iptables/ip6tables are available.
try {
shell("iptables -L BAND-DEFAULT -n >/dev/null 2>&1");
shell("iptables -L OUTPUT -n >/dev/null 2>&1");
shell("ip6tables -L OUTPUT -n >/dev/null 2>&1");
} catch {
return;
}

const cmds: string[] = [
`iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`,
`iptables -A ${chainName} -o lo -j ACCEPT`,
`iptables -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`,
`iptables -A ${chainName} -p udp --dport 53 -j ACCEPT`,
`iptables -A ${chainName} -p tcp --dport 53 -j ACCEPT`,
];
const cmds: string[] = [];
for (const { table } of FIREWALL_TABLES) {
cmds.push(
`${table} -N ${chainName} 2>/dev/null || ${table} -F ${chainName}`,
`${table} -A ${chainName} -o lo -j ACCEPT`,
`${table} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`,
`${table} -A ${chainName} -p udp --dport 53 -j ACCEPT`,
`${table} -A ${chainName} -p tcp --dport 53 -j ACCEPT`,
);
}

// Deny rules FIRST — they punch holes in allow (deny takes precedence)
for (const host of denyNet) {
const resolveHost = shellSafe(host.startsWith("*.") ? host.slice(2) : host, "deny.net host");
cmds.push(
`for ip in $(getent ahosts "${resolveHost}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j REJECT; done`
);
addResolvedFirewallRule(cmds, chainName, resolveHost, "REJECT");
}

// Allow rules
for (const host of allowNet) {
if (host === "*") continue;
if (host.startsWith("*.")) {
const base = shellSafe(host.slice(2), "allow.net host");
cmds.push(
`for ip in $(getent ahosts "${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`,
`for ip in $(getent ahosts "api.${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`,
`for ip in $(getent ahosts "www.${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`
);
addResolvedFirewallRule(cmds, chainName, base, "ACCEPT");
addResolvedFirewallRule(cmds, chainName, `api.${base}`, "ACCEPT");
addResolvedFirewallRule(cmds, chainName, `www.${base}`, "ACCEPT");
} else {
const safeHost = shellSafe(host, "allow.net host");
cmds.push(
`for ip in $(getent ahosts "${safeHost}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`
);
addResolvedFirewallRule(cmds, chainName, safeHost, "ACCEPT");
}
}

if (!allowNet.includes("*")) {
cmds.push(`iptables -A ${chainName} -j REJECT`);
for (const { table } of FIREWALL_TABLES) {
cmds.push(`${table} -A ${chainName} -j REJECT`);
}
}

const { uid } = getCachedBandRunnerIds();
cmds.push(`iptables -I OUTPUT 1 -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName}`);
for (const { table } of FIREWALL_TABLES) {
cmds.push(`${table} -I OUTPUT 1 -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName}`);
}

shell(cmds.join("\n"));
}

function teardownFirewall(chainName: string): void {
const { uid } = getCachedBandRunnerIds();
shellIgnoreError(
`iptables -D OUTPUT -m owner --uid-owner ${getCachedBandRunnerIds().uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` +
`iptables -D OUTPUT -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` +
`iptables -F ${chainName} 2>/dev/null; ` +
`iptables -X ${chainName} 2>/dev/null`
`iptables -X ${chainName} 2>/dev/null; ` +
`ip6tables -D OUTPUT -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` +
`ip6tables -F ${chainName} 2>/dev/null; ` +
`ip6tables -X ${chainName} 2>/dev/null`
);
}

Expand Down
62 changes: 36 additions & 26 deletions packages/runtime/src/banded-skills/lima-exec-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,53 +89,63 @@ export function extractMountPath(pattern: string): string | null {
}

/**
* Build an iptables setup script for the given network rules.
* Build an iptables/ip6tables setup script for the given network rules.
*/
export function buildFirewallScript(
chainName: string,
rules?: { allowNet: string[]; denyNet: string[] }
): string | null {
if (!rules || rules.allowNet.length === 0) return null;

const lines: string[] = [
`iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`,
`iptables -A ${chainName} -o lo -j ACCEPT`,
`iptables -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`,
`iptables -A ${chainName} -p udp --dport 53 -j ACCEPT`,
`iptables -A ${chainName} -p tcp --dport 53 -j ACCEPT`,
const firewallTables = [
{ table: "iptables", resolver: "ahostsv4" },
{ table: "ip6tables", resolver: "ahostsv6" },
];
const lines: string[] = [];

for (const { table } of firewallTables) {
lines.push(
`${table} -N ${chainName} 2>/dev/null || ${table} -F ${chainName}`,
`${table} -A ${chainName} -o lo -j ACCEPT`,
`${table} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`,
`${table} -A ${chainName} -p udp --dport 53 -j ACCEPT`,
`${table} -A ${chainName} -p tcp --dport 53 -j ACCEPT`,
);
}

const addHostRules = (host: string) => {
for (const { table, resolver } of firewallTables) {
lines.push(
`for ip in $(getent ${resolver} "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do`,
` ${table} -A ${chainName} -d "$ip" -j ACCEPT`,
`done`,
);
}
};

for (const host of rules.allowNet) {
if (host === "*") return null;

if (host.startsWith("*.")) {
const baseDomain = host.slice(2);
lines.push(
`# Allow ${host}`,
`for ip in $(getent ahosts "${baseDomain}" 2>/dev/null | awk '{print $1}' | sort -u); do`,
` iptables -A ${chainName} -d "$ip" -j ACCEPT`,
`done`,
);
lines.push(`# Allow ${host}`);
addHostRules(baseDomain);
for (const prefix of ["api", "www"]) {
lines.push(
`for ip in $(getent ahosts "${prefix}.${baseDomain}" 2>/dev/null | awk '{print $1}' | sort -u); do`,
` iptables -A ${chainName} -d "$ip" -j ACCEPT`,
`done`,
);
addHostRules(`${prefix}.${baseDomain}`);
}
} else {
lines.push(
`# Allow ${host}`,
`for ip in $(getent ahosts "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do`,
` iptables -A ${chainName} -d "$ip" -j ACCEPT`,
`done`,
);
lines.push(`# Allow ${host}`);
addHostRules(host);
}
}

lines.push(`iptables -A ${chainName} -j REJECT`);
for (const { table } of firewallTables) {
lines.push(`${table} -A ${chainName} -j REJECT`);
}
// Only route band-runner's new connections through this chain
lines.push(`iptables -I OUTPUT 1 -m state --state NEW -j ${chainName}`);
for (const { table } of firewallTables) {
lines.push(`${table} -I OUTPUT 1 -m state --state NEW -j ${chainName}`);
}

return lines.join("\n");
}
44 changes: 42 additions & 2 deletions packages/runtime/src/banded-skills/lima-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,48 @@
*/

import { execSync } from "child_process";
import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "fs";
import { existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, statSync } from "fs";
import { join, basename } from "path";
import { tmpdir } from "os";
import type { BandExecResult } from "./types";

const DEFAULT_VM_NAME = "bands-executor";
const SERVER_URL = "http://localhost:9000";
const EXEC_LOCK_DIR = join(tmpdir(), "bands-lima-exec.lock");
const EXEC_LOCK_TIMESTAMP = join(EXEC_LOCK_DIR, "created-at");
const EXEC_LOCK_STALE_MS = 120_000;

export function acquireExecLockSync(timeoutMs = 150_000): () => void {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
mkdirSync(EXEC_LOCK_DIR);
writeFileSync(EXEC_LOCK_TIMESTAMP, String(Date.now()));
return () => rmSync(EXEC_LOCK_DIR, { recursive: true, force: true });
} catch (e: any) {
if (e?.code !== "EEXIST") throw e;

try {
const parsedCreatedAt = Number(readFileSync(EXEC_LOCK_TIMESTAMP, "utf-8"));
const createdAt = Number.isNaN(parsedCreatedAt)
? statSync(EXEC_LOCK_DIR).mtimeMs
: parsedCreatedAt;
if (Date.now() - createdAt > EXEC_LOCK_STALE_MS) {
rmSync(EXEC_LOCK_DIR, { recursive: true, force: true });
continue;
}
} catch {
continue;
}

if (Date.now() >= deadline) {
throw new Error("Timed out waiting for exclusive access to the band server");
}
const waitUntil = Date.now() + 100;
while (Date.now() < waitUntil) {}
}
}
}

/**
* Execute a script in the Lima VM via the band server.
Expand Down Expand Up @@ -91,9 +126,12 @@ export async function limaExec(
insist: fileRules?.insist,
};

// POST to the band server
// POST to the band server. The Lima server accepts one execution at a time;
// serialize cross-process callers so parallel test files do not race it.
let resp: Response;
let releaseLock: (() => void) | undefined;
try {
releaseLock = acquireExecLockSync();
resp = await fetch(`${SERVER_URL}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -105,6 +143,8 @@ export async function limaExec(
success: false,
error: `Failed to reach band server at ${SERVER_URL}: ${e.message}. Is the Lima VM running?`,
};
} finally {
releaseLock?.();
}

let result: {
Expand Down
26 changes: 14 additions & 12 deletions packages/runtime/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,21 +179,23 @@ export async function setupLima(options: { force?: boolean } = {}): Promise<void
// [8/8] Set up default firewall (locked down) and verify health
log("[8/8]", "Setting up default firewall and verifying...");

// Default iptables: DROP all outbound from band-runner.
// Default iptables/ip6tables: DROP all outbound from band-runner.
// Only band-runner's traffic is restricted — the server process and SSH
// tunnel (which run as the host user) need unrestricted outbound.
const bandRunnerUid = limaShell("id -u band-runner").trim();
limaShell(`sudo iptables -F OUTPUT 2>/dev/null || true`);
limaShell(`sudo iptables -P OUTPUT ACCEPT`);
// Create a persistent chain for band-runner traffic
limaShell(`sudo iptables -N BAND-DEFAULT 2>/dev/null || sudo iptables -F BAND-DEFAULT`);
limaShell(`sudo iptables -A BAND-DEFAULT -o lo -j ACCEPT`);
limaShell(`sudo iptables -A BAND-DEFAULT -m state --state ESTABLISHED,RELATED -j ACCEPT`);
limaShell(`sudo iptables -A BAND-DEFAULT -p udp --dport 53 -j ACCEPT`);
limaShell(`sudo iptables -A BAND-DEFAULT -p tcp --dport 53 -j ACCEPT`);
limaShell(`sudo iptables -A BAND-DEFAULT -j REJECT`);
// Route band-runner's outbound traffic through the restrictive chain
limaShell(`sudo iptables -A OUTPUT -m owner --uid-owner ${bandRunnerUid} -j BAND-DEFAULT`);
for (const table of ["iptables", "ip6tables"]) {
limaShell(`sudo ${table} -F OUTPUT 2>/dev/null || true`);
limaShell(`sudo ${table} -P OUTPUT ACCEPT`);
// Create a persistent chain for band-runner traffic
limaShell(`sudo ${table} -N BAND-DEFAULT 2>/dev/null || sudo ${table} -F BAND-DEFAULT`);
limaShell(`sudo ${table} -A BAND-DEFAULT -o lo -j ACCEPT`);
limaShell(`sudo ${table} -A BAND-DEFAULT -m state --state ESTABLISHED,RELATED -j ACCEPT`);
limaShell(`sudo ${table} -A BAND-DEFAULT -p udp --dport 53 -j ACCEPT`);
limaShell(`sudo ${table} -A BAND-DEFAULT -p tcp --dport 53 -j ACCEPT`);
limaShell(`sudo ${table} -A BAND-DEFAULT -j REJECT`);
// Route band-runner's outbound traffic through the restrictive chain
limaShell(`sudo ${table} -A OUTPUT -m owner --uid-owner ${bandRunnerUid} -j BAND-DEFAULT`);
}
log(" ", `Default firewall: REJECT all outbound from band-runner (uid ${bandRunnerUid})`);
const healthy = await pollHealth(`http://localhost:${PORT}`, 15_000);

Expand Down
Loading
Loading