Skip to content

Commit ae05058

Browse files
amal66claude
andcommitted
fix(mcp): sweep terminal approval rows past a retention window
Review finding on PR open-legal-products#247: terminal ledger rows (executed / failed / denied / expired) were retained forever, and each one carries the full tool-argument jsonb the model proposed — in a legal product that can be privileged matter data (case ids, document excerpts, party names) sitting in a table with no cleanup path. WHY THIS MATTERS Data-minimization is part of the security posture: every copy of sensitive data is another thing a breach, a misconfigured export, or an over-broad query can leak. The ledger's arguments payload exists for exactly one purpose — showing the user what they are approving and executing precisely that — and that purpose is over within minutes. Terminal rows keep only short-term forensic value ("what ran today?"), so they get a bounded lifetime (24h) instead of an unbounded one. WHAT IS AN OPPORTUNISTIC SWEEP Instead of standing up cron/job infrastructure for a low-traffic table, cleanup piggybacks on an operation that already touches the table: every new pending-call INSERT first deletes terminal rows older than the retention window. The repo already uses insert-time cleanup for the OAuth state store (saveCodeVerifier deletes the stale state row before inserting the new one); this follows the same shape. The table only grows while the approval flow is being used — which is precisely when the sweep runs. HOW THE FIX WORKS await db.from("user_mcp_pending_tool_calls") .delete() .in("status", ["executed", "failed", "denied", "expired"]) .lt("created_at", cutoff); // now - 24h - Only TERMINAL statuses are eligible: pending/approved/executing rows are the approval flow's live state and are never deleted, whatever their age (they are bounded anyway — expires_at retires them to a terminal state within minutes). - Best-effort: a sweep failure is logged and swallowed, because the user is waiting on the approval prompt the insert serves; the next insert retries the sweep. - The sweep is deliberately global (not per-user): the service-role backend is the only writer, and an active user's insert also clears other users' aged-out rows, so retention holds even for users who never return. Test: an insert with aged executed/expired rows, a recent denied row, and an old-but-live pending row on the table deletes exactly the aged terminal rows and nothing else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 52f3f2f commit ae05058

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

backend/migrations/20260802_01_mcp_pending_tool_calls.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
-- states are written only after the MCP call actually finishes, so the
1616
-- ledger records what happened, not what was about to happen.
1717
--
18+
-- Retention: terminal rows (executed / failed / denied / expired) keep the
19+
-- full tool-argument payload, which can contain sensitive matter data, so
20+
-- they are not kept forever. The backend opportunistically deletes terminal
21+
-- rows older than a retention window whenever a new pending call is
22+
-- inserted (see sweepExpiredTerminalMcpToolCalls in lib/mcp/approvals.ts).
23+
--
1824
-- RLS is enabled with no browser policies, matching the other MCP tables:
1925
-- only the service-role backend reads or writes rows, and it always scopes
2026
-- queries by user_id.

backend/src/lib/mcp/__tests__/approvals.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
decideMcpPendingToolCall,
66
markMcpToolCallExecuted,
77
markMcpToolCallFailed,
8+
MCP_PENDING_CALL_RETENTION_MS,
89
waitForMcpApprovalDecision,
910
} from "../approvals";
1011
import type { ConnectorRow, Db, ToolCacheRow } from "../types";
@@ -23,7 +24,7 @@ function createFakeDb(initial: Row[] = []) {
2324

2425
function from(_table: string) {
2526
const state = {
26-
op: "select" as "select" | "insert" | "update",
27+
op: "select" as "select" | "insert" | "update" | "delete",
2728
values: {} as Row,
2829
filters: [] as Array<(row: Row) => boolean>,
2930
single: false,
@@ -39,6 +40,10 @@ function createFakeDb(initial: Row[] = []) {
3940
state.values = values;
4041
return api;
4142
},
43+
delete() {
44+
state.op = "delete";
45+
return api;
46+
},
4247
select(_columns?: string) {
4348
return api;
4449
},
@@ -51,6 +56,14 @@ function createFakeDb(initial: Row[] = []) {
5156
state.filters.push((row) => String(row[key]) > String(value));
5257
return api;
5358
},
59+
lt(key: string, value: unknown) {
60+
state.filters.push((row) => String(row[key]) < String(value));
61+
return api;
62+
},
63+
in(key: string, values: unknown[]) {
64+
state.filters.push((row) => values.includes(row[key]));
65+
return api;
66+
},
5467
single() {
5568
state.single = true;
5669
return api;
@@ -77,6 +90,10 @@ function createFakeDb(initial: Row[] = []) {
7790
for (const row of matched)
7891
Object.assign(row, state.values);
7992
}
93+
if (state.op === "delete") {
94+
for (const row of matched)
95+
rows.splice(rows.indexOf(row), 1);
96+
}
8097
data = state.single
8198
? matched[0]
8299
? { ...matched[0] }
@@ -123,6 +140,50 @@ describe("pending MCP tool call lifecycle", () => {
123140
expect(rows).toHaveLength(1);
124141
});
125142

143+
it("inserting a new pending call sweeps terminal rows past the retention window", async () => {
144+
// Terminal rows carry the full tool-argument payload (sensitive
145+
// matter data), so each new pending-call insert opportunistically
146+
// deletes terminal rows older than the retention window. Live rows
147+
// and recent terminal rows are untouched.
148+
const beyondRetention = new Date(
149+
Date.now() - MCP_PENDING_CALL_RETENTION_MS - 60_000,
150+
).toISOString();
151+
const { db, rows } = createFakeDb([
152+
{
153+
id: "old-executed",
154+
status: "executed",
155+
created_at: beyondRetention,
156+
},
157+
{
158+
id: "old-expired",
159+
status: "expired",
160+
created_at: beyondRetention,
161+
},
162+
{
163+
id: "recent-denied",
164+
status: "denied",
165+
created_at: new Date().toISOString(),
166+
},
167+
{
168+
// Old but non-terminal: the sweep must never delete the
169+
// approval flow's live state, whatever its age.
170+
id: "old-pending",
171+
status: "pending",
172+
created_at: beyondRetention,
173+
expires_at: new Date(Date.now() + 60_000).toISOString(),
174+
},
175+
]);
176+
177+
await seedPending(db);
178+
179+
const ids = rows.map((row) => row.id);
180+
expect(ids).not.toContain("old-executed");
181+
expect(ids).not.toContain("old-expired");
182+
expect(ids).toContain("recent-denied");
183+
expect(ids).toContain("old-pending");
184+
expect(rows).toHaveLength(3);
185+
});
186+
126187
it("approves only for the owning user; a stranger's decision changes nothing", async () => {
127188
const { db, rows } = createFakeDb();
128189
const pending = await seedPending(db);

backend/src/lib/mcp/approvals.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,41 @@ export const MCP_APPROVAL_TTL_MS = 2 * 60 * 1000;
1212
export const MCP_APPROVAL_WAIT_MS = 90 * 1000;
1313
const POLL_INTERVAL_MS = 1_500;
1414

15+
// Terminal ledger rows keep the full tool-argument payload — which in a
16+
// legal product can contain sensitive matter data — so they must not
17+
// accumulate forever. They stay long enough to debug a session and to
18+
// answer "what ran today?", then get swept.
19+
export const MCP_PENDING_CALL_RETENTION_MS = 24 * 60 * 60 * 1000;
20+
const TERMINAL_STATUSES = ["executed", "failed", "denied", "expired"];
21+
1522
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
1623

24+
/**
25+
* Opportunistic retention sweep, piggybacked on every new pending-call
26+
* insert (the same insert-time cleanup pattern the OAuth state store uses):
27+
* no cron/job infrastructure, and the table only sees traffic when the
28+
* approval flow is in use anyway. Only TERMINAL rows past the retention
29+
* window are deleted — live pending/approved/executing rows are the
30+
* approval flow's working state and are never touched. Best-effort by
31+
* design: a failed sweep must not block the approval the user is waiting
32+
* on; the next insert retries it.
33+
*/
34+
async function sweepExpiredTerminalMcpToolCalls(db: Db): Promise<void> {
35+
const cutoff = new Date(
36+
Date.now() - MCP_PENDING_CALL_RETENTION_MS,
37+
).toISOString();
38+
const { error } = await db
39+
.from("user_mcp_pending_tool_calls")
40+
.delete()
41+
.in("status", TERMINAL_STATUSES)
42+
.lt("created_at", cutoff);
43+
if (error) {
44+
console.error("[mcp-approvals] retention sweep failed", {
45+
error: error.message,
46+
});
47+
}
48+
}
49+
1750
/**
1851
* Record the EXACT call the model proposed. What the user later approves is
1952
* this row — execution reads the stored arguments back from it, never the
@@ -26,6 +59,7 @@ export async function createPendingMcpToolCall(
2659
args: Record<string, unknown>,
2760
db: Db = createServerSupabase(),
2861
): Promise<PendingToolCallRow> {
62+
await sweepExpiredTerminalMcpToolCalls(db);
2963
const { data, error } = await db
3064
.from("user_mcp_pending_tool_calls")
3165
.insert({

0 commit comments

Comments
 (0)