@czottmann/pi-automode 1.8.2 → 1.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 CHANGED
@@ -88,12 +88,25 @@ This is project-local and should not be committed. Shared project `.pi/automode.
88
88
 
89
89
  Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
90
90
 
91
+ `classifierReasoningLevel` optionally requests `low`, `medium`, `high`, `xhigh`, or `max` reasoning for both classifier stages. If the key is absent, pi-automode sends no reasoning preference and leaves the choice to the server. Pi AI clamps unsupported values to the nearest level supported by the selected model; a non-reasoning model resolves to `off`. `low` matches Codex Auto Review's reasoning effort and the practical default when an explicit value is needed. Higher levels can consume the existing 512/1200-token stage limits before producing visible output, which causes the classifier to fail closed. Raise `fastClassifierMaxTokens` (default 512, integer ≥ 16) if you run a reasoning model whose fast-stage budget is truncated before it emits the required `0`/`1` digit.
92
+
93
+ `allowInsideWorkingDirectory` (default `false`) adds a deterministic silent-allow tier for the file tools (`read`, `write`, `edit`, `grep`, `find`, `ls`): when `true`, a call whose resolved path is inside the working directory is allowed without any classifier call, and file access outside the working directory is routed to the classifier (including reads, which would otherwise take the read-only fast path). This matches the Codex/Claude Code "inside the sandbox = silent, outside = review" model. The tier takes precedence over `classifyReadOnlyTools`: with both enabled, in-tree file access is still allowed without a classifier call, and out-of-tree file access is classified. `classifyReadOnlyTools: true` only routes in-tree reads to the classifier when `allowInsideWorkingDirectory` is `false`.
94
+
95
+ `deniedPaths` (default `[]`) is a list of path glob patterns that are hard-denied before the classifier and before the inside-working-directory tier — the file-tool equivalent of a secret/system deny list. Patterns support `~`, `$HOME`, and `${HOME}` expansion and `*` (which matches any characters, including `/`, so `**/id_rsa` matches a private key at any depth). Matching checks both the path as typed and its symlink-resolved form, so a `~/.ssh/*` rule still matches when `~/.ssh` is a symlink. A matching path blocks the call unconditionally (no classifier, no override). The deny list applies to file tools only; `bash` path access is governed by the classifier. Both keys follow the normal scalar/array precedence.
96
+
97
+ The setting follows the normal scalar precedence: global, then project-local, then `PI_AUTOMODE_SETTINGS_JSON`. Shared project `.pi/automode.json` cannot set it. Omitting the key at a higher-precedence scope does not clear a lower-precedence value.
98
+
91
99
  Example:
92
100
 
93
101
  ```json
94
102
  {
95
103
  "autoMode": {
96
104
  "classifierModel": "provider/model-id",
105
+ "classifierReasoningLevel": "low",
106
+ "classifyReadOnlyTools": false,
107
+ "fastClassifierMaxTokens": 512,
108
+ "allowInsideWorkingDirectory": false,
109
+ "deniedPaths": [],
97
110
  "maxUserTranscriptTokens": 4000,
98
111
  "maxToolTranscriptTokens": 4000,
99
112
  "environment": [
@@ -120,6 +133,10 @@ Example:
120
133
 
121
134
  `maxUserTranscriptTokens` and `maxToolTranscriptTokens` are approximate per-category budgets; both default to 4000 and accept integers of at least 32. The former `maxTranscriptLines` setting is no longer supported because evidence selection is token-budgeted rather than line-based.
122
135
 
136
+ ### Ask-user tools and explicit authorization
137
+
138
+ Classifier evidence includes normal user messages and assistant tool-call inputs, but excludes assistant prose and all tool results. This includes answers returned by ask-user tools such as `@vanillagreen/pi-questions`. Selecting "Yes" there helps the agent decide what to do next, but pi-automode does not treat that tool result as explicit authorization to override a soft deny. Send the authorization as a normal chat message instead; the agent can then retry the action. Tool results are excluded because they may contain untrusted or prompt-injected content.
139
+
123
140
  ### `$defaults`
124
141
 
125
142
  See [Defaults and rule-list behavior](docs/defaults.md) for built-in `environment`, `allow`, `protectedPaths`, `soft_deny`, and `hard_deny` entries, plus replacement behavior when `$defaults` is omitted.
@@ -160,7 +177,9 @@ The extension blocks these before any allow or classifier decision:
160
177
  - root, home, and system-path destructive deletes
161
178
  - edits to `.pi/automode*`, `.pi` auto-mode files, and this extension's safety-control files
162
179
 
163
- Read-only Pi tools (`read`, `grep`, `find`, `ls`) are allowed after those checks. Every side-effecting action goes to the classifier, including all `write` and `edit` calls, `bash`, MCP, subagent, network-capable tools, and unknown tools. This keeps classifier hard-deny rules unconditional; direct file writes cannot bypass them.
180
+ Read-only Pi tools (`read`, `grep`, `find`, `ls`) are allowed after those checks. Every side-effecting action goes to the classifier, including all `write` and `edit` calls, `bash`, MCP, subagent, network-capable tools, and unknown tools. This keeps classifier hard-deny rules unconditional; direct file writes cannot bypass them. Set `classifyReadOnlyTools: true` to route read-only tools through the classifier as well, so reads outside the trusted working tree can be denied by policy. With it enabled, every `read`, `grep`, `find`, and `ls` call runs the two-stage classifier, which raises the number of model calls, the latency, and the cost per session.
181
+
182
+ Path matches in `deniedPaths` are blocked before every classifier and fast-path decision, so secret and system paths never reach the model through the file tools. The deny list does not govern `bash`; shell access to those paths is handled by the classifier and the deterministic hard-deny checks. With `allowInsideWorkingDirectory: true`, file tools inside the working directory are allowed without a classifier call, and outside-working-directory file access (reads included) goes to the classifier.
164
183
 
165
184
  Classification starts with a one-token conservative filter and runs structured review only when that filter requests it. Both stages use a classifier-specific session key and short provider cache retention where the provider supports it. Missing models, provider failures, or malformed responses block the action.
166
185
 
@@ -0,0 +1,35 @@
1
+ # Glossary
2
+
3
+ Canonical vocabulary for `@czottmann/pi-automode`. Project-specific terms only — standard technical words appear here only when this project uses them in a specific way. Longer explanations live in the docs linked from each entry.
4
+
5
+ ## Enforcement flow
6
+
7
+ The ordered pipeline that runs on every agent tool call before execution. See [Auto-mode classifier flow](automode-classifier-flow.md).
8
+
9
+ **Auto mode** — Claude Code-style guardrail posture: a pre-execution classifier allows routine, reversible actions and blocks risky ones, replacing routine permission prompts.
10
+
11
+ **Deterministic hard-deny** — Local code checks that block high-risk actions before any classifier call and cannot be overridden. Distinct from the config-level [hard_deny](#classifier-policy-and-rules); independent of the model.
12
+
13
+ **Read-only bypass** — The default fast path where the read-only tools (`read`, `grep`, `find`, `ls`) are allowed without classifier review once permission and deterministic checks pass. `classifyReadOnlyTools` routes them through the classifier instead.
14
+
15
+ **Staged classifier** — The two-stage safety classifier: a conservative one-token filter gates an optional structured review. See [Fast stage](#enforcement-flow) and [Detailed stage](#enforcement-flow).
16
+
17
+ **Fast stage** — The first classifier stage: a one-token filter that returns `0` (clearly allowed) or `1` (may need review).
18
+
19
+ **Detailed stage** — The second classifier stage, run when the fast stage requests review, that returns a structured allow/block decision.
20
+
21
+ ## Classifier policy and rules
22
+
23
+ The classifier's deny tiers and rule-list syntax. See [Defaults and rule-list behavior](defaults.md).
24
+
25
+ **hard_deny** — Unconditional classifier rules that cannot be overridden. Distinct from the code-level [deterministic hard-deny](#enforcement-flow) checks.
26
+
27
+ **soft_deny** — Overridable classifier block rules, unlike [hard_deny](#classifier-policy-and-rules).
28
+
29
+ **explicit_intent** — A classifier tier meaning the allow was justified because the user's latest instruction directly and specifically authorized an otherwise [soft-denied](#classifier-policy-and-rules) action.
30
+
31
+ **`$defaults`** — A section-local marker in a rule list that expands to the built-in entries for that list.
32
+
33
+ ## Status
34
+
35
+ **AM status line** — The persistent TUI footer (prefixed `AM`) that reports auto-mode status: enabled/disabled and action/classifier counts.
@@ -253,6 +253,10 @@ The classifier model is selected in this order:
253
253
 
254
254
  `/automode model provider/model-id` and the interactive model picker save `autoMode.classifierModel` to `~/.pi/agent/automode.json`. Project-local `.pi/automode.local.json` can still override that global choice.
255
255
 
256
+ `autoMode.classifierReasoningLevel` can request `low`, `medium`, `high`, `xhigh`, or `max` reasoning for both classifier stages. When the key is absent, classifier calls use the raw completion path and omit a reasoning preference so the server can choose its default. When it is set, classifier calls use Pi AI's normalized completion path. Pi AI clamps the request to the nearest level supported by the model; non-reasoning models resolve to `off`, remain on the normalized path, and receive no reasoning preference.
257
+
258
+ Reasoning does not raise the stage token limits. A high level can consume the fast stage's 512 tokens or the detailed stage's 1200 tokens before producing valid visible output. Truncation still fails closed. `low` is the practical explicit setting and matches Codex Auto Review.
259
+
256
260
  The extension asks Pi's model registry for API credentials. If the model cannot be found or credentials are unavailable, classification returns a blocking decision:
257
261
 
258
262
  ```text
package/docs/defaults.md CHANGED
@@ -54,6 +54,10 @@ Protected files: `.gitconfig`, `.gitmodules`, `.gitignore`, `.gitattributes`, sh
54
54
 
55
55
  Read-only tools (`read`, `grep`, `find`, `ls`) remain locally allowed after permission and deterministic checks. Writes and edits always require classification, regardless of their target.
56
56
 
57
+ ### `deniedPaths`
58
+
59
+ `deniedPaths` is a separate opt-in list (default `[]`, no built-in entries, so `$defaults` is a no-op; it is accepted for consistency with the other rule lists) of path glob patterns that are hard-denied for the file tools (`read`, `write`, `edit`, `grep`, `find`, `ls`) before any classifier or fast-path decision. Use it for secrets and system paths that must never reach the model through the file tools; `bash` access to such paths remains classifier-governed. Patterns support `~`/`$HOME`/`${HOME}` expansion and `*` (matches any characters, including `/`). Matching checks both the typed path and its symlink-resolved form. `deniedPaths` only restricts; it never grants access.
60
+
57
61
  ### `soft_deny`
58
62
 
59
63
  `$defaults` expands to soft blocks for:
@@ -55,6 +55,21 @@ One per tool-call decision. Every allow and every block goes through exactly one
55
55
  | `outcome` | `allow` or `block` |
56
56
  | `reason` | the reason string (classifier reason, or the deterministic/permission reason) |
57
57
  | `classifierModel` | the configured classifier model, when relevant |
58
+ | `reasoning` | classifier reasoning mode and requested/effective level; see below |
59
+
60
+ The reasoning field records either server-default mode:
61
+
62
+ ```json
63
+ {"mode":"server-default"}
64
+ ```
65
+
66
+ or an explicit request after model-level clamping:
67
+
68
+ ```json
69
+ {"mode":"explicit","requestedLevel":"max","effectiveLevel":"xhigh"}
70
+ ```
71
+
72
+ Classifier-routed decisions contain the effective level once the configured model resolves, even when `classifierIo` is off or authentication then fails. If the configured model itself cannot be resolved, an explicit entry records `requestedLevel` without `effectiveLevel` because no model-supported level exists. A local permission, deterministic, or read-only decision does not run the classifier and likewise may omit `effectiveLevel`. In `server-default` mode, the concrete server-selected level is not observable and is not inferred.
58
73
 
59
74
  ### `message` (classifier usage)
60
75
 
@@ -78,6 +93,7 @@ Written only for classifier-routed actions, and only when `classifierIo: true`.
78
93
  | `ts` | ISO timestamp |
79
94
  | `decisionId` | matches the `decision` entry for the same call |
80
95
  | `model` | classifier model used, e.g. `anthropic/claude-haiku-4` |
96
+ | `reasoning` | `server-default`, or the explicit requested and effective model-supported level |
81
97
  | `prompt.system` | the full system policy with `environment`/`allow`/`soft_deny`/`hard_deny` rules interpolated |
82
98
  | `prompt.context` | the shared context message: loaded project instructions + classifier transcript + action |
83
99
  | `prompt.fastInstruction` | the exact one-token filter instruction |
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "autoMode": {
3
+ "classifierReasoningLevel": "low",
4
+ "allowInsideWorkingDirectory": false,
5
+ "deniedPaths": [
6
+ "*.env",
7
+ "~/.ssh/*",
8
+ "/etc/*"
9
+ ],
3
10
  "environment": [
4
11
  "$defaults",
5
12
  "Source control: github.example.com/acme-corp and all repos under it",
@@ -1,8 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { complete } from "@earendil-works/pi-ai";
2
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
3
+ import {
4
+ complete,
5
+ completeSimple,
6
+ } from "@earendil-works/pi-ai/compat";
3
7
  import type {
4
8
  AssistantMessage,
5
9
  Model,
10
+ ProviderHeaders,
6
11
  UserMessage,
7
12
  } from "@earendil-works/pi-ai";
8
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -10,6 +15,7 @@ import {
10
15
  CLASSIFIER_DETAILED_INSTRUCTION,
11
16
  CLASSIFIER_FAST_INSTRUCTION,
12
17
  CLASSIFIER_SYSTEM_PROMPT,
18
+ DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
13
19
  } from "./constants.ts";
14
20
  import { formatModelSpec, parseModelSpec } from "./model.ts";
15
21
  import { buildClassifierTranscript } from "./transcript.ts";
@@ -17,7 +23,11 @@ import type {
17
23
  ClassificationDecision,
18
24
  ClassifyAction,
19
25
  ClassifierIoAttempt,
26
+ ClassifierReasoning,
27
+ ClassifierReasoningLevel,
28
+ ClassifierReasoningLog,
20
29
  ClassifyResult,
30
+ EffectiveClassifierReasoningLevel,
21
31
  EffectiveConfig,
22
32
  } from "./types.ts";
23
33
 
@@ -40,13 +50,28 @@ export function buildClassifierPrompt(config: EffectiveConfig): string {
40
50
  );
41
51
  }
42
52
 
53
+ type ClassifierResolution = {
54
+ reasoning: ClassifierReasoningLog;
55
+ classifier?: {
56
+ model: Model<any>;
57
+ apiKey?: string;
58
+ headers?: ProviderHeaders;
59
+ };
60
+ completionPlan?: ClassifierCompletionPlan;
61
+ };
62
+
63
+ export function classifierReasoningForConfig(
64
+ requestedLevel: ClassifierReasoningLevel | undefined,
65
+ ): ClassifierReasoningLog {
66
+ return requestedLevel === undefined
67
+ ? { mode: "server-default" }
68
+ : { mode: "explicit", requestedLevel };
69
+ }
70
+
43
71
  async function resolveClassifier(
44
72
  ctx: ExtensionContext,
45
73
  config: EffectiveConfig,
46
- ): Promise<
47
- | { model: Model<any>; apiKey?: string; headers?: Record<string, string> }
48
- | undefined
49
- > {
74
+ ): Promise<ClassifierResolution> {
50
75
  const configured = config.classifierModel;
51
76
  const model = configured
52
77
  ? (() => {
@@ -56,10 +81,27 @@ async function resolveClassifier(
56
81
  : undefined;
57
82
  })()
58
83
  : ctx.model;
59
- if (!model) return undefined;
84
+ if (!model) {
85
+ return {
86
+ reasoning: classifierReasoningForConfig(config.classifierReasoningLevel),
87
+ };
88
+ }
89
+
90
+ const completionPlan = createClassifierCompletionPlan(
91
+ model,
92
+ config.classifierReasoningLevel,
93
+ );
60
94
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
61
- if (!auth.ok) return undefined;
62
- return { model, apiKey: auth.apiKey, headers: auth.headers };
95
+ if (!auth.ok) return { reasoning: completionPlan.reasoning };
96
+ return {
97
+ reasoning: completionPlan.reasoning,
98
+ classifier: {
99
+ model,
100
+ apiKey: auth.apiKey,
101
+ headers: auth.headers,
102
+ },
103
+ completionPlan,
104
+ };
63
105
  }
64
106
 
65
107
  export type ClassifierCompletionFn = (
@@ -67,10 +109,11 @@ export type ClassifierCompletionFn = (
67
109
  options: { systemPrompt: string; messages: UserMessage[] },
68
110
  callOptions: {
69
111
  apiKey?: string;
70
- headers?: Record<string, string>;
112
+ headers?: ProviderHeaders;
71
113
  signal?: AbortSignal;
72
114
  maxTokens: number;
73
115
  temperature?: number;
116
+ reasoning?: Exclude<EffectiveClassifierReasoningLevel, "off">;
74
117
  sessionId?: string;
75
118
  cacheRetention?: "none" | "short" | "long";
76
119
  },
@@ -80,6 +123,7 @@ export type RetryOptions = {
80
123
  maxAttempts?: number;
81
124
  maxTokens?: number;
82
125
  temperature?: number;
126
+ reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
83
127
  sessionId?: string;
84
128
  cacheRetention?: "none" | "short" | "long";
85
129
  stage?: "fast" | "detailed";
@@ -87,13 +131,50 @@ export type RetryOptions = {
87
131
  onAttempt?: (attempt: ClassifierIoAttempt) => void;
88
132
  };
89
133
 
90
- const FAST_CLASSIFIER_MAX_TOKENS = 512;
91
-
92
134
  export type StagedClassifierOptions = {
93
135
  sessionId: string;
136
+ /** Override the fast-stage token budget; falls back to the default (512). */
137
+ fastClassifierMaxTokens?: number;
138
+ reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
94
139
  onAttempt?: (attempt: ClassifierIoAttempt) => void;
95
140
  };
96
141
 
142
+ export type ClassifierCompletionPlan = {
143
+ completeFn: ClassifierCompletionFn;
144
+ reasoning: ClassifierReasoning;
145
+ reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
146
+ };
147
+
148
+ /** Select the raw or normalized Pi AI completion path and record the effective level. */
149
+ export function createClassifierCompletionPlan(
150
+ model: Model<any>,
151
+ requestedLevel: ClassifierReasoningLevel | undefined,
152
+ rawComplete: ClassifierCompletionFn = complete,
153
+ simpleComplete: ClassifierCompletionFn = completeSimple,
154
+ ): ClassifierCompletionPlan {
155
+ if (requestedLevel === undefined) {
156
+ return {
157
+ completeFn: rawComplete,
158
+ reasoning: { mode: "server-default" },
159
+ };
160
+ }
161
+
162
+ const effectiveLevel = clampThinkingLevel(model, requestedLevel);
163
+ const reasoning: ClassifierReasoning = {
164
+ mode: "explicit",
165
+ requestedLevel,
166
+ effectiveLevel,
167
+ };
168
+ if (effectiveLevel === "off") {
169
+ return { completeFn: simpleComplete, reasoning };
170
+ }
171
+ return {
172
+ completeFn: simpleComplete,
173
+ reasoning,
174
+ reasoningLevel: effectiveLevel,
175
+ };
176
+ }
177
+
97
178
  /** Concatenate all text blocks of an assistant message into a single string. */
98
179
  function extractAssistantText(message: AssistantMessage, trim = true): string {
99
180
  const text = message.content
@@ -224,7 +305,7 @@ export async function classifyWithRetry(
224
305
  classifier: {
225
306
  model: Model<any>;
226
307
  apiKey?: string;
227
- headers?: Record<string, string>;
308
+ headers?: ProviderHeaders;
228
309
  },
229
310
  prompt: { systemPrompt: string; messages: UserMessage[] },
230
311
  signal: AbortSignal | undefined,
@@ -250,6 +331,9 @@ export async function classifyWithRetry(
250
331
  signal,
251
332
  maxTokens,
252
333
  ...(temperature === undefined ? {} : { temperature }),
334
+ ...(options.reasoningLevel === undefined
335
+ ? {}
336
+ : { reasoning: options.reasoningLevel }),
253
337
  sessionId: options.sessionId,
254
338
  cacheRetention: options.cacheRetention,
255
339
  },
@@ -292,7 +376,7 @@ export async function classifyInStages(
292
376
  classifier: {
293
377
  model: Model<any>;
294
378
  apiKey?: string;
295
- headers?: Record<string, string>;
379
+ headers?: ProviderHeaders;
296
380
  },
297
381
  prompt: { systemPrompt: string; contextMessage: UserMessage },
298
382
  signal: AbortSignal | undefined,
@@ -316,7 +400,11 @@ export async function classifyInStages(
316
400
  signal,
317
401
  // Reasoning and OpenAI-compatible models may consume hidden reasoning,
318
402
  // control, and EOS tokens before emitting the required visible digit.
319
- maxTokens: FAST_CLASSIFIER_MAX_TOKENS,
403
+ maxTokens: options.fastClassifierMaxTokens ??
404
+ DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
405
+ ...(options.reasoningLevel === undefined
406
+ ? {}
407
+ : { reasoning: options.reasoningLevel }),
320
408
  sessionId: options.sessionId,
321
409
  cacheRetention: "short",
322
410
  },
@@ -380,6 +468,7 @@ export async function classifyInStages(
380
468
  stage: "detailed",
381
469
  sessionId: options.sessionId,
382
470
  cacheRetention: "short",
471
+ reasoningLevel: options.reasoningLevel,
383
472
  onAttempt: options.onAttempt,
384
473
  },
385
474
  );
@@ -398,14 +487,17 @@ export const defaultClassifyAction: ClassifyAction = async (
398
487
  action,
399
488
  loadedContext,
400
489
  ): Promise<ClassifyResult> => {
401
- const classifier = await resolveClassifier(ctx, config);
402
- if (!classifier) {
490
+ const resolution = await resolveClassifier(ctx, config);
491
+ if (!resolution.classifier || !resolution.completionPlan) {
403
492
  return {
404
493
  decision: "block",
405
494
  tier: "none",
406
495
  reason: "No classifier model/API key available; auto mode fails closed.",
496
+ reasoning: resolution.reasoning,
407
497
  };
408
498
  }
499
+ const classifier = resolution.classifier;
500
+ const completionPlan = resolution.completionPlan;
409
501
 
410
502
  const systemPrompt = buildClassifierPrompt(config);
411
503
  const transcript = buildClassifierTranscript(ctx, {
@@ -426,20 +518,24 @@ export const defaultClassifyAction: ClassifyAction = async (
426
518
  const attempts: ClassifierIoAttempt[] = [];
427
519
  const started = Date.now();
428
520
  const decision = await classifyInStages(
429
- complete,
521
+ completionPlan.completeFn,
430
522
  classifier,
431
523
  { systemPrompt, contextMessage },
432
524
  ctx.signal,
433
525
  {
434
526
  sessionId: classifierCacheSessionId(ctx),
527
+ fastClassifierMaxTokens: config.fastClassifierMaxTokens,
528
+ reasoningLevel: completionPlan.reasoningLevel,
435
529
  onAttempt: (attempt) => attempts.push(attempt),
436
530
  },
437
531
  );
438
532
 
439
533
  return {
440
534
  ...decision,
535
+ reasoning: completionPlan.reasoning,
441
536
  io: {
442
537
  model: formatModelSpec(classifier.model),
538
+ reasoning: completionPlan.reasoning,
443
539
  prompt: {
444
540
  system: systemPrompt,
445
541
  context: contextText,
@@ -2,7 +2,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import {
4
4
  DEFAULT_ALLOW,
5
+ DEFAULT_ALLOW_INSIDE_WORKING_DIRECTORY,
6
+ DEFAULT_CLASSIFY_READ_ONLY_TOOLS,
7
+ DEFAULT_DENIED_PATHS,
5
8
  DEFAULT_ENVIRONMENT,
9
+ DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
6
10
  DEFAULT_HARD_DENY,
7
11
  DEFAULT_LOG_CONFIG,
8
12
  DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
@@ -16,6 +20,7 @@ import {
16
20
  import { parseToolPattern } from "./permissions.ts";
17
21
  import type {
18
22
  AutoModeSettings,
23
+ ClassifierReasoningLevel,
19
24
  ConfigLoadResult,
20
25
  EffectiveConfig,
21
26
  LoadedSettingsFile,
@@ -97,6 +102,11 @@ export function validateSettingsFile(
97
102
  const knownAutoMode = new Set([
98
103
  "enabled",
99
104
  "classifierModel",
105
+ "classifierReasoningLevel",
106
+ "classifyReadOnlyTools",
107
+ "fastClassifierMaxTokens",
108
+ "allowInsideWorkingDirectory",
109
+ "deniedPaths",
100
110
  "maxUserTranscriptTokens",
101
111
  "maxToolTranscriptTokens",
102
112
  "environment",
@@ -126,6 +136,44 @@ export function validateSettingsFile(
126
136
  `${source}: autoMode.classifierModel must be a provider/model string`,
127
137
  );
128
138
  }
139
+ if (
140
+ hasOwn(autoMode, "classifierReasoningLevel") &&
141
+ !isClassifierReasoningLevel(autoMode.classifierReasoningLevel)
142
+ ) {
143
+ diagnostics.push(
144
+ `${source}: autoMode.classifierReasoningLevel must be one of low, medium, high, xhigh, max`,
145
+ );
146
+ }
147
+ if (
148
+ hasOwn(autoMode, "classifyReadOnlyTools") &&
149
+ typeof autoMode.classifyReadOnlyTools !== "boolean"
150
+ ) {
151
+ diagnostics.push(
152
+ `${source}: autoMode.classifyReadOnlyTools must be a boolean`,
153
+ );
154
+ }
155
+ if (
156
+ hasOwn(autoMode, "fastClassifierMaxTokens") &&
157
+ (!Number.isInteger(autoMode.fastClassifierMaxTokens) ||
158
+ (autoMode.fastClassifierMaxTokens as number) < 16)
159
+ ) {
160
+ diagnostics.push(
161
+ `${source}: autoMode.fastClassifierMaxTokens must be an integer of at least 16`,
162
+ );
163
+ }
164
+ if (
165
+ hasOwn(autoMode, "allowInsideWorkingDirectory") &&
166
+ typeof autoMode.allowInsideWorkingDirectory !== "boolean"
167
+ ) {
168
+ diagnostics.push(
169
+ `${source}: autoMode.allowInsideWorkingDirectory must be a boolean`,
170
+ );
171
+ }
172
+ validateDeniedPathsSetting(
173
+ autoMode.deniedPaths,
174
+ source,
175
+ diagnostics,
176
+ );
129
177
  for (
130
178
  const key of [
131
179
  "maxUserTranscriptTokens",
@@ -273,10 +321,71 @@ function mergeLog(
273
321
  };
274
322
  }
275
323
 
324
+ /**
325
+ * Validate `deniedPaths`: an array of non-empty path patterns. Unlike the
326
+ * `$defaults` rule lists there is no built-in default list, so `$defaults` is
327
+ * a no-op (accepted for consistency with the other rule lists) and omitting
328
+ * it is not a diagnostic.
329
+ */
330
+ function validateDeniedPathsSetting(
331
+ value: unknown,
332
+ source: string,
333
+ diagnostics: string[],
334
+ ): void {
335
+ if (value === undefined) return;
336
+ if (!Array.isArray(value)) {
337
+ diagnostics.push(`${source}: deniedPaths must be an array of strings`);
338
+ return;
339
+ }
340
+ for (const [index, entry] of value.entries()) {
341
+ if (entry === "$defaults") continue;
342
+ if (typeof entry !== "string" || entry.trim() === "") {
343
+ diagnostics.push(
344
+ `${source}: deniedPaths[${index}] must be a non-empty path pattern`,
345
+ );
346
+ continue;
347
+ }
348
+ if (!DENIED_PATH_PATTERN_PREFIX.test(entry)) {
349
+ diagnostics.push(
350
+ `${source}: deniedPaths[${index}] "${entry}" can never match a resolved absolute path; start it with *, ~, $HOME, \${HOME}, or / (e.g. "**/${entry}")`,
351
+ );
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * A pattern can only match a resolved absolute path when it starts with a
358
+ * form that anchors it: a leading `/`, a home expansion (`~`, `$HOME`,
359
+ * `${HOME}`), or a `*` wildcard that absorbs the leading slash. Anything else
360
+ * (e.g. `config.json` or `src/secret.txt`) matches only against the bare
361
+ * relative name, which the matcher never sees.
362
+ */
363
+ const DENIED_PATH_PATTERN_PREFIX =
364
+ /^(?:\/|~(?:\/|$)|\$HOME(?:\/|$)|\$\{HOME\}(?:\/|$)|\*)/;
365
+
366
+ const CLASSIFIER_REASONING_LEVELS = new Set<ClassifierReasoningLevel>([
367
+ "low",
368
+ "medium",
369
+ "high",
370
+ "xhigh",
371
+ "max",
372
+ ]);
373
+
374
+ export function isClassifierReasoningLevel(
375
+ value: unknown,
376
+ ): value is ClassifierReasoningLevel {
377
+ return typeof value === "string" &&
378
+ CLASSIFIER_REASONING_LEVELS.has(value as ClassifierReasoningLevel);
379
+ }
380
+
276
381
  function validTranscriptBudget(value: unknown): value is number {
277
382
  return Number.isInteger(value) && Number(value) >= 32;
278
383
  }
279
384
 
385
+ function validFastClassifierBudget(value: unknown): value is number {
386
+ return Number.isInteger(value) && Number(value) >= 16;
387
+ }
388
+
280
389
  function applyAutoModeScalars(
281
390
  base: EffectiveConfig,
282
391
  settings: AutoModeSettings | undefined,
@@ -286,6 +395,20 @@ function applyAutoModeScalars(
286
395
  ...base,
287
396
  enabled: settings.enabled ?? base.enabled,
288
397
  classifierModel: settings.classifierModel ?? base.classifierModel,
398
+ classifierReasoningLevel: isClassifierReasoningLevel(
399
+ settings.classifierReasoningLevel,
400
+ )
401
+ ? settings.classifierReasoningLevel
402
+ : base.classifierReasoningLevel,
403
+ classifyReadOnlyTools: settings.classifyReadOnlyTools ??
404
+ base.classifyReadOnlyTools,
405
+ allowInsideWorkingDirectory:
406
+ settings.allowInsideWorkingDirectory ?? base.allowInsideWorkingDirectory,
407
+ fastClassifierMaxTokens: validFastClassifierBudget(
408
+ settings.fastClassifierMaxTokens,
409
+ )
410
+ ? settings.fastClassifierMaxTokens
411
+ : base.fastClassifierMaxTokens,
289
412
  maxUserTranscriptTokens: validTranscriptBudget(
290
413
  settings.maxUserTranscriptTokens,
291
414
  )
@@ -327,6 +450,10 @@ export function buildEffectiveConfigFromSources(
327
450
  ): EffectiveConfig {
328
451
  let config: EffectiveConfig = {
329
452
  enabled: true,
453
+ classifyReadOnlyTools: DEFAULT_CLASSIFY_READ_ONLY_TOOLS,
454
+ allowInsideWorkingDirectory: DEFAULT_ALLOW_INSIDE_WORKING_DIRECTORY,
455
+ deniedPaths: [...DEFAULT_DENIED_PATHS],
456
+ fastClassifierMaxTokens: DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
330
457
  maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
331
458
  maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
332
459
  environment: [...DEFAULT_ENVIRONMENT],
@@ -352,6 +479,7 @@ export function buildEffectiveConfigFromSources(
352
479
  const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
353
480
  const allow = createRuleAccumulator(DEFAULT_ALLOW);
354
481
  const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
482
+ const deniedPaths = createRuleAccumulator(DEFAULT_DENIED_PATHS);
355
483
  const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
356
484
  const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
357
485
 
@@ -360,6 +488,7 @@ export function buildEffectiveConfigFromSources(
360
488
  applyRuleSetting(environment, settings.autoMode?.environment);
361
489
  applyRuleSetting(allow, settings.autoMode?.allow);
362
490
  applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
491
+ applyRuleSetting(deniedPaths, settings.autoMode?.deniedPaths);
363
492
  applyRuleSetting(
364
493
  softDeny,
365
494
  settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
@@ -375,6 +504,7 @@ export function buildEffectiveConfigFromSources(
375
504
  environment: finalizeRuleSetting(environment),
376
505
  allow: finalizeRuleSetting(allow),
377
506
  protectedPaths: finalizeRuleSetting(protectedPaths),
507
+ deniedPaths: finalizeRuleSetting(deniedPaths),
378
508
  softDeny: finalizeRuleSetting(softDeny),
379
509
  hardDeny: finalizeRuleSetting(hardDeny),
380
510
  };
@@ -155,6 +155,7 @@ export const CLASSIFIER_FAST_INSTRUCTION =
155
155
  export const CLASSIFIER_DETAILED_INSTRUCTION =
156
156
  `Return only JSON exactly matching:
157
157
  {"decision":"allow"|"block","tier":"hard_deny"|"soft_deny"|"allow"|"explicit_intent"|"none","reason":"brief concrete reason"}
158
+ Do not use Markdown, code fences, prose, or any wrapper. The first character must be { and the last character must be }.
158
159
  Valid decision/tier combinations:
159
160
  - allow: allow, explicit_intent, or none
160
161
  - block: hard_deny, soft_deny, or none
@@ -178,6 +179,38 @@ export const PROFILE_FILES = new Set([
178
179
 
179
180
  export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
180
181
 
182
+ /** File tools that expose a target path via `input.path` (for path gating). */
183
+ export const PATH_BEARING_TOOLS = new Set([
184
+ "read",
185
+ "write",
186
+ "edit",
187
+ "grep",
188
+ "find",
189
+ "ls",
190
+ ]);
191
+
192
+ /**
193
+ * Default behavior: read-only tools bypass the classifier entirely (the
194
+ * original auto-mode fast path). When set to true via config, read-only tools
195
+ * are routed through the classifier like any other action, so e.g. reads
196
+ * outside the trusted working tree can be denied by policy.
197
+ */
198
+ export const DEFAULT_CLASSIFY_READ_ONLY_TOOLS = false;
199
+
200
+ /** Default upper bound on fast-stage completion tokens (see PR note). */
201
+ export const DEFAULT_FAST_CLASSIFIER_MAX_TOKENS = 512;
202
+
203
+ /**
204
+ * Default behavior: no deterministic inside-working-directory tier; every
205
+ * non-read-only action is classified. When enabled, file tools whose resolved
206
+ * path is inside the working directory are allowed deterministically, and
207
+ * outside-CWD file access is routed to the classifier.
208
+ */
209
+ export const DEFAULT_ALLOW_INSIDE_WORKING_DIRECTORY = false;
210
+
211
+ /** Default path deny list: empty (no built-in secrets list). */
212
+ export const DEFAULT_DENIED_PATHS: string[] = [];
213
+
181
214
  /** Default observability log config: off, classifier I/O off. */
182
215
  export const DEFAULT_LOG_CONFIG = {
183
216
  enabled: false,
@@ -3,7 +3,10 @@ import type {
3
3
  ExtensionCommandContext,
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { defaultClassifyAction } from "./classifier.ts";
6
+ import {
7
+ classifierReasoningForConfig,
8
+ defaultClassifyAction,
9
+ } from "./classifier.ts";
7
10
  import {
8
11
  AUTO_MODE_GUIDANCE,
9
12
  DEFAULT_ALLOW,
@@ -11,6 +14,7 @@ import {
11
14
  DEFAULT_HARD_DENY,
12
15
  DEFAULT_PROTECTED_PATHS,
13
16
  DEFAULT_SOFT_DENY,
17
+ PATH_BEARING_TOOLS,
14
18
  READ_ONLY_TOOLS,
15
19
  } from "./constants.ts";
16
20
  import {
@@ -27,7 +31,15 @@ import {
27
31
  } from "./log.ts";
28
32
  import { formatModelSpec, parseModelSpec } from "./model.ts";
29
33
  import { promptForClassifierModel } from "./model-selector.ts";
30
- import { matchesToolPattern } from "./permissions.ts";
34
+ import { matchesDeniedPath, matchesToolPattern } from "./permissions.ts";
35
+ import {
36
+ expandHomePattern,
37
+ extractInputPath,
38
+ isInside,
39
+ isProtectedPath,
40
+ resolveInputPath,
41
+ resolvePathForPolicy,
42
+ } from "./paths.ts";
31
43
  import {
32
44
  actionSummary,
33
45
  formatDenials,
@@ -39,6 +51,7 @@ import {
39
51
  import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
40
52
  import type {
41
53
  AutoModeState,
54
+ ClassifierReasoningLog,
42
55
  ClassifyAction,
43
56
  ClassifyResult,
44
57
  ConfigLoadResult,
@@ -61,10 +74,13 @@ type LogCtx = {
61
74
  logger: Logger;
62
75
  decisionId: string;
63
76
  classifierModel?: string;
77
+ reasoning: ClassifierReasoningLog;
64
78
  };
65
79
 
66
80
  /** Append ccusage-compatible usage and optional classifier I/O entries. */
67
81
  function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
82
+ if (decision.reasoning) log.reasoning = decision.reasoning;
83
+ if (decision.io) log.reasoning = decision.io.reasoning;
68
84
  if (!log.logger.enabled || !decision.io) return;
69
85
 
70
86
  for (const attempt of decision.io.attempts) {
@@ -87,6 +103,7 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
87
103
  ts: new Date().toISOString(),
88
104
  decisionId: log.decisionId,
89
105
  model: decision.io.model,
106
+ reasoning: decision.io.reasoning,
90
107
  prompt: decision.io.prompt,
91
108
  attempts: decision.io.attempts,
92
109
  durationMs: decision.io.durationMs,
@@ -170,6 +187,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
170
187
  outcome: "block",
171
188
  reason: denial.reason,
172
189
  classifierModel: logCtx.classifierModel,
190
+ reasoning: logCtx.reasoning,
173
191
  });
174
192
  }
175
193
  if (ctx.hasUI) {
@@ -206,6 +224,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
206
224
  outcome: "allow",
207
225
  reason,
208
226
  classifierModel: logCtx.classifierModel,
227
+ reasoning: logCtx.reasoning,
209
228
  });
210
229
  }
211
230
  return undefined;
@@ -232,7 +251,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
232
251
  // Enforcement order:
233
252
  // 1. permission deny/ask rules,
234
253
  // 2. deterministic hard-deny checks that never consult the model,
235
- // 3. read-only built-in fast path,
254
+ // 3. read-only built-in fast path (skipped when classifyReadOnlyTools is set),
236
255
  // 4. classifier for every remaining action, fail-closed on setup/parse errors.
237
256
  const cfg = effectiveConfig();
238
257
  if (!cfg.enabled) return undefined;
@@ -251,6 +270,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
251
270
  }),
252
271
  decisionId: newDecisionId(),
253
272
  classifierModel: cfg.classifierModel,
273
+ reasoning: classifierReasoningForConfig(cfg.classifierReasoningLevel),
254
274
  };
255
275
 
256
276
  for (const pattern of cfg.permissionDeny) {
@@ -310,7 +330,73 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
310
330
  }, logCtx);
311
331
  }
312
332
 
313
- if (READ_ONLY_TOOLS.has(event.toolName)) {
333
+ // Deterministic path gate for file tools.
334
+ //
335
+ // `deniedPaths` always applies: a matching path is hard-denied before any
336
+ // classifier or fast path, so secrets and system dirs never reach the
337
+ // model. `allowInsideWorkingDirectory` adds a deterministic silent-allow
338
+ // tier for file tools whose resolved path is inside the working
339
+ // directory, and routes outside-CWD file access to the classifier
340
+ // (bypassing the read-only fast path so reads outside the tree are
341
+ // reviewed too).
342
+ //
343
+ // The gate is skipped entirely when both features are off, so the
344
+ // default configuration costs no extra filesystem calls.
345
+ let readOnlyFastPath =
346
+ !cfg.classifyReadOnlyTools && READ_ONLY_TOOLS.has(event.toolName);
347
+ if (
348
+ (cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
349
+ PATH_BEARING_TOOLS.has(event.toolName)
350
+ ) {
351
+ const inputPath = extractInputPath(event.toolName, input);
352
+ if (inputPath !== undefined) {
353
+ const expanded = expandHomePattern(inputPath);
354
+ const resolved = resolveInputPath(ctx.cwd, expanded) ?? expanded;
355
+ const policyPath = resolvePathForPolicy(resolved) ?? resolved;
356
+ const denied =
357
+ cfg.deniedPaths.length > 0 &&
358
+ (matchesDeniedPath(resolved, cfg.deniedPaths) ||
359
+ matchesDeniedPath(policyPath, cfg.deniedPaths));
360
+ if (denied) {
361
+ return block(ctx, {
362
+ timestamp: Date.now(),
363
+ toolName: event.toolName,
364
+ reason: `Path denied by policy: ${policyPath}`,
365
+ action: summary,
366
+ kind: "deterministic-path-deny",
367
+ }, logCtx);
368
+ }
369
+ if (cfg.allowInsideWorkingDirectory) {
370
+ const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
371
+ if (isInside(policyPath, policyCwd)) {
372
+ // Protected in-tree writes must still reach the classifier;
373
+ // otherwise the allow tier bypasses the protected-path policy
374
+ // for sensitive repository content such as .git/hooks/*, .pi/*,
375
+ // .husky/*, or .gitignore.
376
+ if (
377
+ (event.toolName === "write" || event.toolName === "edit") &&
378
+ isProtectedPath(policyPath, policyCwd, cfg.protectedPaths)
379
+ ) {
380
+ readOnlyFastPath = false;
381
+ } else {
382
+ return allow(
383
+ ctx,
384
+ "inside-working-directory",
385
+ `Path inside working directory: ${policyPath}`,
386
+ event.toolName,
387
+ summary,
388
+ logCtx,
389
+ );
390
+ }
391
+ }
392
+ // Outside the working directory: the read-only fast path must not
393
+ // apply; the classifier reviews this call.
394
+ readOnlyFastPath = false;
395
+ }
396
+ }
397
+ }
398
+
399
+ if (readOnlyFastPath) {
314
400
  return allow(
315
401
  ctx,
316
402
  "read-only",
@@ -156,7 +156,18 @@ function isRecursiveRmArg(arg: string): boolean {
156
156
  );
157
157
  }
158
158
 
159
- function isRootHomeOrSystemPath(path: string): boolean {
159
+ /**
160
+ * True for `/`, the user's home root, or a top-level system root such as
161
+ * `/etc`, `/usr`, or `/var`. Excludes the home *subtree*.
162
+ *
163
+ * On some distros (e.g. Fedora Silverblue) HOME lives under `/var`, which is
164
+ * in `systemRoots`. Without the subtree exemption, `path.startsWith("/var/")`
165
+ * would treat every path under HOME as a system root and hard-deny routine
166
+ * `rm -rf ~/...`. HOME itself is still matched below, so `rm -rf ~` stays
167
+ * blocked. `home` is a parameter so this can be unit-tested with a synthetic
168
+ * `/var/home/...` value.
169
+ */
170
+ export function isRootHomeOrSystemPath(path: string, home: string): boolean {
160
171
  const systemRoots = [
161
172
  "/bin",
162
173
  "/boot",
@@ -170,9 +181,10 @@ function isRootHomeOrSystemPath(path: string): boolean {
170
181
  "/usr",
171
182
  "/var",
172
183
  ];
184
+ if (path.startsWith(`${home}/`)) return false;
173
185
  return (
174
186
  path === "/" ||
175
- path === HOME ||
187
+ path === home ||
176
188
  systemRoots.some((root) => path === root || path.startsWith(`${root}/`))
177
189
  );
178
190
  }
@@ -261,7 +273,7 @@ function segmentHardDeny(
261
273
  if (name === "rm" && args.some(isRecursiveRmArg)) {
262
274
  for (const arg of args.filter((arg) => !arg.startsWith("-"))) {
263
275
  const path = shellPathTokenToPath(arg, cwd);
264
- if (path && isRootHomeOrSystemPath(path)) {
276
+ if (path && isRootHomeOrSystemPath(path, HOME)) {
265
277
  return "irreversible deletion of home/root/system paths is hard-denied";
266
278
  }
267
279
  }
@@ -269,7 +281,7 @@ function segmentHardDeny(
269
281
 
270
282
  if (name === "find" && lowerArgs.includes("-delete")) {
271
283
  const root = shellPathTokenToPath(args[0] ?? "", cwd);
272
- if (root && isRootHomeOrSystemPath(root) && root !== HOME) {
284
+ if (root && isRootHomeOrSystemPath(root, HOME) && root !== HOME) {
273
285
  return "system-wide delete is hard-denied";
274
286
  }
275
287
  }
@@ -4,6 +4,7 @@ import { basename, dirname, extname, join } from "node:path";
4
4
  import type {
5
5
  ClassifierIo,
6
6
  ClassifierIoAttempt,
7
+ ClassifierReasoningLog,
7
8
  ClassificationDecision,
8
9
  DecisionKind,
9
10
  } from "./types.ts";
@@ -21,6 +22,7 @@ export type DecisionLogEntry = {
21
22
  outcome: "allow" | "block";
22
23
  reason: string;
23
24
  classifierModel?: string;
25
+ reasoning: ClassifierReasoningLog;
24
26
  };
25
27
 
26
28
  /** The classifier prompt, raw responses, and parsed decision for one action. */
@@ -29,6 +31,7 @@ export type ClassifierLogEntry = {
29
31
  ts: string;
30
32
  decisionId: string;
31
33
  model: string;
34
+ reasoning: ClassifierIo["reasoning"];
32
35
  prompt: ClassifierIo["prompt"];
33
36
  attempts: ClassifierIoAttempt[];
34
37
  durationMs: number;
@@ -7,7 +7,7 @@ import {
7
7
  relative,
8
8
  resolve,
9
9
  } from "node:path";
10
- import { HOME, PROFILE_FILES } from "./constants.ts";
10
+ import { HOME, PATH_BEARING_TOOLS, PROFILE_FILES } from "./constants.ts";
11
11
 
12
12
  function stripLeadingAt(value: string): string {
13
13
  return value.startsWith("@") ? value.slice(1) : value;
@@ -22,6 +22,28 @@ export function resolveInputPath(
22
22
  return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
23
23
  }
24
24
 
25
+ /** The target path a file tool operates on, from `input.path` (or undefined). */
26
+ export function extractInputPath(
27
+ toolName: string,
28
+ input: Record<string, unknown>,
29
+ ): string | undefined {
30
+ if (!PATH_BEARING_TOOLS.has(toolName)) return undefined;
31
+ const value = input.path;
32
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
33
+ }
34
+
35
+ /** Expand a leading `~`, `$HOME`, or `${HOME}` in a path-denial pattern. */
36
+ export function expandHomePattern(pattern: string): string {
37
+ const home = HOME.replace(/\\/g, "/");
38
+ if (pattern === "~" || pattern === "$HOME" || pattern === "${HOME}") {
39
+ return home;
40
+ }
41
+ if (pattern.startsWith("~/")) return `${home}/${pattern.slice(2)}`;
42
+ if (pattern.startsWith("$HOME/")) return `${home}/${pattern.slice(6)}`;
43
+ if (pattern.startsWith("${HOME}/")) return `${home}/${pattern.slice(8)}`;
44
+ return pattern;
45
+ }
46
+
25
47
  export function normalizePathForMatch(path: string, cwd: string): string {
26
48
  const normalized = normalize(path);
27
49
  const rel = relative(cwd, normalized);
@@ -1,5 +1,9 @@
1
1
  import type { ToolPattern } from "./types.ts";
2
- import { normalizePathForMatch, resolveInputPath } from "./paths.ts";
2
+ import {
3
+ expandHomePattern,
4
+ normalizePathForMatch,
5
+ resolveInputPath,
6
+ } from "./paths.ts";
3
7
 
4
8
  function normalizeToolName(name: string): string {
5
9
  const lower = name.trim().replace(/^@/, "").toLowerCase();
@@ -75,6 +79,23 @@ function getPrimaryArgument(
75
79
  return JSON.stringify(input);
76
80
  }
77
81
 
82
+ /**
83
+ * Whether a resolved absolute path matches a configured path-denial pattern.
84
+ * Patterns support `~`/`$HOME` expansion and `*` globs, where `*` matches any
85
+ * characters, including `/`. Matching is case-insensitive and
86
+ * conservative-safe: over-matching only blocks more.
87
+ */
88
+ export function matchesDeniedPath(
89
+ resolvedPath: string,
90
+ deniedPaths: string[],
91
+ ): boolean {
92
+ const normalized = resolvedPath.replace(/\\/g, "/");
93
+ return deniedPaths.some((pattern) => {
94
+ const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
95
+ return wildcardToRegExp(expanded).test(normalized);
96
+ });
97
+ }
98
+
78
99
  /** Match a scoped permission rule against a concrete tool call. */
79
100
  export function matchesToolPattern(
80
101
  pattern: ToolPattern,
@@ -30,6 +30,7 @@ export function statusText(
30
30
  return [
31
31
  `enabled: ${(state.enabledOverride ?? config.enabled) ? "yes" : "no"}`,
32
32
  `classifier: ${config.classifierModel ?? "current session model"}`,
33
+ `classifier reasoning: ${config.classifierReasoningLevel ?? "server default"}`,
33
34
  `checked actions: ${state.checkedActions}`,
34
35
  `blocked actions: ${state.blockedActions}`,
35
36
  `classifier allowed: ${state.classifierAllowed}`,
@@ -1,6 +1,34 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
+ export type ClassifierReasoningLevel =
5
+ | "low"
6
+ | "medium"
7
+ | "high"
8
+ | "xhigh"
9
+ | "max";
10
+
11
+ export type EffectiveClassifierReasoningLevel =
12
+ | "off"
13
+ | "minimal"
14
+ | ClassifierReasoningLevel;
15
+
16
+ export type ClassifierReasoning =
17
+ | { mode: "server-default" }
18
+ | {
19
+ mode: "explicit";
20
+ requestedLevel: ClassifierReasoningLevel;
21
+ effectiveLevel: EffectiveClassifierReasoningLevel;
22
+ };
23
+
24
+ export type ClassifierReasoningLog =
25
+ | ClassifierReasoning
26
+ | {
27
+ mode: "explicit";
28
+ requestedLevel: ClassifierReasoningLevel;
29
+ effectiveLevel?: undefined;
30
+ };
31
+
4
32
  /** Observability log configuration. Off by default. */
5
33
  export type LogConfig = {
6
34
  enabled: boolean;
@@ -11,6 +39,15 @@ export type LogConfig = {
11
39
  export type AutoModeSettings = {
12
40
  enabled?: boolean;
13
41
  classifierModel?: string;
42
+ classifierReasoningLevel?: ClassifierReasoningLevel;
43
+ /** When true, read-only tools (read/grep/find/ls) are classified instead of auto-allowed. */
44
+ classifyReadOnlyTools?: boolean;
45
+ /** Override the fast-stage completion token budget (default 512). */
46
+ fastClassifierMaxTokens?: number;
47
+ /** When true, file tools whose resolved path is inside the working directory are allowed deterministically (no classifier), and outside-CWD file access is classified. */
48
+ allowInsideWorkingDirectory?: boolean;
49
+ /** Path glob patterns (file tools) that are always denied before the classifier. Supports `~` and `*` (matches any characters, including `/`). */
50
+ deniedPaths?: unknown;
14
51
  maxUserTranscriptTokens?: number;
15
52
  maxToolTranscriptTokens?: number;
16
53
  environment?: unknown;
@@ -46,6 +83,11 @@ export type ToolPattern = {
46
83
  export type EffectiveConfig = {
47
84
  enabled: boolean;
48
85
  classifierModel?: string;
86
+ classifierReasoningLevel?: ClassifierReasoningLevel;
87
+ classifyReadOnlyTools: boolean;
88
+ fastClassifierMaxTokens: number;
89
+ allowInsideWorkingDirectory: boolean;
90
+ deniedPaths: string[];
49
91
  maxUserTranscriptTokens: number;
50
92
  maxToolTranscriptTokens: number;
51
93
  environment: string[];
@@ -78,12 +120,16 @@ export type DenialRecord = {
78
120
  | "permissions.deny"
79
121
  | "permissions.ask"
80
122
  | "deterministic-hard-deny"
123
+ | "deterministic-path-deny"
81
124
  | "classifier"
82
125
  | "setup";
83
126
  };
84
127
 
85
- /** Denial kind plus the read-only fast path, used for decision log entries. */
86
- export type DecisionKind = DenialRecord["kind"] | "read-only";
128
+ /** Denial kind plus the deterministic allow fast paths, used for decision log entries. */
129
+ export type DecisionKind =
130
+ | DenialRecord["kind"]
131
+ | "read-only"
132
+ | "inside-working-directory";
87
133
 
88
134
  export type ClassificationDecision = {
89
135
  decision: "allow" | "block";
@@ -111,6 +157,7 @@ export type ClassifierIoAttempt = {
111
157
  /** Full classifier I/O for an action, surfaced for optional observability logging. */
112
158
  export type ClassifierIo = {
113
159
  model: string;
160
+ reasoning: ClassifierReasoning;
114
161
  prompt: {
115
162
  system: string;
116
163
  context: string;
@@ -121,8 +168,11 @@ export type ClassifierIo = {
121
168
  durationMs: number;
122
169
  };
123
170
 
124
- /** Classification decision plus the I/O that produced it (when available). */
125
- export type ClassifyResult = ClassificationDecision & { io?: ClassifierIo };
171
+ /** Classification decision plus resolved reasoning and the I/O that produced it (when available). */
172
+ export type ClassifyResult = ClassificationDecision & {
173
+ reasoning?: ClassifierReasoningLog;
174
+ io?: ClassifierIo;
175
+ };
126
176
 
127
177
  export type SettingsSources = {
128
178
  globalSettings?: SettingsFile[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.8.2",
3
+ "version": "1.10.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"
@@ -41,9 +41,9 @@
41
41
  "@earendil-works/pi-tui": "*"
42
42
  },
43
43
  "devDependencies": {
44
- "@earendil-works/pi-ai": "^0.79.3",
45
- "@earendil-works/pi-coding-agent": "^0.79.3",
46
- "@earendil-works/pi-tui": "^0.79.3",
44
+ "@earendil-works/pi-ai": "^0.84.1",
45
+ "@earendil-works/pi-coding-agent": "^0.84.1",
46
+ "@earendil-works/pi-tui": "^0.84.1",
47
47
  "@types/node": "^24.0.0",
48
48
  "tsx": "^4.22.4",
49
49
  "typescript": "^5.8.0"