diff --git a/agent-computer/src/download-quarantine.ts b/agent-computer/src/download-quarantine.ts index a029d61a5..ce1dab8dc 100644 --- a/agent-computer/src/download-quarantine.ts +++ b/agent-computer/src/download-quarantine.ts @@ -1,10 +1,12 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; import { + lstat, mkdir, readFile, readdir, rename, - stat, + unlink, writeFile, } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -50,6 +52,10 @@ export type QuarantineRecord = { sourceUrl: string; savedAt: string; sizeBytes: number; + /** Identity of the exact bytes the record refers to. Never a container/host path. */ + sha256: string; + /** Exact bytes ClamAV scanned. Approval/export must still match this digest. */ + scannedSha256?: string; scan?: MalwareScanResult; approvedAt?: string; releasedAt?: string; @@ -58,12 +64,14 @@ export type QuarantineRecord = { type StoredQuarantineRecord = QuarantineRecord & { file: string; metadata: string; + integrityOk: boolean; }; export type QuarantinedDownload = { id: string; file: string; metadata: string; + record: QuarantineRecord; }; export class QuarantineStateError extends Error { @@ -76,11 +84,29 @@ export class QuarantineStateError extends Error { 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; +const SHA256 = /^[a-f0-9]{64}$/; function validId(id: string): boolean { return QUARANTINE_ID.test(id); } +async function fingerprint( + file: string, +): Promise<{ sizeBytes: number; sha256: string }> { + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink()) { + throw new QuarantineStateError( + "A quarantined download must be a regular file.", + ); + } + + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) { + hash.update(chunk); + } + return { sizeBytes: info.size, sha256: hash.digest("hex") }; +} + async function writeMetadata( path: string, record: QuarantineRecord, @@ -100,7 +126,12 @@ async function writeMetadata( } function publicRecord(record: StoredQuarantineRecord): QuarantineRecord { - const { file: _file, metadata: _metadata, ...safe } = record; + const { + file: _file, + metadata: _metadata, + integrityOk: _integrityOk, + ...safe + } = record; return safe; } @@ -119,6 +150,26 @@ function normalizeStatus(value: unknown): QuarantineStatus { return "scan_failed"; } +function integrityFailure(record: QuarantineRecord): QuarantineRecord { + const { + approvedAt: _approvedAt, + releasedAt: _releasedAt, + scannedSha256: _scannedSha256, + ...untrusted + } = record; + return { + ...untrusted, + status: "scan_failed", + scan: { + status: "scan_failed", + scanner: "clamav", + detail: + "The quarantined file changed after its recorded identity/scan and must be rescanned.", + scannedAt: new Date().toISOString(), + }, + }; +} + async function readStoredMetadata( metadata: string, expectedBotId: string, @@ -141,14 +192,24 @@ async function readStoredMetadata( ); } - const fileInfo = await stat(file); - if (!fileInfo.isFile()) { - throw new QuarantineStateError("The quarantined download is not a file."); - } + const current = await fingerprint(file); + const recordedSha = + typeof raw.sha256 === "string" && SHA256.test(raw.sha256) + ? raw.sha256 + : current.sha256; + const recordedSize = + typeof raw.sizeBytes === "number" && + Number.isSafeInteger(raw.sizeBytes) && + raw.sizeBytes >= 0 + ? raw.sizeBytes + : current.sizeBytes; + const status = normalizeStatus(raw.status); + const integrityOk = + recordedSha === current.sha256 && recordedSize === current.sizeBytes; - return { + const parsed: QuarantineRecord = { version: 2, - status: normalizeStatus(raw.status), + status, id, botId: expectedBotId, originalName: @@ -157,10 +218,12 @@ async function readStoredMetadata( : 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, + typeof raw.savedAt === "string" ? raw.savedAt : new Date().toISOString(), + sizeBytes: recordedSize, + sha256: recordedSha, + ...(typeof raw.scannedSha256 === "string" && SHA256.test(raw.scannedSha256) + ? { scannedSha256: raw.scannedSha256 } + : {}), ...(raw.scan && typeof raw.scan === "object" ? { scan: raw.scan as MalwareScanResult } : {}), @@ -170,8 +233,18 @@ async function readStoredMetadata( ...(typeof raw.releasedAt === "string" ? { releasedAt: raw.releasedAt } : {}), + }; + + const safe = + integrityOk || status === "pending" || status === "blocked" + ? parsed + : integrityFailure(parsed); + + return { + ...safe, file, metadata, + integrityOk, }; } @@ -209,8 +282,8 @@ export async function listQuarantinedDownloads( 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. + // An incomplete/corrupt sidecar is never promoted into a trusted-looking entry. Reset can + // still clear the complete quarantine volume for recovery. } } return records.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); @@ -229,17 +302,37 @@ export async function scanQuarantinedDownload( ); } + const before = await fingerprint(stored.file); const scan = await scanner(stored.file); + const after = await fingerprint(stored.file); const current = publicRecord(stored); const { approvedAt: _approvedAt, releasedAt: _releasedAt, + scannedSha256: _scannedSha256, ...unapproved } = current; + + const changedDuringScan = + before.sha256 !== after.sha256 || before.sizeBytes !== after.sizeBytes; + const effectiveScan: MalwareScanResult = changedDuringScan + ? { + status: "scan_failed", + scanner: "clamav", + detail: + "The quarantined file changed while malware scanning was in progress.", + scannedAt: new Date().toISOString(), + } + : scan; const updated: QuarantineRecord = { ...unapproved, - status: scan.status, - scan, + sizeBytes: after.sizeBytes, + sha256: after.sha256, + status: effectiveScan.status, + scan: effectiveScan, + ...(effectiveScan.status === "clean" + ? { scannedSha256: after.sha256 } + : {}), }; await writeMetadata(stored.metadata, updated); return updated; @@ -251,10 +344,20 @@ export async function approveQuarantinedDownload( id: string, ): Promise { const stored = await findStored(root, botId, id); - if (stored.status === "approved") return publicRecord(stored); - if (stored.status !== "clean") { + if ( + stored.status === "approved" && + stored.integrityOk && + stored.scannedSha256 === stored.sha256 + ) { + return publicRecord(stored); + } + if ( + stored.status !== "clean" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { throw new QuarantineStateError( - `Only a clean scanned download can be approved for export (current status: ${stored.status}).`, + `Only unchanged bytes from a clean scan can be approved for export (current status: ${stored.status}).`, ); } @@ -267,6 +370,79 @@ export async function approveQuarantinedDownload( return updated; } +/** + * Resolve bytes for the native export worker, never for a browser/model response. + * + * The caller still has to choose a host destination natively. Returning a path internally avoids + * loading hostile bytes into JSON while binding export to the exact digest that was scanned. + */ +export async function approvedQuarantineFile( + root: string, + botId: string, + id: string, +): Promise<{ file: string; record: QuarantineRecord }> { + const stored = await findStored(root, botId, id); + if ( + stored.status !== "approved" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { + throw new QuarantineStateError( + "This download is not an unchanged, clean, explicitly approved file.", + ); + } + return { file: stored.file, record: publicRecord(stored) }; +} + +export async function markQuarantinedDownloadReleased( + root: string, + botId: string, + id: string, +): Promise { + const stored = await findStored(root, botId, id); + if ( + stored.status !== "approved" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { + throw new QuarantineStateError( + "Only the unchanged approved bytes can be marked released.", + ); + } + const updated: QuarantineRecord = { + ...publicRecord(stored), + status: "released", + releasedAt: new Date().toISOString(), + }; + await writeMetadata(stored.metadata, updated); + return updated; +} + +export async function deleteQuarantinedDownload( + root: string, + botId: string, + id: string, +): Promise { + const stored = await findStored(root, botId, id).catch((error) => { + if ( + error instanceof QuarantineStateError && + error.message === "That quarantined download was not found." + ) { + return null; + } + throw error; + }); + if (!stored) return false; + + await unlink(stored.file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + await unlink(stored.metadata).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + return true; +} + export async function quarantineDownload( root: string, botId: string, @@ -289,11 +465,10 @@ export async function quarantineDownload( ); await download.saveAs(file); - const fileInfo = await stat(file); - const metadata = `${file}${METADATA_SUFFIX}`; - await writeMetadata( - metadata, - { + try { + const { sizeBytes, sha256 } = await fingerprint(file); + const metadata = `${file}${METADATA_SUFFIX}`; + const record: QuarantineRecord = { version: 2, status: "pending", id, @@ -301,10 +476,15 @@ export async function quarantineDownload( originalName: download.suggestedFilename(), sourceUrl: download.url(), savedAt: new Date().toISOString(), - sizeBytes: fileInfo.size, - }, - true, - ); - - return { id, file, metadata }; + sizeBytes, + sha256, + }; + await writeMetadata(metadata, record, true); + return { id, file, metadata, record }; + } catch (error) { + // Untracked hostile bytes are worse than a failed download. If identity/metadata cannot be + // established, remove the bytes rather than leave something the UI cannot account for. + await unlink(file).catch(() => undefined); + throw error; + } } diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 162bb7e2e..ceb54efb6 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -25,7 +25,10 @@ import { identity } from "./identity"; import { collectComputerMetrics } from "./metrics"; import { approveQuarantinedDownload, + approvedQuarantineFile, + deleteQuarantinedDownload, listQuarantinedDownloads, + markQuarantinedDownloadReleased, QuarantineStateError, scanQuarantinedDownload, } from "./download-quarantine"; @@ -1028,6 +1031,104 @@ serve({ } } + /** + * Internal byte stream used only by the authenticated native desktop export worker. + * + * The API server never exposes this response to the web UI/model. The helper checks that the + * exact bytes are still approved and still match the SHA-256 ClamAV scanned before opening them. + */ + if ( + url.pathname === "/quarantine/export-internal" && + 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 { + const exportable = await approvedQuarantineFile( + QUARANTINE_ROOT, + botId, + body.id, + ); + return new Response(Bun.file(exportable.file), { + headers: { + "content-type": "application/octet-stream", + "content-length": String(exportable.record.sizeBytes), + "x-openbot-sha256": exportable.record.sha256, + }, + }); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file is not approved for export.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if (url.pathname === "/quarantine/released" && 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 markQuarantinedDownloadReleased( + QUARANTINE_ROOT, + botId, + body.id, + ), + ); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be marked released.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if (url.pathname === "/quarantine/delete" && 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({ + deleted: await deleteQuarantinedDownload( + QUARANTINE_ROOT, + botId, + body.id, + ), + }); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be deleted.", + ), + }, + 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/tests/download-quarantine.test.ts b/agent-computer/tests/download-quarantine.test.ts index e4d00c87f..d82d7e182 100644 --- a/agent-computer/tests/download-quarantine.test.ts +++ b/agent-computer/tests/download-quarantine.test.ts @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { approveQuarantinedDownload, + approvedQuarantineFile, + deleteQuarantinedDownload, listQuarantinedDownloads, + markQuarantinedDownloadReleased, quarantineDirectoryFor, quarantineDownload, QuarantineStateError, @@ -59,7 +62,7 @@ describe("download quarantine", () => { expect(quarantineDirectoryFor(root, "agent-a")).toBe(join(root, "agent-a")); }); - test("persists a download only under quarantine as pending untrusted input", async () => { + test("persists identity only under quarantine as pending untrusted input", async () => { let savedAs = ""; const result = await quarantineDownload(root, "agent-a", { failure: async () => null, @@ -74,15 +77,21 @@ describe("download quarantine", () => { expect(savedAs.startsWith(join(root, "agent-a"))).toBe(true); expect(savedAs.endsWith("-installer.exe")).toBe(true); expect(await readFile(result.file, "utf8")).toBe("untrusted bytes"); + expect(result.record.status).toBe("pending"); + expect(result.record.sha256).toMatch(/^[a-f0-9]{64}$/); const metadata = JSON.parse(await readFile(result.metadata, "utf8")) as { status: string; originalName: string; sourceUrl: string; + sha256: string; + file?: unknown; }; expect(metadata.status).toBe("pending"); expect(metadata.originalName).toBe("../../installer.exe"); expect(metadata.sourceUrl).toBe("https://example.test/installer.exe"); + expect(metadata.sha256).toBe(result.record.sha256); + expect(metadata.file).toBeUndefined(); }); test("a failed scanner is never treated as clean or approvable", async () => { @@ -116,7 +125,28 @@ describe("download quarantine", () => { ).rejects.toThrow(QuarantineStateError); }); - test("approval requires a clean scan and still does not export or execute the file", async () => { + test("changing the file during a clean scan fails closed", async () => { + const download = await addDownload(); + const scanned = await scanQuarantinedDownload( + root, + "agent-a", + download.id, + async (file) => { + await Bun.write(file, "different bytes after scan started"); + return { + status: "clean", + scanner: "clamav", + detail: "no malware", + scannedAt: new Date().toISOString(), + }; + }, + ); + expect(scanned.status).toBe("scan_failed"); + expect(scanned.scannedSha256).toBeUndefined(); + expect(scanned.scan?.detail).toContain("changed"); + }); + + test("approval and export stay bound to the exact clean bytes", async () => { const download = await addDownload(); const scanned = await scanQuarantinedDownload( root, @@ -125,6 +155,7 @@ describe("download quarantine", () => { fakeScan("clean", "no malware"), ); expect(scanned.status).toBe("clean"); + expect(scanned.scannedSha256).toBe(scanned.sha256); const approved = await approveQuarantinedDownload( root, @@ -133,8 +164,57 @@ describe("download quarantine", () => { ); 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); + + const exportable = await approvedQuarantineFile( + root, + "agent-a", + download.id, + ); + expect(exportable.record.sha256).toBe(scanned.sha256); + expect(await readFile(exportable.file, "utf8")).toBe("untrusted bytes"); + + await Bun.write(download.file, "tampered but still quarantined"); + await expect( + approvedQuarantineFile(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + await expect( + markQuarantinedDownloadReleased(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("release is a separate transition after native export succeeds", async () => { + const download = await addDownload(); + await scanQuarantinedDownload( + root, + "agent-a", + download.id, + fakeScan("clean"), + ); + await approveQuarantinedDownload(root, "agent-a", download.id); + const released = await markQuarantinedDownloadReleased( + root, + "agent-a", + download.id, + ); + expect(released.status).toBe("released"); + expect(released.releasedAt).toBeTruthy(); + await expect( + approvedQuarantineFile(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("delete accepts only the generated id and remains per-Agent", async () => { + const download = await addDownload("agent-a"); + await expect( + deleteQuarantinedDownload(root, "agent-a", "../../installer.exe"), + ).rejects.toThrow(QuarantineStateError); + expect(await deleteQuarantinedDownload(root, "agent-b", download.id)).toBe( + false, + ); + expect(await deleteQuarantinedDownload(root, "agent-a", download.id)).toBe( + true, + ); + expect(await listQuarantinedDownloads(root, "agent-a")).toEqual([]); }); test("one Agent cannot list or scan another Agent's quarantine", async () => { diff --git a/app/src/components/computers/computer-quarantine-dialog.tsx b/app/src/components/computers/computer-quarantine-dialog.tsx new file mode 100644 index 000000000..e22a14aeb --- /dev/null +++ b/app/src/components/computers/computer-quarantine-dialog.tsx @@ -0,0 +1,238 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + approveQuarantinedDownloadMutationOptions, + exportQuarantinedDownloadMutationOptions, + scanQuarantinedDownloadMutationOptions, +} from "@/lib/computers/mutations"; +import { + quarantineQueryOptions, + type QuarantineEntry, +} from "@/lib/computers/queries"; + +export function ComputerQuarantineDialog({ + botId, + botName, + open, + onOpenChange, +}: { + botId: string; + botName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const quarantine = useQuery({ + ...quarantineQueryOptions(botId), + enabled: open, + }); + const scan = useMutation(scanQuarantinedDownloadMutationOptions(queryClient)); + const approve = useMutation( + approveQuarantinedDownloadMutationOptions(queryClient), + ); + const exportFile = useMutation( + exportQuarantinedDownloadMutationOptions(queryClient), + ); + + const problem = + quarantine.error instanceof Error + ? quarantine.error.message + : scan.error instanceof Error + ? scan.error.message + : approve.error instanceof Error + ? approve.error.message + : exportFile.error instanceof Error + ? exportFile.error.message + : null; + + const busyId = + (scan.isPending ? scan.variables?.id : null) ?? + (approve.isPending ? approve.variables?.id : null) ?? + (exportFile.isPending ? exportFile.variables?.id : null) ?? + null; + + return ( + + + + {botName}'s quarantine + + Browser downloads stay isolated until they pass malware scanning, + are explicitly approved, and you choose a Windows destination. + + + +
+ Untrusted files. Export uses a native Save As dialog, + verifies the approved SHA-256 and byte size again, and never + auto-opens or runs the file. +
+ + {problem ? ( +

+ {problem} +

+ ) : null} + +
+ {quarantine.isPending ? ( +

Loading…

+ ) : (quarantine.data?.downloads.length ?? 0) === 0 ? ( +

+ No quarantined downloads. +

+ ) : ( +
+ {quarantine.data?.downloads.map((entry) => ( +
+
+
+

+ {entry.originalName} +

+

+ {formatBytes(entry.sizeBytes)} · downloaded{" "} + {new Date(entry.savedAt).toLocaleString()} +

+
+ +
+ +
+
Source
+
+ {entry.sourceUrl} +
+
SHA-256
+
{entry.sha256}
+ {entry.scan ? ( + <> +
Scanner
+
+ {entry.scan.scanner} · {entry.scan.detail} +
+ + ) : null} +
+ +
+ {entry.status === "pending" || + entry.status === "blocked" || + entry.status === "scan_failed" ? ( + + ) : null} + + {entry.status === "clean" ? ( + + ) : null} + + {entry.status === "approved" ? ( + + ) : null} +
+
+ ))} +
+ )} +
+ + + + A failed scan is never treated as clean. Export requires native + confirmation every time. + + + +
+
+ ); +} + +function StatusBadge({ entry }: { entry: QuarantineEntry }) { + const label = { + pending: "Needs scan", + clean: "Clean · approval needed", + blocked: "Blocked", + scan_failed: "Scan failed", + approved: "Approved · not exported", + released: "Exported", + }[entry.status]; + + const tone = + entry.status === "blocked" || entry.status === "scan_failed" + ? "border-destructive/40 text-destructive" + : entry.status === "clean" || + entry.status === "approved" || + entry.status === "released" + ? "border-emerald-500/40 text-emerald-700 dark:text-emerald-300" + : "border-amber-500/40 text-amber-700 dark:text-amber-300"; + + return ( + + {label} + + ); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"] as const; + let value = bytes; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`; +} diff --git a/app/src/lib/computers/mutations.ts b/app/src/lib/computers/mutations.ts index d59b125f8..71d66da23 100644 --- a/app/src/lib/computers/mutations.ts +++ b/app/src/lib/computers/mutations.ts @@ -55,6 +55,77 @@ export function stopAllComputersMutationOptions(queryClient: QueryClient) { }); } +export function scanQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client( + `/api/computers/${encodeURIComponent(variables.botId)}/quarantine/scan`, + { + method: "POST", + body: { id: variables.id }, + fallback: "The quarantined file could not be scanned.", + }, + ); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + +export function approveQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client( + `/api/computers/${encodeURIComponent(variables.botId)}/quarantine/approve`, + { + method: "POST", + body: { + id: variables.id, + botId: variables.botId, + confirm: "APPROVE", + }, + fallback: "The quarantined file could not be approved.", + }, + ); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + +export function exportQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client("/api/host-access/quarantine/export", { + method: "POST", + body: { + botId: variables.botId, + id: variables.id, + confirm: "EXPORT_QUARANTINED_FILE", + }, + fallback: "The quarantined file could not be exported.", + }); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + /** * Replace the whole policy. * diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts index e6dd4f50b..a0127b886 100644 --- a/app/src/lib/computers/queries.ts +++ b/app/src/lib/computers/queries.ts @@ -13,6 +13,39 @@ export type ComputerResourceMetrics = { browserRunning?: boolean; }; +export type QuarantineStatus = + | "pending" + | "clean" + | "blocked" + | "scan_failed" + | "approved" + | "released"; + +export type QuarantineEntry = { + version: 2; + status: QuarantineStatus; + id: string; + botId: string; + originalName: string; + sourceUrl: string; + savedAt: string; + sizeBytes: number; + sha256: string; + scannedSha256?: string; + scan?: { + status: "clean" | "blocked" | "scan_failed"; + scanner: "clamav"; + detail: string; + scannedAt: string; + }; + approvedAt?: string; + releasedAt?: string; +}; + +export type QuarantineList = { + downloads: QuarantineEntry[]; +}; + export type ComputerProfile = { botId: string; running: boolean; @@ -51,6 +84,7 @@ export const computerKeys = { all: ["computers"] as const, fleet: () => ["computers", "fleet"] as const, policy: () => ["computers", "policy"] as const, + quarantine: (botId: string) => ["computers", "quarantine", botId] as const, }; /** @@ -78,6 +112,21 @@ export function computerFleetQueryOptions() { }); } +export function quarantineQueryOptions(botId: string) { + return queryOptions({ + queryKey: computerKeys.quarantine(botId), + queryFn: async (): Promise => { + const response = await client( + `/api/computers/${encodeURIComponent(botId)}/quarantine`, + { + fallback: "The quarantine could not be listed.", + }, + ); + return response.json(); + }, + }); +} + export function actionPolicyQueryOptions() { return queryOptions({ queryKey: computerKeys.policy(), diff --git a/app/src/routes/_authed/admin/computers.tsx b/app/src/routes/_authed/admin/computers.tsx index 378a703d7..884903eae 100644 --- a/app/src/routes/_authed/admin/computers.tsx +++ b/app/src/routes/_authed/admin/computers.tsx @@ -9,6 +9,7 @@ import { } from "@/components/layout/page-shell"; import { StaggerItem } from "@/components/layout/stagger"; import { ComputerFilesDialog } from "@/components/computers/computer-files-dialog"; +import { ComputerQuarantineDialog } from "@/components/computers/computer-quarantine-dialog"; import { ComputerScreenDialog } from "@/components/computers/computer-screen-dialog"; import { Button } from "@/components/ui/button"; import { @@ -56,6 +57,8 @@ function ComputersPage() { const [filesFor, setFilesFor] = useState(null); /** Computer whose browser is being watched or driven by the administrator. */ const [screenFor, setScreenFor] = useState(null); + /** Computer whose untrusted browser downloads are being reviewed. */ + const [quarantineFor, setQuarantineFor] = useState(null); const queryClient = useQueryClient(); const nameFor = useBotNames(); @@ -118,6 +121,18 @@ function ComputersPage() { } }; + const showQuarantine = async (botId: string, running: boolean) => { + setBusy(botId); + try { + if (!running) { + await setState.mutateAsync({ action: "start", botId }); + } + setQuarantineFor(botId); + } finally { + setBusy(null); + } + }; + return ( Files +