fix(#274): escape page_changed injection surface, drop dead content_hash (review F1-F5)

F1: escape the collaborative page title before interpolating into
    <page_changed page="..."> (and the pre-existing openedPage attr) — strip
    <>" and collapse whitespace, so a crafted title can't break out of the
    attribute into the system prompt (cross-user injection).
F2: neutralize <page_changed>/</page_changed> occurrences inside the diff body
    so a crafted line can't close the block early.
F3: remove the dead content_hash column (written every turn, never read) —
    migration, repo, service hashing + crypto import, db.d.ts, spec asserts.
F4: test the best-effort catch branches (detectPageChange / snapshotOpenPage
    swallow errors and don't break the turn).
F5: soften the overstated 'diff cannot smuggle instructions' comment to
    defense-in-depth framing referencing the F1/F2 mitigations + safety sandwich.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-02 05:43:46 +03:00
parent 8c5b57ebfa
commit 6e681a9c66
8 changed files with 158 additions and 37 deletions
@@ -34,9 +34,6 @@ export async function up(db: Kysely<any>): Promise<void> {
// against the live page.updated_at as a cheap fast path: equal => nothing
// changed, skip the render + diff entirely.
.addColumn('page_updated_at', 'timestamptz', (col) => col.notNull())
// Optional content fingerprint (informational; the updated_at fast path is the
// primary change signal). Nullable so a snapshot can be written without one.
.addColumn('content_hash', 'varchar', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
@@ -102,7 +102,6 @@ describe('AiChatPageSnapshotRepo', () => {
workspaceId: 'ws1',
contentMd: '# hello',
pageUpdatedAt,
contentHash: 'abc',
});
expect(rec.table).toBe('aiChatPageSnapshots');
@@ -112,31 +111,13 @@ describe('AiChatPageSnapshotRepo', () => {
workspaceId: 'ws1',
contentMd: '# hello',
pageUpdatedAt,
contentHash: 'abc',
});
expect(rec.conflictColumns).toEqual(['chatId', 'pageId']);
expect(rec.conflictUpdate).toMatchObject({
contentMd: '# hello',
pageUpdatedAt,
contentHash: 'abc',
});
expect(rec.conflictUpdate?.updatedAt).toBeInstanceOf(Date);
});
it('defaults a missing content hash to null (insert and conflict update)', async () => {
const { db, rec } = makeDb({ id: 's1' });
const repo = new AiChatPageSnapshotRepo(db);
await repo.upsert({
chatId: 'c1',
pageId: 'p1',
workspaceId: 'ws1',
contentMd: 'body',
pageUpdatedAt: new Date('2026-07-02T10:00:00Z'),
});
expect(rec.values?.contentHash).toBeNull();
expect(rec.conflictUpdate?.contentHash).toBeNull();
});
});
});
@@ -48,7 +48,6 @@ export class AiChatPageSnapshotRepo {
workspaceId: string;
contentMd: string;
pageUpdatedAt: Date;
contentHash?: string | null;
},
trx?: KyselyTransaction,
): Promise<AiChatPageSnapshot> {
@@ -61,13 +60,11 @@ export class AiChatPageSnapshotRepo {
workspaceId: values.workspaceId,
contentMd: values.contentMd,
pageUpdatedAt: values.pageUpdatedAt,
contentHash: values.contentHash ?? null,
})
.onConflict((oc) =>
oc.columns(['chatId', 'pageId']).doUpdateSet({
contentMd: values.contentMd,
pageUpdatedAt: values.pageUpdatedAt,
contentHash: values.contentHash ?? null,
updatedAt: new Date(),
}),
)
-1
View File
@@ -657,7 +657,6 @@ export interface AiChatPageSnapshots {
workspaceId: string;
contentMd: string;
pageUpdatedAt: Timestamp;
contentHash: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}