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 type { DatabaseSync } from "node:sqlite";
import { readFileSync } from "node:fs";
import { FILE_SCAN_STATE } from "../../shared/domain"; import { FILE_SCAN_STATE } from "../../shared/domain";
import { requireString } from "./agent-tool-args"; import { requireString } from "./agent-tool-args";
import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; 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 { export function readAttachmentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const fileId = requireString(args.file_id, "file_id"); 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 = ?") 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; scan_state: string } | undefined; .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) 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.`); 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); const content = utf8Text(bytes);
if (content === null) return `${file.name} is a binary file (${bytes.length} bytes); it has no text to read.`; 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)); 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 { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
@@ -27,12 +28,12 @@ export function buildTaskHandoff(
args: JsonRecord, args: JsonRecord,
): TaskHandoff { ): TaskHandoff {
const inputs = optionalStringArray(args.input_file_ids, "input_file_ids").map((fileId) => { 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 = ?") 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 } | undefined; .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.`); if (!file) throw new Error(`No file with file_id "${fileId}" exists in this company.`);
const sha256 = currentSha256(db, orgId, file.id); // The handoff pins the recorded content; bytes that no longer match it are refused, not pinned as if they were.
if (!sha256) throw new Error(`File ${file.name} (file_id "${fileId}") is missing on disk.`); readVerifiedFile({ name: file.name, storagePath: file.storage_path, sha256: file.sha256 });
return { fileId: file.id, name: file.name, sha256 }; return { fileId: file.id, name: file.name, sha256: file.sha256 };
}); });
return { return {
sourceTaskId: source.taskId, 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 { DatabaseSync } from "node:sqlite";
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import { import {
@@ -436,6 +437,8 @@ export class ChatService {
if (!file) throw new Error("Attachment file was not found."); 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 (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."); 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 { return {
fileId, fileId,
checksum: file.sha256, 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 { FILE_SCAN_STATE } from "../../shared/domain";
import { createId } from "../../shared/ids"; import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service"; 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 { export class FileService {
private readonly audit: AuditService; private readonly audit: AuditService;
@@ -77,10 +77,12 @@ export class FileService {
async openFile(input: OpenFileInput): Promise<void> { async openFile(input: OpenFileInput): Promise<void> {
const row = this.db.prepare( const row = this.db.prepare(
"select storage_path as storagePath, scan_state as scanState from file_objects where id = ? and org_id = ?", "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 { storagePath: string; scanState: string } | undefined; ).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) throw new Error("File was not found.");
if (row.scanState !== FILE_SCAN_STATE.APPROVED) throw new Error("Blocked files cannot be opened."); 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); const failure = await shell.openPath(row.storagePath);
if (failure) throw new Error(failure); 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. */ /** 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 { function keepCopy(workspaceRoot: string, source: string, sha256: string, bytes: Buffer): string {
const copy = join(workspaceRoot, ".agentcompany", "files", `${sha256}${extname(source)}`); const copy = join(workspaceRoot, ".agentcompany", "files", `${sha256}${extname(source)}`);
+3 -3
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -87,8 +87,8 @@ async function founderLine() {
const fileId = `fil_${randomUUID()}`; const fileId = `fil_${randomUUID()}`;
connection.db.prepare(` connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, 'md', ?, 'x', ?, 'employee', ?, ?) values (?, ?, ?, 'md', ?, ?, ?, 'employee', ?, ?)
`).run(fileId, orgId, name, content.length, path, founderId, new Date().toISOString()); `).run(fileId, orgId, name, content.length, createHash("sha256").update(content).digest("hex"), path, founderId, new Date().toISOString());
return fileId; return fileId;
}; };
const sayAs = (senderType: "employee" | "board", senderId: string, bodyMd: string, threadId: string) => const sayAs = (senderType: "employee" | "board", senderId: string, bodyMd: string, threadId: string) =>
+3 -3
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -23,8 +23,8 @@ function harness() {
const fileId = `fil_${randomUUID()}`; const fileId = `fil_${randomUUID()}`;
connection.db.prepare(` connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at, scan_state) insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at, scan_state)
values (?, ?, ?, 'md', 1, 'x', ?, 'employee', null, ?, ?) values (?, ?, ?, 'md', 1, ?, ?, 'employee', null, ?, ?)
`).run(fileId, owner, name, path, new Date().toISOString(), scanState); `).run(fileId, owner, name, createHash("sha256").update(content).digest("hex"), path, new Date().toISOString(), scanState);
return fileId; return fileId;
}; };
const ctx: AgentToolContext = { const ctx: AgentToolContext = {
+9
View File
@@ -86,6 +86,15 @@ describe("structured task handoff", () => {
h.connection.close(); h.connection.close();
}); });
test("an input whose bytes no longer match its record is refused, not pinned", () => {
const h = harness();
writeFileSync(h.specPath, "tampered");
expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "Y", description: "d", input_file_ids: ["fil_spec"] })))
.toBe("ERROR: spec.md changed on disk since it was recorded; it is not the content that was handed over.");
expect(h.connection.db.prepare("select count(*) as n from tasks where title = 'Y'").get()).toEqual({ n: 0 });
h.connection.close();
});
test("an unknown input file id fails the call and creates nothing", () => { test("an unknown input file id fails the call and creates nothing", () => {
const h = harness(); const h = harness();
expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "X", description: "d", input_file_ids: ["fil_missing"] }))) expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "X", description: "d", input_file_ids: ["fil_missing"] })))
+11 -2
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { writeFileSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
@@ -11,6 +12,14 @@ import { providerAdapters } from "../../src/main/services/provider-adapters";
import { SnapshotService } from "../../src/main/services/snapshot-service"; import { SnapshotService } from "../../src/main/services/snapshot-service";
import { RISK_LEVEL } from "../../src/shared/domain"; import { RISK_LEVEL } from "../../src/shared/domain";
/** A real file and its hash, as a recorded file needs: bytes are checked against the hash when read. */
function realFile(content: string): [string, string] {
const path = join(tmpdir(), `ac-file-${randomUUID()}.md`);
writeFileSync(path, content);
return [createHash("sha256").update(content).digest("hex"), path];
}
function createConnection(): DatabaseConnection { function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`)); return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`));
} }
@@ -273,7 +282,7 @@ describe("chat governance", () => {
connection.db.prepare(` connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "release-notes.md", "md", 24, "8f3a", "artifacts/release-notes.md", "employee", employeeId, now); `).run(fileId, orgId, "release-notes.md", "md", 24, ...realFile("Release notes."), "employee", employeeId, now);
agentMessageId = chat.sendMessage({ agentMessageId = chat.sendMessage({
orgId, orgId,
spaceId, spaceId,
+17 -6
View File
@@ -15,6 +15,17 @@ import { OnboardingService } from "../../src/main/services/onboarding-service";
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain"; import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids"; import { createId } from "../../src/shared/ids";
/** A real file and its hash, as a recorded file needs: bytes are checked against the hash when read. */
const runbook = realFile("Runbook steps.");
const releaseNotes = realFile("Release notes.");
function realFile(content: string): [string, string] {
const path = join(tmpdir(), `ac-file-${randomUUID()}.md`);
writeFileSync(path, content);
return [createHash("sha256").update(content).digest("hex"), path];
}
function createConnection(): DatabaseConnection { function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-${randomUUID()}.db`)); return new DatabaseConnection(join(tmpdir(), `agentcompany-new-${randomUUID()}.db`));
} }
@@ -287,7 +298,7 @@ describe("domain services", () => {
connection.db.prepare(` connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "runbook.md", "text/markdown", 16, "8f3a", "artifacts/runbook.md", "employee", employee.id, new Date().toISOString()); `).run(fileId, orgId, "runbook.md", "text/markdown", 16, ...runbook, "employee", employee.id, new Date().toISOString());
chat.sendMessage({ chat.sendMessage({
orgId, orgId,
@@ -311,10 +322,10 @@ describe("domain services", () => {
fileId, fileId,
targetPrincipalType: "board", targetPrincipalType: "board",
targetPrincipalId: orgId, targetPrincipalId: orgId,
checksum: "8f3a", checksum: runbook[0],
scanState: "approved", scanState: "approved",
policyDecision: "approved_for_handoff", policyDecision: "approved_for_handoff",
objectRef: "artifacts/runbook.md", objectRef: runbook[1],
}); });
expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId }); expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId });
expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" }); expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
@@ -376,7 +387,7 @@ describe("domain services", () => {
owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at
) )
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(approvedFileId, orgId, "release-notes.md", "md", 24, "sha-approved", "artifacts/release-notes.md", "agent", adaId, null, 0, "approved", "[]", now, now, now); `).run(approvedFileId, orgId, "release-notes.md", "md", 24, ...releaseNotes, "agent", adaId, null, 0, "approved", "[]", now, now, now);
connection.db.prepare(` connection.db.prepare(`
insert into file_objects( insert into file_objects(
id, org_id, name, kind, size_bytes, sha256, storage_path, id, org_id, name, kind, size_bytes, sha256, storage_path,
@@ -438,8 +449,8 @@ describe("domain services", () => {
sourcePrincipalType: "agent", sourcePrincipalType: "agent",
sourcePrincipalId: adaId, sourcePrincipalId: adaId,
targetPrincipalType: "agent", targetPrincipalType: "agent",
checksum: "sha-approved", checksum: releaseNotes[0],
objectRef: "artifacts/release-notes.md", objectRef: releaseNotes[1],
scanState: "approved", scanState: "approved",
policyDecision: "approved_for_handoff", policyDecision: "approved_for_handoff",
}); });
@@ -6,6 +6,8 @@ import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection"; import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service"; import { OrganizationService } from "../../src/main/services/organization-service";
import { preserveRecordedFiles, recordWorkspaceFile } from "../../src/main/services/files/file-object-store"; import { preserveRecordedFiles, recordWorkspaceFile } from "../../src/main/services/files/file-object-store";
import { ChatService } from "../../src/main/services/chat-service";
import { readAttachmentTool } from "../../src/main/services/agent-attachment-tools";
describe("recorded workspace files", () => { describe("recorded workspace files", () => {
test("a file id keeps the bytes it was recorded with after the workspace file is rewritten", () => { test("a file id keeps the bytes it was recorded with after the workspace file is rewritten", () => {
@@ -58,3 +60,24 @@ describe("recorded workspace files", () => {
connection.close(); connection.close();
}); });
}); });
describe("opening a recorded file", () => {
test("bytes that no longer match the recorded hash are refused, not shown as the recorded file", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-verify-${randomUUID()}.db`));
const root = join(tmpdir(), `ac-verify-ws-${randomUUID()}`);
mkdirSync(root, { recursive: true });
let orgId = "";
connection.transaction(() => {
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
});
writeFileSync(join(root, "plan.md"), "recorded");
const fileId = recordWorkspaceFile(connection.db, { orgId, ownerType: "agent", ownerId: null, workspaceRoot: root, relativePath: "plan.md" });
const deps = { db: connection.db, organization: new OrganizationService(connection.db), chat: new ChatService(connection.db) };
const ctx = { orgId, agent: { id: "agt_x", modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null };
expect(readAttachmentTool(deps, ctx, { file_id: fileId })).toContain("recorded");
const stored = (connection.db.prepare("select storage_path from file_objects where id = ?").get(fileId) as { storage_path: string }).storage_path;
writeFileSync(stored, "tampered");
expect(() => readAttachmentTool(deps, ctx, { file_id: fileId })).toThrow("plan.md changed on disk since it was recorded; it is not the content that was handed over.");
connection.close();
});
});