@andares/pi-toolkits 0.9.8 → 0.11.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 +117 -5
- package/README.zh-CN.md +102 -5
- package/package.json +8 -3
- package/src/features/init/analyze.ts +162 -0
- package/src/features/init/apply.test.ts +264 -0
- package/src/features/init/apply.ts +693 -0
- package/src/features/init/audit.test.ts +292 -0
- package/src/features/init/audit.ts +586 -0
- package/src/features/init/coverage.test.ts +93 -0
- package/src/features/init/coverage.ts +163 -0
- package/src/features/init/detect.test.ts +237 -0
- package/src/features/init/detect.ts +808 -0
- package/src/features/init/dispatch.ts +135 -0
- package/src/features/init/index.test.ts +331 -0
- package/src/features/init/index.ts +569 -0
- package/src/features/init/plan.test.ts +261 -0
- package/src/features/init/plan.ts +867 -0
- package/src/features/init/render.ts +131 -0
- package/src/features/init/spec.ts +429 -0
- package/src/features/init/templates/agents-skeleton.md +44 -0
- package/src/features/init/templates/memory-readme.md +99 -0
- package/src/features/init/templates/signpost.md +15 -0
- package/src/features/init/templates/state-gitignore +6 -0
- package/src/features/init/templates.test.ts +58 -0
- package/src/features/init/templates.ts +79 -0
- package/src/features/init/test-helpers.ts +89 -0
- package/src/features/init/tool.ts +72 -0
- package/src/features/init/types.ts +284 -0
- package/src/features/init/ui.ts +160 -0
- 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/index.ts +9 -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 +291 -10
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
|
-
| {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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")
|
|
126
|
-
|
|
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
|
|
130
|
-
const
|
|
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
|
}
|