Skip to content

Commit eb12f0a

Browse files
CL-9384: migrate persisted approval stores to purge update_plan keys (#1186)
* test(permissions): pin update_plan purge migration for approval stores * feat(permissions): purge persisted update_plan keys with backup-then-rewrite migration * fix(permissions): harden approval migration call site and writes Guard the session-start migration call so a future throw degrades to log-and-continue, and route the migration rewrite through the shared chained tmp+rename writer so a concurrent grant mint serializes with the purge instead of losing an update. * fix(permissions): write approval backups atomically and exclusively A direct wx write of .bak can crash mid-write and leave a torn rollback copy. The next start treats that EEXIST as success and purges live anyway. Write the full backup to a sibling tmp, then link it onto .bak so the name appears complete or not at all, and a pre-existing original still wins.
1 parent 187b155 commit eb12f0a

5 files changed

Lines changed: 605 additions & 4 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import {
3+
chmod,
4+
mkdir,
5+
mkdtemp,
6+
readFile,
7+
rm,
8+
writeFile,
9+
} from "node:fs/promises";
10+
import { tmpdir } from "node:os";
11+
import { dirname, join } from "node:path";
12+
import { generateSessionId, sessionDir } from "../session/index.js";
13+
import { loadSeededApprovals } from "../session/runtime-assembly.js";
14+
import { normalizeSeededApprovals } from "./authz-grants.js";
15+
import { migratePersistedApprovalStores } from "./approval-store-migration.js";
16+
import { saveGlobalApproval } from "./store.js";
17+
18+
let cwd = "";
19+
let home = "";
20+
let sessionId = "";
21+
22+
const sessionStorePath = (): string =>
23+
join(sessionDir(cwd, sessionId, home), "permissions.json");
24+
const projectStorePath = (): string =>
25+
join(cwd, ".corbits", "permissions.json");
26+
const globalStorePath = (): string =>
27+
join(home, ".corbits", "permissions.json");
28+
const backupPath = (path: string): string => `${path}.bak`;
29+
30+
async function readJson(path: string): Promise<unknown> {
31+
return JSON.parse(await readFile(path, "utf-8")) as unknown;
32+
}
33+
34+
beforeEach(async () => {
35+
cwd = await mkdtemp(join(tmpdir(), "approval-migration-"));
36+
home = await mkdtemp(join(tmpdir(), "approval-migration-home-"));
37+
sessionId = generateSessionId();
38+
await mkdir(sessionDir(cwd, sessionId, home), { recursive: true });
39+
});
40+
41+
afterEach(async () => {
42+
await rm(cwd, { recursive: true, force: true });
43+
await rm(home, { recursive: true, force: true });
44+
});
45+
46+
describe("migratePersistedApprovalStores", () => {
47+
test("purges update_plan keys from every store, backs up, and re-runs as a no-op", async () => {
48+
const sessionOriginal = {
49+
approvals: [
50+
{ tool: "update_plan", pattern: "plan *" },
51+
{ tool: "run_shell", pattern: "npm *" },
52+
{ tool: "bash", pattern: "git *" },
53+
],
54+
};
55+
const projectOriginal = {
56+
approvals: [
57+
{ tool: "update_plan", pattern: "plan *" },
58+
{ tool: "manage_tasks", pattern: "tasks *" },
59+
],
60+
};
61+
const globalOriginal = {
62+
approvals: [
63+
{ tool: "update_plan", pattern: "plan *" },
64+
{ tool: "run_shell", pattern: "git *" },
65+
],
66+
providerModels: {
67+
"openai:gpt-5": [
68+
{ tool: "update_plan", pattern: "plan *" },
69+
{ tool: "run_shell", pattern: "npm *" },
70+
],
71+
"anthropic:opus": [{ tool: "run_shell", pattern: "ls *" }],
72+
},
73+
};
74+
await writeFile(sessionStorePath(), JSON.stringify(sessionOriginal));
75+
await mkdir(join(cwd, ".corbits"), { recursive: true });
76+
await writeFile(projectStorePath(), JSON.stringify(projectOriginal));
77+
await mkdir(join(home, ".corbits"), { recursive: true });
78+
await writeFile(globalStorePath(), JSON.stringify(globalOriginal));
79+
80+
const first = await migratePersistedApprovalStores(cwd, sessionId, home);
81+
82+
expect(first.purged).toBe(4);
83+
expect(first.backups).toHaveLength(3);
84+
85+
expect(await readJson(sessionStorePath())).toEqual({
86+
approvals: [
87+
{ tool: "run_shell", pattern: "npm *" },
88+
{ tool: "bash", pattern: "git *" },
89+
],
90+
});
91+
expect(await readJson(projectStorePath())).toEqual({
92+
approvals: [{ tool: "manage_tasks", pattern: "tasks *" }],
93+
});
94+
expect(await readJson(globalStorePath())).toEqual({
95+
approvals: [{ tool: "run_shell", pattern: "git *" }],
96+
providerModels: {
97+
"openai:gpt-5": [{ tool: "run_shell", pattern: "npm *" }],
98+
"anthropic:opus": [{ tool: "run_shell", pattern: "ls *" }],
99+
},
100+
});
101+
102+
for (const [path, original] of [
103+
[sessionStorePath(), sessionOriginal],
104+
[projectStorePath(), projectOriginal],
105+
[globalStorePath(), globalOriginal],
106+
] as const) {
107+
expect(await readJson(backupPath(path))).toEqual(original);
108+
}
109+
110+
const sessionAfterFirst = await readFile(sessionStorePath(), "utf-8");
111+
const second = await migratePersistedApprovalStores(cwd, sessionId, home);
112+
expect(second.purged).toBe(0);
113+
expect(second.backups).toEqual([]);
114+
expect(await readFile(sessionStorePath(), "utf-8")).toBe(sessionAfterFirst);
115+
});
116+
117+
test("leaves clean stores untouched with no backup written", async () => {
118+
const sessionOriginal = {
119+
approvals: [{ tool: "run_shell", pattern: "npm *" }],
120+
};
121+
await writeFile(sessionStorePath(), JSON.stringify(sessionOriginal));
122+
123+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
124+
125+
expect(result.purged).toBe(0);
126+
expect(result.backups).toEqual([]);
127+
expect(await readJson(sessionStorePath())).toEqual(sessionOriginal);
128+
await expect(
129+
readFile(backupPath(sessionStorePath()), "utf-8"),
130+
).rejects.toThrow();
131+
});
132+
133+
test("treats missing and corrupt stores as no-ops", async () => {
134+
await mkdir(join(cwd, ".corbits"), { recursive: true });
135+
await writeFile(projectStorePath(), "not json{{{");
136+
137+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
138+
139+
expect(result.purged).toBe(0);
140+
expect(result.backups).toEqual([]);
141+
expect(await readFile(projectStorePath(), "utf-8")).toBe("not json{{{");
142+
});
143+
144+
test("purges exactly the keys the load-time normalizer drops", async () => {
145+
const tools = [
146+
"update_plan",
147+
"Update_Plan",
148+
"default.update_plan",
149+
"manage_tasks",
150+
"run_shell",
151+
];
152+
await writeFile(
153+
sessionStorePath(),
154+
JSON.stringify({
155+
approvals: tools.map((tool) => ({ tool, pattern: "x *" })),
156+
}),
157+
);
158+
159+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
160+
161+
const seeded = tools.map((tool) => ({ tool, pattern: "x *" }));
162+
const droppedByNormalizer = seeded.filter(
163+
(approval) =>
164+
!normalizeSeededApprovals([approval]).some(
165+
(kept: { tool: string }) => kept.tool === approval.tool,
166+
),
167+
);
168+
expect(result.purged).toBe(droppedByNormalizer.length);
169+
expect(result.purged).toBe(3);
170+
const remaining = (
171+
(await readJson(sessionStorePath())) as {
172+
approvals: { tool: string }[];
173+
}
174+
).approvals.map((approval) => approval.tool);
175+
expect(remaining).toEqual(["manage_tasks", "run_shell"]);
176+
});
177+
178+
test("a grant minted while the migration runs is not lost and the file stays valid", async () => {
179+
await mkdir(join(home, ".corbits"), { recursive: true });
180+
await writeFile(
181+
globalStorePath(),
182+
JSON.stringify({
183+
approvals: [
184+
{ tool: "update_plan", pattern: "plan *" },
185+
{ tool: "run_shell", pattern: "git *" },
186+
],
187+
}),
188+
);
189+
190+
const minted = { tool: "run_shell", pattern: "npm *" };
191+
const [result] = await Promise.all([
192+
migratePersistedApprovalStores(cwd, sessionId, home),
193+
saveGlobalApproval(minted, home),
194+
]);
195+
196+
expect(result.purged).toBe(1);
197+
const final = (await readJson(globalStorePath())) as {
198+
approvals: { tool: string; pattern: string }[];
199+
};
200+
expect(
201+
final.approvals.some((approval) => approval.tool === "update_plan"),
202+
).toBe(false);
203+
expect(final.approvals).toContainEqual({
204+
tool: "run_shell",
205+
pattern: "git *",
206+
});
207+
expect(final.approvals).toContainEqual(minted);
208+
const backup = (await readJson(backupPath(globalStorePath()))) as {
209+
approvals: { tool: string; pattern: string }[];
210+
};
211+
expect(backup.approvals).toContainEqual({
212+
tool: "update_plan",
213+
pattern: "plan *",
214+
});
215+
});
216+
217+
test("a storm of concurrent grants around the migration loses nothing", async () => {
218+
await mkdir(join(home, ".corbits"), { recursive: true });
219+
await writeFile(
220+
globalStorePath(),
221+
JSON.stringify({
222+
approvals: [{ tool: "update_plan", pattern: "plan *" }],
223+
}),
224+
);
225+
226+
const minted = Array.from({ length: 10 }, (_, i) => ({
227+
tool: "run_shell",
228+
pattern: `storm-${i} *`,
229+
}));
230+
await Promise.all([
231+
migratePersistedApprovalStores(cwd, sessionId, home),
232+
...minted.map((approval) => saveGlobalApproval(approval, home)),
233+
]);
234+
235+
const final = (await readJson(globalStorePath())) as {
236+
approvals: { tool: string; pattern: string }[];
237+
};
238+
expect(
239+
final.approvals.some((approval) => approval.tool === "update_plan"),
240+
).toBe(false);
241+
for (const approval of minted) {
242+
expect(final.approvals).toContainEqual(approval);
243+
}
244+
});
245+
246+
test("seed loading purges on-disk update_plan keys while the normalizer still drops them in memory", async () => {
247+
await writeFile(
248+
sessionStorePath(),
249+
JSON.stringify({
250+
approvals: [
251+
{ tool: "update_plan", pattern: "plan *" },
252+
{ tool: "run_shell", pattern: "npm *" },
253+
],
254+
}),
255+
);
256+
await mkdir(join(cwd, ".corbits"), { recursive: true });
257+
await writeFile(
258+
projectStorePath(),
259+
JSON.stringify({
260+
approvals: [{ tool: "update_plan", pattern: "plan *" }],
261+
}),
262+
);
263+
264+
const seeded = await loadSeededApprovals(cwd, sessionId, home);
265+
266+
expect(seeded).toEqual(
267+
expect.arrayContaining([{ tool: "run_shell", pattern: "npm *" }]),
268+
);
269+
expect(seeded.some((approval) => approval.tool === "update_plan")).toBe(
270+
false,
271+
);
272+
expect(await readJson(sessionStorePath())).toEqual({
273+
approvals: [{ tool: "run_shell", pattern: "npm *" }],
274+
});
275+
expect(await readJson(projectStorePath())).toEqual({ approvals: [] });
276+
});
277+
278+
test("EACCES writing the backup leaves live bytes unchanged", async () => {
279+
const path = sessionStorePath();
280+
const original = {
281+
approvals: [
282+
{ tool: "update_plan", pattern: "plan *" },
283+
{ tool: "run_shell", pattern: "npm *" },
284+
],
285+
};
286+
await writeFile(path, JSON.stringify(original));
287+
const liveBytes = await readFile(path, "utf-8");
288+
const dir = dirname(path);
289+
await chmod(dir, 0o555);
290+
try {
291+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
292+
expect(result.purged).toBe(0);
293+
expect(result.backups).toEqual([]);
294+
expect(await readFile(path, "utf-8")).toBe(liveBytes);
295+
} finally {
296+
await chmod(dir, 0o755);
297+
}
298+
});
299+
300+
test("existing torn .bak plus live update_plan still purges live", async () => {
301+
const path = sessionStorePath();
302+
const original = {
303+
approvals: [
304+
{ tool: "update_plan", pattern: "plan *" },
305+
{ tool: "run_shell", pattern: "npm *" },
306+
],
307+
};
308+
await writeFile(path, JSON.stringify(original));
309+
const torn = '{"approvals":[{"tool":"update_plan"';
310+
await writeFile(backupPath(path), torn);
311+
312+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
313+
314+
expect(result.purged).toBe(1);
315+
expect(result.backups).toEqual([backupPath(path)]);
316+
expect(await readFile(backupPath(path), "utf-8")).toBe(torn);
317+
expect(await readJson(path)).toEqual({
318+
approvals: [{ tool: "run_shell", pattern: "npm *" }],
319+
});
320+
});
321+
});

0 commit comments

Comments
 (0)