@czottmann/pi-automode 1.6.0 → 1.8.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.
@@ -19,10 +19,15 @@ import {
19
19
  writeGlobalClassifierModel,
20
20
  } from "./config.ts";
21
21
  import { deterministicHardDeny } from "./hard-deny.ts";
22
+ import {
23
+ createLogger,
24
+ newDecisionId,
25
+ resolveLogPath,
26
+ type Logger,
27
+ } from "./log.ts";
22
28
  import { formatModelSpec, parseModelSpec } from "./model.ts";
23
29
  import { promptForClassifierModel } from "./model-selector.ts";
24
30
  import { matchesToolPattern } from "./permissions.ts";
25
- import { isProtectedPath, resolveInputPath } from "./paths.ts";
26
31
  import {
27
32
  actionSummary,
28
33
  formatDenials,
@@ -35,7 +40,9 @@ import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
35
40
  import type {
36
41
  AutoModeState,
37
42
  ClassifyAction,
43
+ ClassifyResult,
38
44
  ConfigLoadResult,
45
+ DecisionKind,
39
46
  DenialRecord,
40
47
  EffectiveConfig,
41
48
  } from "./types.ts";
@@ -50,6 +57,47 @@ export type PiAutomodeOptions = {
50
57
  saveClassifierModel?: (classifierModel: string) => void;
51
58
  };
52
59
 
60
+ type LogCtx = {
61
+ logger: Logger;
62
+ decisionId: string;
63
+ classifierModel?: string;
64
+ };
65
+
66
+ /** Append ccusage-compatible usage and optional classifier I/O entries. */
67
+ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
68
+ if (!log.logger.enabled || !decision.io) return;
69
+
70
+ for (const attempt of decision.io.attempts) {
71
+ const response = attempt.response;
72
+ if (!response) continue;
73
+ log.logger.append({
74
+ type: "message",
75
+ timestamp: new Date(response.timestamp).toISOString(),
76
+ message: {
77
+ role: "assistant",
78
+ model: response.model,
79
+ usage: response.usage,
80
+ },
81
+ });
82
+ }
83
+
84
+ if (!log.logger.classifierIo) return;
85
+ log.logger.append({
86
+ type: "classifier",
87
+ ts: new Date().toISOString(),
88
+ decisionId: log.decisionId,
89
+ model: decision.io.model,
90
+ prompt: decision.io.prompt,
91
+ attempts: decision.io.attempts,
92
+ durationMs: decision.io.durationMs,
93
+ parsed: {
94
+ decision: decision.decision,
95
+ tier: decision.tier,
96
+ reason: decision.reason,
97
+ },
98
+ });
99
+ }
100
+
53
101
  /** Create a Pi extension instance. Default export uses production dependencies. */
54
102
  export function createPiAutomode(options: PiAutomodeOptions = {}) {
55
103
  const loadConfigWithDiagnostics = options.loadConfig
@@ -101,6 +149,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
101
149
  function block(
102
150
  ctx: ExtensionContext,
103
151
  denial: DenialRecord,
152
+ logCtx: LogCtx,
104
153
  ): { block: true; reason: string } {
105
154
  state.blockedActions += 1;
106
155
  state.lastDecision = "block";
@@ -108,6 +157,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
108
157
  pushDenial(state, denial);
109
158
  persist();
110
159
  updateUi(ctx);
160
+ if (logCtx.logger.enabled) {
161
+ logCtx.logger.append({
162
+ type: "decision",
163
+ ts: new Date().toISOString(),
164
+ decisionId: logCtx.decisionId,
165
+ sessionId: ctx.sessionManager.getSessionId?.(),
166
+ cwd: ctx.cwd,
167
+ tool: denial.toolName,
168
+ summary: denial.action,
169
+ kind: denial.kind,
170
+ outcome: "block",
171
+ reason: denial.reason,
172
+ classifierModel: logCtx.classifierModel,
173
+ });
174
+ }
111
175
  if (ctx.hasUI) {
112
176
  ctx.ui.notify(
113
177
  `Auto mode blocked ${denial.toolName}: ${denial.reason}`,
@@ -117,6 +181,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
117
181
  return { block: true, reason: `[pi-automode] ${denial.reason}` };
118
182
  }
119
183
 
184
+ function allow(
185
+ ctx: ExtensionContext,
186
+ kind: DecisionKind,
187
+ reason: string,
188
+ toolName: string,
189
+ summary: string,
190
+ logCtx: LogCtx,
191
+ ): undefined {
192
+ state.lastDecision = "allow";
193
+ state.lastReason = reason;
194
+ persist();
195
+ updateUi(ctx);
196
+ if (logCtx.logger.enabled) {
197
+ logCtx.logger.append({
198
+ type: "decision",
199
+ ts: new Date().toISOString(),
200
+ decisionId: logCtx.decisionId,
201
+ sessionId: ctx.sessionManager.getSessionId?.(),
202
+ cwd: ctx.cwd,
203
+ tool: toolName,
204
+ summary,
205
+ kind,
206
+ outcome: "allow",
207
+ reason,
208
+ classifierModel: logCtx.classifierModel,
209
+ });
210
+ }
211
+ return undefined;
212
+ }
213
+
120
214
  pi.on("session_start", (_event, ctx) => {
121
215
  loadResult = loadConfigWithDiagnostics(ctx.cwd);
122
216
  config = loadResult.config;
@@ -135,10 +229,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
135
229
  });
136
230
 
137
231
  pi.on("tool_call", async (event, ctx) => {
138
- // Enforcement order mirrors Claude Code's documented model:
232
+ // Enforcement order:
139
233
  // 1. permission deny/ask rules,
140
234
  // 2. deterministic hard-deny checks that never consult the model,
141
- // 3. read-only fast path,
235
+ // 3. read-only built-in fast path,
142
236
  // 4. classifier for every remaining action, fail-closed on setup/parse errors.
143
237
  const cfg = effectiveConfig();
144
238
  if (!cfg.enabled) return undefined;
@@ -147,6 +241,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
147
241
  const input = event.input as Record<string, unknown>;
148
242
  const summary = actionSummary(event.toolName, input);
149
243
  state.checkedActions += 1;
244
+ const logCtx: LogCtx = {
245
+ logger: createLogger({
246
+ enabled: cfg.log.enabled,
247
+ classifierIo: cfg.log.classifierIo,
248
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
249
+ sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
250
+ sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
251
+ }),
252
+ decisionId: newDecisionId(),
253
+ classifierModel: cfg.classifierModel,
254
+ };
150
255
 
151
256
  for (const pattern of cfg.permissionDeny) {
152
257
  if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
@@ -156,7 +261,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
156
261
  reason: `Blocked by permissions.deny: ${pattern.raw}`,
157
262
  action: summary,
158
263
  kind: "permissions.deny",
159
- });
264
+ }, logCtx);
160
265
  }
161
266
  }
162
267
 
@@ -172,7 +277,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
172
277
  `Matched permissions.ask (${pattern.raw}) but no UI is available`,
173
278
  action: summary,
174
279
  kind: "permissions.ask",
175
- });
280
+ }, logCtx);
176
281
  }
177
282
  const allowed = await ctx.ui.confirm(
178
283
  "Auto mode permission ask",
@@ -186,7 +291,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
186
291
  reason: `Declined permissions.ask: ${pattern.raw}`,
187
292
  action: summary,
188
293
  kind: "permissions.ask",
189
- });
294
+ }, logCtx);
190
295
  }
191
296
  }
192
297
 
@@ -202,49 +307,32 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
202
307
  reason: deterministicReason,
203
308
  action: summary,
204
309
  kind: "deterministic-hard-deny",
205
- });
310
+ }, logCtx);
206
311
  }
207
312
 
208
313
  if (READ_ONLY_TOOLS.has(event.toolName)) {
209
- state.lastDecision = "allow";
210
- state.lastReason = `Read-only built-in tool: ${event.toolName}`;
211
- persist();
212
- updateUi(ctx);
213
- return undefined;
214
- }
215
-
216
- // Protected paths go to the classifier regardless of allow rules.
217
- if (event.toolName === "write" || event.toolName === "edit") {
218
- const path = resolveInputPath(ctx.cwd, input.path);
219
- if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
220
- const decision = await classify(ctx, cfg, summary, loadedContext);
221
- if (decision.decision === "allow") {
222
- state.classifierAllowed += 1;
223
- state.lastDecision = "allow";
224
- state.lastReason = decision.reason;
225
- persist();
226
- updateUi(ctx);
227
- return undefined;
228
- }
229
- state.classifierDenied += 1;
230
- return block(ctx, {
231
- timestamp: Date.now(),
232
- toolName: event.toolName,
233
- reason: decision.reason,
234
- action: summary,
235
- kind: "classifier",
236
- });
237
- }
314
+ return allow(
315
+ ctx,
316
+ "read-only",
317
+ `Read-only built-in tool: ${event.toolName}`,
318
+ event.toolName,
319
+ summary,
320
+ logCtx,
321
+ );
238
322
  }
239
323
 
240
324
  const decision = await classify(ctx, cfg, summary, loadedContext);
325
+ logClassifierIo(decision, logCtx);
241
326
  if (decision.decision === "allow") {
242
327
  state.classifierAllowed += 1;
243
- state.lastDecision = "allow";
244
- state.lastReason = decision.reason;
245
- persist();
246
- updateUi(ctx);
247
- return undefined;
328
+ return allow(
329
+ ctx,
330
+ "classifier",
331
+ decision.reason,
332
+ event.toolName,
333
+ summary,
334
+ logCtx,
335
+ );
248
336
  }
249
337
 
250
338
  state.classifierDenied += 1;
@@ -254,7 +342,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
254
342
  reason: decision.reason,
255
343
  action: summary,
256
344
  kind: "classifier",
257
- });
345
+ }, logCtx);
258
346
  });
259
347
 
260
348
  async function handleAutomodeCommand(
@@ -328,9 +416,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
328
416
  return;
329
417
  }
330
418
  if (command === "config") {
419
+ const logFile = resolveLogPath(
420
+ ctx.sessionManager.getSessionFile?.(),
421
+ ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
422
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
423
+ );
331
424
  ctx.ui.notify(
332
425
  safeJson(
333
- { config: effectiveConfig(), diagnostics: configDiagnostics },
426
+ {
427
+ config: effectiveConfig(),
428
+ logFile,
429
+ diagnostics: configDiagnostics,
430
+ },
334
431
  16000,
335
432
  ),
336
433
  configDiagnostics.length > 0 ? "warning" : "info",
@@ -4,6 +4,7 @@ import {
4
4
  isProfileOrAuthorizedKeysPath,
5
5
  isSafetyControlPath,
6
6
  resolveInputPath,
7
+ resolvePathForPolicy,
7
8
  shellPathTokenToPath,
8
9
  } from "./paths.ts";
9
10
 
@@ -331,9 +332,11 @@ export function deterministicHardDeny(
331
332
  if (toolName === "write" || toolName === "edit") {
332
333
  const path = resolveInputPath(cwd, input.path);
333
334
  if (!path) return undefined;
334
- const profileReason = isProfileOrAuthorizedKeysPath(path);
335
+ const policyPath = resolvePathForPolicy(path) ?? path;
336
+ const policyCwd = resolvePathForPolicy(cwd) ?? cwd;
337
+ const profileReason = isProfileOrAuthorizedKeysPath(policyPath);
335
338
  if (profileReason) return profileReason;
336
- if (isSafetyControlPath(path, cwd)) {
339
+ if (isSafetyControlPath(policyPath, policyCwd)) {
337
340
  return "auto-mode or permission safety-control modification is hard-denied";
338
341
  }
339
342
  }
@@ -0,0 +1,115 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { basename, dirname, extname, join } from "node:path";
4
+ import type {
5
+ ClassifierIo,
6
+ ClassifierIoAttempt,
7
+ ClassificationDecision,
8
+ DecisionKind,
9
+ } from "./types.ts";
10
+
11
+ /** A final allow/block decision for a tool call. */
12
+ export type DecisionLogEntry = {
13
+ type: "decision";
14
+ ts: string;
15
+ decisionId: string;
16
+ sessionId?: string;
17
+ cwd: string;
18
+ tool: string;
19
+ summary: string;
20
+ kind: DecisionKind;
21
+ outcome: "allow" | "block";
22
+ reason: string;
23
+ classifierModel?: string;
24
+ };
25
+
26
+ /** The classifier prompt, raw responses, and parsed decision for one action. */
27
+ export type ClassifierLogEntry = {
28
+ type: "classifier";
29
+ ts: string;
30
+ decisionId: string;
31
+ model: string;
32
+ prompt: ClassifierIo["prompt"];
33
+ attempts: ClassifierIoAttempt[];
34
+ durationMs: number;
35
+ parsed: ClassificationDecision;
36
+ };
37
+
38
+ /** A ccusage-compatible record for one classifier model response. */
39
+ export type ClassifierUsageLogEntry = {
40
+ type: "message";
41
+ timestamp: string;
42
+ message: {
43
+ role: "assistant";
44
+ model: string;
45
+ usage: NonNullable<ClassifierIoAttempt["response"]>["usage"];
46
+ };
47
+ };
48
+
49
+ export type LogEntry =
50
+ | DecisionLogEntry
51
+ | ClassifierLogEntry
52
+ | ClassifierUsageLogEntry;
53
+
54
+ export type Logger = {
55
+ enabled: boolean;
56
+ classifierIo: boolean;
57
+ append(entry: LogEntry): void;
58
+ };
59
+
60
+ export type LoggerOptions = {
61
+ enabled: boolean;
62
+ classifierIo: boolean;
63
+ sessionFile?: string;
64
+ sessionDir: string;
65
+ sessionId: string;
66
+ };
67
+
68
+ /** Short id linking a classifier entry to its decision entry in the same file. */
69
+ export function newDecisionId(): string {
70
+ return randomBytes(4).toString("hex");
71
+ }
72
+
73
+ /**
74
+ * Derive the log file path from the current session: the session file's
75
+ * directory with `-pi-automode` inserted before the extension. Falls back to
76
+ * `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
77
+ */
78
+ export function resolveLogPath(
79
+ sessionFile: string | undefined,
80
+ sessionDir: string,
81
+ sessionId: string,
82
+ ): string {
83
+ if (sessionFile) {
84
+ const ext = extname(sessionFile);
85
+ const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
86
+ return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
87
+ }
88
+ return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
89
+ }
90
+
91
+ /** Append one JSON object as a line. Failures are swallowed: logging must
92
+ * never change a safety decision. */
93
+ function appendJsonl(path: string, entry: unknown): void {
94
+ try {
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
97
+ } catch {
98
+ // Fail open.
99
+ }
100
+ }
101
+
102
+ /** Build a logger bound to one session's log path. No-ops when disabled. */
103
+ export function createLogger(opts: LoggerOptions): Logger {
104
+ const { enabled, classifierIo } = opts;
105
+ const path = resolveLogPath(opts.sessionFile, opts.sessionDir, opts.sessionId);
106
+ return {
107
+ enabled,
108
+ classifierIo,
109
+ append(entry) {
110
+ if (!enabled) return;
111
+ if (entry.type === "classifier" && !classifierIo) return;
112
+ appendJsonl(path, entry);
113
+ },
114
+ };
115
+ }
@@ -1,9 +1,8 @@
1
- import { realpathSync } from "node:fs";
1
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import {
3
3
  basename,
4
4
  dirname,
5
5
  isAbsolute,
6
- join,
7
6
  normalize,
8
7
  relative,
9
8
  resolve,
@@ -34,53 +33,85 @@ export function isInside(child: string, parent: string): boolean {
34
33
  return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
35
34
  }
36
35
 
37
- export function isProtectedPath(
36
+ /** Resolve symlinks through the nearest existing ancestor of a path. */
37
+ export function resolvePathForPolicy(path: string): string | undefined {
38
+ return resolvePathForPolicyInner(resolve(path), new Set<string>());
39
+ }
40
+
41
+ function resolvePathForPolicyInner(
38
42
  path: string,
39
- cwd: string,
40
- protectedPaths: string[],
41
- ): boolean {
42
- // Resolve symlinks so writes through symlinks (e.g. not-git -> .git) are caught.
43
- let resolved = path;
44
- try {
45
- resolved = realpathSync(path);
46
- } catch {
47
- // File doesn't exist yet — try resolving the parent directory.
43
+ visitedSymlinks: Set<string>,
44
+ ): string | undefined {
45
+ let current = path;
46
+ const missingSegments: string[] = [];
47
+
48
+ while (true) {
48
49
  try {
49
- const dir = dirname(path);
50
- const base = basename(path);
51
- resolved = join(realpathSync(dir), base);
50
+ return resolve(realpathSync(current), ...missingSegments);
52
51
  } catch {
53
- // Parent doesn't exist either — fall through with raw path.
52
+ try {
53
+ const stat = lstatSync(current);
54
+ if (!stat.isSymbolicLink() || visitedSymlinks.has(current)) {
55
+ return undefined;
56
+ }
57
+ visitedSymlinks.add(current);
58
+ const target = resolve(dirname(current), readlinkSync(current));
59
+ return resolvePathForPolicyInner(
60
+ resolve(target, ...missingSegments),
61
+ visitedSymlinks,
62
+ );
63
+ } catch (error) {
64
+ const code = error && typeof error === "object" && "code" in error
65
+ ? String(error.code)
66
+ : undefined;
67
+ if (code !== "ENOENT" && code !== "ENOTDIR") return undefined;
68
+ const parent = dirname(current);
69
+ if (parent === current) return undefined;
70
+ missingSegments.unshift(basename(current));
71
+ current = parent;
72
+ }
54
73
  }
55
74
  }
75
+ }
76
+
77
+ export function matchesProtectedPath(
78
+ relativePath: string,
79
+ protectedPaths: string[],
80
+ ): boolean {
81
+ const normalizedPath = relativePath.replace(/\\/g, "/");
82
+ return protectedPaths.some((pattern) => {
83
+ const normalizedPattern = pattern.replace(/\\/g, "/");
84
+ return normalizedPath === normalizedPattern ||
85
+ normalizedPath.startsWith(`${normalizedPattern}/`);
86
+ });
87
+ }
88
+
89
+ export function isProtectedPath(
90
+ path: string,
91
+ cwd: string,
92
+ protectedPaths: string[],
93
+ ): boolean {
94
+ // Resolve through the nearest existing ancestor so symlinked directories are
95
+ // respected even when the final write target does not exist yet.
96
+ const resolved = resolvePathForPolicy(path) ?? path;
97
+ const resolvedCwd = resolvePathForPolicy(cwd) ?? cwd;
56
98
 
57
99
  // For paths inside the project: use relative path for matching.
58
- if (resolved.startsWith(cwd)) {
59
- const relativePath = relative(cwd, resolved);
60
- for (const pattern of protectedPaths) {
61
- if (
62
- relativePath === pattern ||
63
- relativePath.startsWith(`${pattern}/`)
64
- ) {
65
- return true;
66
- }
67
- }
68
- return false;
100
+ if (isInside(resolved, resolvedCwd)) {
101
+ return matchesProtectedPath(
102
+ relative(resolvedCwd, resolved),
103
+ protectedPaths,
104
+ );
69
105
  }
70
106
 
71
107
  // For paths outside the project: check every path component suffix.
72
108
  // This catches writes like ../other-project/.git/config even when cwd
73
109
  // doesn't contain the target.
74
- const segments = resolved.split("/").filter(Boolean);
110
+ const normalizedResolved = resolved.replace(/\\/g, "/");
111
+ const segments = normalizedResolved.split("/").filter(Boolean);
75
112
  for (let i = 0; i < segments.length; i++) {
76
- const suffix = segments.slice(i).join("/");
77
- for (const pattern of protectedPaths) {
78
- if (
79
- suffix === pattern ||
80
- suffix.startsWith(`${pattern}/`)
81
- ) {
82
- return true;
83
- }
113
+ if (matchesProtectedPath(segments.slice(i).join("/"), protectedPaths)) {
114
+ return true;
84
115
  }
85
116
  }
86
117
  return false;