Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a058ea0
feat(computer): bind quarantine scans to exact file bytes
duc15052006-dotcom Sep 18, 2026
aa99507
test(computer): pin quarantine byte identity and release transition
duc15052006-dotcom Sep 18, 2026
d0d01f2
feat(computer): expose quarantine byte identity
duc15052006-dotcom Sep 18, 2026
862ea99
feat(computer): add guarded quarantine export stream
duc15052006-dotcom Sep 18, 2026
9e70762
feat(computer): stream approved quarantine bytes internally
duc15052006-dotcom Sep 18, 2026
098ccbd
test(computer): keep quarantine export bytes out of JSON
duc15052006-dotcom Sep 18, 2026
1082ceb
feat(computer): define quarantine delete result
duc15052006-dotcom Sep 18, 2026
b96b504
feat(audit): record quarantine release and delete
duc15052006-dotcom Sep 18, 2026
80944af
feat(computer): stream approved quarantine through gateway
duc15052006-dotcom Sep 18, 2026
a1d6018
feat(host-access): define native quarantine export operation
duc15052006-dotcom Sep 18, 2026
a3ca481
feat(host-access): broker native quarantine exports
duc15052006-dotcom Sep 18, 2026
c0e7ea0
feat(host-access): stream quarantine only to active desktop export
duc15052006-dotcom Sep 18, 2026
3e522f9
feat(desktop): verify and save approved quarantine exports
duc15052006-dotcom Sep 18, 2026
b2d7ce9
feat(desktop): require native Save As approval for quarantine export
duc15052006-dotcom Sep 18, 2026
6549e5a
style: satisfy quarantine formatter checks
duc15052006-dotcom Sep 18, 2026
38abf21
style: satisfy quarantine formatter checks
duc15052006-dotcom Sep 18, 2026
2be0202
fix(desktop): update quarantine export test fixtures and formatting
duc15052006-dotcom Sep 18, 2026
13a1704
fix(desktop): return structured quarantine export success
duc15052006-dotcom Sep 18, 2026
1bab26d
feat(host-access): release quarantine only after native export success
duc15052006-dotcom Sep 18, 2026
cd3dd92
feat(app): query per-Agent quarantine lifecycle
duc15052006-dotcom Sep 18, 2026
ed3fc37
feat(app): add quarantine scan approve export actions
duc15052006-dotcom Sep 18, 2026
38bd191
feat(app): add quarantine scan approve export dialog
duc15052006-dotcom Sep 18, 2026
079bda4
feat(app): expose quarantine manager in Computer Manager
duc15052006-dotcom Sep 18, 2026
8caa490
test(host-access): pin quarantine export broker semantics
duc15052006-dotcom Sep 18, 2026
293b044
test(host-access): release only after native quarantine export
duc15052006-dotcom Sep 18, 2026
9c6136a
test(release): pin native quarantine export invariants
duc15052006-dotcom Sep 18, 2026
0038c74
style(app): format quarantine dialog
duc15052006-dotcom Sep 18, 2026
a3c725e
style(app): format Computer Manager quarantine copy
duc15052006-dotcom Sep 18, 2026
2e7f13f
style(server): format quarantine export route
duc15052006-dotcom Sep 18, 2026
b495f69
style(server): format quarantine broker tests
duc15052006-dotcom Sep 18, 2026
79a31ba
style(server): format quarantine export route tests
duc15052006-dotcom Sep 18, 2026
e0633b6
fix(server): satisfy quarantine export lint guards
duc15052006-dotcom Sep 18, 2026
969f791
fix(release): align quarantine export preflight with guarded code
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
242 changes: 211 additions & 31 deletions agent-computer/src/download-quarantine.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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;
}

Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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 }
: {}),
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -251,10 +344,20 @@ export async function approveQuarantinedDownload(
id: string,
): Promise<QuarantineRecord> {
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}).`,
);
}

Expand All @@ -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<QuarantineRecord> {
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<boolean> {
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,
Expand All @@ -289,22 +465,26 @@ 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,
botId,
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;
}
}
Loading
Loading