@andares/pi-toolkits 0.9.8 → 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/src/lib/gates.ts CHANGED
@@ -23,12 +23,22 @@
23
23
  * at a time instead of a pile of stacked dialogs on 1.4.0.
24
24
  * - A gate that throws blocks the call (fail closed), matching pi's own
25
25
  * `tool_call` handler contract.
26
+ *
27
+ * The hub is also the single audit point (`lib/approval-log.ts`): it is the
28
+ * only place that sees the gate verdict, the prompt, the operator's answer,
29
+ * and the final outcome in one frame. Features attach a structured `audit`
30
+ * payload; the hub writes blocks and asks, while a gate that auto-allows an
31
+ * operation it considers worth recording writes its own `allow` record through
32
+ * the same log (permission's session-grant allows). Logging never changes a
33
+ * verdict: the writer fails open and the hub swallows payload errors.
26
34
  */
27
35
  import type {
28
36
  ExtensionAPI,
29
37
  ExtensionContext,
30
38
  ToolCallEvent,
31
39
  } from "@earendil-works/pi-coding-agent";
40
+ import type { ApprovalLog, ApprovalRecord } from "./approval-log.js";
41
+ import { auditBase, auditTool, clipText, sessionIdOf } from "./approval-log.js";
32
42
 
33
43
  /** What the operator chose in an interactive ask. */
34
44
  export type AskDecision =
@@ -52,17 +62,37 @@ export interface AskRequest {
52
62
  readonly details: readonly string[];
53
63
  /** The directory a session grant would cover. */
54
64
  readonly root: string;
65
+ /**
66
+ * Structured context written with the audit record when the log is on.
67
+ * Built by the feature; the hub adds the tool call, answer and outcome.
68
+ */
69
+ readonly audit?: ApprovalRecord;
55
70
  present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
56
71
  apply(decision: AskDecision): void;
57
72
  /** Model-facing reason when the operator answers deny/deny-session. */
58
73
  denyReason(decision: AskDecision): string;
74
+ /**
75
+ * Called exactly once when the ask settles — answer, cancel, or "never
76
+ * shown because an earlier gate blocked". Returned fields are merged into
77
+ * the audit record (for example the session grant an answer created).
78
+ * A throwing settle is ignored: auditing must not affect the verdict.
79
+ */
80
+ settle?(
81
+ decision: AskDecision | undefined,
82
+ meta: { presented: boolean; ms: number },
83
+ ): ApprovalRecord | undefined;
59
84
  }
60
85
 
61
86
  /** A gate's opinion about one tool call. */
62
87
  export type GateVerdict =
63
88
  | undefined
64
89
  | { readonly kind: "allow" }
65
- | { readonly kind: "block"; readonly reason: string }
90
+ | {
91
+ readonly kind: "block";
92
+ readonly reason: string;
93
+ /** Structured context for the audit log. */
94
+ readonly audit?: ApprovalRecord;
95
+ }
66
96
  | { readonly kind: "ask"; readonly request: AskRequest };
67
97
 
68
98
  /** One feature's tool-call policy. Lower `order` runs first. */
@@ -78,6 +108,11 @@ export interface ToolGate {
78
108
  /** Result of composing the gates: a block reason, or nothing (allow). */
79
109
  export type GateResult = { readonly reason: string } | undefined;
80
110
 
111
+ export interface GateHubOptions {
112
+ /** Write-only audit sink; absent in tests and when logging is off. */
113
+ readonly audit?: ApprovalLog;
114
+ }
115
+
81
116
  export interface GateHub {
82
117
  register(gate: ToolGate): void;
83
118
  /** Compose every gate for one call (also the unit-test entry point). */
@@ -86,14 +121,74 @@ export interface GateHub {
86
121
  install(pi: ExtensionAPI): void;
87
122
  }
88
123
 
124
+ interface PendingAsk {
125
+ readonly gate: string;
126
+ readonly request: AskRequest;
127
+ }
128
+
129
+ interface SessionCounters {
130
+ session?: string;
131
+ startedAt: number;
132
+ calls: number;
133
+ asks: number;
134
+ blocks: number;
135
+ answers: Record<string, number>;
136
+ base?: ApprovalRecord;
137
+ }
138
+
89
139
  function messageOf(error: unknown): string {
90
140
  return error instanceof Error ? error.message : String(error);
91
141
  }
92
142
 
93
- export function createGateHub(): GateHub {
143
+ function emptyCounters(session?: string, base?: ApprovalRecord): SessionCounters {
144
+ return {
145
+ session,
146
+ startedAt: Date.now(),
147
+ calls: 0,
148
+ asks: 0,
149
+ blocks: 0,
150
+ answers: {},
151
+ base,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Outcome label plus the counter key for a settled ask. A plain nested ternary
157
+ * chain here was unreadable and easy to get wrong, so the mapping lives in one
158
+ * named place.
159
+ */
160
+ function answerLabel(
161
+ decision: AskDecision | undefined,
162
+ presented: boolean,
163
+ error: unknown,
164
+ ): { outcome: string; key: string } {
165
+ if (error !== undefined) return { outcome: "error", key: "error" };
166
+ if (!presented) return { outcome: "not-presented", key: "not-presented" };
167
+ if (decision === undefined) return { outcome: "cancelled", key: "cancelled" };
168
+ if (decision === "allow" || decision === "allow-session") {
169
+ return { outcome: "allowed", key: decision };
170
+ }
171
+ return { outcome: "blocked", key: decision };
172
+ }
173
+
174
+ export function createGateHub(options: GateHubOptions = {}): GateHub {
94
175
  const gates: ToolGate[] = [];
95
176
  let queue: Promise<void> = Promise.resolve();
96
177
 
178
+ const audit = options.audit;
179
+ const logging = audit !== undefined && audit.mode !== "off";
180
+ /**
181
+ * Counters per session id. A single slot would be wrong as soon as two
182
+ * sessions run in one process (a child/subagent session or a parent and a
183
+ * replaced runtime): each call from the other session would flush the first
184
+ * one's partial counters and start over, producing a burst of tiny summaries
185
+ * with misattributed numbers. Keyed storage keeps each session's counters
186
+ * intact regardless of interleaving.
187
+ */
188
+ const countersBySession = new Map<string, SessionCounters>();
189
+ /** Sessions kept in memory at once; each entry is a handful of numbers. */
190
+ const MAX_TRACKED_SESSIONS = 8;
191
+
97
192
  /**
98
193
  * Serialize dialogs without letting one rejection poison the queue:
99
194
  * the tail always settles, the caller still sees its own outcome.
@@ -107,27 +202,203 @@ export function createGateHub(): GateHub {
107
202
  return next;
108
203
  }
109
204
 
205
+ /**
206
+ * One summary record per session gives the analysis a denominator without
207
+ * filling the ring buffer with auto-allowed read/grep calls. It is written on
208
+ * shutdown (quit / reload / session replacement) and when a stale session is
209
+ * evicted; a crash loses at most the live sessions' counters.
210
+ */
211
+ function flushSession(
212
+ key: string,
213
+ reason: string,
214
+ fallback?: ApprovalRecord,
215
+ ): void {
216
+ const counters = countersBySession.get(key);
217
+ if (!counters) return;
218
+ countersBySession.delete(key);
219
+ if (!logging) return;
220
+ const base = counters.base ?? fallback;
221
+ const hadWork =
222
+ counters.calls > 0 || counters.asks > 0 || counters.blocks > 0;
223
+ if (base && hadWork) {
224
+ audit?.append({
225
+ ...base,
226
+ ts: new Date().toISOString(),
227
+ event: "session",
228
+ reason,
229
+ durationMs: Date.now() - counters.startedAt,
230
+ counts: {
231
+ calls: counters.calls,
232
+ asks: counters.asks,
233
+ blocks: counters.blocks,
234
+ answers: counters.answers,
235
+ },
236
+ });
237
+ }
238
+ }
239
+
240
+ /** Counters for this call's session, creating the slot on first use. */
241
+ function countersFor(
242
+ session: string | undefined,
243
+ base: ApprovalRecord,
244
+ ): SessionCounters {
245
+ const key = session ?? "";
246
+ let counters = countersBySession.get(key);
247
+ if (!counters) {
248
+ counters = emptyCounters(session, base);
249
+ countersBySession.set(key, counters);
250
+ if (countersBySession.size > MAX_TRACKED_SESSIONS) {
251
+ const oldest = countersBySession.keys().next().value;
252
+ if (oldest !== undefined && oldest !== key) {
253
+ flushSession(oldest, "evicted");
254
+ }
255
+ }
256
+ } else {
257
+ // Keep the newest envelope (cwd / model) for the summary.
258
+ counters.base = base;
259
+ }
260
+ return counters;
261
+ }
262
+
263
+ function logBlock(
264
+ gateName: string,
265
+ reason: string,
266
+ base: ApprovalRecord,
267
+ tool: ApprovalRecord,
268
+ payload: ApprovalRecord | undefined,
269
+ counters: SessionCounters | undefined,
270
+ ): void {
271
+ if (!logging) return;
272
+ if (counters) counters.blocks++;
273
+ audit?.append({
274
+ ...base,
275
+ ...tool,
276
+ ...(payload ?? {}),
277
+ event: "block",
278
+ gate: gateName,
279
+ reason: clipText(reason, 2048),
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Settle one ask and write its record. `presented: false` means an earlier
285
+ * gate blocked before this dialog could run — recorded, not silently lost.
286
+ */
287
+ function logAsk(
288
+ pending: PendingAsk,
289
+ base: ApprovalRecord,
290
+ tool: ApprovalRecord,
291
+ decision: AskDecision | undefined,
292
+ presented: boolean,
293
+ ms: number,
294
+ counters: SessionCounters | undefined,
295
+ queueMs?: number,
296
+ error?: unknown,
297
+ ): void {
298
+ if (!logging) return;
299
+ let patch: ApprovalRecord | undefined;
300
+ try {
301
+ patch = pending.request.settle?.(error ? undefined : decision, {
302
+ presented,
303
+ ms,
304
+ });
305
+ } catch {
306
+ // Auditing must never change the verdict.
307
+ }
308
+ const { outcome, key } = answerLabel(decision, presented, error);
309
+ if (counters) counters.answers[key] = (counters.answers[key] ?? 0) + 1;
310
+
311
+ const request = pending.request;
312
+ const prompt = Array.isArray(request.audit?.prompt)
313
+ ? request.audit.prompt
314
+ : [request.title, ...request.details];
315
+ audit?.append({
316
+ ...base,
317
+ ...tool,
318
+ ...(request.audit ?? {}),
319
+ ...(patch ?? {}),
320
+ event: "ask",
321
+ gate: pending.gate,
322
+ title: request.title,
323
+ prompt,
324
+ answer: decision
325
+ ? { choice: decision, ms, ...(queueMs === undefined ? {} : { queueMs }) }
326
+ : null,
327
+ outcome,
328
+ ...(error === undefined
329
+ ? {}
330
+ : { error: clipText(messageOf(error), 1024) }),
331
+ });
332
+ }
333
+
110
334
  async function handle(
111
335
  event: ToolCallEvent,
112
336
  ctx: ExtensionContext,
113
337
  ): Promise<GateResult> {
114
- const pending: AskRequest[] = [];
338
+ const base = logging ? auditBase(ctx) : {};
339
+ const counters = logging ? countersFor(sessionIdOf(ctx), base) : undefined;
340
+ const tool = logging ? auditTool(event) : {};
341
+ if (counters) counters.calls++;
342
+
343
+ const pending: PendingAsk[] = [];
115
344
  for (const gate of [...gates].sort((a, b) => a.order - b.order)) {
116
345
  let verdict: GateVerdict;
117
346
  try {
118
347
  verdict = await gate.evaluate(event, ctx);
119
348
  } catch (error) {
120
- return {
121
- reason: `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`,
122
- };
349
+ const reason = `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`;
350
+ logBlock(gate.name, reason, base, tool, {
351
+ verdict: { action: "block", layer: "gate-error" },
352
+ }, counters);
353
+ for (const abandoned of pending) {
354
+ logAsk(abandoned, base, tool, undefined, false, 0, counters);
355
+ }
356
+ return { reason };
123
357
  }
124
358
  if (!verdict || verdict.kind === "allow") continue;
125
- if (verdict.kind === "block") return { reason: verdict.reason };
126
- pending.push(verdict.request);
359
+ if (verdict.kind === "block") {
360
+ logBlock(
361
+ gate.name,
362
+ verdict.reason,
363
+ base,
364
+ tool,
365
+ verdict.audit,
366
+ counters,
367
+ );
368
+ for (const abandoned of pending) {
369
+ logAsk(abandoned, base, tool, undefined, false, 0, counters);
370
+ }
371
+ return { reason: verdict.reason };
372
+ }
373
+ if (counters) counters.asks++;
374
+ pending.push({ gate: gate.name, request: verdict.request });
127
375
  }
128
376
 
129
- for (const request of pending) {
130
- const decision = await runExclusive(() => request.present(ctx));
377
+ for (const current of pending) {
378
+ const request = current.request;
379
+ const started = Date.now();
380
+ let presentedAt = 0;
381
+ let decision: AskDecision | undefined;
382
+ let error: unknown;
383
+ try {
384
+ decision = await runExclusive(() => {
385
+ presentedAt = Date.now();
386
+ return request.present(ctx);
387
+ });
388
+ } catch (caught) {
389
+ error = caught;
390
+ }
391
+ const ms = Date.now() - started;
392
+ // `ms` is how long the call waited in total; `queueMs` is the part spent
393
+ // behind another dialog in the same assistant message. Analysis needs
394
+ // the split, otherwise a batch looks like a slow operator.
395
+ const queueMs = presentedAt === 0 ? ms : presentedAt - started;
396
+ logAsk(current, base, tool, decision, true, ms, counters, queueMs, error);
397
+ if (error !== undefined) {
398
+ return {
399
+ reason: `Permission dialog "${request.title}" failed: ${messageOf(error)}. The call was blocked.`,
400
+ };
401
+ }
131
402
  if (decision === undefined) {
132
403
  return {
133
404
  reason: `Permission check "${request.title}" was not answered; the call was blocked.`,
@@ -152,6 +423,16 @@ export function createGateHub(): GateHub {
152
423
  if (!result) return;
153
424
  return { block: true, reason: result.reason };
154
425
  });
426
+ // `session_shutdown` fires for quit / reload / session replacement;
427
+ // the summary closes the current segment before the runtime goes away.
428
+ pi.on("session_shutdown", (event, ctx) => {
429
+ if (!logging) return;
430
+ // `ctx` is the session being torn down, so this flushes exactly that
431
+ // session's counters. Other tracked sessions stay untouched: an
432
+ // in-process child session ending must not truncate its parent's
433
+ // summary into two halves. Stale slots are reclaimed by eviction.
434
+ flushSession(sessionIdOf(ctx) ?? "", event.reason, auditBase(ctx));
435
+ });
155
436
  },
156
437
  };
157
438
  }