Skip to content

Commit 53cd950

Browse files
Merge pull request #729 from corbitsdev/cl-7264-keep-approval-persistence-failures-non-fatal
Keep approval persistence failures from crashing the session
2 parents 2f3dbf2 + 32431ee commit 53cd950

6 files changed

Lines changed: 187 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2727
previous model no longer covers the same action, new grants store under the
2828
new pair, and Kimi/Moonshot sessions get non-recursive `present` schemas
2929
immediately (canonical schemas restore when switching away).
30+
- A failed write of a project, global, or provider-model approval no longer
31+
crashes the session. The grant still applies in memory, the approved tool
32+
call still completes, and the operator is told remember did not stick.
3033

3134
## [0.3.10] - 2026-08-30
3235

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ recovery line instead of dumping the file path and parse details.
8787

8888
## Safety Model
8989

90-
- **Tiered permission gate** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) run freely. Every consequential tool (`write_file`, `edit_file`, `run_shell`, …) is gated. The operator can Allow Once or Allow Always (scoped to a file, a directory, or a command shape); "Allow Always" choices persist per working directory so repeat actions don't interrupt flow.
90+
- **Tiered permission gate** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) run freely. Every consequential tool (`write_file`, `edit_file`, `run_shell`, …) is gated. The operator can Allow Once or Allow Always (scoped to a file, a directory, or a command shape). Allow Always applies for the rest of the session; Corbits also tries to remember it on disk so later sessions don't re-ask. If that write fails, the grant still holds this session and the operator is told remember did not stick.
9191
- **Secret guard** — Path-keyed tools (`read_file`, `write_file`, …) hard-deny sensitive files (`.env`, `id_rsa`, `*.pem`, `.aws/credentials`, `.ssh/*`, `.git-credentials`, and similar), even with approval, `--dangerously-skip-permissions`, or `/yolo`. Template files like `.env.example` are exempt. Shell commands that _reference_ those paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval and never auto-run in auto mode; once approved, they proceed. Tool-result scrubbing still redacts credential-shaped output that reaches the transcript.
9292
- **Catastrophic-command deny** — Destructive shell patterns that target system roots (`rm -rf /`, home, `/etc`, …), plus `mkfs`, `dd`, `sudo`, fork bombs, `curl | bash`, force-push, … are blocked before they run. Recursive delete of ordinary workspace paths is not hard-denied but requires operator approval (never auto in auto mode).
9393
- **Constrained auto mode** — Default is on (`auto = true`). Pass `--no-auto` to start in ask mode, or `--auto` to force it on; there is currently no in-session key to toggle it. Auto mode auto-approves workspace file writes/edits/deletes and unconstrained shell without per-action prompts, but it is not a free-for-all:

src/exec/runner.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
376376
model: config.model,
377377
requestApproval: (request: PermissionRequest): Promise<ApprovalOutcome> =>
378378
promptPermission(request, interactive),
379-
persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`),
379+
persist: createApprovalPersist(
380+
config.cwd,
381+
() => `${config.providerName}:${config.model}`,
382+
(text) => {
383+
stderr.write(`${text}\n`);
384+
},
385+
),
380386
approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)),
381387
interactive,
382388
skipPermissions: config.dangerouslySkipPermissions,

src/session/runtime-assembly.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
22
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5+
import { getLogger } from "@intx/log";
6+
import type { ToolCall } from "@intx/types/runtime";
57

8+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
69
import * as permissionStore from "../permission/store.js";
10+
import { createPermissionGate } from "../permission/gate.js";
11+
import type { GrantScope } from "../permission/types.js";
712
import {
13+
APPROVAL_PERSIST_FAILURE_NOTICE,
814
buildSubAgentProvider,
915
createApprovalPersist,
1016
createLiveSubAgentSources,
@@ -160,6 +166,12 @@ describe("loadSeededApprovals merge order", () => {
160166
});
161167

162168
describe("createApprovalPersist", () => {
169+
const persistLogger = getLogger([LOG_NAMESPACE_ROOT, "session", "approvals"]);
170+
171+
beforeEach(() => {
172+
spyOn(persistLogger, "warn");
173+
});
174+
163175
afterEach(() => {
164176
mock.restore();
165177
});
@@ -203,6 +215,120 @@ describe("createApprovalPersist", () => {
203215
expect(providerModel).toHaveBeenNthCalledWith(1, "openai:gpt-5", approval);
204216
expect(providerModel).toHaveBeenNthCalledWith(2, "anthropic:claude-opus", approval);
205217
});
218+
219+
const persistedScopes: {
220+
scope: Exclude<GrantScope, "session">;
221+
reject: (message: string) => void;
222+
}[] = [
223+
{
224+
scope: "project",
225+
reject: (message) => {
226+
spyOn(permissionStore, "saveProjectApproval").mockRejectedValue(new Error(message));
227+
},
228+
},
229+
{
230+
scope: "global",
231+
reject: (message) => {
232+
spyOn(permissionStore, "saveGlobalApproval").mockRejectedValue(new Error(message));
233+
},
234+
},
235+
{
236+
scope: "provider-model",
237+
reject: (message) => {
238+
spyOn(permissionStore, "saveProviderModelApproval").mockRejectedValue(new Error(message));
239+
},
240+
},
241+
];
242+
243+
const shellCall = (command: string): ToolCall => ({
244+
id: "c",
245+
name: "run_shell",
246+
arguments: { command },
247+
});
248+
249+
async function flushUnhandledRejections(): Promise<unknown> {
250+
let unhandled: unknown = null;
251+
const onUnhandled = (reason: unknown): void => {
252+
unhandled = reason;
253+
};
254+
process.on("unhandledRejection", onUnhandled);
255+
try {
256+
await new Promise((resolve) => setTimeout(resolve, 0));
257+
} finally {
258+
process.off("unhandledRejection", onUnhandled);
259+
}
260+
return unhandled;
261+
}
262+
263+
for (const { scope, reject } of persistedScopes) {
264+
test(`a rejected ${scope} write is contained, logged, noticed, and never becomes an unhandled rejection`, async () => {
265+
const message = `${scope} disk full`;
266+
reject(message);
267+
const notices: string[] = [];
268+
269+
const persist = createApprovalPersist(
270+
"/tmp/proj",
271+
() => "openai:gpt-5",
272+
(text) => {
273+
notices.push(text);
274+
},
275+
);
276+
persist({ tool: "run_shell", pattern: "npm *" }, scope);
277+
278+
expect(await flushUnhandledRejections()).toBeNull();
279+
expect(persistLogger.warn).toHaveBeenCalledTimes(1);
280+
expect(persistLogger.warn).toHaveBeenCalledWith(
281+
"Failed to persist {scope} approval: {error}",
282+
{
283+
scope,
284+
error: message,
285+
},
286+
);
287+
expect(notices).toEqual([APPROVAL_PERSIST_FAILURE_NOTICE]);
288+
});
289+
290+
test(`a throwing ${scope} persist notice is contained and never becomes an unhandled rejection`, async () => {
291+
reject(`${scope} EIO`);
292+
293+
const persist = createApprovalPersist(
294+
"/tmp/proj",
295+
() => "openai:gpt-5",
296+
() => {
297+
throw new Error("notice exploded");
298+
},
299+
);
300+
persist({ tool: "run_shell", pattern: "npm *" }, scope);
301+
302+
expect(await flushUnhandledRejections()).toBeNull();
303+
});
304+
305+
test(`an approved call still completes and the in-memory ${scope} grant still applies when persist rejects`, async () => {
306+
reject(`${scope} EACCES`);
307+
const persist = createApprovalPersist("/tmp/proj", () => "openai:gpt-5");
308+
let asked = 0;
309+
const gate = createPermissionGate({
310+
approvals: [],
311+
requestApproval: async () => {
312+
asked++;
313+
return {
314+
allow: true,
315+
persist: { id: scope, label: "", pattern: "npm *", grant: scope },
316+
};
317+
},
318+
persist,
319+
interactive: true,
320+
skipPermissions: false,
321+
providerName: "openai",
322+
model: "gpt-5",
323+
});
324+
325+
expect((await gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
326+
expect(asked).toBe(1);
327+
expect(await flushUnhandledRejections()).toBeNull();
328+
expect((await gate.evaluate(shellCall("npm run build"))).allowed).toBe(true);
329+
expect(asked).toBe(1);
330+
});
331+
}
206332
});
207333

208334
describe("skillDirsFromEnabledPlugins", () => {

src/session/runtime-assembly.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Only near-verbatim blocks live here. Gate / toolset / director construction
44
// bind runner-specific state and stay in each runner.
55

6+
import { getLogger } from "@intx/log";
67
import type { ConversationTurn, InferenceSource } from "@intx/types/runtime";
78
import type { Compactor } from "@intx/types/runtime";
89

@@ -13,6 +14,7 @@ import {
1314
loadAgentContextExtensions,
1415
loadSystemPromptOverrides,
1516
} from "../agent/context-extensions.js";
17+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
1618
import type { ProviderCatalogEntry } from "../config/index.js";
1719
import { buildMainSessionSources } from "../config/inference-sources.js";
1820
import type { SessionMode } from "../config/session-mode.js";
@@ -119,21 +121,57 @@ export async function loadSeededApprovals(
119121
return [...sessionApprovals, ...projectApprovals, ...globalApprovals, ...providerModelApprovals];
120122
}
121123

124+
const persistLogger = getLogger([LOG_NAMESPACE_ROOT, "session", "approvals"]);
125+
126+
/** Operator-facing copy when an Allow Always write fails. The in-session grant still holds. */
127+
export const APPROVAL_PERSIST_FAILURE_NOTICE =
128+
"Allow Always applies this session, but remember did not stick.";
129+
130+
// The persist callback is fire-and-forget from the gate. A rejected write must
131+
// not become an unhandledRejection (that path is fatal at process level); the
132+
// in-memory grant already applies, so the approved call still completes.
133+
function persistBestEffort(
134+
scope: GrantScope,
135+
write: Promise<void>,
136+
onPersistFailure?: (text: string) => void,
137+
): void {
138+
void write.catch((err: unknown) => {
139+
persistLogger.warn("Failed to persist {scope} approval: {error}", {
140+
scope,
141+
error: err instanceof Error ? err.message : String(err),
142+
});
143+
try {
144+
onPersistFailure?.(APPROVAL_PERSIST_FAILURE_NOTICE);
145+
} catch {
146+
// Notice is best-effort; never rethrow into an unhandledRejection.
147+
}
148+
});
149+
}
150+
122151
/**
123152
* Route a gate-persisted grant to the store its scope selects.
124153
* Session grants never reach here — the gate keeps those in memory only.
125154
* `getActiveProviderModel` is read at persist time so a live model switch
126155
* stores new provider-model grants under the pair now in use.
156+
* Disk failures are logged, surfaced to the operator when a notice hook is
157+
* provided, and swallowed so they cannot crash the session.
127158
*/
128159
export function createApprovalPersist(
129160
cwd: string,
130161
getActiveProviderModel: () => string,
162+
onPersistFailure?: (text: string) => void,
131163
): (approval: Approval, scope: GrantScope) => void {
132164
return (approval: Approval, scope: GrantScope) => {
133-
if (scope === "project") void saveProjectApproval(cwd, approval);
134-
else if (scope === "global") void saveGlobalApproval(approval);
165+
if (scope === "project")
166+
persistBestEffort(scope, saveProjectApproval(cwd, approval), onPersistFailure);
167+
else if (scope === "global")
168+
persistBestEffort(scope, saveGlobalApproval(approval), onPersistFailure);
135169
else if (scope === "provider-model") {
136-
void saveProviderModelApproval(getActiveProviderModel(), approval);
170+
persistBestEffort(
171+
scope,
172+
saveProviderModelApproval(getActiveProviderModel(), approval),
173+
onPersistFailure,
174+
);
137175
}
138176
};
139177
}

src/tui/runner.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
857857
const isXaiAuthError = (err: unknown): boolean =>
858858
err instanceof Error && err.name === "XaiAuthError";
859859

860+
const approvalPersistNotice: { notify?: (text: string) => void } = {};
861+
860862
// Shared by the permission gate and every operator-gate emission site: an
861863
// unattended auto-continue run must not park on any gate forever, whichever
862864
// kind it is. No caller arms this today — the goal subsystem was the only
@@ -880,7 +882,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
880882
emitGate: (event) => emitter.emit("permission.gate", event),
881883
approvalTimeout,
882884
}),
883-
persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`),
885+
persist: createApprovalPersist(
886+
config.cwd,
887+
() => `${config.providerName}:${config.model}`,
888+
(text) => approvalPersistNotice.notify?.(text),
889+
),
884890
approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)),
885891
interactive: true,
886892
skipPermissions: config.dangerouslySkipPermissions,
@@ -2125,6 +2131,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
21252131
const systemNotice = (text: string): void => {
21262132
surfaceSystemNotice(host.shell, text);
21272133
};
2134+
approvalPersistNotice.notify = systemNotice;
21282135

21292136
/** Settle the shell after a rejected send so the run does not look live. */
21302137
const handleSendFailure = (err: unknown): void => {

0 commit comments

Comments
 (0)