Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cba81b6
feat(computer): add fail-closed ClamAV scanner
duc15052006-dotcom Sep 18, 2026
339bd13
test(computer): pin malware scanner verdicts
duc15052006-dotcom Sep 18, 2026
77c6be1
feat(computer): track quarantine scan lifecycle
duc15052006-dotcom Sep 18, 2026
d2fd962
build(computer): install ClamAV scanner
duc15052006-dotcom Sep 18, 2026
865eab2
fix(computer): harden quarantine metadata transitions
duc15052006-dotcom Sep 18, 2026
9d73579
feat(computer): expose quarantine scan and approval API
duc15052006-dotcom Sep 18, 2026
bba309d
feat(computer): define quarantine API contract
duc15052006-dotcom Sep 18, 2026
32eb9e6
feat(computer): proxy and audit quarantine scanning
duc15052006-dotcom Sep 18, 2026
7ed0c6f
feat(computer): require explicit quarantine approval
duc15052006-dotcom Sep 18, 2026
00e5ab5
test(computer): enforce quarantine scan and Agent isolation
duc15052006-dotcom Sep 18, 2026
917ed0d
fix(audit): register quarantine security events
duc15052006-dotcom Sep 18, 2026
9d5e097
style(computer): format quarantine lifecycle
duc15052006-dotcom Sep 18, 2026
8e7b74b
style(computer): format quarantine endpoints
duc15052006-dotcom Sep 18, 2026
67c52c9
style(computer): format ClamAV runner
duc15052006-dotcom Sep 18, 2026
8c2f208
style(computer): format quarantine tests
duc15052006-dotcom Sep 18, 2026
984d0a2
style(computer): format quarantine gateway
duc15052006-dotcom Sep 18, 2026
7c2861b
style(computer): format quarantine routes
duc15052006-dotcom Sep 18, 2026
d4a8874
style(computer): format quarantine schema
duc15052006-dotcom Sep 18, 2026
4783c2d
fix(computer): avoid unchecked quarantine metadata access
duc15052006-dotcom Sep 18, 2026
a37b81a
test(release): pin fail-closed quarantine scanning
duc15052006-dotcom Sep 18, 2026
017ea26
fix(release): validate quarantine state union accurately
duc15052006-dotcom Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent-computer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
270 changes: 251 additions & 19 deletions agent-computer/src/download-quarantine.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<void> {
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<StoredQuarantineRecord> {
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<StoredQuarantineRecord> {
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<QuarantineRecord[]> {
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<MalwareScanResult> = scanWithClamAv,
): Promise<QuarantineRecord> {
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<QuarantineRecord> {
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,
Expand All @@ -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 };
Expand Down
Loading
Loading