@andares/pi-toolkits 0.9.7 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -12
- package/README.zh-CN.md +42 -12
- package/package.json +6 -5
- package/src/features/ask/reasons.ts +1 -1
- package/src/features/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +33 -10
- package/src/features/compact/index.ts +27 -34
- package/src/features/compact/prompt.ts +23 -12
- package/src/features/favorites/constants.ts +6 -5
- package/src/features/favorites/patch-cycle.ts +4 -3
- package/src/features/favorites/patch-selector.ts +2 -2
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/features/stash/stash.ts +1 -1
- package/src/index.ts +6 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +295 -10
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +1 -1
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
ToolCallEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Text } from "@earendil-works/pi-tui";
|
|
25
|
+
import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
|
|
26
|
+
import { auditBase, auditTool } from "../../lib/approval-log.js";
|
|
25
27
|
import type { AskRequest, GateHub } from "../../lib/gates.js";
|
|
26
28
|
import { isJailedTool } from "../../lib/tools.js";
|
|
27
29
|
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
} from "./paths.js";
|
|
47
49
|
import {
|
|
48
50
|
decideProgram,
|
|
51
|
+
decideUnit,
|
|
49
52
|
decisionEffect,
|
|
50
53
|
decisionGrantRoot,
|
|
51
54
|
formatDenial,
|
|
@@ -55,6 +58,7 @@ import {
|
|
|
55
58
|
import { askPermission } from "./prompt.js";
|
|
56
59
|
import { SessionRules } from "./session-rules.js";
|
|
57
60
|
import { parseCommandUnits, type CommandUnit } from "./units.js";
|
|
61
|
+
import { buildPermissionAudit, commandDigest } from "./audit.js";
|
|
58
62
|
|
|
59
63
|
/** Tools that read one path. */
|
|
60
64
|
const READ_PATH_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
@@ -123,7 +127,17 @@ interface NoticeData {
|
|
|
123
127
|
text: string;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
export
|
|
130
|
+
export interface PermissionOptions {
|
|
131
|
+
/** Write-only audit sink; kept optional so tests need no filesystem. */
|
|
132
|
+
readonly audit?: ApprovalLog;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerPermission(
|
|
136
|
+
pi: ExtensionAPI,
|
|
137
|
+
gates: GateHub,
|
|
138
|
+
options: PermissionOptions = {},
|
|
139
|
+
): void {
|
|
140
|
+
const audit = options.audit;
|
|
127
141
|
pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
|
|
128
142
|
const text = (entry.data as NoticeData | undefined)?.text;
|
|
129
143
|
return text === undefined
|
|
@@ -148,6 +162,22 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
148
162
|
rootsCache = undefined;
|
|
149
163
|
}
|
|
150
164
|
|
|
165
|
+
/** Record an on/off or mode change; never affects the state machine. */
|
|
166
|
+
function recordMode(
|
|
167
|
+
ctx: ExtensionContext,
|
|
168
|
+
state: { enabled: boolean; source: string; via: string },
|
|
169
|
+
): void {
|
|
170
|
+
if (!audit || audit.mode === "off") return;
|
|
171
|
+
audit.append({
|
|
172
|
+
...auditBase(ctx),
|
|
173
|
+
event: "mode",
|
|
174
|
+
enabled: state.enabled,
|
|
175
|
+
modes: { ...modes },
|
|
176
|
+
source: state.source,
|
|
177
|
+
via: state.via,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
/** Session id, or `undefined` when the host exposes none. */
|
|
152
182
|
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
153
183
|
try {
|
|
@@ -234,11 +264,13 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
234
264
|
decision: PolicyDecision,
|
|
235
265
|
fallbackCwd: string,
|
|
236
266
|
units: readonly CommandUnit[],
|
|
267
|
+
payload: ApprovalRecord | undefined,
|
|
237
268
|
): AskRequest {
|
|
238
269
|
const root = decisionGrantRoot(decision, () => fallbackCwd);
|
|
239
270
|
const effect = decisionEffect(decision);
|
|
240
271
|
const scope =
|
|
241
272
|
decision.target?.scope === "out" ? "outside project" : "inside project";
|
|
273
|
+
const title = "Permission required";
|
|
242
274
|
const details = [
|
|
243
275
|
`Command: ${decision.unit.raw || decision.unit.command}`,
|
|
244
276
|
...(decision.target
|
|
@@ -249,12 +281,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
249
281
|
? "Session grant would cover: this exact command"
|
|
250
282
|
: `Session grant would cover: ${root}`,
|
|
251
283
|
];
|
|
284
|
+
const auditPayload =
|
|
285
|
+
payload === undefined
|
|
286
|
+
? undefined
|
|
287
|
+
: { ...payload, prompt: [title, ...details] };
|
|
252
288
|
return {
|
|
253
|
-
title
|
|
289
|
+
title,
|
|
254
290
|
details,
|
|
255
291
|
root,
|
|
256
|
-
|
|
257
|
-
|
|
292
|
+
audit: auditPayload,
|
|
293
|
+
present: (ctx) => askPermission(ctx, { title, details }),
|
|
258
294
|
apply: (choice) => {
|
|
259
295
|
if (choice === "allow-session") {
|
|
260
296
|
if (decision.unit.opaque) rules.allowCommand(programKey(units));
|
|
@@ -273,6 +309,20 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
273
309
|
? `${decision.reason ?? "operation"} — denied for this session`
|
|
274
310
|
: (decision.reason ?? "denied by the operator"),
|
|
275
311
|
}),
|
|
312
|
+
settle: (choice) => {
|
|
313
|
+
if (choice !== "allow-session" && choice !== "deny-session") {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
const commandScope = decision.unit.opaque;
|
|
317
|
+
return {
|
|
318
|
+
grant: {
|
|
319
|
+
kind: choice === "allow-session" ? "allow" : "deny",
|
|
320
|
+
scope: commandScope ? "command" : "root",
|
|
321
|
+
effect,
|
|
322
|
+
...(commandScope ? { digest: commandDigest(units) } : { root }),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
},
|
|
276
326
|
};
|
|
277
327
|
}
|
|
278
328
|
|
|
@@ -298,6 +348,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
298
348
|
handler: async (args, ctx) => {
|
|
299
349
|
// Startup notices are entries; interactive ones stay in-place toasts.
|
|
300
350
|
const startup = consumeStartupCommand("ask-perm");
|
|
351
|
+
const via = startup ? "autostart" : "manual";
|
|
301
352
|
const notice = (text: string): void => {
|
|
302
353
|
if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
|
|
303
354
|
else ctx.ui.notify(text, "info");
|
|
@@ -322,6 +373,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
322
373
|
if (changed) rules.clear();
|
|
323
374
|
enabled = true;
|
|
324
375
|
syncStatus(ctx);
|
|
376
|
+
recordMode(ctx, { enabled: true, source: "args", via });
|
|
325
377
|
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
326
378
|
return;
|
|
327
379
|
}
|
|
@@ -330,6 +382,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
330
382
|
enabled = false;
|
|
331
383
|
rules.clear();
|
|
332
384
|
syncStatus(ctx);
|
|
385
|
+
recordMode(ctx, { enabled: false, source: "toggle", via });
|
|
333
386
|
notice("Permission OFF — no boundary checks in this session.");
|
|
334
387
|
return;
|
|
335
388
|
}
|
|
@@ -346,6 +399,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
346
399
|
rules.clear();
|
|
347
400
|
enabled = true;
|
|
348
401
|
syncStatus(ctx);
|
|
402
|
+
recordMode(ctx, { enabled: true, source: "panel", via });
|
|
349
403
|
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
350
404
|
},
|
|
351
405
|
});
|
|
@@ -364,16 +418,58 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
364
418
|
if (isJailedTool(event.toolName)) return undefined;
|
|
365
419
|
const units = unitsFor(event, ctx.cwd);
|
|
366
420
|
if (units.length === 0) return undefined;
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (decision.action === "allow")
|
|
421
|
+
const roots = rootsFor(ctx);
|
|
422
|
+
const policyCtx = { modes, roots, rules };
|
|
423
|
+
const decision = decideProgram(units, policyCtx);
|
|
424
|
+
const logOn = audit !== undefined && audit.mode !== "off";
|
|
425
|
+
|
|
426
|
+
if (decision.action === "allow") {
|
|
427
|
+
// Session-grant allows show how much friction a grant removed; plain
|
|
428
|
+
// mode allows are logged only under `PI_APPROVAL_LOG=all`, otherwise
|
|
429
|
+
// their volume would bury the interesting records. A grant on an
|
|
430
|
+
// opaque program is keyed by its text (`session-command`), so both
|
|
431
|
+
// grant layers must be recorded — otherwise "allow for this session"
|
|
432
|
+
// on a command the parser cannot resolve would leave no trace.
|
|
433
|
+
const granted =
|
|
434
|
+
decision.layer === "session-allow" ||
|
|
435
|
+
decision.layer === "session-command";
|
|
436
|
+
if (logOn && (granted || audit?.mode === "all")) {
|
|
437
|
+
const payload = buildPermissionAudit({
|
|
438
|
+
cwd: ctx.cwd,
|
|
439
|
+
modes,
|
|
440
|
+
roots,
|
|
441
|
+
units,
|
|
442
|
+
unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
|
|
443
|
+
decision,
|
|
444
|
+
});
|
|
445
|
+
audit?.append({
|
|
446
|
+
...auditBase(ctx),
|
|
447
|
+
...auditTool(event),
|
|
448
|
+
...payload,
|
|
449
|
+
event: "allow",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let payload: ApprovalRecord | undefined;
|
|
456
|
+
if (logOn) {
|
|
457
|
+
payload = buildPermissionAudit({
|
|
458
|
+
cwd: ctx.cwd,
|
|
459
|
+
modes,
|
|
460
|
+
roots,
|
|
461
|
+
units,
|
|
462
|
+
unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
|
|
463
|
+
decision,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
373
466
|
if (decision.action === "deny") {
|
|
374
|
-
return { kind: "block", reason: formatDenial(decision) };
|
|
467
|
+
return { kind: "block", reason: formatDenial(decision), audit: payload };
|
|
375
468
|
}
|
|
376
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
kind: "ask",
|
|
471
|
+
request: askRequest(decision, ctx.cwd, units, payload),
|
|
472
|
+
};
|
|
377
473
|
},
|
|
378
474
|
});
|
|
379
475
|
|
|
@@ -30,12 +30,31 @@ export interface PolicyTarget {
|
|
|
30
30
|
|
|
31
31
|
export interface PolicyDecision {
|
|
32
32
|
readonly action: PolicyAction;
|
|
33
|
+
/** Machine-readable reason class, for the audit log and tests. */
|
|
34
|
+
readonly layer: PolicyLayer;
|
|
33
35
|
readonly unit: CommandUnit;
|
|
34
36
|
readonly target?: PolicyTarget;
|
|
35
37
|
/** Human/model-facing explanation for ask and deny. */
|
|
36
38
|
readonly reason?: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Which rule decided a unit: the four modes, one of the fixed tables, a session
|
|
43
|
+
* grant, or a parser fallback. Kept separate from the human-facing reason so
|
|
44
|
+
* the audit log can group decisions without parsing English.
|
|
45
|
+
*/
|
|
46
|
+
export type PolicyLayer =
|
|
47
|
+
| "mode"
|
|
48
|
+
| "secret"
|
|
49
|
+
| "session-allow"
|
|
50
|
+
| "session-deny"
|
|
51
|
+
| "session-command"
|
|
52
|
+
| "danger"
|
|
53
|
+
| "privileged"
|
|
54
|
+
| "opaque"
|
|
55
|
+
| "unresolved"
|
|
56
|
+
| "empty";
|
|
57
|
+
|
|
39
58
|
export interface PolicyContext {
|
|
40
59
|
readonly modes: PermissionModes;
|
|
41
60
|
/** Canonical session roots (working directory plus added directories). */
|
|
@@ -81,6 +100,7 @@ function decidePath(
|
|
|
81
100
|
if (secret !== undefined) {
|
|
82
101
|
return {
|
|
83
102
|
action: "deny",
|
|
103
|
+
layer: "secret",
|
|
84
104
|
unit,
|
|
85
105
|
target: { path: pathValue, effect, scope },
|
|
86
106
|
reason: `${secret} — denied by the fixed safety table`,
|
|
@@ -90,19 +110,23 @@ function decidePath(
|
|
|
90
110
|
if (ctx.rules.denies(effect, pathValue)) {
|
|
91
111
|
return {
|
|
92
112
|
action: "deny",
|
|
113
|
+
layer: "session-deny",
|
|
93
114
|
unit,
|
|
94
115
|
target,
|
|
95
116
|
reason: `${effect} under ${pathValue} was denied for this session`,
|
|
96
117
|
};
|
|
97
118
|
}
|
|
98
119
|
if (ctx.rules.allows(effect, pathValue)) {
|
|
99
|
-
return { action: "allow", unit, target };
|
|
120
|
+
return { action: "allow", layer: "session-allow", unit, target };
|
|
100
121
|
}
|
|
101
122
|
const mode = modeFor(ctx.modes, effect, scope);
|
|
102
|
-
if (mode === "allow")
|
|
123
|
+
if (mode === "allow") {
|
|
124
|
+
return { action: "allow", layer: "mode", unit, target };
|
|
125
|
+
}
|
|
103
126
|
if (mode === "deny") {
|
|
104
127
|
return {
|
|
105
128
|
action: "deny",
|
|
129
|
+
layer: "mode",
|
|
106
130
|
unit,
|
|
107
131
|
target,
|
|
108
132
|
reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
|
|
@@ -110,6 +134,7 @@ function decidePath(
|
|
|
110
134
|
}
|
|
111
135
|
return {
|
|
112
136
|
action: "ask",
|
|
137
|
+
layer: "mode",
|
|
113
138
|
unit,
|
|
114
139
|
target,
|
|
115
140
|
reason:
|
|
@@ -130,6 +155,7 @@ export function decideUnit(
|
|
|
130
155
|
// to grant.
|
|
131
156
|
return {
|
|
132
157
|
action: "ask",
|
|
158
|
+
layer: "opaque",
|
|
133
159
|
unit,
|
|
134
160
|
reason: unit.note ?? "this command could not be analysed",
|
|
135
161
|
};
|
|
@@ -149,6 +175,7 @@ export function decideUnit(
|
|
|
149
175
|
if (secret?.reason !== undefined) {
|
|
150
176
|
return {
|
|
151
177
|
action: "deny",
|
|
178
|
+
layer: "secret",
|
|
152
179
|
unit,
|
|
153
180
|
target: {
|
|
154
181
|
path: secret.target.path,
|
|
@@ -163,6 +190,7 @@ export function decideUnit(
|
|
|
163
190
|
if (baseSecret !== undefined) {
|
|
164
191
|
return {
|
|
165
192
|
action: "deny",
|
|
193
|
+
layer: "secret",
|
|
166
194
|
unit,
|
|
167
195
|
target: {
|
|
168
196
|
path: canonicalBase,
|
|
@@ -221,6 +249,7 @@ export function decideUnit(
|
|
|
221
249
|
}
|
|
222
250
|
return {
|
|
223
251
|
action: "ask",
|
|
252
|
+
layer: danger !== undefined ? "danger" : "privileged",
|
|
224
253
|
unit,
|
|
225
254
|
target,
|
|
226
255
|
reason: danger ?? "privileged command",
|
|
@@ -231,6 +260,7 @@ export function decideUnit(
|
|
|
231
260
|
if (canonicalBase === null) {
|
|
232
261
|
return {
|
|
233
262
|
action: "ask",
|
|
263
|
+
layer: "unresolved",
|
|
234
264
|
unit,
|
|
235
265
|
reason: "the working directory could not be resolved",
|
|
236
266
|
};
|
|
@@ -263,6 +293,7 @@ export function decideProgram(
|
|
|
263
293
|
if (units.length === 0) {
|
|
264
294
|
return {
|
|
265
295
|
action: "allow",
|
|
296
|
+
layer: "empty",
|
|
266
297
|
unit: {
|
|
267
298
|
raw: "",
|
|
268
299
|
headWord: "",
|
|
@@ -284,12 +315,17 @@ export function decideProgram(
|
|
|
284
315
|
if (ctx.rules.deniesCommand(key)) {
|
|
285
316
|
return {
|
|
286
317
|
action: "deny",
|
|
318
|
+
layer: "session-command",
|
|
287
319
|
unit: opaqueUnit,
|
|
288
320
|
reason: "this command was denied for this session",
|
|
289
321
|
};
|
|
290
322
|
}
|
|
291
323
|
if (ctx.rules.allowsCommand(key)) {
|
|
292
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
action: "allow",
|
|
326
|
+
layer: "session-command",
|
|
327
|
+
unit: opaqueUnit,
|
|
328
|
+
};
|
|
293
329
|
}
|
|
294
330
|
}
|
|
295
331
|
|
|
@@ -21,7 +21,7 @@ import type { KeyId } from "@earendil-works/pi-tui";
|
|
|
21
21
|
/**
|
|
22
22
|
* Stash hotkey.
|
|
23
23
|
*
|
|
24
|
-
* Compatibility rationale (verified against pi
|
|
24
|
+
* Compatibility rationale (verified against pi 0.99.0–1.4.0 keybindings, live
|
|
25
25
|
* matchesKey probes and terminal/OS behavior):
|
|
26
26
|
* - pi: no default binding for ctrl+alt+<letter> (only ctrl+alt+] is taken);
|
|
27
27
|
* extension shortcuts dispatch first while the editor is focused
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* each other. The bus is installed once, after every gate is registered.
|
|
19
19
|
*/
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { createApprovalLog } from "./lib/approval-log.js";
|
|
21
22
|
import { createGateHub } from "./lib/gates.js";
|
|
22
23
|
import { registerAsk } from "./features/ask/index.js";
|
|
23
24
|
import { registerAutostart } from "./features/autostart/index.js";
|
|
@@ -28,12 +29,15 @@ import { registerReview } from "./features/review/index.js";
|
|
|
28
29
|
import { registerStash } from "./features/stash/index.js";
|
|
29
30
|
|
|
30
31
|
export default function piToolkits(pi: ExtensionAPI): void {
|
|
31
|
-
|
|
32
|
+
// One rolling audit sink for every gate. Creation is lazy and side-effect
|
|
33
|
+
// free: the directory is only touched when the first record is written.
|
|
34
|
+
const audit = createApprovalLog();
|
|
35
|
+
const gates = createGateHub({ audit });
|
|
32
36
|
|
|
33
37
|
registerAsk(pi, gates);
|
|
34
38
|
// Permission registers its session_start reset before autostart, so a
|
|
35
39
|
// project-declared `/ask-perm ...` runs against a freshly reset mode.
|
|
36
|
-
registerPermission(pi, gates);
|
|
40
|
+
registerPermission(pi, gates, { audit });
|
|
37
41
|
registerAutostart(pi);
|
|
38
42
|
registerCompact(pi);
|
|
39
43
|
registerFavorites(pi);
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
clipText,
|
|
14
|
+
createApprovalLog,
|
|
15
|
+
sanitizeToolInput,
|
|
16
|
+
} from "./approval-log.js";
|
|
17
|
+
|
|
18
|
+
function tempDir(prefix = "pi-approval-"): string {
|
|
19
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function logFiles(dir: string): string[] {
|
|
23
|
+
return readdirSync(dir)
|
|
24
|
+
.filter((name) => /^audit-\d{6,}\.jsonl$/.test(name))
|
|
25
|
+
.sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function linesOf(dir: string, name: string): string[] {
|
|
29
|
+
return readFileSync(join(dir, name), "utf8")
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter((line) => line !== "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("approval log", () => {
|
|
35
|
+
it("writes one JSON object per line and creates the directory lazily", () => {
|
|
36
|
+
const root = tempDir();
|
|
37
|
+
const dir = join(root, "nested", "log");
|
|
38
|
+
const log = createApprovalLog({
|
|
39
|
+
dir,
|
|
40
|
+
mode: "interesting",
|
|
41
|
+
now: () => new Date("2026-01-02T03:04:05.000Z"),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(existsSync(dir)).toBe(false);
|
|
45
|
+
log.append({
|
|
46
|
+
event: "block",
|
|
47
|
+
tool: "bash",
|
|
48
|
+
verdict: { action: "deny", layer: "secret" },
|
|
49
|
+
});
|
|
50
|
+
expect(logFiles(dir)).toEqual(["audit-000001.jsonl"]);
|
|
51
|
+
|
|
52
|
+
const lines = linesOf(dir, "audit-000001.jsonl");
|
|
53
|
+
expect(lines).toHaveLength(1);
|
|
54
|
+
const record = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
|
55
|
+
expect(record).toMatchObject({
|
|
56
|
+
v: 1,
|
|
57
|
+
ts: "2026-01-02T03:04:05.000Z",
|
|
58
|
+
event: "block",
|
|
59
|
+
tool: "bash",
|
|
60
|
+
verdict: { action: "deny", layer: "secret" },
|
|
61
|
+
});
|
|
62
|
+
if (process.platform !== "win32") {
|
|
63
|
+
expect(statSync(join(dir, "audit-000001.jsonl")).mode & 0o777).toBe(
|
|
64
|
+
0o600,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rotates at the record cap and removes the oldest file", () => {
|
|
70
|
+
const dir = tempDir();
|
|
71
|
+
const log = createApprovalLog({
|
|
72
|
+
dir,
|
|
73
|
+
mode: "interesting",
|
|
74
|
+
maxRecords: 3,
|
|
75
|
+
maxFiles: 2,
|
|
76
|
+
});
|
|
77
|
+
for (let i = 1; i <= 7; i++) log.append({ event: "ask", n: i });
|
|
78
|
+
|
|
79
|
+
expect(logFiles(dir)).toEqual(["audit-000002.jsonl", "audit-000003.jsonl"]);
|
|
80
|
+
expect(linesOf(dir, "audit-000002.jsonl")).toHaveLength(3);
|
|
81
|
+
expect(linesOf(dir, "audit-000003.jsonl")).toHaveLength(1);
|
|
82
|
+
expect(
|
|
83
|
+
linesOf(dir, "audit-000003.jsonl")
|
|
84
|
+
.map((line) => (JSON.parse(line) as { n: number }).n)
|
|
85
|
+
.join(","),
|
|
86
|
+
).toBe("7");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rotates at the byte cap before the record cap", () => {
|
|
90
|
+
const dir = tempDir();
|
|
91
|
+
const log = createApprovalLog({
|
|
92
|
+
dir,
|
|
93
|
+
mode: "interesting",
|
|
94
|
+
maxFileBytes: 300,
|
|
95
|
+
maxRecords: 1000,
|
|
96
|
+
});
|
|
97
|
+
for (let i = 1; i <= 3; i++) {
|
|
98
|
+
log.append({ event: "ask", n: i, payload: "x".repeat(200) });
|
|
99
|
+
}
|
|
100
|
+
const files = logFiles(dir);
|
|
101
|
+
expect(files).toHaveLength(2);
|
|
102
|
+
expect(linesOf(dir, files[0] ?? "")).toHaveLength(2);
|
|
103
|
+
expect(linesOf(dir, files[1] ?? "")).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("terminates a crash-truncated tail before appending", () => {
|
|
107
|
+
const dir = tempDir();
|
|
108
|
+
writeFileSync(
|
|
109
|
+
join(dir, "audit-000001.jsonl"),
|
|
110
|
+
'{"v":1,"event":"block"}\n{"v":1,"event":"as',
|
|
111
|
+
);
|
|
112
|
+
const log = createApprovalLog({ dir, mode: "interesting" });
|
|
113
|
+
log.append({ event: "ask", n: 2 });
|
|
114
|
+
|
|
115
|
+
const lines = linesOf(dir, "audit-000001.jsonl");
|
|
116
|
+
expect(lines).toHaveLength(3);
|
|
117
|
+
expect(() => JSON.parse(lines[0] ?? "")).not.toThrow();
|
|
118
|
+
expect(() => JSON.parse(lines[1] ?? "")).toThrow();
|
|
119
|
+
expect(JSON.parse(lines[2] ?? "") as Record<string, unknown>).toMatchObject({
|
|
120
|
+
event: "ask",
|
|
121
|
+
n: 2,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("redacts credential-shaped strings anywhere in the record", () => {
|
|
126
|
+
const dir = tempDir();
|
|
127
|
+
const log = createApprovalLog({ dir, mode: "interesting" });
|
|
128
|
+
log.append({
|
|
129
|
+
event: "ask",
|
|
130
|
+
input: {
|
|
131
|
+
command:
|
|
132
|
+
"curl -H 'Authorization: Bearer abcdefgh12345678' https://user:pass@example.com/x?token=supersecretvalue",
|
|
133
|
+
},
|
|
134
|
+
units: [{ raw: "export TOKEN=anothersecretvalue" }],
|
|
135
|
+
reason: "password=hunter2",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const line = readFileSync(join(dir, "audit-000001.jsonl"), "utf8");
|
|
139
|
+
expect(line).toContain('"redacted":true');
|
|
140
|
+
expect(line).not.toContain("abcdefgh12345678");
|
|
141
|
+
expect(line).not.toContain("supersecretvalue");
|
|
142
|
+
expect(line).not.toContain("anothersecretvalue");
|
|
143
|
+
expect(line).not.toContain("hunter2");
|
|
144
|
+
expect(line).toContain("[redacted]");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("drops write/edit bodies but keeps their size", () => {
|
|
148
|
+
expect(sanitizeToolInput("write", { path: "/tmp/a", content: "hello" }))
|
|
149
|
+
.toEqual({ path: "/tmp/a", contentBytes: 5 });
|
|
150
|
+
expect(
|
|
151
|
+
sanitizeToolInput("edit", {
|
|
152
|
+
path: "/tmp/a",
|
|
153
|
+
oldText: "abc",
|
|
154
|
+
newText: "abcdef",
|
|
155
|
+
}),
|
|
156
|
+
).toEqual({ path: "/tmp/a", contentBytes: 6, oldTextBytes: 3 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("clips overlong text with a visible marker", () => {
|
|
160
|
+
const clipped = clipText("x".repeat(5000), 100);
|
|
161
|
+
expect(clipped).toContain("[+4900 chars]");
|
|
162
|
+
expect(clipped.length).toBeLessThan(5000);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("caps a pathological record without losing the verdict", () => {
|
|
166
|
+
const dir = tempDir();
|
|
167
|
+
const log = createApprovalLog({
|
|
168
|
+
dir,
|
|
169
|
+
mode: "interesting",
|
|
170
|
+
maxRecordBytes: 600,
|
|
171
|
+
});
|
|
172
|
+
log.append({
|
|
173
|
+
event: "ask",
|
|
174
|
+
verdict: { action: "ask", layer: "mode", target: "/tmp/x" },
|
|
175
|
+
verdictCode: 1,
|
|
176
|
+
units: Array.from({ length: 40 }, (_, index) => ({
|
|
177
|
+
raw: `${index}:${"y".repeat(300)}`,
|
|
178
|
+
})),
|
|
179
|
+
input: { command: "z".repeat(5000) },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const line = readFileSync(join(dir, "audit-000001.jsonl"), "utf8").trim();
|
|
183
|
+
const record = JSON.parse(line) as Record<string, unknown>;
|
|
184
|
+
expect(record.event).toBe("ask");
|
|
185
|
+
expect(record.verdict).toMatchObject({ action: "ask", layer: "mode" });
|
|
186
|
+
expect(record.units === undefined || record.truncated === true).toBe(true);
|
|
187
|
+
expect(line.length).toBeLessThan(2000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("skips one unserializable record instead of disabling the log", () => {
|
|
191
|
+
const dir = tempDir();
|
|
192
|
+
const warnings: string[] = [];
|
|
193
|
+
const log = createApprovalLog({
|
|
194
|
+
dir,
|
|
195
|
+
mode: "interesting",
|
|
196
|
+
warn: (message) => warnings.push(message),
|
|
197
|
+
});
|
|
198
|
+
// A BigInt inside the envelope cannot be stringified even after every
|
|
199
|
+
// optional field is dropped. It must cost one record, not the process.
|
|
200
|
+
log.append({ event: "ask", verdict: { action: 1n as unknown as number } });
|
|
201
|
+
expect(log.active).toBe(true);
|
|
202
|
+
log.append({ event: "block", n: 2 });
|
|
203
|
+
|
|
204
|
+
const lines = linesOf(dir, "audit-000001.jsonl");
|
|
205
|
+
expect(lines).toHaveLength(1);
|
|
206
|
+
expect(JSON.parse(lines[0] ?? "{}") as Record<string, unknown>).toMatchObject({
|
|
207
|
+
event: "block",
|
|
208
|
+
n: 2,
|
|
209
|
+
});
|
|
210
|
+
expect(warnings).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a record whose envelope survives the dropped detail", () => {
|
|
214
|
+
const dir = tempDir();
|
|
215
|
+
const log = createApprovalLog({ dir, mode: "interesting" });
|
|
216
|
+
// The BigInt sits in a field the last-resort envelope drops, so the
|
|
217
|
+
// decision itself is still recorded (with `truncated`).
|
|
218
|
+
log.append({ event: "ask", extra: 1n as unknown as number, verdict: "ask" });
|
|
219
|
+
|
|
220
|
+
const record = JSON.parse(
|
|
221
|
+
linesOf(dir, "audit-000001.jsonl")[0] ?? "{}",
|
|
222
|
+
) as Record<string, unknown>;
|
|
223
|
+
expect(record.event).toBe("ask");
|
|
224
|
+
expect(record.truncated).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("fails open and warns once when the directory is unusable", () => {
|
|
228
|
+
const root = tempDir();
|
|
229
|
+
const file = join(root, "not-a-dir");
|
|
230
|
+
writeFileSync(file, "");
|
|
231
|
+
const warnings: string[] = [];
|
|
232
|
+
const log = createApprovalLog({
|
|
233
|
+
dir: join(file, "log"),
|
|
234
|
+
mode: "interesting",
|
|
235
|
+
warn: (message) => warnings.push(message),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(() => log.append({ event: "ask" })).not.toThrow();
|
|
239
|
+
expect(log.active).toBe(false);
|
|
240
|
+
expect(warnings).toHaveLength(1);
|
|
241
|
+
log.append({ event: "ask" });
|
|
242
|
+
expect(warnings).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("writes nothing when the mode is off", () => {
|
|
246
|
+
const root = tempDir();
|
|
247
|
+
const dir = join(root, "off");
|
|
248
|
+
const log = createApprovalLog({ dir, mode: "off" });
|
|
249
|
+
log.append({ event: "ask" });
|
|
250
|
+
expect(log.active).toBe(false);
|
|
251
|
+
expect(existsSync(dir)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("keeps two writers within the ring without corrupting lines", () => {
|
|
255
|
+
const dir = tempDir();
|
|
256
|
+
const first = createApprovalLog({
|
|
257
|
+
dir,
|
|
258
|
+
mode: "interesting",
|
|
259
|
+
maxRecords: 3,
|
|
260
|
+
maxFiles: 2,
|
|
261
|
+
});
|
|
262
|
+
const second = createApprovalLog({
|
|
263
|
+
dir,
|
|
264
|
+
mode: "interesting",
|
|
265
|
+
maxRecords: 3,
|
|
266
|
+
maxFiles: 2,
|
|
267
|
+
});
|
|
268
|
+
for (let i = 0; i < 10; i++) {
|
|
269
|
+
(i % 2 === 0 ? first : second).append({ event: "ask", n: i });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const files = logFiles(dir);
|
|
273
|
+
expect(files.length).toBeLessThanOrEqual(2);
|
|
274
|
+
let total = 0;
|
|
275
|
+
for (const name of files) {
|
|
276
|
+
const lines = linesOf(dir, name);
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
279
|
+
}
|
|
280
|
+
// Each writer checks the count before appending, so two writers can
|
|
281
|
+
// overshoot the cap by at most one record — not by 2x, which is what a
|
|
282
|
+
// per-process counter would do.
|
|
283
|
+
expect(lines.length).toBeLessThanOrEqual(4);
|
|
284
|
+
total += lines.length;
|
|
285
|
+
}
|
|
286
|
+
expect(total).toBeGreaterThan(0);
|
|
287
|
+
expect(total).toBeLessThanOrEqual(10);
|
|
288
|
+
});
|
|
289
|
+
});
|