Q122: reading a recorded file checks its bytes against the recorded hash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 19:44:51 -04:00
1 parent 8f192eabe7
commit 69b83d6b77
11 files changed
+95 -26

No files matched your search

+4 -4
View File
@@ -1,5 +1,5 @@
import { readVerifiedFile } from "./files/file-object-store";
import type { DatabaseSync } from "node:sqlite";
import { readFileSync } from "node:fs";
import { FILE_SCAN_STATE } from "../../shared/domain";
import { requireString } from "./agent-tool-args";
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
@@ -32,11 +32,11 @@ export function attachmentNote(db: DatabaseSync, messageId: string): string {
export function readAttachmentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const fileId = requireString(args.file_id, "file_id");
const file = deps.db.prepare("select name, storage_path, scan_state from file_objects where id = ? and org_id = ?")
.get(fileId, ctx.orgId) as { name: string; storage_path: string; scan_state: string } | undefined;
const file = deps.db.prepare("select name, storage_path, sha256, scan_state from file_objects where id = ? and org_id = ?")
.get(fileId, ctx.orgId) as { name: string; storage_path: string; sha256: string; scan_state: string } | undefined;
if (!file) throw new Error(`No file with file_id "${fileId}".`);
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error(`${file.name} was blocked by the file scan and cannot be read.`);
const bytes = readFileSync(file.storage_path);
const bytes = readVerifiedFile({ name: file.name, storagePath: file.storage_path, sha256: file.sha256 });
const content = utf8Text(bytes);
if (content === null) return `${file.name} is a binary file (${bytes.length} bytes); it has no text to read.`;
return pageText(file.name, content, readPageArgs(args));
+6 -5
View File
@@ -1,3 +1,4 @@
import { readVerifiedFile } from "./files/file-object-store";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
@@ -27,12 +28,12 @@ export function buildTaskHandoff(
args: JsonRecord,
): TaskHandoff {
const inputs = optionalStringArray(args.input_file_ids, "input_file_ids").map((fileId) => {
const file = db.prepare("select id, name from file_objects where id = ? and org_id = ?")
.get(fileId, orgId) as { id: string; name: string } | undefined;
const file = db.prepare("select id, name, storage_path, sha256 from file_objects where id = ? and org_id = ?")
.get(fileId, orgId) as { id: string; name: string; storage_path: string; sha256: string } | undefined;
if (!file) throw new Error(`No file with file_id "${fileId}" exists in this company.`);
const sha256 = currentSha256(db, orgId, file.id);
if (!sha256) throw new Error(`File ${file.name} (file_id "${fileId}") is missing on disk.`);
return { fileId: file.id, name: file.name, sha256 };
// The handoff pins the recorded content; bytes that no longer match it are refused, not pinned as if they were.
readVerifiedFile({ name: file.name, storagePath: file.storage_path, sha256: file.sha256 });
return { fileId: file.id, name: file.name, sha256: file.sha256 };
});
return {
sourceTaskId: source.taskId,
+3
View File
@@ -1,3 +1,4 @@
import { readVerifiedFile } from "./files/file-object-store";
import type { DatabaseSync } from "node:sqlite";
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import {
@@ -436,6 +437,8 @@ export class ChatService {
if (!file) throw new Error("Attachment file was not found.");
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
// A handoff carries the recorded checksum, so the bytes must still be the recorded ones.
readVerifiedFile({ name: file.id, storagePath: file.storage_path, sha256: file.sha256 });
return {
fileId,
checksum: file.sha256,
+5 -3
View File
@@ -7,7 +7,7 @@ import type { ImportFileInput, OpenFileInput } from "../../shared/commands";
import { FILE_SCAN_STATE } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { fileKind, scanFileContent, upsertFileObject } from "./files/file-object-store";
import { fileKind, readVerifiedFile, scanFileContent, upsertFileObject } from "./files/file-object-store";
export class FileService {
private readonly audit: AuditService;
@@ -77,10 +77,12 @@ export class FileService {
async openFile(input: OpenFileInput): Promise<void> {
const row = this.db.prepare(
"select storage_path as storagePath, scan_state as scanState from file_objects where id = ? and org_id = ?",
).get(input.fileId, input.orgId) as { storagePath: string; scanState: string } | undefined;
"select name, storage_path as storagePath, sha256, scan_state as scanState from file_objects where id = ? and org_id = ?",
).get(input.fileId, input.orgId) as { name: string; storagePath: string; sha256: string; scanState: string } | undefined;
if (!row) throw new Error("File was not found.");
if (row.scanState !== FILE_SCAN_STATE.APPROVED) throw new Error("Blocked files cannot be opened.");
// What opens must still be what was recorded; a file changed by another program is refused, not shown under the old record.
readVerifiedFile(row);
const failure = await shell.openPath(row.storagePath);
if (failure) throw new Error(failure);
}
@@ -184,6 +184,17 @@ export function recordWorkspaceFile(db: DatabaseSync, input: {
}));
}
/** A recorded file's bytes, refused when they are gone or no longer match the hash it was recorded with. */
export function readVerifiedFile(file: { name: string; storagePath: string; sha256: string }): Buffer {
if (!existsSync(file.storagePath)) throw new Error(`${file.name} is no longer on disk.`);
const bytes = readFileSync(file.storagePath);
if (createHash("sha256").update(bytes).digest("hex") !== file.sha256) {
console.warn(`[files] ${file.storagePath} no longer matches its recorded hash ${file.sha256}`);
throw new Error(`${file.name} changed on disk since it was recorded; it is not the content that was handed over.`);
}
return bytes;
}
/** The content-addressed copy of a workspace file, written once; it keeps the extension so the file still opens with its app. */
function keepCopy(workspaceRoot: string, source: string, sha256: string, bytes: Buffer): string {
const copy = join(workspaceRoot, ".agentcompany", "files", `${sha256}${extname(source)}`);