diff --git a/packages/adapters/src/desktop-sandbox-env.test.ts b/packages/adapters/src/desktop-sandbox-env.test.ts new file mode 100644 index 0000000..5be3746 --- /dev/null +++ b/packages/adapters/src/desktop-sandbox-env.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DesktopSandboxProvider } from "./desktop-sandbox.js"; +import { desktopCommandEnvironment } from "./desktop-sandbox-env.js"; + +const ctx = { + operationId: "1", + traceId: "1", + spaceId: "w", + userId: "u", + signal: new AbortController().signal, +}; + +const HOST_DATABASE_URL = "postgres://desktop-host-secret@127.0.0.1/rakazo"; +const HOST_API_KEY = "desktop-host-api-secret"; +const AGENT_TOKEN = "agent-scoped-token"; + +describe("desktop shell environment", () => { + const previous = { + DATABASE_URL: process.env.DATABASE_URL, + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + }; + let root: string | undefined; + + afterEach(async () => { + if (previous.DATABASE_URL === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previous.DATABASE_URL; + if (previous.OPENROUTER_API_KEY === undefined) delete process.env.OPENROUTER_API_KEY; + else process.env.OPENROUTER_API_KEY = previous.OPENROUTER_API_KEY; + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; + }); + + it("keeps host secrets out of the shell and applies the command environment", async () => { + process.env.DATABASE_URL = HOST_DATABASE_URL; + process.env.OPENROUTER_API_KEY = HOST_API_KEY; + root = await mkdtemp(path.join(tmpdir(), "rakazo-desktop-env-")); + const desktop = new DesktopSandboxProvider({ root }); + const computer = await desktop.provision({ botId: "env", homePath: "/unused" }, ctx); + + let stdout = ""; + let code = 1; + for await (const event of desktop.execute( + computer, + { + argv: ["bash", "-lc", 'printf \'%s|%s\' "$DATABASE_URL" "$BOT_TOKEN"'], + env: { BOT_TOKEN: AGENT_TOKEN }, + }, + ctx, + )) { + if (event.type === "stdout") stdout += event.data; + if (event.type === "exit") code = event.code; + } + + expect(code).toBe(0); + expect(stdout).toBe(`|${AGENT_TOKEN}`); + await desktop.destroy(computer, ctx); + }); +}); + +describe("desktopCommandEnvironment", () => { + it("drops host credentials and keeps the command environment", () => { + const env = desktopCommandEnvironment( + { + PATH: "/usr/bin", + HOME: "/home/user", + DATABASE_URL: HOST_DATABASE_URL, + OPENROUTER_API_KEY: HOST_API_KEY, + BETTER_AUTH_SECRET: "auth-secret", + ENCRYPTION_KEY: "encryption-key", + SANDBOX_SUPERVISOR_TOKEN: "supervisor-token", + PGPASSWORD: "pg-password", + AWS_ACCESS_KEY_ID: "aws-key", + SSH_AUTH_SOCK: "/tmp/ssh-agent", + }, + { BOT_TOKEN: AGENT_TOKEN, "not valid": "nope", LOWER_token: "nope" }, + ); + expect(env.PATH).toBe("/usr/bin"); + expect(env.HOME).toBe("/home/user"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent"); + expect(env.BOT_TOKEN).toBe(AGENT_TOKEN); + expect(env.DATABASE_URL).toBeUndefined(); + expect(env.OPENROUTER_API_KEY).toBeUndefined(); + expect(env.BETTER_AUTH_SECRET).toBeUndefined(); + expect(env.ENCRYPTION_KEY).toBeUndefined(); + expect(env.SANDBOX_SUPERVISOR_TOKEN).toBeUndefined(); + expect(env.PGPASSWORD).toBeUndefined(); + expect(env.AWS_ACCESS_KEY_ID).toBeUndefined(); + expect(env["not valid"]).toBeUndefined(); + expect(env.LOWER_token).toBeUndefined(); + }); + + it("lets an explicit command variable replace a scrubbed host secret", () => { + const env = desktopCommandEnvironment( + { DATABASE_URL: HOST_DATABASE_URL }, + { DATABASE_URL: "postgres://agent@127.0.0.1/bot" }, + ); + expect(env.DATABASE_URL).toBe("postgres://agent@127.0.0.1/bot"); + }); +}); diff --git a/packages/adapters/src/desktop-sandbox-env.ts b/packages/adapters/src/desktop-sandbox-env.ts new file mode 100644 index 0000000..14df46e --- /dev/null +++ b/packages/adapters/src/desktop-sandbox-env.ts @@ -0,0 +1,30 @@ +import { AGENT_SECRET_NAME_PATTERN } from "@rakazo/contracts"; + +/** + * Credential-shaped names from the evOo process. Docker exec starts from a fixed + * environment and then adds the command env; the desktop shell must not inherit + * deployment secrets that are absent from the run redaction list. + */ +const HOST_SECRET_ENV_NAME = + /(?:^|_)(?:secret|token|password|passwd|credential|api_key|private_key|database_url|encryption_key|auth_secret|authtoken|key)(?:_|$)|(?:password|passwd|secret|token|api_key)$/i; + +export function desktopCommandEnvironment( + inherited: NodeJS.ProcessEnv, + requestEnv: Record | undefined, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(inherited)) { + if (value === undefined || isHostSecretEnvName(key)) continue; + env[key] = value; + } + if (!requestEnv) return env; + for (const [key, value] of Object.entries(requestEnv)) { + if (!AGENT_SECRET_NAME_PATTERN.test(key) || value.includes("\0")) continue; + env[key] = value; + } + return env; +} + +function isHostSecretEnvName(name: string): boolean { + return HOST_SECRET_ENV_NAME.test(name); +} diff --git a/packages/adapters/src/desktop-sandbox.ts b/packages/adapters/src/desktop-sandbox.ts index dc1d781..f4c1638 100644 --- a/packages/adapters/src/desktop-sandbox.ts +++ b/packages/adapters/src/desktop-sandbox.ts @@ -35,6 +35,7 @@ import { normalizeWorkspacePath, placeholderObservation, } from "./computer-support.js"; +import { desktopCommandEnvironment } from "./desktop-sandbox-env.js"; import { isAllowedDesktopPath, normalizeDesktopWorkspacePath, @@ -145,6 +146,7 @@ export class DesktopSandboxProvider implements SandboxProvider { cwd, boundedSandboxCommandTimeoutMs(request.timeoutMs), context.signal, + desktopCommandEnvironment(process.env, request.env), ); if (result.stdout) yield { type: "stdout", data: result.stdout }; if (result.stderr) yield { type: "stderr", data: result.stderr }; @@ -305,7 +307,6 @@ export class DesktopSandboxProvider implements SandboxProvider { if (!box) throw new Error("computer not found"); return box; } - } async function localWorkspaceTarget(home: string, relative: string, mustExist: boolean) { @@ -689,11 +690,12 @@ function runCommand( cwd: string, timeoutMs: number, signal: AbortSignal, + env: NodeJS.ProcessEnv, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { const child = spawn(argv[0]!, argv.slice(1), { cwd, - env: process.env, + env, detached: process.platform !== "win32", }); let stdout = "";