From 56f6368c64c175e9a6b4aa82cc47b42c66a81c75 Mon Sep 17 00:00:00 2001 From: mrjf Date: Sat, 16 May 2026 10:35:36 -0700 Subject: [PATCH 1/2] Add worktree + chmod sandbox mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative to bwrap mount-namespace isolation. Uses git sparse-checkout worktrees and POSIX file permissions to enforce allow/deny rules: 1. Create sparse-checkout worktree (only allowed paths materialized) 2. chmod -R 000 (deny everything) 3. chmod a+x on traversal dirs (navigate but not list) 4. chmod a+r on allow.read files 5. chmod a+rw 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 New files: - packages/runtime/src/worktree-chmod.ts — pure functions for plan generation, script building, sparse-checkout patterns, symlink filtering, path safety validation - packages/runtime/test/unit/worktree-chmod.test.ts — 45 tests covering traversal computation, chmod plan generation, script ordering, shell injection prevention, security invariants Integration: - band-server.ts gains sandboxMode: "bwrap" | "worktree-chmod" in ExecRequest, dispatching to the appropriate sandbox - ExecutionConfig.lima gains sandboxMode field - Default remains "bwrap" — worktree-chmod is opt-in Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/format/src/types.ts | 2 + packages/runtime/src/band-server.ts | 92 +++- packages/runtime/src/worktree-chmod.ts | 267 +++++++++++ .../runtime/test/unit/worktree-chmod.test.ts | 448 ++++++++++++++++++ 4 files changed, 805 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/worktree-chmod.ts create mode 100644 packages/runtime/test/unit/worktree-chmod.test.ts diff --git a/packages/format/src/types.ts b/packages/format/src/types.ts index 736326c..1b09222 100644 --- a/packages/format/src/types.ts +++ b/packages/format/src/types.ts @@ -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"; }; } diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index 585f4d3..ae55442 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -573,6 +573,8 @@ interface ExecRequest { net?: string[]; }; timeoutMs?: number; + sandboxMode?: "bwrap" | "worktree-chmod"; + repoPath?: string; } interface ExecResponse { @@ -670,11 +672,93 @@ async function executeScript(req: ExecRequest): Promise { 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; + + 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(); diff --git a/packages/runtime/src/worktree-chmod.ts b/packages/runtime/src/worktree-chmod.ts new file mode 100644 index 0000000..12aa954 --- /dev/null +++ b/packages/runtime/src/worktree-chmod.ts @@ -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+rw 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(); + 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+rw 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+rw) + if (plan.writableFiles.length > 0) { + lines.push(`# Phase 4: writable files`); + for (const file of plan.writableFiles) { + lines.push(`chmod a+rw ${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(); + + 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; +} diff --git a/packages/runtime/test/unit/worktree-chmod.test.ts b/packages/runtime/test/unit/worktree-chmod.test.ts new file mode 100644 index 0000000..6d07a1f --- /dev/null +++ b/packages/runtime/test/unit/worktree-chmod.test.ts @@ -0,0 +1,448 @@ +/** + * Tests for worktree-chmod sandbox module. + * + * These test the pure logic (plan generation, script building, traversal + * computation) without any VM, git, or filesystem. + */ + +import { describe, test, expect } from "bun:test"; +import { + computeTraversalDirs, + filterUnsafeSymlinks, + buildChmodPlan, + buildChmodScript, + buildSparseCheckoutPatterns, + buildWorktreeSetupScript, + buildWorktreeTeardownScript, + shellQuote, + isPathSafe, +} from "../../src/worktree-chmod"; + +describe("computeTraversalDirs", () => { + test("single file produces chain of parent dirs", () => { + const dirs = computeTraversalDirs(["/wt/src/deep/file.ts"], "/wt"); + expect(dirs).toContain("/wt"); + expect(dirs).toContain("/wt/src"); + expect(dirs).toContain("/wt/src/deep"); + expect(dirs).not.toContain("/wt/src/deep/file.ts"); + }); + + test("file at root produces only root", () => { + const dirs = computeTraversalDirs(["/wt/file.ts"], "/wt"); + expect(dirs).toEqual(["/wt"]); + }); + + test("multiple files in same dir deduplicate", () => { + const dirs = computeTraversalDirs( + ["/wt/src/a.ts", "/wt/src/b.ts"], + "/wt" + ); + const srcCount = dirs.filter((d) => d === "/wt/src").length; + expect(srcCount).toBe(1); + }); + + test("files at different depths produce correct union", () => { + const dirs = computeTraversalDirs( + ["/wt/a.ts", "/wt/src/deep/b.ts"], + "/wt" + ); + expect(dirs).toContain("/wt"); + expect(dirs).toContain("/wt/src"); + expect(dirs).toContain("/wt/src/deep"); + }); + + test("sorted shortest-first", () => { + const dirs = computeTraversalDirs( + ["/wt/a/b/c/d.ts"], + "/wt" + ); + for (let i = 1; i < dirs.length; i++) { + expect(dirs[i].length).toBeGreaterThanOrEqual(dirs[i - 1].length); + } + }); + + test("empty files list returns only root", () => { + const dirs = computeTraversalDirs([], "/wt"); + expect(dirs).toEqual(["/wt"]); + }); +}); + +describe("filterUnsafeSymlinks", () => { + test("non-symlink files are safe", () => { + const { safe, unsafe } = filterUnsafeSymlinks( + ["/wt/file.ts"], + "/wt", + () => null + ); + expect(safe).toEqual(["/wt/file.ts"]); + expect(unsafe).toEqual([]); + }); + + test("symlink inside worktree is safe", () => { + const { safe, unsafe } = filterUnsafeSymlinks( + ["/wt/link.ts"], + "/wt", + (path) => (path === "/wt/link.ts" ? "./real.ts" : null) + ); + expect(safe).toEqual(["/wt/link.ts"]); + expect(unsafe).toEqual([]); + }); + + test("symlink outside worktree is unsafe", () => { + const { safe, unsafe } = filterUnsafeSymlinks( + ["/wt/link.ts"], + "/wt", + (path) => (path === "/wt/link.ts" ? "/etc/passwd" : null) + ); + expect(safe).toEqual([]); + expect(unsafe).toEqual(["/wt/link.ts"]); + }); + + test("mixed safe and unsafe", () => { + const { safe, unsafe } = filterUnsafeSymlinks( + ["/wt/good.ts", "/wt/bad.ts", "/wt/normal.ts"], + "/wt", + (path) => { + if (path === "/wt/bad.ts") return "/etc/shadow"; + if (path === "/wt/good.ts") return "./other.ts"; + return null; + } + ); + expect(safe).toEqual(["/wt/good.ts", "/wt/normal.ts"]); + expect(unsafe).toEqual(["/wt/bad.ts"]); + }); +}); + +describe("buildChmodPlan", () => { + test("single readable file", () => { + const plan = buildChmodPlan("/wt", ["/wt/data/file.csv"], [], []); + expect(plan.worktreeRoot).toBe("/wt"); + expect(plan.readableFiles).toEqual(["/wt/data/file.csv"]); + expect(plan.writableFiles).toEqual([]); + expect(plan.deniedFiles).toEqual([]); + expect(plan.traversalDirs).toContain("/wt"); + expect(plan.traversalDirs).toContain("/wt/data"); + }); + + test("single writable file", () => { + const plan = buildChmodPlan("/wt", [], ["/wt/output/result.json"], []); + expect(plan.writableFiles).toEqual(["/wt/output/result.json"]); + expect(plan.traversalDirs).toContain("/wt/output"); + }); + + test("mixed read and write", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/input.csv"], + ["/wt/output/result.json"], + [] + ); + expect(plan.readableFiles).toEqual(["/wt/data/input.csv"]); + expect(plan.writableFiles).toEqual(["/wt/output/result.json"]); + expect(plan.traversalDirs).toContain("/wt/data"); + expect(plan.traversalDirs).toContain("/wt/output"); + }); + + test("deny overrides in plan", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/file.csv", "/wt/data/.env"], + [], + ["/wt/data/.env"] + ); + expect(plan.readableFiles).toContain("/wt/data/file.csv"); + expect(plan.readableFiles).toContain("/wt/data/.env"); + expect(plan.deniedFiles).toEqual(["/wt/data/.env"]); + }); + + test("empty patterns produce empty plan", () => { + const plan = buildChmodPlan("/wt", [], [], []); + expect(plan.readableFiles).toEqual([]); + expect(plan.writableFiles).toEqual([]); + expect(plan.deniedFiles).toEqual([]); + expect(plan.lockdownDirs).toEqual(["/wt"]); + }); + + test("deduplicates files in read and write", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/file.ts", "/wt/file.ts"], + ["/wt/file.ts"], + [] + ); + // traversalDirs should not have duplicates + const unique = new Set(plan.traversalDirs); + expect(unique.size).toBe(plan.traversalDirs.length); + }); +}); + +describe("buildChmodScript", () => { + test("correct phase ordering", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/file.csv"], + ["/wt/output/result.json"], + ["/wt/data/.env"] + ); + const script = buildChmodScript(plan); + + const phase1 = script.indexOf("chmod -R 000"); + const phase2 = script.indexOf("chmod a+x"); + const phase3 = script.indexOf("chmod a+r "); + const phase4 = script.indexOf("chmod a+rw"); + const phase5 = script.lastIndexOf("chmod 000 "); + + // All phases present + expect(phase1).toBeGreaterThan(-1); + expect(phase2).toBeGreaterThan(-1); + expect(phase3).toBeGreaterThan(-1); + expect(phase4).toBeGreaterThan(-1); + expect(phase5).toBeGreaterThan(-1); + + // Correct order: lockdown → traversal → read → write → deny + expect(phase1).toBeLessThan(phase2); + expect(phase2).toBeLessThan(phase3); + expect(phase3).toBeLessThan(phase4); + expect(phase4).toBeLessThan(phase5); + }); + + test("no a+r on traversal directories", () => { + const plan = buildChmodPlan("/wt", ["/wt/src/deep/file.ts"], [], []); + const script = buildChmodScript(plan); + + // Traversal dirs should only get a+x + const lines = script.split("\n"); + const traversalLines = lines.filter((l) => l.includes("Phase 2") || (l.includes("chmod a+x") && !l.includes("a+r"))); + expect(traversalLines.length).toBeGreaterThan(0); + + // No a+r on directories (only on files in Phase 3) + for (const dir of plan.traversalDirs) { + const dirReadLine = lines.find((l) => l.includes(`chmod a+r`) && l.includes(`'${dir}'`) && !l.includes("a+rw")); + expect(dirReadLine).toBeUndefined(); + } + }); + + test("empty plan has lockdown and root traversal only", () => { + const plan = buildChmodPlan("/wt", [], [], []); + const script = buildChmodScript(plan); + expect(script).toContain("chmod -R 000"); + expect(script).toContain("chmod a+x '/wt'"); + expect(script).not.toContain("chmod a+r "); + expect(script).not.toContain("chmod a+rw"); + }); + + test("deny phase comes after allow phases", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/.env"], + [], + ["/wt/data/.env"] + ); + const script = buildChmodScript(plan); + + // The allow (a+r) comes before the deny (000) + const allowIdx = script.indexOf("chmod a+r"); + const denyIdx = script.lastIndexOf("chmod 000 "); + expect(allowIdx).toBeLessThan(denyIdx); + }); +}); + +describe("shellQuote", () => { + test("simple string", () => { + expect(shellQuote("/wt/file.ts")).toBe("'/wt/file.ts'"); + }); + + test("string with spaces", () => { + expect(shellQuote("/wt/my file.ts")).toBe("'/wt/my file.ts'"); + }); + + test("string with single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); + + test("string with special chars", () => { + const quoted = shellQuote("$(rm -rf /)"); + expect(quoted).toBe("'$(rm -rf /)'"); + }); + + test("string with newlines", () => { + const quoted = shellQuote("line1\nline2"); + expect(quoted).toBe("'line1\nline2'"); + }); +}); + +describe("isPathSafe", () => { + test("path inside worktree is safe", () => { + expect(isPathSafe("src/file.ts", "/wt")).toBe(true); + }); + + test("path traversal is unsafe", () => { + expect(isPathSafe("../../etc/passwd", "/wt")).toBe(false); + }); + + test("absolute path outside worktree is unsafe", () => { + expect(isPathSafe("/etc/passwd", "/wt")).toBe(false); + }); + + test("null byte is unsafe", () => { + expect(isPathSafe("file\0.ts", "/wt")).toBe(false); + }); + + test("worktree root itself is safe", () => { + expect(isPathSafe(".", "/wt")).toBe(true); + }); + + test("deeply nested path is safe", () => { + expect(isPathSafe("a/b/c/d/e/f.ts", "/wt")).toBe(true); + }); +}); + +describe("buildSparseCheckoutPatterns", () => { + test("simple file paths extract directory", () => { + const patterns = buildSparseCheckoutPatterns( + ["./data/input.csv"], + ["./output/result.json"] + ); + expect(patterns).toContain("data"); + expect(patterns).toContain("output"); + }); + + test("glob patterns extract prefix directory", () => { + const patterns = buildSparseCheckoutPatterns( + ["./src/**/*.ts"], + [] + ); + expect(patterns).toContain("src"); + }); + + test("root-level glob produces wildcard", () => { + const patterns = buildSparseCheckoutPatterns(["*.json"], []); + expect(patterns).toContain("*"); + }); + + test("deduplicates overlapping patterns", () => { + const patterns = buildSparseCheckoutPatterns( + ["./src/a.ts", "./src/b.ts"], + ["./src/c.ts"] + ); + const srcCount = patterns.filter((p) => p === "src").length; + expect(srcCount).toBe(1); + }); + + test("strips leading ./", () => { + const patterns = buildSparseCheckoutPatterns(["./data/file.csv"], []); + expect(patterns).not.toContain("./data"); + expect(patterns).toContain("data"); + }); +}); + +describe("buildWorktreeSetupScript", () => { + test("generates valid setup script", () => { + const script = buildWorktreeSetupScript("/repo", "/tmp/wt-123", ["src", "data"]); + expect(script).toContain("git -C '/repo' worktree add --detach '/tmp/wt-123'"); + expect(script).toContain("git sparse-checkout init --cone"); + expect(script).toContain("git sparse-checkout set 'src' 'data'"); + }); + + test("wildcard pattern skips sparse-checkout set", () => { + const script = buildWorktreeSetupScript("/repo", "/tmp/wt-123", ["*"]); + expect(script).toContain("sparse-checkout init"); + expect(script).not.toContain("sparse-checkout set"); + }); +}); + +describe("buildWorktreeTeardownScript", () => { + test("restores permissions before removal", () => { + const script = buildWorktreeTeardownScript("/repo", "/tmp/wt-123"); + const chmodIdx = script.indexOf("chmod -R u+rwx"); + const removeIdx = script.indexOf("worktree remove"); + expect(chmodIdx).toBeGreaterThan(-1); + expect(removeIdx).toBeGreaterThan(-1); + expect(chmodIdx).toBeLessThan(removeIdx); + }); + + test("has rm -rf fallback", () => { + const script = buildWorktreeTeardownScript("/repo", "/tmp/wt-123"); + expect(script).toContain("rm -rf"); + }); +}); + +describe("security invariants", () => { + test("file outside all patterns is not in plan", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/allowed.csv"], + [], + [] + ); + expect(plan.readableFiles).not.toContain("/wt/secrets/key.pem"); + expect(plan.writableFiles).not.toContain("/wt/secrets/key.pem"); + }); + + test("deny pattern overrides allow in plan ordering", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/data/config.json", "/wt/data/.env"], + [], + ["/wt/data/.env"] + ); + const script = buildChmodScript(plan); + + // .env gets a+r in phase 3 then 000 in phase 5 + const readLine = script.indexOf("chmod a+r '/wt/data/.env'"); + const denyLine = script.indexOf("chmod 000 '/wt/data/.env'"); + expect(readLine).toBeGreaterThan(-1); + expect(denyLine).toBeGreaterThan(-1); + expect(readLine).toBeLessThan(denyLine); + }); + + test("no directory gets a+w", () => { + const plan = buildChmodPlan( + "/wt", + [], + ["/wt/output/file.txt"], + [] + ); + const script = buildChmodScript(plan); + const lines = script.split("\n"); + + // Only the file itself should get a+rw, not the directory + for (const dir of plan.traversalDirs) { + const writeLine = lines.find((l) => l.includes("chmod a+rw") && l.includes(`'${dir}'`)); + expect(writeLine).toBeUndefined(); + } + }); + + test("worktree root gets a+x but not a+r", () => { + const plan = buildChmodPlan("/wt", ["/wt/src/file.ts"], [], []); + const script = buildChmodScript(plan); + + // Root should have a+x for traversal + expect(script).toContain("chmod a+x '/wt'"); + // But NOT a+r (no directory listing) + const lines = script.split("\n"); + const rootReadLine = lines.find( + (l) => l.match(/chmod a\+r\b/) && l.includes("'/wt'") && !l.includes("a+rw") + ); + expect(rootReadLine).toBeUndefined(); + }); + + test("shellQuote prevents injection in generated script", () => { + const plan = buildChmodPlan( + "/wt", + ["/wt/$(whoami)/file.ts"], + [], + [] + ); + const script = buildChmodScript(plan); + // The dangerous path should be inside single quotes in the script + expect(script).toContain("'/wt/$(whoami)/file.ts'"); + // Should not appear unquoted + const lines = script.split("\n"); + for (const line of lines) { + if (line.includes("$(whoami)")) { + expect(line).toMatch(/'/); + } + } + }); +}); From 204f281a91a4031e434412bfb958f2ae64c0c002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 19:13:44 +0000 Subject: [PATCH 2/2] fix: make worktree allow.write write-only --- packages/runtime/src/worktree-chmod.ts | 8 ++++---- .../runtime/test/unit/worktree-chmod.test.ts | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/worktree-chmod.ts b/packages/runtime/src/worktree-chmod.ts index 12aa954..c403cea 100644 --- a/packages/runtime/src/worktree-chmod.ts +++ b/packages/runtime/src/worktree-chmod.ts @@ -9,7 +9,7 @@ * 2. chmod -R 000 (deny everything) * 3. chmod a+x on traversal directories * 4. chmod a+r on allow.read files - * 5. chmod a+rw on allow.write 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 @@ -121,7 +121,7 @@ export function buildChmodPlan( * 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+rw on writable 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. @@ -155,11 +155,11 @@ export function buildChmodScript(plan: ChmodPlan): string { lines.push(""); } - // Phase 4: writable files (a+rw) + // 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+rw ${shellQuote(file)}`); + lines.push(`chmod a+w ${shellQuote(file)}`); } lines.push(""); } diff --git a/packages/runtime/test/unit/worktree-chmod.test.ts b/packages/runtime/test/unit/worktree-chmod.test.ts index 6d07a1f..2c483b7 100644 --- a/packages/runtime/test/unit/worktree-chmod.test.ts +++ b/packages/runtime/test/unit/worktree-chmod.test.ts @@ -189,7 +189,7 @@ describe("buildChmodScript", () => { const phase1 = script.indexOf("chmod -R 000"); const phase2 = script.indexOf("chmod a+x"); const phase3 = script.indexOf("chmod a+r "); - const phase4 = script.indexOf("chmod a+rw"); + const phase4 = script.indexOf("chmod a+w"); const phase5 = script.lastIndexOf("chmod 000 "); // All phases present @@ -228,7 +228,7 @@ describe("buildChmodScript", () => { expect(script).toContain("chmod -R 000"); expect(script).toContain("chmod a+x '/wt'"); expect(script).not.toContain("chmod a+r "); - expect(script).not.toContain("chmod a+rw"); + expect(script).not.toContain("chmod a+w"); }); test("deny phase comes after allow phases", () => { @@ -406,13 +406,23 @@ describe("security invariants", () => { const script = buildChmodScript(plan); const lines = script.split("\n"); - // Only the file itself should get a+rw, not the directory + // Only the file itself should get a+w, not the directory for (const dir of plan.traversalDirs) { - const writeLine = lines.find((l) => l.includes("chmod a+rw") && l.includes(`'${dir}'`)); + const writeLine = lines.find((l) => l.includes("chmod a+w") && l.includes(`'${dir}'`)); expect(writeLine).toBeUndefined(); } }); + test("write-only files do not implicitly become readable", () => { + const plan = buildChmodPlan("/wt", [], ["/wt/output/file.txt"], []); + const script = buildChmodScript(plan); + const lines = script.split("\n"); + + expect(lines).toContain("chmod a+w '/wt/output/file.txt'"); + const readLine = lines.find((l) => l === "chmod a+r '/wt/output/file.txt'"); + expect(readLine).toBeUndefined(); + }); + test("worktree root gets a+x but not a+r", () => { const plan = buildChmodPlan("/wt", ["/wt/src/file.ts"], [], []); const script = buildChmodScript(plan);