fix(adapters): stop desktop shell from inheriting host secrets (#77)
* fix(adapters): stop desktop shell from inheriting host secrets The this-mac shell spawned with the server process environment and ignored the command environment, so Autopilot could read deployment credentials that are not on the run redaction list. Drop credential-shaped host variables, then apply the request environment. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style(adapters): biome format desktop-sandbox after env scrub Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
1ed4868dcf
commit
c746c51c78
102
packages/adapters/src/desktop-sandbox-env.test.ts
Normal file
102
packages/adapters/src/desktop-sandbox-env.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/adapters/src/desktop-sandbox-env.ts
Normal file
30
packages/adapters/src/desktop-sandbox-env.ts
Normal file
@ -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<string, string> | 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);
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ import {
|
|||||||
normalizeWorkspacePath,
|
normalizeWorkspacePath,
|
||||||
placeholderObservation,
|
placeholderObservation,
|
||||||
} from "./computer-support.js";
|
} from "./computer-support.js";
|
||||||
|
import { desktopCommandEnvironment } from "./desktop-sandbox-env.js";
|
||||||
import {
|
import {
|
||||||
isAllowedDesktopPath,
|
isAllowedDesktopPath,
|
||||||
normalizeDesktopWorkspacePath,
|
normalizeDesktopWorkspacePath,
|
||||||
@ -145,6 +146,7 @@ export class DesktopSandboxProvider implements SandboxProvider {
|
|||||||
cwd,
|
cwd,
|
||||||
boundedSandboxCommandTimeoutMs(request.timeoutMs),
|
boundedSandboxCommandTimeoutMs(request.timeoutMs),
|
||||||
context.signal,
|
context.signal,
|
||||||
|
desktopCommandEnvironment(process.env, request.env),
|
||||||
);
|
);
|
||||||
if (result.stdout) yield { type: "stdout", data: result.stdout };
|
if (result.stdout) yield { type: "stdout", data: result.stdout };
|
||||||
if (result.stderr) yield { type: "stderr", data: result.stderr };
|
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");
|
if (!box) throw new Error("computer not found");
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function localWorkspaceTarget(home: string, relative: string, mustExist: boolean) {
|
async function localWorkspaceTarget(home: string, relative: string, mustExist: boolean) {
|
||||||
@ -689,11 +690,12 @@ function runCommand(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(argv[0]!, argv.slice(1), {
|
const child = spawn(argv[0]!, argv.slice(1), {
|
||||||
cwd,
|
cwd,
|
||||||
env: process.env,
|
env,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user