Skip to content

Commit 28865fc

Browse files
committed
feat(permissions): purge persisted update_plan keys with backup-then-rewrite migration
1 parent b6045a8 commit 28865fc

3 files changed

Lines changed: 189 additions & 3 deletions

File tree

‎src/permission/approval-store-migration.test.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ let sessionId = "";
1313

1414
const sessionStorePath = (): string =>
1515
join(sessionDir(cwd, sessionId, home), "permissions.json");
16-
const projectStorePath = (): string => join(cwd, ".corbits", "permissions.json");
17-
const globalStorePath = (): string => join(home, ".corbits", "permissions.json");
16+
const projectStorePath = (): string =>
17+
join(cwd, ".corbits", "permissions.json");
18+
const globalStorePath = (): string =>
19+
join(home, ".corbits", "permissions.json");
1820
const backupPath = (path: string): string => `${path}.bak`;
1921

2022
async function readJson(path: string): Promise<unknown> {
@@ -185,7 +187,12 @@ describe("migratePersistedApprovalStores", () => {
185187

186188
const seeded = await loadSeededApprovals(cwd, sessionId, home);
187189

188-
expect(seeded).toEqual([{ tool: "run_shell", pattern: "npm *" }]);
190+
expect(seeded).toEqual(
191+
expect.arrayContaining([{ tool: "run_shell", pattern: "npm *" }]),
192+
);
193+
expect(seeded.some((approval) => approval.tool === "update_plan")).toBe(
194+
false,
195+
);
189196
expect(await readJson(sessionStorePath())).toEqual({
190197
approvals: [{ tool: "run_shell", pattern: "npm *" }],
191198
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import { homedir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
5+
import { getLogger } from "@intx/log";
6+
7+
import { canonicalGrantTool } from "../agent/canonical-tool-name.js";
8+
import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js";
9+
import { sessionDir } from "../session/index.js";
10+
11+
const log = getLogger([LOG_NAMESPACE_ROOT, "permission", "approval-migration"]);
12+
13+
export interface ApprovalStoreMigrationFileResult {
14+
path: string;
15+
purged: number;
16+
backupPath?: string | undefined;
17+
}
18+
19+
export interface ApprovalStoreMigrationResult {
20+
purged: number;
21+
backups: string[];
22+
files: ApprovalStoreMigrationFileResult[];
23+
}
24+
25+
// canonicalGrantTool is the single owner of "is an update_plan key": it maps
26+
// update_plan (any case, default.-prefixed, or doubled) to null so the
27+
// load-time normalizer drops it fail-closed. The migration delegates to it so
28+
// disk and memory can never disagree about which keys purge.
29+
function isUpdatePlanKey(tool: unknown): boolean {
30+
return typeof tool === "string" && canonicalGrantTool(tool) === null;
31+
}
32+
33+
function isUpdatePlanEntry(entry: unknown): boolean {
34+
return (
35+
typeof entry === "object" &&
36+
entry !== null &&
37+
!Array.isArray(entry) &&
38+
isUpdatePlanKey((entry as Record<string, unknown>).tool)
39+
);
40+
}
41+
42+
function purgeList(
43+
list: unknown,
44+
onPurge: () => void,
45+
): { kept: unknown[]; changed: boolean } {
46+
if (!Array.isArray(list)) return { kept: [], changed: false };
47+
const kept: unknown[] = [];
48+
for (const entry of list) {
49+
if (isUpdatePlanEntry(entry)) onPurge();
50+
else kept.push(entry);
51+
}
52+
return { kept, changed: kept.length !== list.length };
53+
}
54+
55+
function isFileExistsError(err: unknown): boolean {
56+
return (
57+
typeof err === "object" &&
58+
err !== null &&
59+
"code" in err &&
60+
(err as { code?: unknown }).code === "EEXIST"
61+
);
62+
}
63+
64+
// Purge update_plan keys from one approvals file (session, project, or
65+
// global store shape: an `approvals` array plus, for the global file, a
66+
// `providerModels` map of arrays). Backup-then-rewrite: the pre-migration
67+
// bytes are saved to `<path>.bak` first (an existing backup is kept, so the
68+
// first backup always holds the true original), and a file with nothing to
69+
// left untouched (no backup, byte-identical) so re-runs are no-ops. Entries
70+
// that are not positive update_plan matches are kept verbatim — pure renames
71+
// are never collapsed here; that stays the load-time normalizer's job.
72+
// Missing, unreadable, or corrupt files are no-ops; write failures propagate.
73+
export async function migrateApprovalStoreFile(
74+
path: string,
75+
): Promise<ApprovalStoreMigrationFileResult> {
76+
let raw: string;
77+
try {
78+
raw = await readFile(path, "utf-8");
79+
} catch {
80+
return { path, purged: 0 };
81+
}
82+
let parsed: unknown;
83+
try {
84+
parsed = JSON.parse(raw) as unknown;
85+
} catch {
86+
return { path, purged: 0 };
87+
}
88+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
89+
return { path, purged: 0 };
90+
}
91+
const record = parsed as Record<string, unknown>;
92+
const next: Record<string, unknown> = { ...record };
93+
let purged = 0;
94+
const onPurge = (): void => {
95+
purged += 1;
96+
};
97+
if (Array.isArray(record.approvals)) {
98+
const { kept, changed } = purgeList(record.approvals, onPurge);
99+
if (changed) next.approvals = kept;
100+
}
101+
const providerModels = record.providerModels;
102+
if (
103+
typeof providerModels === "object" &&
104+
providerModels !== null &&
105+
!Array.isArray(providerModels)
106+
) {
107+
const map = providerModels as Record<string, unknown>;
108+
const nextMap: Record<string, unknown> = {};
109+
let mapChanged = false;
110+
for (const [key, list] of Object.entries(map)) {
111+
const { kept, changed } = purgeList(list, onPurge);
112+
if (changed) {
113+
nextMap[key] = kept;
114+
mapChanged = true;
115+
} else {
116+
nextMap[key] = list;
117+
}
118+
}
119+
if (mapChanged) next.providerModels = nextMap;
120+
}
121+
if (purged === 0) return { path, purged: 0 };
122+
const backupPath = `${path}.bak`;
123+
try {
124+
await writeFile(backupPath, raw, { flag: "wx" });
125+
} catch (err) {
126+
if (!isFileExistsError(err)) {
127+
log.warn("Skipping approval-store migration for {path}: {error}", {
128+
path,
129+
error: err instanceof Error ? err.message : String(err),
130+
});
131+
return { path, purged: 0 };
132+
}
133+
}
134+
await mkdir(dirname(path), { recursive: true });
135+
await writeFile(path, JSON.stringify(next, null, 2));
136+
return { path, purged, backupPath };
137+
}
138+
139+
// One-time migration over the persisted approval stores (session, project,
140+
// global including provider-model grants): purge on-disk update_plan keys so
141+
// removing the load-time normalizer later cannot resurrect the hole. The
142+
// paths mirror store.ts; the files array in the result keeps them explicit.
143+
// Best-effort and idempotent — never throws, and a clean tree is a no-op.
144+
export async function migratePersistedApprovalStores(
145+
cwd: string,
146+
sessionId: string,
147+
home: string = homedir(),
148+
): Promise<ApprovalStoreMigrationResult> {
149+
const paths = [
150+
join(sessionDir(cwd, sessionId, home), "permissions.json"),
151+
join(cwd, SETTINGS_DIR_NAME, "permissions.json"),
152+
join(home, SETTINGS_DIR_NAME, "permissions.json"),
153+
];
154+
const files: ApprovalStoreMigrationFileResult[] = [];
155+
for (const path of paths) {
156+
try {
157+
files.push(await migrateApprovalStoreFile(path));
158+
} catch (err) {
159+
log.warn("Skipping approval-store migration for {path}: {error}", {
160+
path,
161+
error: err instanceof Error ? err.message : String(err),
162+
});
163+
files.push({ path, purged: 0 });
164+
}
165+
}
166+
return {
167+
purged: files.reduce((total, file) => total + file.purged, 0),
168+
backups: files.flatMap((file) =>
169+
file.backupPath !== undefined ? [file.backupPath] : [],
170+
),
171+
files,
172+
};
173+
}

‎src/session/runtime-assembly.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type PluginModule,
3939
} from "../plugins/loader.js";
4040
import { isPluginModuleEnabled } from "../plugins/register.js";
41+
import { migratePersistedApprovalStores } from "../permission/approval-store-migration.js";
4142
import {
4243
formatPendingProjectApprovals,
4344
loadApprovals,
@@ -135,6 +136,11 @@ export async function loadSeededApprovals(
135136
home?: string,
136137
opts?: { onPendingProjectGrants?: ((text: string) => void) | undefined },
137138
): Promise<Approval[]> {
139+
// One-time migration: purge persisted update_plan keys (dropped at load by
140+
// normalizeSeededApprovals but never rewritten) so removing the normalizer
141+
// later cannot resurrect them. Best-effort and idempotent — a clean tree is
142+
// a no-op, and the normalizer stays as defense-in-depth regardless.
143+
await migratePersistedApprovalStores(cwd, sessionId, home);
138144
const sessionApprovals = await loadApprovals(cwd, sessionId, home);
139145
const [
140146
projectApprovals,

0 commit comments

Comments
 (0)