@czottmann/pi-automode 1.7.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { complete } from "@earendil-works/pi-ai";
2
3
  import type {
3
4
  AssistantMessage,
@@ -5,9 +6,13 @@ import type {
5
6
  UserMessage,
6
7
  } from "@earendil-works/pi-ai";
7
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { CLASSIFIER_SYSTEM_PROMPT } from "./constants.ts";
9
+ import {
10
+ CLASSIFIER_DETAILED_INSTRUCTION,
11
+ CLASSIFIER_FAST_INSTRUCTION,
12
+ CLASSIFIER_SYSTEM_PROMPT,
13
+ } from "./constants.ts";
9
14
  import { formatModelSpec, parseModelSpec } from "./model.ts";
10
- import { buildTranscript } from "./transcript.ts";
15
+ import { buildClassifierTranscript } from "./transcript.ts";
11
16
  import type {
12
17
  ClassificationDecision,
13
18
  ClassifyAction,
@@ -65,7 +70,9 @@ export type ClassifierCompletionFn = (
65
70
  headers?: Record<string, string>;
66
71
  signal?: AbortSignal;
67
72
  maxTokens: number;
68
- temperature: number;
73
+ temperature?: number;
74
+ sessionId?: string;
75
+ cacheRetention?: "none" | "short" | "long";
69
76
  },
70
77
  ) => Promise<AssistantMessage>;
71
78
 
@@ -73,60 +80,144 @@ export type RetryOptions = {
73
80
  maxAttempts?: number;
74
81
  maxTokens?: number;
75
82
  temperature?: number;
83
+ sessionId?: string;
84
+ cacheRetention?: "none" | "short" | "long";
85
+ stage?: "fast" | "detailed";
76
86
  /** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
77
87
  onAttempt?: (attempt: ClassifierIoAttempt) => void;
78
88
  };
79
89
 
90
+ const FAST_CLASSIFIER_MAX_TOKENS = 4;
91
+
92
+ export type StagedClassifierOptions = {
93
+ sessionId: string;
94
+ onAttempt?: (attempt: ClassifierIoAttempt) => void;
95
+ };
96
+
80
97
  /** Concatenate all text blocks of an assistant message into a single string. */
81
- function extractAssistantText(message: AssistantMessage): string {
82
- return message.content
98
+ function extractAssistantText(message: AssistantMessage, trim = true): string {
99
+ const text = message.content
83
100
  .filter(
84
101
  (block): block is { type: "text"; text: string } => block.type === "text",
85
102
  )
86
103
  .map((block) => block.text)
87
- .join("\n")
88
- .trim();
104
+ .join("\n");
105
+ return trim ? text.trim() : text;
89
106
  }
90
107
 
91
- /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
108
+ /** Parse the exact detailed-stage JSON contract; any wrapper or shape drift fails closed. */
92
109
  export function parseClassifierDecision(
93
110
  message: AssistantMessage,
94
111
  ): ClassificationDecision | undefined {
95
112
  const text = extractAssistantText(message);
96
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
97
- const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
98
- Boolean,
99
- ) as string[];
100
- for (const candidate of candidates) {
101
- try {
102
- const parsed = JSON.parse(candidate) as Partial<ClassificationDecision>;
103
- if (
104
- (parsed.decision === "allow" || parsed.decision === "block") &&
105
- typeof parsed.reason === "string"
106
- ) {
107
- return {
108
- decision: parsed.decision,
109
- tier: parsed.tier ?? "none",
110
- reason: parsed.reason,
111
- };
112
- }
113
- } catch {
114
- // Try next candidate.
113
+ const validTiers = new Set<ClassificationDecision["tier"]>([
114
+ "hard_deny",
115
+ "soft_deny",
116
+ "allow",
117
+ "explicit_intent",
118
+ "none",
119
+ ]);
120
+ try {
121
+ for (const key of ["decision", "tier", "reason"]) {
122
+ const occurrences = text.match(new RegExp(`"${key}"\\s*:`, "g"))?.length ?? 0;
123
+ if (occurrences !== 1) return undefined;
124
+ }
125
+ const parsed = JSON.parse(text) as Record<string, unknown>;
126
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
127
+ return undefined;
128
+ }
129
+ const keys = Object.keys(parsed).sort();
130
+ if (keys.join(",") !== "decision,reason,tier") return undefined;
131
+ if (parsed.decision !== "allow" && parsed.decision !== "block") {
132
+ return undefined;
133
+ }
134
+ if (!validTiers.has(parsed.tier as ClassificationDecision["tier"])) {
135
+ return undefined;
136
+ }
137
+ const tier = parsed.tier as ClassificationDecision["tier"];
138
+ if (
139
+ (parsed.decision === "allow" &&
140
+ !["allow", "explicit_intent", "none"].includes(tier)) ||
141
+ (parsed.decision === "block" &&
142
+ !["hard_deny", "soft_deny", "none"].includes(tier))
143
+ ) {
144
+ return undefined;
115
145
  }
146
+ if (typeof parsed.reason !== "string" || parsed.reason.trim() === "") {
147
+ return undefined;
148
+ }
149
+ return {
150
+ decision: parsed.decision,
151
+ tier,
152
+ reason: parsed.reason,
153
+ };
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ function stageMessage(text: string): UserMessage {
160
+ return {
161
+ role: "user",
162
+ content: [{ type: "text", text }],
163
+ timestamp: Date.now(),
164
+ };
165
+ }
166
+
167
+ function responseAttempt(
168
+ stage: "fast" | "detailed",
169
+ attempt: number,
170
+ response: AssistantMessage,
171
+ durationMs: number,
172
+ parsed?: ClassificationDecision,
173
+ trimText = true,
174
+ ): ClassifierIoAttempt {
175
+ return {
176
+ stage,
177
+ attempt,
178
+ response: {
179
+ stopReason: response.stopReason,
180
+ text: extractAssistantText(response, trimText),
181
+ model: response.model,
182
+ timestamp: response.timestamp,
183
+ usage: response.usage,
184
+ ...(response.errorMessage === undefined
185
+ ? {}
186
+ : { errorMessage: response.errorMessage }),
187
+ },
188
+ parsed,
189
+ durationMs,
190
+ };
191
+ }
192
+
193
+ function classifierFailure(
194
+ response: AssistantMessage,
195
+ label: "Classifier" | "Fast classifier",
196
+ retryLength = false,
197
+ ): ClassificationDecision | undefined {
198
+ if (
199
+ response.stopReason === "stop" ||
200
+ (retryLength && response.stopReason === "length")
201
+ ) {
202
+ return undefined;
116
203
  }
117
- return undefined;
204
+ const fallback = response.stopReason === "aborted"
205
+ ? "Classifier model request was aborted."
206
+ : response.stopReason === "error"
207
+ ? "Classifier model returned an error response."
208
+ : `${label} response did not stop cleanly (${response.stopReason}).`;
209
+ return {
210
+ decision: "block",
211
+ tier: "none",
212
+ reason: `${label} failed; auto mode fails closed: ${
213
+ response.errorMessage || fallback
214
+ }`,
215
+ };
118
216
  }
119
217
 
120
218
  /**
121
- * Call the classifier model and parse its decision, retrying when the model
122
- * returns malformed or truncated output. Small classifier models occasionally
123
- * ramble into the token cap (stopReason "length") or emit prose instead of
124
- * JSON; a single retry recovers most of these transient failures.
125
- *
126
- * Safety is preserved: an "allow" is only returned when a response actually
127
- * parses to a valid allow decision. If every attempt fails to parse, the
128
- * function fails closed with a block decision. Thrown errors (network/auth)
129
- * are not retried — they fail closed immediately, matching prior behavior.
219
+ * Call the detailed classifier and parse its decision, retrying malformed or
220
+ * truncated output. Provider errors and exhausted retries fail closed.
130
221
  */
131
222
  export async function classifyWithRetry(
132
223
  completeFn: ClassifierCompletionFn,
@@ -141,7 +232,8 @@ export async function classifyWithRetry(
141
232
  ): Promise<ClassificationDecision> {
142
233
  const maxAttempts = options.maxAttempts ?? 2;
143
234
  const maxTokens = options.maxTokens ?? 1200;
144
- const temperature = options.temperature ?? 0;
235
+ const temperature = options.temperature;
236
+ const stage = options.stage ?? "detailed";
145
237
  const onAttempt = options.onAttempt;
146
238
  let lastReason =
147
239
  "Classifier response was not valid decision JSON; auto mode fails closed.";
@@ -157,12 +249,15 @@ export async function classifyWithRetry(
157
249
  headers: classifier.headers,
158
250
  signal,
159
251
  maxTokens,
160
- temperature,
252
+ ...(temperature === undefined ? {} : { temperature }),
253
+ sessionId: options.sessionId,
254
+ cacheRetention: options.cacheRetention,
161
255
  },
162
256
  );
163
257
  } catch (error) {
164
258
  const message = error instanceof Error ? error.message : String(error);
165
259
  onAttempt?.({
260
+ stage,
166
261
  attempt: attempt + 1,
167
262
  error: message,
168
263
  durationMs: Date.now() - started,
@@ -174,16 +269,14 @@ export async function classifyWithRetry(
174
269
  };
175
270
  }
176
271
  const durationMs = Date.now() - started;
177
- const decision = parseClassifierDecision(response);
178
- onAttempt?.({
179
- attempt: attempt + 1,
180
- response: {
181
- stopReason: response.stopReason,
182
- text: extractAssistantText(response),
183
- },
184
- parsed: decision,
185
- durationMs,
186
- });
272
+ const failure = classifierFailure(response, "Classifier", true);
273
+ const decision = response.stopReason === "stop"
274
+ ? parseClassifierDecision(response)
275
+ : undefined;
276
+ onAttempt?.(
277
+ responseAttempt(stage, attempt + 1, response, durationMs, decision, false),
278
+ );
279
+ if (failure) return failure;
187
280
  if (decision) return decision;
188
281
  lastReason =
189
282
  response.stopReason === "length"
@@ -193,6 +286,112 @@ export async function classifyWithRetry(
193
286
  return { decision: "block", tier: "none", reason: lastReason };
194
287
  }
195
288
 
289
+ /** Run the one-token conservative gate, then detailed review only when requested. */
290
+ export async function classifyInStages(
291
+ completeFn: ClassifierCompletionFn,
292
+ classifier: {
293
+ model: Model<any>;
294
+ apiKey?: string;
295
+ headers?: Record<string, string>;
296
+ },
297
+ prompt: { systemPrompt: string; contextMessage: UserMessage },
298
+ signal: AbortSignal | undefined,
299
+ options: StagedClassifierOptions,
300
+ ): Promise<ClassificationDecision> {
301
+ const fastStarted = Date.now();
302
+ let fastResponse: AssistantMessage;
303
+ try {
304
+ fastResponse = await completeFn(
305
+ classifier.model,
306
+ {
307
+ systemPrompt: prompt.systemPrompt,
308
+ messages: [
309
+ prompt.contextMessage,
310
+ stageMessage(CLASSIFIER_FAST_INSTRUCTION),
311
+ ],
312
+ },
313
+ {
314
+ apiKey: classifier.apiKey,
315
+ headers: classifier.headers,
316
+ signal,
317
+ // Some OpenAI-compatible servers count an initial control token and
318
+ // EOS against max_tokens. Four tokens reliably permit one visible digit.
319
+ maxTokens: FAST_CLASSIFIER_MAX_TOKENS,
320
+ sessionId: options.sessionId,
321
+ cacheRetention: "short",
322
+ },
323
+ );
324
+ } catch (error) {
325
+ const message = error instanceof Error ? error.message : String(error);
326
+ options.onAttempt?.({
327
+ stage: "fast",
328
+ attempt: 1,
329
+ error: message,
330
+ durationMs: Date.now() - fastStarted,
331
+ });
332
+ return {
333
+ decision: "block",
334
+ tier: "none",
335
+ reason: `Fast classifier failed; auto mode fails closed: ${message}`,
336
+ };
337
+ }
338
+
339
+ const fastText = extractAssistantText(fastResponse, false);
340
+ const failure = classifierFailure(fastResponse, "Fast classifier");
341
+ options.onAttempt?.(
342
+ responseAttempt(
343
+ "fast",
344
+ 1,
345
+ fastResponse,
346
+ Date.now() - fastStarted,
347
+ undefined,
348
+ false,
349
+ ),
350
+ );
351
+ if (failure) return failure;
352
+ if (fastText === "0") {
353
+ return {
354
+ decision: "allow",
355
+ tier: "none",
356
+ reason: "Fast classifier found no policy-relevant risk.",
357
+ };
358
+ }
359
+ if (fastText !== "1") {
360
+ return {
361
+ decision: "block",
362
+ tier: "none",
363
+ reason:
364
+ "Fast classifier response was not exactly 0 or 1; auto mode fails closed.",
365
+ };
366
+ }
367
+
368
+ return classifyWithRetry(
369
+ completeFn,
370
+ classifier,
371
+ {
372
+ systemPrompt: prompt.systemPrompt,
373
+ messages: [
374
+ prompt.contextMessage,
375
+ stageMessage(CLASSIFIER_DETAILED_INSTRUCTION),
376
+ ],
377
+ },
378
+ signal,
379
+ {
380
+ stage: "detailed",
381
+ sessionId: options.sessionId,
382
+ cacheRetention: "short",
383
+ onAttempt: options.onAttempt,
384
+ },
385
+ );
386
+ }
387
+
388
+ export function classifierCacheSessionId(ctx: ExtensionContext): string {
389
+ const source = ctx.sessionManager.getSessionId?.() ??
390
+ ctx.sessionManager.getSessionFile?.() ?? ctx.cwd;
391
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 32);
392
+ return `pi-automode-${digest}`;
393
+ }
394
+
196
395
  export const defaultClassifyAction: ClassifyAction = async (
197
396
  ctx,
198
397
  config,
@@ -209,32 +408,44 @@ export const defaultClassifyAction: ClassifyAction = async (
209
408
  }
210
409
 
211
410
  const systemPrompt = buildClassifierPrompt(config);
212
- const userText = `<loaded-project-instructions>\n${
411
+ const transcript = buildClassifierTranscript(ctx, {
412
+ maxUserTokens: config.maxUserTranscriptTokens,
413
+ maxToolTokens: config.maxToolTranscriptTokens,
414
+ });
415
+ const contextText = `<loaded-project-instructions>\n${
213
416
  loadedContext || "(none)"
214
- }\n</loaded-project-instructions>\n\n<transcript>\n${
215
- buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
216
- }\n</transcript>\n\nLatest action to classify:\n${action}`;
217
- const userMessage: UserMessage = {
417
+ }\n</loaded-project-instructions>\n\n<classifier-transcript>\n${
418
+ transcript || "(none)"
419
+ }\n</classifier-transcript>\n\nLatest action to classify:\n${action}`;
420
+ const contextMessage: UserMessage = {
218
421
  role: "user",
219
- content: [{ type: "text", text: userText }],
422
+ content: [{ type: "text", text: contextText }],
220
423
  timestamp: Date.now(),
221
424
  };
222
425
 
223
426
  const attempts: ClassifierIoAttempt[] = [];
224
427
  const started = Date.now();
225
- const decision = await classifyWithRetry(
428
+ const decision = await classifyInStages(
226
429
  complete,
227
430
  classifier,
228
- { systemPrompt, messages: [userMessage] },
431
+ { systemPrompt, contextMessage },
229
432
  ctx.signal,
230
- { onAttempt: (attempt) => attempts.push(attempt) },
433
+ {
434
+ sessionId: classifierCacheSessionId(ctx),
435
+ onAttempt: (attempt) => attempts.push(attempt),
436
+ },
231
437
  );
232
438
 
233
439
  return {
234
440
  ...decision,
235
441
  io: {
236
442
  model: formatModelSpec(classifier.model),
237
- prompt: { system: systemPrompt, user: userText },
443
+ prompt: {
444
+ system: systemPrompt,
445
+ context: contextText,
446
+ fastInstruction: CLASSIFIER_FAST_INSTRUCTION,
447
+ detailedInstruction: CLASSIFIER_DETAILED_INSTRUCTION,
448
+ },
238
449
  attempts,
239
450
  durationMs: Date.now() - started,
240
451
  },
@@ -5,7 +5,8 @@ import {
5
5
  DEFAULT_ENVIRONMENT,
6
6
  DEFAULT_HARD_DENY,
7
7
  DEFAULT_LOG_CONFIG,
8
- DEFAULT_MAX_TRANSCRIPT_LINES,
8
+ DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
9
+ DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
9
10
  DEFAULT_PROTECTED_PATHS,
10
11
  DEFAULT_SOFT_DENY,
11
12
  PI_GLOBAL_SETTINGS,
@@ -96,7 +97,8 @@ export function validateSettingsFile(
96
97
  const knownAutoMode = new Set([
97
98
  "enabled",
98
99
  "classifierModel",
99
- "maxTranscriptLines",
100
+ "maxUserTranscriptTokens",
101
+ "maxToolTranscriptTokens",
100
102
  "environment",
101
103
  "allow",
102
104
  "protectedPaths",
@@ -124,14 +126,20 @@ export function validateSettingsFile(
124
126
  `${source}: autoMode.classifierModel must be a provider/model string`,
125
127
  );
126
128
  }
127
- if (
128
- hasOwn(autoMode, "maxTranscriptLines") &&
129
- (!Number.isInteger(autoMode.maxTranscriptLines) ||
130
- Number(autoMode.maxTranscriptLines) <= 0)
129
+ for (
130
+ const key of [
131
+ "maxUserTranscriptTokens",
132
+ "maxToolTranscriptTokens",
133
+ ] as const
131
134
  ) {
132
- diagnostics.push(
133
- `${source}: autoMode.maxTranscriptLines must be a positive integer`,
134
- );
135
+ if (
136
+ hasOwn(autoMode, key) &&
137
+ (!Number.isInteger(autoMode[key]) || Number(autoMode[key]) < 32)
138
+ ) {
139
+ diagnostics.push(
140
+ `${source}: autoMode.${key} must be an integer of at least 32`,
141
+ );
142
+ }
135
143
  }
136
144
  validateStringArraySetting(
137
145
  autoMode.environment,
@@ -265,6 +273,10 @@ function mergeLog(
265
273
  };
266
274
  }
267
275
 
276
+ function validTranscriptBudget(value: unknown): value is number {
277
+ return Number.isInteger(value) && Number(value) >= 32;
278
+ }
279
+
268
280
  function applyAutoModeScalars(
269
281
  base: EffectiveConfig,
270
282
  settings: AutoModeSettings | undefined,
@@ -274,7 +286,16 @@ function applyAutoModeScalars(
274
286
  ...base,
275
287
  enabled: settings.enabled ?? base.enabled,
276
288
  classifierModel: settings.classifierModel ?? base.classifierModel,
277
- maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
289
+ maxUserTranscriptTokens: validTranscriptBudget(
290
+ settings.maxUserTranscriptTokens,
291
+ )
292
+ ? settings.maxUserTranscriptTokens
293
+ : base.maxUserTranscriptTokens,
294
+ maxToolTranscriptTokens: validTranscriptBudget(
295
+ settings.maxToolTranscriptTokens,
296
+ )
297
+ ? settings.maxToolTranscriptTokens
298
+ : base.maxToolTranscriptTokens,
278
299
  log: mergeLog(base.log, settings.log),
279
300
  };
280
301
  }
@@ -306,7 +327,8 @@ export function buildEffectiveConfigFromSources(
306
327
  ): EffectiveConfig {
307
328
  let config: EffectiveConfig = {
308
329
  enabled: true,
309
- maxTranscriptLines: DEFAULT_MAX_TRANSCRIPT_LINES,
330
+ maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
331
+ maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
310
332
  environment: [...DEFAULT_ENVIRONMENT],
311
333
  allow: [...DEFAULT_ALLOW],
312
334
  protectedPaths: [...DEFAULT_PROTECTED_PATHS],
@@ -55,7 +55,8 @@ export const DEFAULT_PROTECTED_PATHS = [
55
55
  ".mcp.json",
56
56
  ];
57
57
 
58
- export const DEFAULT_MAX_TRANSCRIPT_LINES = 80;
58
+ export const DEFAULT_MAX_USER_TRANSCRIPT_TOKENS = 4000;
59
+ export const DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS = 4000;
59
60
  export const DENIAL_HISTORY_LIMIT = 12;
60
61
 
61
62
  /** Built-in trusted environment. Users extend this with `$defaults`. */
@@ -115,7 +116,7 @@ Auto mode is active. Routine permission prompts are replaced by a pre-execution
115
116
  export const CLASSIFIER_SYSTEM_PROMPT =
116
117
  `You are an auto-mode security classifier for an autonomous coding agent.
117
118
 
118
- You evaluate the agent's latest tool call before it runs. Return only JSON.
119
+ You evaluate the agent's latest tool call before it runs. Follow the final output instruction exactly.
119
120
 
120
121
  Core semantics:
121
122
  1. HARD_DENY rules block unconditionally. User intent and allow exceptions do not override them.
@@ -144,9 +145,13 @@ Examples:
144
145
  - cat ~/.ssh/id_rsa or echo $API_TOKEN => block, hard_deny, credential exposure.
145
146
  - npm test, swift test, xcodebuild test, or local formatting/linting => allow unless the action includes a blocked side effect.
146
147
  - Uploading repository contents, logs with secrets, or internal data to an untrusted domain => block, hard_deny.
147
- - Modifying .pi/automode*, this extension, or permission rules => block, hard_deny.
148
+ - Modifying .pi/automode*, this extension, or permission rules => block, hard_deny.`;
148
149
 
149
- Respond with JSON exactly matching:
150
+ export const CLASSIFIER_FAST_INSTRUCTION =
151
+ `Return exactly one digit and nothing else: 0 only when the action is clearly allowed by the policy; 1 when it may need blocking or you are uncertain. Err on 1.`;
152
+
153
+ export const CLASSIFIER_DETAILED_INSTRUCTION =
154
+ `Return only JSON exactly matching:
150
155
  {"decision":"allow"|"block","tier":"hard_deny"|"soft_deny"|"allow"|"explicit_intent"|"none","reason":"brief concrete reason"}`;
151
156
 
152
157
  export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
@@ -28,7 +28,6 @@ import {
28
28
  import { formatModelSpec, parseModelSpec } from "./model.ts";
29
29
  import { promptForClassifierModel } from "./model-selector.ts";
30
30
  import { matchesToolPattern } from "./permissions.ts";
31
- import { isProtectedPath, resolveInputPath } from "./paths.ts";
32
31
  import {
33
32
  actionSummary,
34
33
  formatDenials,
@@ -64,9 +63,25 @@ type LogCtx = {
64
63
  classifierModel?: string;
65
64
  };
66
65
 
67
- /** Append a classifier I/O entry when classifier logging is enabled. */
66
+ /** Append ccusage-compatible usage and optional classifier I/O entries. */
68
67
  function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
69
- if (!log.logger.enabled || !log.logger.classifierIo || !decision.io) return;
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;
70
85
  log.logger.append({
71
86
  type: "classifier",
72
87
  ts: new Date().toISOString(),
@@ -214,10 +229,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
214
229
  });
215
230
 
216
231
  pi.on("tool_call", async (event, ctx) => {
217
- // Enforcement order mirrors Claude Code's documented model:
232
+ // Enforcement order:
218
233
  // 1. permission deny/ask rules,
219
234
  // 2. deterministic hard-deny checks that never consult the model,
220
- // 3. read-only fast path,
235
+ // 3. read-only built-in fast path,
221
236
  // 4. classifier for every remaining action, fail-closed on setup/parse errors.
222
237
  const cfg = effectiveConfig();
223
238
  if (!cfg.enabled) return undefined;
@@ -306,34 +321,6 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
306
321
  );
307
322
  }
308
323
 
309
- // Protected paths go to the classifier regardless of allow rules.
310
- if (event.toolName === "write" || event.toolName === "edit") {
311
- const path = resolveInputPath(ctx.cwd, input.path);
312
- if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
313
- const decision = await classify(ctx, cfg, summary, loadedContext);
314
- logClassifierIo(decision, logCtx);
315
- if (decision.decision === "allow") {
316
- state.classifierAllowed += 1;
317
- return allow(
318
- ctx,
319
- "classifier",
320
- decision.reason,
321
- event.toolName,
322
- summary,
323
- logCtx,
324
- );
325
- }
326
- state.classifierDenied += 1;
327
- return block(ctx, {
328
- timestamp: Date.now(),
329
- toolName: event.toolName,
330
- reason: decision.reason,
331
- action: summary,
332
- kind: "classifier",
333
- }, logCtx);
334
- }
335
- }
336
-
337
324
  const decision = await classify(ctx, cfg, summary, loadedContext);
338
325
  logClassifierIo(decision, logCtx);
339
326
  if (decision.decision === "allow") {
@@ -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
  }