From 373c877c14ef56472dba5690c81612b707cbaa42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:14:10 +0000 Subject: [PATCH 1/6] Initial plan From 2fc225562d061c8c5cfdc9ca419ef501dc3b11c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:20:31 +0000 Subject: [PATCH 2/6] Secure runtime secret exports --- packages/runtime/src/band-server.ts | 42 +++++++++++- .../runtime/test/unit/band-server.test.ts | 67 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/test/unit/band-server.test.ts diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index 211c568..a714bea 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -153,6 +153,36 @@ function writeFile(path: string, content: string): void { shell(`echo '${b64}' | base64 -d > ${path}`); } +function writePrivateFile(path: string, content: string): void { + const proc = Bun.spawnSync( + ["sudo", "bash", "-c", `cat > "$1" && chmod 600 "$1"`, "bash", path], + { stdin: content } + ); + if (proc.exitCode !== 0) { + throw new Error(proc.stderr.toString() || `Command failed: write ${path}`); + } +} + +function shellEnvName(value: string, label: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } + return value; +} + +export function buildSecretEnvLines( + workdir: string, + secrets: Record +): string[] { + const lines: string[] = []; + for (const key of Object.keys(secrets)) { + const name = shellEnvName(key, "secret name"); + lines.push(`IFS= read -r -d '' ${name} < "${workdir}/secrets/${name}" || true`); + lines.push(`export ${name}`); + } + return lines; +} + // ── Firewall ────────────────────────────────────────────────────────── function setupFirewall( @@ -516,9 +546,15 @@ async function executeScript(req: ExecRequest): Promise { if (req.config) { envLines.push(`export CONFIG_PATH=${workdir}/config.json`); } - for (const [key, value] of Object.entries(req.secrets ?? {})) { - const b64 = Buffer.from(value).toString("base64"); - envLines.push(`export ${key}=$(echo '${b64}' | base64 -d)`); + const secretEnvLines = buildSecretEnvLines(workdir, req.secrets ?? {}); + if (secretEnvLines.length > 0) { + const secretsDir = `${workdir}/secrets`; + shell(`mkdir -p ${secretsDir} && chmod 700 ${secretsDir}`); + for (const [key, value] of Object.entries(req.secrets ?? {})) { + const name = shellEnvName(key, "secret name"); + writePrivateFile(`${secretsDir}/${name}`, value); + } + envLines.push(...secretEnvLines); } // Point PATH to the cooked CLI wrapper dir (mounted at workdir/.band-cli inside bwrap) diff --git a/packages/runtime/test/unit/band-server.test.ts b/packages/runtime/test/unit/band-server.test.ts new file mode 100644 index 0000000..b385cf4 --- /dev/null +++ b/packages/runtime/test/unit/band-server.test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const originalSpawnSync = Bun.spawnSync; + +(Bun as any).spawnSync = ((cmd: string[], opts?: any) => { + if (cmd[0] === "id" && cmd[2] === "band-runner") { + return { + exitCode: 0, + stdout: Buffer.from("1000\n"), + stderr: Buffer.from(""), + }; + } + return originalSpawnSync(cmd as any, opts); +}) as typeof Bun.spawnSync; + +afterAll(() => { + (Bun as any).spawnSync = originalSpawnSync; +}); + +const { buildSecretEnvLines } = await import( + `../../src/band-server.ts?test=${Date.now()}` +); + +describe("band-server secret env", () => { + test("reads secrets from files without embedding values or base64 subprocesses", () => { + const lines = buildSecretEnvLines("/tmp/band-exec-test", { + API_KEY: "super-secret", + }); + + expect(lines).toEqual([ + `IFS= read -r -d '' API_KEY < "/tmp/band-exec-test/secrets/API_KEY" || true`, + "export API_KEY", + ]); + expect(lines.join("\n")).not.toContain("super-secret"); + expect(lines.join("\n")).not.toContain("base64"); + expect(lines.join("\n")).not.toContain("$("); + }); + + test("generated env lines export multiline secret file contents", () => { + const workdir = mkdtempSync(join(tmpdir(), "band-secret-env-")); + try { + mkdirSync(join(workdir, "secrets")); + const secret = "top secret\nsecond line"; + writeFileSync(join(workdir, "secrets", "API_KEY"), secret); + + const script = [ + ...buildSecretEnvLines(workdir, { API_KEY: secret }), + `printf '%s' "$API_KEY"`, + ].join("\n"); + const proc = Bun.spawnSync(["bash", "-c", script]); + + expect(proc.exitCode).toBe(0); + expect(proc.stdout.toString()).toBe(secret); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("rejects invalid secret names before writing env lines", () => { + expect(() => + buildSecretEnvLines("/tmp/band-exec-test", { "BAD/KEY": "secret" }) + ).toThrow("Invalid secret name: BAD/KEY"); + }); +}); From 77e5f11cfe69a32ebe718c419aa9cea3fc21ba38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:54 +0000 Subject: [PATCH 3/6] Address validation feedback --- packages/runtime/src/band-server.ts | 4 ++-- packages/runtime/test/unit/band-server.test.ts | 11 +++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index a714bea..a046899 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -20,7 +20,7 @@ */ import { Hono } from "hono"; -import { createHash } from "crypto"; +import { createHash, randomBytes } from "crypto"; const BAND_RUNNER_USER = "band-runner"; let executing = false; @@ -137,7 +137,7 @@ function shellIgnoreError(cmd: string): void { } function randomId(): string { - return Math.random().toString(36).slice(2, 10); + return randomBytes(6).toString("hex"); } /** Reject values containing shell metacharacters. Used for any band-config value interpolated into shell commands. */ diff --git a/packages/runtime/test/unit/band-server.test.ts b/packages/runtime/test/unit/band-server.test.ts index b385cf4..f68e56e 100644 --- a/packages/runtime/test/unit/band-server.test.ts +++ b/packages/runtime/test/unit/band-server.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -16,13 +16,8 @@ const originalSpawnSync = Bun.spawnSync; return originalSpawnSync(cmd as any, opts); }) as typeof Bun.spawnSync; -afterAll(() => { - (Bun as any).spawnSync = originalSpawnSync; -}); - -const { buildSecretEnvLines } = await import( - `../../src/band-server.ts?test=${Date.now()}` -); +const { buildSecretEnvLines } = await import("../../src/band-server.ts"); +(Bun as any).spawnSync = originalSpawnSync; describe("band-server secret env", () => { test("reads secrets from files without embedding values or base64 subprocesses", () => { From ec63a7085f682fa8f609dd156cb5cbdeb8f0012c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:25:15 +0000 Subject: [PATCH 4/6] Harden secret path handling --- packages/runtime/src/band-server.ts | 34 +++++++++++++++---- .../runtime/test/unit/band-server.test.ts | 26 ++++++-------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index a046899..748e822 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -40,7 +40,14 @@ function getBandRunnerIds(): { uid: number; gid: number } { return { uid, gid }; } -const bandRunnerIds = getBandRunnerIds(); +let bandRunnerIds: { uid: number; gid: number } | null = null; + +function getCachedBandRunnerIds(): { uid: number; gid: number } { + if (!bandRunnerIds) { + bandRunnerIds = getBandRunnerIds(); + } + return bandRunnerIds; +} // ── Cook state ─────────────────────────────────────────────────────── @@ -153,7 +160,17 @@ function writeFile(path: string, content: string): void { shell(`echo '${b64}' | base64 -d > ${path}`); } -function writePrivateFile(path: string, content: string): void { +function shellWorkdir(value: string): string { + if (!/^\/tmp\/band-exec-[A-Za-z0-9_-]+$/.test(value)) { + throw new Error(`Invalid workdir: ${value}`); + } + return value; +} + +function writeSecretFile(path: string, content: string): void { + if (!/^\/tmp\/band-exec-[A-Za-z0-9_-]+\/secrets\/[A-Za-z_][A-Za-z0-9_]*$/.test(path)) { + throw new Error(`Invalid secret path: ${path}`); + } const proc = Bun.spawnSync( ["sudo", "bash", "-c", `cat > "$1" && chmod 600 "$1"`, "bash", path], { stdin: content } @@ -174,10 +191,11 @@ export function buildSecretEnvLines( workdir: string, secrets: Record ): string[] { + const safeWorkdir = shellWorkdir(workdir); const lines: string[] = []; for (const key of Object.keys(secrets)) { const name = shellEnvName(key, "secret name"); - lines.push(`IFS= read -r -d '' ${name} < "${workdir}/secrets/${name}" || true`); + lines.push(`IFS= read -r -d '' ${name} < "${safeWorkdir}/secrets/${name}" || true`); lines.push(`export ${name}`); } return lines; @@ -200,6 +218,7 @@ function setupFirewall( return; } + const { uid } = getCachedBandRunnerIds(); const cmds: string[] = [ `iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`, `iptables -A ${chainName} -o lo -j ACCEPT`, @@ -238,14 +257,15 @@ function setupFirewall( cmds.push(`iptables -A ${chainName} -j REJECT`); } - cmds.push(`iptables -I OUTPUT 1 -m owner --uid-owner ${bandRunnerIds.uid} -m state --state NEW -j ${chainName}`); + cmds.push(`iptables -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 ${bandRunnerIds.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` ); @@ -548,11 +568,11 @@ async function executeScript(req: ExecRequest): Promise { } const secretEnvLines = buildSecretEnvLines(workdir, req.secrets ?? {}); if (secretEnvLines.length > 0) { - const secretsDir = `${workdir}/secrets`; + const secretsDir = `${shellWorkdir(workdir)}/secrets`; shell(`mkdir -p ${secretsDir} && chmod 700 ${secretsDir}`); for (const [key, value] of Object.entries(req.secrets ?? {})) { const name = shellEnvName(key, "secret name"); - writePrivateFile(`${secretsDir}/${name}`, value); + writeSecretFile(`${secretsDir}/${name}`, value); } envLines.push(...secretEnvLines); } diff --git a/packages/runtime/test/unit/band-server.test.ts b/packages/runtime/test/unit/band-server.test.ts index f68e56e..8de1156 100644 --- a/packages/runtime/test/unit/band-server.test.ts +++ b/packages/runtime/test/unit/band-server.test.ts @@ -3,21 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -const originalSpawnSync = Bun.spawnSync; - -(Bun as any).spawnSync = ((cmd: string[], opts?: any) => { - if (cmd[0] === "id" && cmd[2] === "band-runner") { - return { - exitCode: 0, - stdout: Buffer.from("1000\n"), - stderr: Buffer.from(""), - }; - } - return originalSpawnSync(cmd as any, opts); -}) as typeof Bun.spawnSync; - -const { buildSecretEnvLines } = await import("../../src/band-server.ts"); -(Bun as any).spawnSync = originalSpawnSync; +import { buildSecretEnvLines } from "../../src/band-server"; describe("band-server secret env", () => { test("reads secrets from files without embedding values or base64 subprocesses", () => { @@ -35,7 +21,7 @@ describe("band-server secret env", () => { }); test("generated env lines export multiline secret file contents", () => { - const workdir = mkdtempSync(join(tmpdir(), "band-secret-env-")); + const workdir = mkdtempSync(join(tmpdir(), "band-exec-")); try { mkdirSync(join(workdir, "secrets")); const secret = "top secret\nsecond line"; @@ -59,4 +45,12 @@ describe("band-server secret env", () => { buildSecretEnvLines("/tmp/band-exec-test", { "BAD/KEY": "secret" }) ).toThrow("Invalid secret name: BAD/KEY"); }); + + test("rejects unexpected workdir paths before writing env lines", () => { + expect(() => + buildSecretEnvLines("/tmp/band-exec-test;touch /tmp/bad", { + API_KEY: "secret", + }) + ).toThrow("Invalid workdir: /tmp/band-exec-test;touch /tmp/bad"); + }); }); From 5db261ff7822cb56fa480be64ec816d45f1a9060 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:26:44 +0000 Subject: [PATCH 5/6] Align secret path validation --- packages/runtime/src/band-server.ts | 4 ++-- packages/runtime/test/unit/band-server.test.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index 748e822..7f90fda 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -161,14 +161,14 @@ function writeFile(path: string, content: string): void { } function shellWorkdir(value: string): string { - if (!/^\/tmp\/band-exec-[A-Za-z0-9_-]+$/.test(value)) { + if (!/^\/tmp\/band-exec-[0-9a-f]+$/.test(value)) { throw new Error(`Invalid workdir: ${value}`); } return value; } function writeSecretFile(path: string, content: string): void { - if (!/^\/tmp\/band-exec-[A-Za-z0-9_-]+\/secrets\/[A-Za-z_][A-Za-z0-9_]*$/.test(path)) { + if (!/^\/tmp\/band-exec-[0-9a-f]+\/secrets\/[A-Za-z_][A-Za-z0-9_]*$/.test(path)) { throw new Error(`Invalid secret path: ${path}`); } const proc = Bun.spawnSync( diff --git a/packages/runtime/test/unit/band-server.test.ts b/packages/runtime/test/unit/band-server.test.ts index 8de1156..2ad1c8f 100644 --- a/packages/runtime/test/unit/band-server.test.ts +++ b/packages/runtime/test/unit/band-server.test.ts @@ -1,18 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { randomBytes } from "crypto"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { buildSecretEnvLines } from "../../src/band-server"; +const validWorkdir = "/tmp/band-exec-deadbeef"; + describe("band-server secret env", () => { test("reads secrets from files without embedding values or base64 subprocesses", () => { - const lines = buildSecretEnvLines("/tmp/band-exec-test", { + const lines = buildSecretEnvLines(validWorkdir, { API_KEY: "super-secret", }); expect(lines).toEqual([ - `IFS= read -r -d '' API_KEY < "/tmp/band-exec-test/secrets/API_KEY" || true`, + `IFS= read -r -d '' API_KEY < "${validWorkdir}/secrets/API_KEY" || true`, "export API_KEY", ]); expect(lines.join("\n")).not.toContain("super-secret"); @@ -21,7 +24,8 @@ describe("band-server secret env", () => { }); test("generated env lines export multiline secret file contents", () => { - const workdir = mkdtempSync(join(tmpdir(), "band-exec-")); + const workdir = join(tmpdir(), `band-exec-${randomBytes(6).toString("hex")}`); + mkdirSync(workdir); try { mkdirSync(join(workdir, "secrets")); const secret = "top secret\nsecond line"; @@ -42,7 +46,7 @@ describe("band-server secret env", () => { test("rejects invalid secret names before writing env lines", () => { expect(() => - buildSecretEnvLines("/tmp/band-exec-test", { "BAD/KEY": "secret" }) + buildSecretEnvLines(validWorkdir, { "BAD/KEY": "secret" }) ).toThrow("Invalid secret name: BAD/KEY"); }); From 71c81dfd0f51161c5c7737330209cb1f93e7938f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 17:25:13 +0000 Subject: [PATCH 6/6] fix: write secret files through Bun stdin pipe --- packages/runtime/src/band-server.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index e6d2252..223c974 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -169,16 +169,22 @@ function shellWorkdir(value: string): string { return value; } -function writeSecretFile(path: string, content: string): void { +async function writeSecretFile(path: string, content: string): Promise { if (!/^\/tmp\/band-exec-[0-9a-f]+\/secrets\/[A-Za-z_][A-Za-z0-9_]*$/.test(path)) { throw new Error(`Invalid secret path: ${path}`); } - const proc = Bun.spawnSync( + const proc = Bun.spawn( ["sudo", "bash", "-c", `cat > "$1" && chmod 600 "$1"`, "bash", path], - { stdin: content } + { stdin: "pipe", stdout: "pipe", stderr: "pipe" } ); - if (proc.exitCode !== 0) { - throw new Error(proc.stderr.toString() || `Command failed: write ${path}`); + proc.stdin.write(content); + proc.stdin.end(); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(stderr || `Command failed: write ${path}`); } } @@ -693,7 +699,7 @@ async function executeScript(req: ExecRequest): Promise { shell(`mkdir -p ${secretsDir} && chmod 700 ${secretsDir}`); for (const [key, value] of Object.entries(req.secrets ?? {})) { const name = shellEnvName(key, "secret name"); - writeSecretFile(`${secretsDir}/${name}`, value); + await writeSecretFile(`${secretsDir}/${name}`, value); } envLines.push(...secretEnvLines); }