diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index c53ab496c..f525f4d2d 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -57,6 +57,7 @@ import { parseScrollDelta, } from "./request-validation"; import { type InputMessage, startScreencast } from "./screencast"; +import { hideProcessFromChildren, takeSecretFromEnv } from "./secret-env"; import { HIDDEN_FIELD_SELECTOR, hiddenValues, @@ -125,7 +126,23 @@ import { createWorkspaces } from "./workspaces"; * Refusing to start without a token makes missing authentication a deployment failure, never an open * computer. */ -const COMPUTER_TOKEN = process.env.COMPUTER_TOKEN?.trim(); +/* + * Taken out of the environment as it is read, and the process marked non-dumpable, so neither the + * browser, nor Xvfb, nor a Bot's shell command can read it back from `/proc`. See secret-env.ts. + */ +const COMPUTER_TOKEN = takeSecretFromEnv("COMPUTER_TOKEN"); +{ + const hidden = await hideProcessFromChildren(); + if (!hidden.hidden) { + console.warn( + JSON.stringify({ + type: "computer-process-dumpable", + note: "This process could not be marked non-dumpable, so a process running as the same user may be able to read its memory.", + reason: hidden.reason, + }), + ); + } +} if (!COMPUTER_TOKEN) { console.error( "COMPUTER_TOKEN is not set. This process drives a browser holding real logins and will not start without the secret its caller must present.", diff --git a/agent-computer/src/secret-env.ts b/agent-computer/src/secret-env.ts new file mode 100644 index 000000000..fc2bb4ce3 --- /dev/null +++ b/agent-computer/src/secret-env.ts @@ -0,0 +1,70 @@ +/** + * Keeping this process's secrets away from the programs it starts. + * + * A Bot's shell runs as the same user as this process, and so do Chromium and Xvfb. Leaving + * `COMPUTER_TOKEN` in the environment would hand it to every one of them: Playwright launches the + * browser with this process's environment unless told otherwise, and any same-user process can read + * another's `/proc//environ`. A command scrubbed of its own environment (shell.ts) could still + * print the token by reading its parent's, or the browser's. + * + * Two steps, and both are needed. Taking the value out of `process.env` keeps it out of every child + * started afterwards. Marking the process non-dumpable makes its own `/proc/` entries readable + * only with CAP_SYS_PTRACE, which no container gets by default, so the copy the kernel keeps of the + * environment this process STARTED with stops being readable by its children too. + * + * Kept out of `index.ts`, which imports Playwright at module scope, so a test can reach it. + */ + +/** Read a secret from the environment and remove it, so no child process inherits it. */ +export function takeSecretFromEnv( + name: string, + env: Record = process.env, +): string | undefined { + const value = env[name]?.trim(); + delete env[name]; + return value || undefined; +} + +/** `PR_SET_DUMPABLE` from . */ +const PR_SET_DUMPABLE = 4; + +/** + * Mark this process non-dumpable, so a same-user process cannot read its memory or environment. + * + * Linux only, and best effort: where prctl cannot be reached (macOS for OpenBot Desktop, a libc + * under another name) it answers why rather than throwing, and the caller decides what to say. + * `COMPUTER_DUMPABLE=on` leaves the process dumpable, for attaching a debugger. + */ +export async function hideProcessFromChildren( + env: Record = process.env, + platform: string = process.platform, +): Promise<{ hidden: true } | { hidden: false; reason: string }> { + if (env.COMPUTER_DUMPABLE?.trim() === "on") { + return { hidden: false, reason: "COMPUTER_DUMPABLE=on" }; + } + if (platform !== "linux") { + return { hidden: false, reason: `not on Linux (${platform})` }; + } + try { + const { dlopen, FFIType } = await import("bun:ffi"); + const libc = dlopen("libc.so.6", { + prctl: { + args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64], + returns: FFIType.i32, + }, + }); + try { + const result = libc.symbols.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); + return result === 0 + ? { hidden: true } + : { hidden: false, reason: `prctl returned ${result}` }; + } finally { + libc.close(); + } + } catch (error) { + return { + hidden: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/agent-computer/tests/secret-env.test.ts b/agent-computer/tests/secret-env.test.ts new file mode 100644 index 000000000..c178c0478 --- /dev/null +++ b/agent-computer/tests/secret-env.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { hideProcessFromChildren, takeSecretFromEnv } from "../src/secret-env"; + +describe("a secret read from the environment", () => { + test("is returned trimmed and removed, so no child inherits it", () => { + const env: Record = { + COMPUTER_TOKEN: " s3cret ", + PATH: "/usr/bin", + }; + expect(takeSecretFromEnv("COMPUTER_TOKEN", env)).toBe("s3cret"); + expect("COMPUTER_TOKEN" in env).toBe(false); + expect(env.PATH).toBe("/usr/bin"); + }); + + test("an absent or blank one is undefined, and still removed", () => { + const env: Record = { COMPUTER_TOKEN: " " }; + expect(takeSecretFromEnv("COMPUTER_TOKEN", env)).toBeUndefined(); + expect("COMPUTER_TOKEN" in env).toBe(false); + expect(takeSecretFromEnv("COMPUTER_TOKEN", {})).toBeUndefined(); + }); + + test("a child started afterwards does not see it", async () => { + const env: Record = { + ...process.env, + OPENBOT_TEST_SECRET: "do-not-inherit", + }; + takeSecretFromEnv("OPENBOT_TEST_SECRET", env); + const child = Bun.spawn(["/usr/bin/env"], { + env: env as Record, + stdout: "pipe", + }); + const printed = await new Response(child.stdout).text(); + expect(printed).not.toContain("do-not-inherit"); + }); +}); + +describe("hiding the process from its children", () => { + test("is skipped, and says why, when asked to stay dumpable", async () => { + expect(await hideProcessFromChildren({ COMPUTER_DUMPABLE: "on" })).toEqual({ + hidden: false, + reason: "COMPUTER_DUMPABLE=on", + }); + }); + + test("is skipped off Linux", async () => { + const answer = await hideProcessFromChildren({}, "darwin"); + expect(answer.hidden).toBe(false); + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 7650efc6f..65124abc3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,7 +82,7 @@ With `COMPUTER_SUPERVISOR_URL`, each person gets a computer container of their o Each computer also filters its own outbound connections. The browser and shell commands go through a filter proxy that applies the Bot's network policy (`allow_all`, `defaults_plus_allowlist`, `allowlist_only` or `deny_all`, which is what a Bot gets when its owner's Cloud network access is switched off). The server pushes the policy before the computer acts and whenever it wakes, and until one arrives nothing may leave. `EGRESS_POLICY_REQUIRED=0` is for a computer run with no API server. Cloud metadata addresses are refused in every mode. A process that ignores the proxy variables and opens a raw socket is not stopped by this; that is a Kubernetes NetworkPolicy's job. -A command on the computer inherits PATH, locale and terminal names, and the proxy variables, not the rest of the process environment. Userinfo is stripped from a proxy URL. `COMPUTER_SHELL_ENV` names anything else a deployment wants passed. +A command on the computer inherits PATH, locale and terminal names, and the proxy variables, not the rest of the process environment. Userinfo is stripped from a proxy URL. `COMPUTER_SHELL_ENV` names anything else a deployment wants passed. The computer removes `COMPUTER_TOKEN` from its environment as it reads it, so neither the browser nor a command inherits it, and marks itself non-dumpable on Linux so a process running as the same user cannot read it back from `/proc` (`COMPUTER_DUMPABLE=on` turns that off for debugging). A command that can become root with CAP_SYS_PTRACE, or read the container's own environment files, is not stopped by this: a computer per person or per Bot is the boundary between people. The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network: Docker Compose binds it to `127.0.0.1:4500`, and a deployment running the server inside the compose network reaches it as `supervisor:4300` and needs no published port at all. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it.