diff --git a/agent-computer/Dockerfile b/agent-computer/Dockerfile index a8210c3eb..c5597276d 100644 --- a/agent-computer/Dockerfile +++ b/agent-computer/Dockerfile @@ -16,7 +16,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright COPY --from=node-toolchain /usr/local /usr/local COPY --from=bun-toolchain /usr/local/bin/bun /usr/local/bin/bun RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl unzip xz-utils \ + ca-certificates curl unzip xz-utils clamav \ && ln -s bun /usr/local/bin/bunx \ && bunx --bun "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium \ && rm -rf /root/.cache /tmp/* /var/lib/apt/lists/* diff --git a/agent-computer/src/download-quarantine.ts b/agent-computer/src/download-quarantine.ts index 02e52ffb2..a029d61a5 100644 --- a/agent-computer/src/download-quarantine.ts +++ b/agent-computer/src/download-quarantine.ts @@ -1,8 +1,16 @@ import { randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + readdir, + rename, + stat, + writeFile, +} from "node:fs/promises"; import { basename, join } from "node:path"; import type { Download } from "playwright"; import { isPlainBotId } from "./bot-id"; +import { scanWithClamAv, type MalwareScanResult } from "./quarantine-scanner"; /** * Browser downloads are hostile input until somebody explicitly releases them. @@ -25,12 +33,240 @@ export function quarantineDirectoryFor(root: string, botId: string): string { return join(root, botId); } +export type QuarantineStatus = + | "pending" + | "clean" + | "blocked" + | "scan_failed" + | "approved" + | "released"; + +export type QuarantineRecord = { + version: 2; + status: QuarantineStatus; + id: string; + botId: string; + originalName: string; + sourceUrl: string; + savedAt: string; + sizeBytes: number; + scan?: MalwareScanResult; + approvedAt?: string; + releasedAt?: string; +}; + +type StoredQuarantineRecord = QuarantineRecord & { + file: string; + metadata: string; +}; + export type QuarantinedDownload = { id: string; file: string; metadata: string; }; +export class QuarantineStateError extends Error { + constructor(message: string) { + super(message); + this.name = "QuarantineStateError"; + } +} + +const METADATA_SUFFIX = ".openbot.json"; +const QUARANTINE_ID = + /^\d{10,16}-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function validId(id: string): boolean { + return QUARANTINE_ID.test(id); +} + +async function writeMetadata( + path: string, + record: QuarantineRecord, + exclusive = false, +): Promise { + if (exclusive) { + await writeFile(path, JSON.stringify(record, null, 2), { + encoding: "utf8", + flag: "wx", + }); + return; + } + + const temporary = `${path}.${randomUUID()}.tmp`; + await writeFile(temporary, JSON.stringify(record, null, 2), "utf8"); + await rename(temporary, path); +} + +function publicRecord(record: StoredQuarantineRecord): QuarantineRecord { + const { file: _file, metadata: _metadata, ...safe } = record; + return safe; +} + +function normalizeStatus(value: unknown): QuarantineStatus { + if (value === "quarantined") return "pending"; + if ( + value === "pending" || + value === "clean" || + value === "blocked" || + value === "scan_failed" || + value === "approved" || + value === "released" + ) { + return value; + } + return "scan_failed"; +} + +async function readStoredMetadata( + metadata: string, + expectedBotId: string, +): Promise { + const raw = JSON.parse(await readFile(metadata, "utf8")) as Record< + string, + unknown + >; + const file = metadata.slice(0, -METADATA_SUFFIX.length); + const filename = basename(file); + if (typeof raw.id !== "string" || !validId(raw.id)) { + throw new QuarantineStateError( + "Quarantine metadata has an invalid download id.", + ); + } + const id = raw.id; + if (raw.botId !== expectedBotId || !filename.startsWith(`${id}-`)) { + throw new QuarantineStateError( + "Quarantine metadata does not belong to this Bot or download.", + ); + } + + const fileInfo = await stat(file); + if (!fileInfo.isFile()) { + throw new QuarantineStateError("The quarantined download is not a file."); + } + + return { + version: 2, + status: normalizeStatus(raw.status), + id, + botId: expectedBotId, + originalName: + typeof raw.originalName === "string" + ? raw.originalName + : filename.slice(id.length + 1), + sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "", + savedAt: + typeof raw.savedAt === "string" + ? raw.savedAt + : new Date(fileInfo.mtimeMs).toISOString(), + sizeBytes: fileInfo.size, + ...(raw.scan && typeof raw.scan === "object" + ? { scan: raw.scan as MalwareScanResult } + : {}), + ...(typeof raw.approvedAt === "string" + ? { approvedAt: raw.approvedAt } + : {}), + ...(typeof raw.releasedAt === "string" + ? { releasedAt: raw.releasedAt } + : {}), + file, + metadata, + }; +} + +async function findStored( + root: string, + botId: string, + id: string, +): Promise { + if (!validId(id)) { + throw new QuarantineStateError("That quarantine download id is invalid."); + } + const directory = quarantineDirectoryFor(root, botId); + const entries = await readdir(directory).catch(() => []); + const matches = entries.filter( + (entry) => entry.startsWith(`${id}-`) && entry.endsWith(METADATA_SUFFIX), + ); + const metadata = matches[0]; + if (matches.length !== 1 || !metadata) { + throw new QuarantineStateError("That quarantined download was not found."); + } + return readStoredMetadata(join(directory, metadata), botId); +} + +export async function listQuarantinedDownloads( + root: string, + botId: string, +): Promise { + const directory = quarantineDirectoryFor(root, botId); + const entries = await readdir(directory).catch(() => []); + const records: QuarantineRecord[] = []; + for (const entry of entries) { + if (!entry.endsWith(METADATA_SUFFIX)) continue; + try { + records.push( + publicRecord(await readStoredMetadata(join(directory, entry), botId)), + ); + } catch { + // An incomplete/corrupt sidecar is not silently called clean. It is omitted from the normal + // list and still remains physically quarantined for diagnostics/recovery. + } + } + return records.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); +} + +export async function scanQuarantinedDownload( + root: string, + botId: string, + id: string, + scanner: (file: string) => Promise = scanWithClamAv, +): Promise { + const stored = await findStored(root, botId, id); + if (stored.status === "released") { + throw new QuarantineStateError( + "A released download cannot be scanned in place.", + ); + } + + const scan = await scanner(stored.file); + const current = publicRecord(stored); + const { + approvedAt: _approvedAt, + releasedAt: _releasedAt, + ...unapproved + } = current; + const updated: QuarantineRecord = { + ...unapproved, + status: scan.status, + scan, + }; + await writeMetadata(stored.metadata, updated); + return updated; +} + +export async function approveQuarantinedDownload( + root: string, + botId: string, + id: string, +): Promise { + const stored = await findStored(root, botId, id); + if (stored.status === "approved") return publicRecord(stored); + if (stored.status !== "clean") { + throw new QuarantineStateError( + `Only a clean scanned download can be approved for export (current status: ${stored.status}).`, + ); + } + + const updated: QuarantineRecord = { + ...publicRecord(stored), + status: "approved", + approvedAt: new Date().toISOString(), + }; + await writeMetadata(stored.metadata, updated); + return updated; +} + export async function quarantineDownload( root: string, botId: string, @@ -53,25 +289,21 @@ export async function quarantineDownload( ); await download.saveAs(file); - const metadata = `${file}.openbot.json`; - await writeFile( + const fileInfo = await stat(file); + const metadata = `${file}${METADATA_SUFFIX}`; + await writeMetadata( metadata, - JSON.stringify( - { - version: 1, - status: "quarantined", - id, - botId, - originalName: download.suggestedFilename(), - sourceUrl: download.url(), - savedAt: new Date().toISOString(), - file, - note: "Untrusted browser download. Do not execute or export without explicit user approval and scanning policy.", - }, - null, - 2, - ), - { encoding: "utf8", flag: "wx" }, + { + version: 2, + status: "pending", + id, + botId, + originalName: download.suggestedFilename(), + sourceUrl: download.url(), + savedAt: new Date().toISOString(), + sizeBytes: fileInfo.size, + }, + true, ); return { id, file, metadata }; diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index d7bc3e70b..162bb7e2e 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -23,6 +23,12 @@ import { } from "./control"; import { identity } from "./identity"; import { collectComputerMetrics } from "./metrics"; +import { + approveQuarantinedDownload, + listQuarantinedDownloads, + QuarantineStateError, + scanQuarantinedDownload, +} from "./download-quarantine"; import { createProfiles, numberFromEnv, VIEWPORT } from "./profiles"; import { parseExecTimeout, @@ -211,6 +217,7 @@ function botIdOf(request: Request, fallback?: string | null): string { * lives in workspace.ts. */ const WORKSPACE_ROOT = process.env.WORKSPACE_DIR?.trim() || "/workspace"; +const QUARANTINE_ROOT = process.env.QUARANTINE_DIR?.trim() || "/quarantine"; const WORKSPACE_MAX_BYTES = numberFromEnv( "COMPUTER_WORKSPACE_MAX_BYTES", 4 * 1024 * 1024 * 1024, @@ -941,6 +948,86 @@ serve({ } } + /** + * Untrusted browser downloads for this Bot only. + * + * Listing and scanning never move a file out of quarantine. Approval is a separate, explicit + * transition and still does not copy, open or execute the file; the native export path will be + * responsible for choosing a host destination and marking the final release. + */ + if (url.pathname === "/quarantine" && request.method === "GET") { + try { + return json({ + downloads: await listQuarantinedDownloads(QUARANTINE_ROOT, botId), + }); + } catch (error) { + return json( + { error: describe(error, "The quarantine could not be listed.") }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if (url.pathname === "/quarantine/scan" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + id?: unknown; + } | null; + if (typeof body?.id !== "string" || !body.id) { + return json({ error: "A quarantine download id is required." }, 400); + } + try { + return json( + await scanQuarantinedDownload(QUARANTINE_ROOT, botId, body.id), + ); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be scanned.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if (url.pathname === "/quarantine/approve" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + id?: unknown; + botId?: unknown; + confirm?: unknown; + } | null; + if ( + typeof body?.id !== "string" || + body.confirm !== "APPROVE" || + body.botId !== botId + ) { + return json( + { + error: + "Approval requires APPROVE confirmation, the exact Bot id, and the quarantine download id.", + }, + 400, + ); + } + try { + return json( + await approveQuarantinedDownload(QUARANTINE_ROOT, botId, body.id), + ); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be approved.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + // The Bot's files. Confined to the workspace by workspace.ts. Nothing here decides whether a Bot // MAY touch a path: the gateway in front of this process does that. if (url.pathname === "/files/read" && request.method === "POST") { diff --git a/agent-computer/src/quarantine-scanner.ts b/agent-computer/src/quarantine-scanner.ts new file mode 100644 index 000000000..a38b26e8d --- /dev/null +++ b/agent-computer/src/quarantine-scanner.ts @@ -0,0 +1,125 @@ +export type MalwareScanStatus = "clean" | "blocked" | "scan_failed"; + +export type MalwareScanResult = { + status: MalwareScanStatus; + scanner: "clamav"; + detail: string; + scannedAt: string; +}; + +const DEFAULT_SCAN_TIMEOUT_MS = 120_000; +const DETAIL_LIMIT = 1_000; + +function concise(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length > DETAIL_LIMIT + ? `${normalized.slice(0, DETAIL_LIMIT)}…` + : normalized; +} + +/** + * ClamAV's documented exit contract: + * 0 = no virus found, 1 = virus found, anything else = scanner failure. + * + * A scanner failure is deliberately not "clean". Export/release code may only accept the exact + * clean state, so a missing signature database, missing executable, timeout, or internal error all + * fail closed. + */ +export function classifyClamAvResult( + exitCode: number, + stdout: string, + stderr: string, + killed = false, +): MalwareScanResult { + const scannedAt = new Date().toISOString(); + if (killed) { + return { + status: "scan_failed", + scanner: "clamav", + detail: "ClamAV did not finish before the scan timeout.", + scannedAt, + }; + } + + if (exitCode === 0) { + return { + status: "clean", + scanner: "clamav", + detail: "ClamAV reported no malware.", + scannedAt, + }; + } + + if (exitCode === 1) { + const found = + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => / FOUND$/i.test(line)) ?? "ClamAV reported malware."; + return { + status: "blocked", + scanner: "clamav", + detail: concise(found), + scannedAt, + }; + } + + return { + status: "scan_failed", + scanner: "clamav", + detail: + concise(stderr) || + concise(stdout) || + `ClamAV failed with exit code ${exitCode}.`, + scannedAt, + }; +} + +export async function scanWithClamAv( + file: string, + options: { + executable?: string; + timeoutMs?: number; + } = {}, +): Promise { + const executable = + options.executable?.trim() || + process.env.COMPUTER_CLAMAV_PATH?.trim() || + "clamscan"; + const timeoutMs = options.timeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS; + + try { + const process = Bun.spawn([executable, "--no-summary", "--", file], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + + const stdoutPromise = + process.stdout instanceof ReadableStream + ? new Response(process.stdout).text() + : Promise.resolve(""); + const stderrPromise = + process.stderr instanceof ReadableStream + ? new Response(process.stderr).text() + : Promise.resolve(""); + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + stdoutPromise, + stderrPromise, + ]); + + return classifyClamAvResult(exitCode, stdout, stderr, process.killed); + } catch (error) { + return { + status: "scan_failed", + scanner: "clamav", + detail: concise( + error instanceof Error ? error.message : "ClamAV could not be started.", + ), + scannedAt: new Date().toISOString(), + }; + } +} diff --git a/agent-computer/tests/download-quarantine.test.ts b/agent-computer/tests/download-quarantine.test.ts index 584c4732e..e4d00c87f 100644 --- a/agent-computer/tests/download-quarantine.test.ts +++ b/agent-computer/tests/download-quarantine.test.ts @@ -3,10 +3,15 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + approveQuarantinedDownload, + listQuarantinedDownloads, quarantineDirectoryFor, quarantineDownload, + QuarantineStateError, safeDownloadName, + scanQuarantinedDownload, } from "../src/download-quarantine"; +import type { MalwareScanResult } from "../src/quarantine-scanner"; let root: string; @@ -18,6 +23,29 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +function fakeScan( + status: MalwareScanResult["status"], + detail = status, +): (file: string) => Promise { + return async () => ({ + status, + scanner: "clamav", + detail, + scannedAt: new Date().toISOString(), + }); +} + +async function addDownload(botId = "agent-a") { + return quarantineDownload(root, botId, { + failure: async () => null, + saveAs: async (path: string) => { + await Bun.write(path, "untrusted bytes"); + }, + suggestedFilename: () => "../../installer.exe", + url: () => "https://example.test/installer.exe", + }); +} + describe("download quarantine", () => { test("sanitizes path-shaped and Windows-hostile file names", () => { expect(safeDownloadName("../../evil.exe")).toBe("evil.exe"); @@ -31,7 +59,7 @@ describe("download quarantine", () => { expect(quarantineDirectoryFor(root, "agent-a")).toBe(join(root, "agent-a")); }); - test("persists a download only under quarantine with quarantine metadata", async () => { + test("persists a download only under quarantine as pending untrusted input", async () => { let savedAs = ""; const result = await quarantineDownload(root, "agent-a", { failure: async () => null, @@ -52,8 +80,68 @@ describe("download quarantine", () => { originalName: string; sourceUrl: string; }; - expect(metadata.status).toBe("quarantined"); + expect(metadata.status).toBe("pending"); expect(metadata.originalName).toBe("../../installer.exe"); expect(metadata.sourceUrl).toBe("https://example.test/installer.exe"); }); + + test("a failed scanner is never treated as clean or approvable", async () => { + const download = await addDownload(); + const scanned = await scanQuarantinedDownload( + root, + "agent-a", + download.id, + fakeScan("scan_failed", "signature database unavailable"), + ); + expect(scanned.status).toBe("scan_failed"); + expect(scanned.scan?.detail).toContain("database"); + + await expect( + approveQuarantinedDownload(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("a malware verdict stays blocked and cannot be approved", async () => { + const download = await addDownload(); + const scanned = await scanQuarantinedDownload( + root, + "agent-a", + download.id, + fakeScan("blocked", "Eicar-Test-Signature FOUND"), + ); + expect(scanned.status).toBe("blocked"); + + await expect( + approveQuarantinedDownload(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("approval requires a clean scan and still does not export or execute the file", async () => { + const download = await addDownload(); + const scanned = await scanQuarantinedDownload( + root, + "agent-a", + download.id, + fakeScan("clean", "no malware"), + ); + expect(scanned.status).toBe("clean"); + + const approved = await approveQuarantinedDownload( + root, + "agent-a", + download.id, + ); + expect(approved.status).toBe("approved"); + expect(approved.approvedAt).toBeTruthy(); + expect(await readFile(download.file, "utf8")).toBe("untrusted bytes"); + expect(download.file.startsWith(join(root, "agent-a"))).toBe(true); + }); + + test("one Agent cannot list or scan another Agent's quarantine", async () => { + const download = await addDownload("agent-a"); + expect(await listQuarantinedDownloads(root, "agent-b")).toEqual([]); + await expect( + scanQuarantinedDownload(root, "agent-b", download.id, fakeScan("clean")), + ).rejects.toThrow(QuarantineStateError); + }); }); diff --git a/agent-computer/tests/quarantine-scanner.test.ts b/agent-computer/tests/quarantine-scanner.test.ts new file mode 100644 index 000000000..3afa067b2 --- /dev/null +++ b/agent-computer/tests/quarantine-scanner.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { classifyClamAvResult } from "../src/quarantine-scanner"; + +describe("ClamAV result classification", () => { + test("only exit code zero is clean", () => { + expect(classifyClamAvResult(0, "", "").status).toBe("clean"); + }); + + test("malware findings are blocked", () => { + const result = classifyClamAvResult( + 1, + "/quarantine/file: Eicar-Test-Signature FOUND\n", + "", + ); + expect(result.status).toBe("blocked"); + expect(result.detail).toContain("FOUND"); + }); + + test("scanner errors and timeouts fail closed", () => { + expect(classifyClamAvResult(2, "", "database missing").status).toBe( + "scan_failed", + ); + expect(classifyClamAvResult(0, "", "", true).status).toBe("scan_failed"); + }); +}); diff --git a/scripts/release-preflight.ts b/scripts/release-preflight.ts index a4ece0d87..27f7b6ab0 100644 --- a/scripts/release-preflight.ts +++ b/scripts/release-preflight.ts @@ -264,16 +264,28 @@ function checkComputerSandboxBoundary(): void { } const downloads = read("agent-computer/src/download-quarantine.ts"); + const quarantineScanner = read("agent-computer/src/quarantine-scanner.ts"); const profiles = read("agent-computer/src/profiles.ts"); for (const evidence of [ - 'status: "quarantined"', + 'status: "pending"', "download.saveAs(file)", "quarantineDirectoryFor(root, botId)", + "Only a clean scanned download can be approved for export", ]) { if (!downloads.includes(evidence)) { fail(`computer: download quarantine is missing ${evidence}`); } } + for (const evidence of [ + 'status: "scan_failed"', + "exitCode === 0", + "exitCode === 1", + "process.killed", + ]) { + if (!quarantineScanner.includes(evidence)) { + fail(`computer: fail-closed malware scanner is missing ${evidence}`); + } + } if ( !profiles.includes("quarantineDownload(QUARANTINE_ROOT, botId, download)") ) { @@ -380,13 +392,30 @@ function checkInteractiveComputerControls(): void { } for (const evidence of [ - 'status: "quarantined"', - "Do not execute or export without explicit user approval", + '| "pending"', + '| "clean"', + '| "blocked"', + '| "scan_failed"', + '| "approved"', + '| "released"', + "Only a clean scanned download can be approved for export", ]) { if (!quarantine.includes(evidence)) { fail(`computer: download quarantine metadata is missing ${evidence}`); } } + + const computerRoutes = read("server/src/computer/routes.ts"); + for (const evidence of [ + 'routes.post("/:botId/quarantine/scan"', + 'routes.post("/:botId/quarantine/approve"', + 'body.confirm !== "APPROVE"', + "body.botId !== botId", + ]) { + if (!computerRoutes.includes(evidence)) { + fail(`computer: explicit quarantine approval is missing ${evidence}`); + } + } } function checkReleaseWiring(): void { diff --git a/server/src/audit.ts b/server/src/audit.ts index 264f6c4a0..9261bc5eb 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -258,6 +258,10 @@ export const auditEventTypes = [ "computer.restarted", "computer.stopped", "computer.reset", + // Untrusted downloads stay quarantined through scanning and explicit human approval. These rows + // record the security decision without copying file contents or scanner output into the trail. + "computer.quarantine_scanned", + "computer.quarantine_approved", /** * The boundary this deployment booted with. * diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index e0212376b..45e0acf1c 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -60,6 +60,8 @@ import type { ReadFileInput, ReadFileResult, ReadResult, + QuarantineListResult, + QuarantineRecord, RunCommandInput, RunCommandResult, ScreenshotResult, @@ -132,6 +134,17 @@ export interface ComputerGateway { screenshot(botId: string): Promise; snapshot(botId: string): Promise; read(botId: string): Promise; + listQuarantine(botId: string): Promise; + scanQuarantine( + botId: string, + actor: ActionActor, + id: string, + ): Promise; + approveQuarantine( + botId: string, + actor: ActionActor, + id: string, + ): Promise; navigate( botId: string, actor: ActionActor, @@ -650,6 +663,36 @@ export function createComputerGateway( snapshot, read, + listQuarantine(botId: string): Promise { + return get(botId, "/quarantine"); + }, + + async scanQuarantine(botId: string, actor: ActionActor, id: string) { + const result = await post(botId, "/quarantine/scan", { + id, + }); + await writeControlEvent(auditStore, "computer.quarantine_scanned", { + botId, + actor, + reason: `${id}: ${result.status}`, + }); + return result; + }, + + async approveQuarantine(botId: string, actor: ActionActor, id: string) { + const result = await post( + botId, + "/quarantine/approve", + { id, botId, confirm: "APPROVE" }, + ); + await writeControlEvent(auditStore, "computer.quarantine_approved", { + botId, + actor, + reason: `${id}: explicitly approved after clean scan`, + }); + return result; + }, + status(botId: string): Promise { return provider.status(botId); }, @@ -1348,7 +1391,9 @@ async function writeControlEvent( | "computer.started" | "computer.restarted" | "computer.stopped" - | "computer.reset", + | "computer.reset" + | "computer.quarantine_scanned" + | "computer.quarantine_approved", entry: { botId: string; actor: ActionActor; diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index de5e96f70..3ae0c3a8d 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -110,6 +110,80 @@ export function createComputerRoutes( } }); + routes.get("/:botId/quarantine", async (context) => { + try { + return context.json( + await gateway.listQuarantine(context.req.param("botId")), + ); + } catch (error) { + return context.json(errorBody(error), statusFor(error)); + } + }); + + routes.post("/:botId/quarantine/scan", async (context) => { + const body = (await context.req.json().catch(() => null)) as { + id?: unknown; + } | null; + if (typeof body?.id !== "string" || !body.id) { + return context.json( + { error: "A quarantine download id is required." }, + 400, + ); + } + const record = context.var.actor; + try { + return context.json( + await gateway.scanQuarantine( + context.req.param("botId"), + { + id: record.id, + ...(record.email === DEV_ACTOR_EMAIL ? {} : { userId: record.id }), + }, + body.id, + ), + ); + } catch (error) { + return context.json(errorBody(error), statusFor(error)); + } + }); + + routes.post("/:botId/quarantine/approve", async (context) => { + const botId = context.req.param("botId"); + const body = (await context.req.json().catch(() => null)) as { + id?: unknown; + botId?: unknown; + confirm?: unknown; + } | null; + if ( + typeof body?.id !== "string" || + body.confirm !== "APPROVE" || + body.botId !== botId + ) { + return context.json( + { + error: + "Approval requires APPROVE confirmation, the exact Bot id, and the quarantine download id.", + }, + 400, + ); + } + const record = context.var.actor; + try { + return context.json( + await gateway.approveQuarantine( + botId, + { + id: record.id, + ...(record.email === DEV_ACTOR_EMAIL ? {} : { userId: record.id }), + }, + body.id, + ), + ); + } catch (error) { + return context.json(errorBody(error), statusFor(error)); + } + }); + /** * Whether the same page is on both, ignoring the two ways one page spells itself. * diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index 9cdd13f15..ff45fabd6 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -369,3 +369,37 @@ export type ComputerResourceMetrics = { /** Whether Chromium is resident. Absent on older Computer images. */ browserRunning?: boolean; }; + +/** Malware-scanning lifecycle for an untrusted browser download. */ +export type QuarantineStatus = + | "pending" + | "clean" + | "blocked" + | "scan_failed" + | "approved" + | "released"; + +export type QuarantineScan = { + status: "clean" | "blocked" | "scan_failed"; + scanner: "clamav"; + detail: string; + scannedAt: string; +}; + +export type QuarantineRecord = { + version: 2; + status: QuarantineStatus; + id: string; + botId: string; + originalName: string; + sourceUrl: string; + savedAt: string; + sizeBytes: number; + scan?: QuarantineScan; + approvedAt?: string; + releasedAt?: string; +}; + +export type QuarantineListResult = { + downloads: QuarantineRecord[]; +};