Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/delegation/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,13 @@ export async function queueAction(
export async function pendingActions(caller: VerifiedCaller) {
const rows = await listQueuedActions(caller, 50);
const config = getRuntimeConfig();
return rows.map((row) => {
const validActions = [];
for (const row of rows) {
if (row.crypto_version !== 1 || row.action_schema_version !== 1) {
throw new DelegationError("invalid_data", "Stored AI action version is unsupported.");
try {
await completeActionRow(caller, row.id, "rejected", "unsupported_version");
} catch {}
continue;
}
let value: unknown;
try {
Expand All @@ -284,18 +288,25 @@ export async function pendingActions(caller: VerifiedCaller) {
nonce: row.action_nonce,
});
} catch {
throw new DelegationError("invalid_data", "Stored AI action could not be authenticated.");
try {
await completeActionRow(caller, row.id, "rejected", "decryption_failed");
} catch {}
continue;
}
const parsed = AiActionSchema.safeParse(value);
if (!parsed.success || parsed.data.expectedRevision !== row.expected_revision) {
throw new DelegationError("invalid_data", "Stored AI action is inconsistent.");
try {
await completeActionRow(caller, row.id, "rejected", "malformed_payload");
} catch {}
continue;
}
return {
validActions.push({
id: row.id,
createdAt: row.created_at,
action: parsed.data,
};
});
});
}
return validActions;
}

export async function completeAction(caller: VerifiedCaller, actionId: string, value: unknown) {
Expand Down
68 changes: 68 additions & 0 deletions tests/delegation-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock("@/src/db/supabase-rest", () => ({
}));

import {
pendingActions,
publishSnapshot,
queueAction,
readSnapshot,
Expand Down Expand Up @@ -358,4 +359,71 @@ describe("delegation failure boundaries", () => {
expect.objectContaining({ user_id: caller.userId, enabled: true, revision: 1 }),
);
});

it("quarantines malformed queued actions per item without failing valid actions", async () => {
const validAction = {
schemaVersion: 1 as const,
kind: "update_gap_preferences" as const,
expectedRevision: 4,
patch: { riskTolerance: "low" as const },
};
const encryptedValid = encryptJson(TEST_AI_KEY, "action", caller.userId, 4, validAction);

const rows = [
{
id: "action-valid-1",
user_id: caller.userId,
crypto_version: 1,
action_schema_version: 1,
expected_revision: 4,
action_ciphertext: encryptedValid.ciphertext,
action_nonce: encryptedValid.nonce,
status: "queued",
created_at: "2026-08-30T00:00:00.000Z",
},
{
id: "action-corrupt-crypto",
user_id: caller.userId,
crypto_version: 2,
action_schema_version: 1,
expected_revision: 4,
action_ciphertext: "deadbeef",
action_nonce: "deadbeef",
status: "queued",
created_at: "2026-08-30T00:01:00.000Z",
},
{
id: "action-bad-ciphertext",
user_id: caller.userId,
crypto_version: 1,
action_schema_version: 1,
expected_revision: 4,
action_ciphertext: "badciphertext",
action_nonce: encryptedValid.nonce,
status: "queued",
created_at: "2026-08-30T00:02:00.000Z",
},
];

db.listQueuedActions.mockResolvedValue(rows);
db.completeActionRow.mockResolvedValue({ id: "mock" });

const result = await pendingActions(caller);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("action-valid-1");
expect(result[0]!.action).toEqual(validAction);

expect(db.completeActionRow).toHaveBeenCalledWith(
caller,
"action-corrupt-crypto",
"rejected",
"unsupported_version",
);
expect(db.completeActionRow).toHaveBeenCalledWith(
caller,
"action-bad-ciphertext",
"rejected",
"decryption_failed",
);
});
});
Loading