diff --git a/src/main/services/agent-attachment-tools.ts b/src/main/services/agent-attachment-tools.ts index 7c5ae8e8..f15a5731 100644 --- a/src/main/services/agent-attachment-tools.ts +++ b/src/main/services/agent-attachment-tools.ts @@ -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)); diff --git a/src/main/services/agent-task-handoff.ts b/src/main/services/agent-task-handoff.ts index e9760245..29565d61 100644 --- a/src/main/services/agent-task-handoff.ts +++ b/src/main/services/agent-task-handoff.ts @@ -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, diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index f31f34fc..a0229e53 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -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, diff --git a/src/main/services/file-service.ts b/src/main/services/file-service.ts index 533ba140..35a07263 100644 --- a/src/main/services/file-service.ts +++ b/src/main/services/file-service.ts @@ -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 { 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); } diff --git a/src/main/services/files/file-object-store.ts b/src/main/services/files/file-object-store.ts index a5860fa1..717c1984 100644 --- a/src/main/services/files/file-object-store.ts +++ b/src/main/services/files/file-object-store.ts @@ -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)}`); diff --git a/tests/integration/chat-context.test.ts b/tests/integration/chat-context.test.ts index 0c60849d..63fe89c3 100644 --- a/tests/integration/chat-context.test.ts +++ b/tests/integration/chat-context.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -87,8 +87,8 @@ async function founderLine() { const fileId = `fil_${randomUUID()}`; connection.db.prepare(` insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) - values (?, ?, ?, 'md', ?, 'x', ?, 'employee', ?, ?) - `).run(fileId, orgId, name, content.length, path, founderId, new Date().toISOString()); + values (?, ?, ?, 'md', ?, ?, ?, 'employee', ?, ?) + `).run(fileId, orgId, name, content.length, createHash("sha256").update(content).digest("hex"), path, founderId, new Date().toISOString()); return fileId; }; const sayAs = (senderType: "employee" | "board", senderId: string, bodyMd: string, threadId: string) => diff --git a/tests/unit/agent-attachment-tools.test.ts b/tests/unit/agent-attachment-tools.test.ts index 52b4932e..59fe0442 100644 --- a/tests/unit/agent-attachment-tools.test.ts +++ b/tests/unit/agent-attachment-tools.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,8 +23,8 @@ function harness() { const fileId = `fil_${randomUUID()}`; 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) - values (?, ?, ?, 'md', 1, 'x', ?, 'employee', null, ?, ?) - `).run(fileId, owner, name, path, new Date().toISOString(), scanState); + values (?, ?, ?, 'md', 1, ?, ?, 'employee', null, ?, ?) + `).run(fileId, owner, name, createHash("sha256").update(content).digest("hex"), path, new Date().toISOString(), scanState); return fileId; }; const ctx: AgentToolContext = { diff --git a/tests/unit/agent-task-handoff.test.ts b/tests/unit/agent-task-handoff.test.ts index 11e6b31b..7e4803de 100644 --- a/tests/unit/agent-task-handoff.test.ts +++ b/tests/unit/agent-task-handoff.test.ts @@ -86,6 +86,15 @@ describe("structured task handoff", () => { 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", () => { const h = harness(); expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "X", description: "d", input_file_ids: ["fil_missing"] }))) diff --git a/tests/unit/chat-governance.test.ts b/tests/unit/chat-governance.test.ts index c64f9114..e1240c8b 100644 --- a/tests/unit/chat-governance.test.ts +++ b/tests/unit/chat-governance.test.ts @@ -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 { join } from "node:path"; 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 { 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 { return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`)); } @@ -273,7 +282,7 @@ describe("chat governance", () => { connection.db.prepare(` insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) 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({ orgId, spaceId, diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index d10c9688..75295f31 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -15,6 +15,17 @@ import { OnboardingService } from "../../src/main/services/onboarding-service"; import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain"; 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 { return new DatabaseConnection(join(tmpdir(), `agentcompany-new-${randomUUID()}.db`)); } @@ -287,7 +298,7 @@ describe("domain services", () => { connection.db.prepare(` insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) 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({ orgId, @@ -311,10 +322,10 @@ describe("domain services", () => { fileId, targetPrincipalType: "board", targetPrincipalId: orgId, - checksum: "8f3a", + checksum: runbook[0], scanState: "approved", policyDecision: "approved_for_handoff", - objectRef: "artifacts/runbook.md", + objectRef: runbook[1], }); expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId }); 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 ) 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(` insert into file_objects( id, org_id, name, kind, size_bytes, sha256, storage_path, @@ -438,8 +449,8 @@ describe("domain services", () => { sourcePrincipalType: "agent", sourcePrincipalId: adaId, targetPrincipalType: "agent", - checksum: "sha-approved", - objectRef: "artifacts/release-notes.md", + checksum: releaseNotes[0], + objectRef: releaseNotes[1], scanState: "approved", policyDecision: "approved_for_handoff", }); diff --git a/tests/unit/workspace-file-versions.test.ts b/tests/unit/workspace-file-versions.test.ts index bec13905..122620b2 100644 --- a/tests/unit/workspace-file-versions.test.ts +++ b/tests/unit/workspace-file-versions.test.ts @@ -6,6 +6,8 @@ import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; 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", () => { 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(); }); }); + +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(); + }); +});