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:
1 parent
8f192eabe7
commit
69b83d6b77
11 files changed
+95
-26
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"] })))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user