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
2 changes: 2 additions & 0 deletions packages/format/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ExecutionConfig {
vmName?: string;
/** Port for the band server (default: 9000) */
port?: number;
/** Sandbox mode (default: bwrap) */
sandboxMode?: "bwrap" | "worktree-chmod";
};
}

Expand Down
92 changes: 88 additions & 4 deletions packages/runtime/src/band-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,8 @@ interface ExecRequest {
net?: string[];
};
timeoutMs?: number;
sandboxMode?: "bwrap" | "worktree-chmod";
repoPath?: string;
}

interface ExecResponse {
Expand Down Expand Up @@ -670,11 +672,93 @@ async function executeScript(req: ExecRequest): Promise<ExecResponse> {
setupFirewall(chainName, allowNet, denyNet);
}

// Run script inside bwrap using cooked mount list, excluding deny patterns
const bwrapArgs = ["sudo", ...buildBwrapArgs(workdir, cook, req.denyRead ?? [], req.denyWrite ?? [])];

const timeout = req.timeoutMs ?? 60_000;
const proc = Bun.spawnSync(bwrapArgs, { timeout });
let proc: ReturnType<typeof Bun.spawnSync>;

if (req.sandboxMode === "worktree-chmod" && req.repoPath) {
// Worktree + chmod sandbox: file isolation via POSIX permissions
const { buildChmodPlan, buildChmodScript, buildSparseCheckoutPatterns, shellQuote } = await import("./worktree-chmod");
const worktreePath = `/tmp/band-wt-${execId}`;

try {
// Create sparse-checkout worktree
const sparsePatterns = buildSparseCheckoutPatterns(req.allowRead ?? [], req.allowWrite ?? []);
shell(`git -C ${shellQuote(req.repoPath)} worktree add --detach ${shellQuote(worktreePath)} 2>/dev/null`);
shell(`cd ${shellQuote(worktreePath)} && git sparse-checkout init --cone`);
if (sparsePatterns.length > 0 && !sparsePatterns.includes("*")) {
shell(`cd ${shellQuote(worktreePath)} && git sparse-checkout set ${sparsePatterns.map(shellQuote).join(" ")}`);
}

// Expand globs against actual filesystem to get concrete file lists
const expandGlob = (patterns: string[]): string[] => {
if (patterns.length === 0) return [];
const results: string[] = [];
for (const pattern of patterns) {
const cleaned = pattern.replace(/^\.\//, "");
const found = shell(`find ${shellQuote(worktreePath)} -path ${shellQuote(worktreePath + "/" + cleaned)} -type f 2>/dev/null || true`).trim();
if (found) results.push(...found.split("\n").filter(Boolean));
}
return [...new Set(results)];
};

const readFiles = expandGlob(req.allowRead ?? []);
const writeFiles = expandGlob(req.allowWrite ?? []);
const denyReadFiles = expandGlob(req.denyRead ?? []);
const denyWriteFiles = expandGlob(req.denyWrite ?? []);
const denyFiles = [...new Set([...denyReadFiles, ...denyWriteFiles])];

// Build and apply chmod plan
const plan = buildChmodPlan(worktreePath, readFiles, writeFiles, denyFiles);
const chmodScript = buildChmodScript(plan);
writeFile(`${workdir}/chmod-plan.sh`, chmodScript);
shell(`bash ${workdir}/chmod-plan.sh`);

// Copy workdir files into worktree for script access
shell(`cp ${workdir}/run.sh ${worktreePath}/.band-run.sh`);
shell(`cp ${workdir}/env.sh ${worktreePath}/.band-env.sh`);
shell(`cp ${workdir}/input.json ${worktreePath}/.band-input.json`);
if (req.config) {
shell(`cp ${workdir}/config.json ${worktreePath}/.band-config.json`);
}
shell(`chmod a+r ${worktreePath}/.band-run.sh ${worktreePath}/.band-env.sh ${worktreePath}/.band-input.json`);
if (hasInsist) {
shell(`touch ${worktreePath}/.band-ops && chmod a+rw ${worktreePath}/.band-ops`);
}

// Update env paths to point to worktree
const envPatch = [
`export INPUT_PATH=${worktreePath}/.band-input.json`,
`export OUTPUT_PATH=${worktreePath}/.band-output.json`,
].join("\n");
shell(`echo '${envPatch}' >> ${worktreePath}/.band-env.sh`);
shell(`chmod a+rw ${worktreePath}/.band-env.sh`);

// Create writable output location
shell(`touch ${worktreePath}/.band-output.json && chmod a+rw ${worktreePath}/.band-output.json`);

// Run as band-runner without bwrap
proc = Bun.spawnSync(
["sudo", "-u", BAND_RUNNER_USER, "bash", "-c",
`source ${worktreePath}/.band-env.sh && cd ${worktreePath} && bash ${worktreePath}/.band-run.sh`],
{ timeout }
);

// Copy output back to workdir
shellIgnoreError(`cp ${worktreePath}/.band-output.json ${workdir}/output.json 2>/dev/null`);
if (hasInsist) {
shellIgnoreError(`cp ${worktreePath}/.band-ops ${opsFile} 2>/dev/null`);
}
} finally {
// Teardown worktree
shellIgnoreError(`chmod -R u+rwx ${worktreePath} 2>/dev/null`);
shellIgnoreError(`git -C ${shellQuote(req.repoPath)} worktree remove --force ${shellQuote(worktreePath)} 2>/dev/null`);
shellIgnoreError(`rm -rf ${worktreePath} 2>/dev/null`);
}
} else {
// Default: bwrap mount-namespace sandbox
const bwrapArgs = ["sudo", ...buildBwrapArgs(workdir, cook, req.denyRead ?? [], req.denyWrite ?? [])];
proc = Bun.spawnSync(bwrapArgs, { timeout });
}

const exitCode = proc.exitCode;
const stderr = proc.stderr.toString();
Expand Down
267 changes: 267 additions & 0 deletions packages/runtime/src/worktree-chmod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* Worktree + chmod sandbox.
*
* Alternative to bwrap mount-namespace isolation. Uses git sparse-checkout
* worktrees and POSIX file permissions to enforce allow/deny rules.
*
* Flow:
* 1. Create a sparse-checkout worktree (only allowed paths exist)
* 2. chmod -R 000 (deny everything)
* 3. chmod a+x on traversal directories
* 4. chmod a+r on allow.read files
* 5. chmod a+w on allow.write files
* 6. chmod 000 on deny.read / deny.write (override allow)
* 7. Run script as band-runner (unprivileged)
* 8. Tear down worktree
*
* Security model: identical to bwrap — deny by default, allow selectively,
* deny overrides allow. Enforcement is kernel-level POSIX permissions.
*/

import { join, dirname, relative, resolve, sep } from "path";

/**
* Plan for chmod operations. Pure data — no side effects.
*/
export interface ChmodPlan {
worktreeRoot: string;
lockdownDirs: string[];
traversalDirs: string[];
readableFiles: string[];
writableFiles: string[];
deniedFiles: string[];
}

/**
* Compute traversal directories needed for a set of file paths.
* Returns all ancestor directories between worktreeRoot and each file,
* deduplicated and sorted shortest-first.
*/
export function computeTraversalDirs(files: string[], worktreeRoot: string): string[] {
const dirs = new Set<string>();
const root = resolve(worktreeRoot);
dirs.add(root);

for (const file of files) {
let dir = dirname(resolve(file));
while (dir.length >= root.length && dir !== root) {
dirs.add(dir);
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}

return [...dirs].sort((a, b) => a.length - b.length);
}

/**
* Filter out symlinks that point outside the worktree root.
* Returns only safe paths.
*/
export function filterUnsafeSymlinks(
files: string[],
worktreeRoot: string,
resolveLink: (path: string) => string | null
): { safe: string[]; unsafe: string[] } {
const root = resolve(worktreeRoot);
const safe: string[] = [];
const unsafe: string[] = [];

for (const file of files) {
const target = resolveLink(file);
if (target === null) {
safe.push(file);
continue;
}
const resolved = resolve(dirname(file), target);
if (resolved.startsWith(root + sep) || resolved === root) {
safe.push(file);
} else {
unsafe.push(file);
}
}

return { safe, unsafe };
}

/**
* Build a chmod plan from permission patterns and expanded file lists.
*
* readFiles/writeFiles are already-expanded absolute paths (glob expansion
* happens at the call site using the actual filesystem).
*
* denyFiles are paths that matched deny.read or deny.write patterns —
* these get chmod 000 AFTER the allow pass, overriding any grants.
*/
export function buildChmodPlan(
worktreeRoot: string,
readFiles: string[],
writeFiles: string[],
denyFiles: string[]
): ChmodPlan {
const root = resolve(worktreeRoot);
const allGranted = [...new Set([...readFiles, ...writeFiles])];
const traversalDirs = computeTraversalDirs(allGranted, root);

return {
worktreeRoot: root,
lockdownDirs: [root],
traversalDirs,
readableFiles: readFiles,
writableFiles: writeFiles,
deniedFiles: denyFiles,
};
}

/**
* Generate the shell script that applies a chmod plan.
*
* Ordering is critical for security:
* 1. chmod -R 000 (deny all)
* 2. chmod a+x on traversal dirs (allow navigation)
* 3. chmod a+r on readable files
* 4. chmod a+w on writable files
* 5. chmod 000 on denied files (deny overrides allow)
*
* Traversal dirs get a+x but NOT a+r — you can cd into them but not ls.
* This prevents directory enumeration of files outside the allowed set.
*/
export function buildChmodScript(plan: ChmodPlan): string {
const lines: string[] = ["#!/bin/bash", "set -euo pipefail", ""];

// Phase 1: deny everything
lines.push(`# Phase 1: deny all`);
for (const dir of plan.lockdownDirs) {
lines.push(`chmod -R 000 ${shellQuote(dir)}`);
}
lines.push("");

// Phase 2: traversal (a+x only, no a+r)
if (plan.traversalDirs.length > 0) {
lines.push(`# Phase 2: traversal directories`);
for (const dir of plan.traversalDirs) {
lines.push(`chmod a+x ${shellQuote(dir)}`);
}
lines.push("");
}

// Phase 3: readable files (a+r)
if (plan.readableFiles.length > 0) {
lines.push(`# Phase 3: readable files`);
for (const file of plan.readableFiles) {
lines.push(`chmod a+r ${shellQuote(file)}`);
}
lines.push("");
}

// Phase 4: writable files (a+w)
if (plan.writableFiles.length > 0) {
lines.push(`# Phase 4: writable files`);
for (const file of plan.writableFiles) {
lines.push(`chmod a+w ${shellQuote(file)}`);
}
lines.push("");
}

// Phase 5: denied files (000 — overrides allow)
if (plan.deniedFiles.length > 0) {
lines.push(`# Phase 5: deny overrides`);
for (const file of plan.deniedFiles) {
lines.push(`chmod 000 ${shellQuote(file)}`);
}
lines.push("");
}

return lines.join("\n");
}

/**
* Generate git sparse-checkout patterns from band read/write globs.
*
* Git sparse-checkout cone mode works on directory prefixes.
* We extract the directory portion of each pattern.
*/
export function buildSparseCheckoutPatterns(
allowRead: string[],
allowWrite: string[]
): string[] {
const dirs = new Set<string>();

for (const pattern of [...allowRead, ...allowWrite]) {
const cleaned = pattern.replace(/^\.\//, "");
const parts = cleaned.split("/");
// Find the first directory component (before any wildcards or filename)
const dirParts: string[] = [];
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i].includes("*") || parts[i].includes("?")) break;
dirParts.push(parts[i]);
}
const dir = dirParts.join("/");
if (dir) {
dirs.add(dir);
} else {
dirs.add("*");
}
}

return [...dirs].sort();
}

/**
* Generate the worktree setup script.
*/
export function buildWorktreeSetupScript(
repoPath: string,
worktreePath: string,
sparsePatterns: string[]
): string {
const lines = [
"#!/bin/bash",
"set -euo pipefail",
"",
`git -C ${shellQuote(repoPath)} worktree add --detach ${shellQuote(worktreePath)} 2>/dev/null`,
`cd ${shellQuote(worktreePath)}`,
`git sparse-checkout init --cone`,
];

if (sparsePatterns.length > 0 && !sparsePatterns.includes("*")) {
lines.push(`git sparse-checkout set ${sparsePatterns.map(shellQuote).join(" ")}`);
}

return lines.join("\n") + "\n";
}

/**
* Generate the worktree teardown script.
*/
export function buildWorktreeTeardownScript(
repoPath: string,
worktreePath: string
): string {
return [
"#!/bin/bash",
`chmod -R u+rwx ${shellQuote(worktreePath)} 2>/dev/null || true`,
`git -C ${shellQuote(repoPath)} worktree remove --force ${shellQuote(worktreePath)} 2>/dev/null || true`,
`rm -rf ${shellQuote(worktreePath)} 2>/dev/null || true`,
"",
].join("\n");
}

/**
* Shell-quote a string to prevent injection.
* Uses single quotes with escaped single quotes.
*/
export function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}

/**
* Validate that a path is safe (no path traversal, no null bytes).
*/
export function isPathSafe(path: string, worktreeRoot: string): boolean {
if (path.includes("\0")) return false;
const resolved = resolve(worktreeRoot, path);
const root = resolve(worktreeRoot);
return resolved.startsWith(root + sep) || resolved === root;
}
Loading
Loading