@czottmann/pi-automode 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,7 +88,11 @@ 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 is 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.
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`. Writes and edits to protected in-tree paths (`.git/hooks`, `.pi` controls, shell profiles, config files) are exempt from the silent-allow tier and still go to the classifier.
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.
92
96
 
93
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.
94
98
 
@@ -99,6 +103,10 @@ Example:
99
103
  "autoMode": {
100
104
  "classifierModel": "provider/model-id",
101
105
  "classifierReasoningLevel": "low",
106
+ "classifyReadOnlyTools": false,
107
+ "fastClassifierMaxTokens": 512,
108
+ "allowInsideWorkingDirectory": false,
109
+ "deniedPaths": [],
102
110
  "maxUserTranscriptTokens": 4000,
103
111
  "maxToolTranscriptTokens": 4000,
104
112
  "environment": [
@@ -125,6 +133,10 @@ Example:
125
133
 
126
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.
127
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
+
128
140
  ### `$defaults`
129
141
 
130
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.
@@ -165,7 +177,9 @@ The extension blocks these before any allow or classifier decision:
165
177
  - root, home, and system-path destructive deletes
166
178
  - edits to `.pi/automode*`, `.pi` auto-mode files, and this extension's safety-control files
167
179
 
168
- 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` (default `false`) 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.
169
183
 
170
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.
171
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.
@@ -12,10 +12,11 @@ For each Pi `tool_call` event, the extension does this:
12
12
  4. Check `permissions.deny` rules.
13
13
  5. Check `permissions.ask` rules and ask the user when needed.
14
14
  6. Run deterministic hard-deny checks.
15
- 7. Allow read-only built-in tools without a classifier call.
16
- 8. Send every remaining action, including all writes and edits, through a one-token conservative filter.
17
- 9. Run structured classifier review only when the filter requests it, then allow or block.
18
- 10. Persist state and update the UI status/denial history.
15
+ 7. Run the path gate: `deniedPaths` matches block locally; with `allowInsideWorkingDirectory`, in-tree non-protected file access is allowed without a classifier call.
16
+ 8. Allow read-only built-in tools without a classifier call, unless `classifyReadOnlyTools` routes them through the classifier.
17
+ 9. Send every remaining action, including all writes and edits, through a one-token conservative filter.
18
+ 10. Run structured classifier review only when the filter requests it, then allow or block.
19
+ 11. Persist state and update the UI status/denial history.
19
20
 
20
21
  The default posture is fail-closed. If the classifier cannot be resolved, has no API key, errors, or returns an invalid stage response, the action is blocked.
21
22
 
@@ -43,7 +44,11 @@ flowchart TD
43
44
 
44
45
  J --> K{Deterministic hard-deny?}
45
46
  K -- yes --> K1[Block locally]
46
- K -- no --> L{Read-only built-in tool?}
47
+ K -- no --> K2{Path gate: deniedPaths match or in-tree allow tier?}
48
+
49
+ K2 -- denied --> K1[Block locally]
50
+ K2 -- in-tree, non-protected --> L1[Allow locally]
51
+ K2 -- no match or tier off --> L{Read-only built-in tool?}
47
52
 
48
53
  L -- yes --> L1[Allow locally]
49
54
  L -- no --> N[Run one-token filter]
@@ -145,9 +150,11 @@ Current deterministic blocks include:
145
150
 
146
151
  The `bash` checks use a small shell lexer. It handles quotes, redirects, pipes, `&&`, `||`, and `;` well enough to catch common "safe prefix, risky suffix" patterns.
147
152
 
148
- ### Read-only bypass
153
+ Recursive-delete checks treat `/`, the user's home root, and top-level system roots as hard-denied, but exempt the home *subtree*: subpaths of the user's home are user data, not system paths. On distros where `HOME` lives under `/var` (e.g. Fedora Silverblue with `/var/home/<user>`), `rm -rf` on home subpaths is therefore not hard-denied as a system-path delete, while `rm -rf ~` stays blocked.
154
+
155
+ ### Read-only bypass and the path gate
149
156
 
150
- Read-only built-in tools are allowed without classifier review after the checks above pass.
157
+ Read-only built-in tools are allowed without classifier review after the checks above pass, unless `classifyReadOnlyTools: true` routes them through the classifier instead.
151
158
 
152
159
  The read-only tool set is:
153
160
 
@@ -155,11 +162,13 @@ The read-only tool set is:
155
162
  read, grep, find, ls
156
163
  ```
157
164
 
158
- Reads to protected paths are still allowed. Every write and edit is classifier-reviewed, whether or not its target is protected.
165
+ Reads to protected paths are still allowed.
166
+
167
+ Two opt-in settings change the deterministic tier. `deniedPaths` blocks matching file-tool paths locally, before the classifier and any fast path. `allowInsideWorkingDirectory: true` allows file access inside the working directory without a classifier call — writes and edits included — while out-of-tree file access is routed to the classifier (reads included). Writes and edits to protected in-tree paths are exempt from the silent-allow tier and still reach the classifier. In the default configuration (both settings off), every write and edit is classifier-reviewed, whether or not its target is protected.
159
168
 
160
169
  ## Protected paths
161
170
 
162
- The protected-path configuration identifies safety-sensitive targets such as `.git`, `.pi`, editor config directories, shell profiles, package-manager config files, hook configs, and similar files. All writes and edits now go to the classifier, so there is no direct-write allow path that can bypass classifier policy for these or any other target.
171
+ The protected-path configuration identifies safety-sensitive targets such as `.git`, `.pi`, editor config directories, shell profiles, package-manager config files, hook configs, and similar files. In the default configuration every write and edit goes to the classifier, so there is no direct-write allow path that can bypass classifier policy. With `allowInsideWorkingDirectory: true`, non-protected in-tree writes take the deterministic allow tier, but protected targets still route to the classifier. `deniedPaths` can hard-deny any of these targets before the classifier.
163
172
 
164
173
  Deterministic safety-control checks still resolve paths canonically before classification. This catches writes through symlinks to auto-mode controls, shell profiles, and SSH authorization files without relying on the model.
165
174
 
package/docs/defaults.md CHANGED
@@ -46,13 +46,17 @@ These are exceptions to `soft_deny`, not to `hard_deny`.
46
46
 
47
47
  ### `protectedPaths`
48
48
 
49
- `$defaults` expands to safety-sensitive paths. Every `write` and `edit` call now goes to the classifier, so `protectedPaths` no longer changes whether a model call occurs; it remains part of the resolved configuration for compatibility and inspection. No path can be reached through a direct-write allow path, and `allow` rules cannot override a classifier hard-deny decision.
49
+ `$defaults` expands to safety-sensitive paths. In the default configuration every `write` and `edit` call goes to the classifier, so `protectedPaths` does not change whether a model call occurs; it remains part of the resolved configuration for compatibility and inspection. With `allowInsideWorkingDirectory: true`, non-protected in-tree file access takes the deterministic allow tier, but writes and edits to these protected paths still reach the classifier; `allow` rules cannot override a classifier hard-deny decision.
50
50
 
51
51
  Protected directories: `.git`, `.config/git`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.pi`.
52
52
 
53
53
  Protected files: `.gitconfig`, `.gitmodules`, `.gitignore`, `.gitattributes`, shell profiles (`.bashrc`, `.zshrc`, `.profile`, etc.), `.envrc`, package manager configs (`.npmrc`, `.yarnrc`, `.yarnrc.yml`, `.pnp.cjs`, `bunfig.toml`, etc.), Bazel configs (`.bazelrc`, `.bazelversion`, `.bazeliskrc`), hook configs (`.pre-commit-config.yaml`, `lefthook.yml`), Gradle/Maven wrappers, `.devcontainer.json`, `.ripgreprc`, `pyrightconfig.json`, `.mcp.json`.
54
54
 
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.
55
+ Read-only tools (`read`, `grep`, `find`, `ls`) remain locally allowed after permission and deterministic checks. In the default configuration writes and edits always require classification, regardless of their target; with `allowInsideWorkingDirectory` enabled, only protected targets still require it.
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.
56
60
 
57
61
  ### `soft_deny`
58
62
 
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "autoMode": {
3
3
  "classifierReasoningLevel": "low",
4
+ "allowInsideWorkingDirectory": false,
5
+ "deniedPaths": [
6
+ "*.env",
7
+ "~/.ssh/*",
8
+ "/etc/*"
9
+ ],
4
10
  "environment": [
5
11
  "$defaults",
6
12
  "Source control: github.example.com/acme-corp and all repos under it",
@@ -7,6 +7,7 @@ import {
7
7
  import type {
8
8
  AssistantMessage,
9
9
  Model,
10
+ ProviderHeaders,
10
11
  UserMessage,
11
12
  } from "@earendil-works/pi-ai";
12
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -14,6 +15,7 @@ import {
14
15
  CLASSIFIER_DETAILED_INSTRUCTION,
15
16
  CLASSIFIER_FAST_INSTRUCTION,
16
17
  CLASSIFIER_SYSTEM_PROMPT,
18
+ DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
17
19
  } from "./constants.ts";
18
20
  import { formatModelSpec, parseModelSpec } from "./model.ts";
19
21
  import { buildClassifierTranscript } from "./transcript.ts";
@@ -53,7 +55,7 @@ type ClassifierResolution = {
53
55
  classifier?: {
54
56
  model: Model<any>;
55
57
  apiKey?: string;
56
- headers?: Record<string, string>;
58
+ headers?: ProviderHeaders;
57
59
  };
58
60
  completionPlan?: ClassifierCompletionPlan;
59
61
  };
@@ -107,7 +109,7 @@ export type ClassifierCompletionFn = (
107
109
  options: { systemPrompt: string; messages: UserMessage[] },
108
110
  callOptions: {
109
111
  apiKey?: string;
110
- headers?: Record<string, string>;
112
+ headers?: ProviderHeaders;
111
113
  signal?: AbortSignal;
112
114
  maxTokens: number;
113
115
  temperature?: number;
@@ -129,10 +131,10 @@ export type RetryOptions = {
129
131
  onAttempt?: (attempt: ClassifierIoAttempt) => void;
130
132
  };
131
133
 
132
- const FAST_CLASSIFIER_MAX_TOKENS = 512;
133
-
134
134
  export type StagedClassifierOptions = {
135
135
  sessionId: string;
136
+ /** Override the fast-stage token budget; falls back to the default (512). */
137
+ fastClassifierMaxTokens?: number;
136
138
  reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
137
139
  onAttempt?: (attempt: ClassifierIoAttempt) => void;
138
140
  };
@@ -303,7 +305,7 @@ export async function classifyWithRetry(
303
305
  classifier: {
304
306
  model: Model<any>;
305
307
  apiKey?: string;
306
- headers?: Record<string, string>;
308
+ headers?: ProviderHeaders;
307
309
  },
308
310
  prompt: { systemPrompt: string; messages: UserMessage[] },
309
311
  signal: AbortSignal | undefined,
@@ -374,7 +376,7 @@ export async function classifyInStages(
374
376
  classifier: {
375
377
  model: Model<any>;
376
378
  apiKey?: string;
377
- headers?: Record<string, string>;
379
+ headers?: ProviderHeaders;
378
380
  },
379
381
  prompt: { systemPrompt: string; contextMessage: UserMessage },
380
382
  signal: AbortSignal | undefined,
@@ -398,7 +400,8 @@ export async function classifyInStages(
398
400
  signal,
399
401
  // Reasoning and OpenAI-compatible models may consume hidden reasoning,
400
402
  // control, and EOS tokens before emitting the required visible digit.
401
- maxTokens: FAST_CLASSIFIER_MAX_TOKENS,
403
+ maxTokens: options.fastClassifierMaxTokens ??
404
+ DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
402
405
  ...(options.reasoningLevel === undefined
403
406
  ? {}
404
407
  : { reasoning: options.reasoningLevel }),
@@ -521,6 +524,7 @@ export const defaultClassifyAction: ClassifyAction = async (
521
524
  ctx.signal,
522
525
  {
523
526
  sessionId: classifierCacheSessionId(ctx),
527
+ fastClassifierMaxTokens: config.fastClassifierMaxTokens,
524
528
  reasoningLevel: completionPlan.reasoningLevel,
525
529
  onAttempt: (attempt) => attempts.push(attempt),
526
530
  },
@@ -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,
@@ -99,6 +103,10 @@ export function validateSettingsFile(
99
103
  "enabled",
100
104
  "classifierModel",
101
105
  "classifierReasoningLevel",
106
+ "classifyReadOnlyTools",
107
+ "fastClassifierMaxTokens",
108
+ "allowInsideWorkingDirectory",
109
+ "deniedPaths",
102
110
  "maxUserTranscriptTokens",
103
111
  "maxToolTranscriptTokens",
104
112
  "environment",
@@ -136,6 +144,36 @@ export function validateSettingsFile(
136
144
  `${source}: autoMode.classifierReasoningLevel must be one of low, medium, high, xhigh, max`,
137
145
  );
138
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
+ );
139
177
  for (
140
178
  const key of [
141
179
  "maxUserTranscriptTokens",
@@ -283,6 +321,48 @@ function mergeLog(
283
321
  };
284
322
  }
285
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
+
286
366
  const CLASSIFIER_REASONING_LEVELS = new Set<ClassifierReasoningLevel>([
287
367
  "low",
288
368
  "medium",
@@ -302,6 +382,10 @@ function validTranscriptBudget(value: unknown): value is number {
302
382
  return Number.isInteger(value) && Number(value) >= 32;
303
383
  }
304
384
 
385
+ function validFastClassifierBudget(value: unknown): value is number {
386
+ return Number.isInteger(value) && Number(value) >= 16;
387
+ }
388
+
305
389
  function applyAutoModeScalars(
306
390
  base: EffectiveConfig,
307
391
  settings: AutoModeSettings | undefined,
@@ -316,6 +400,15 @@ function applyAutoModeScalars(
316
400
  )
317
401
  ? settings.classifierReasoningLevel
318
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,
319
412
  maxUserTranscriptTokens: validTranscriptBudget(
320
413
  settings.maxUserTranscriptTokens,
321
414
  )
@@ -357,6 +450,10 @@ export function buildEffectiveConfigFromSources(
357
450
  ): EffectiveConfig {
358
451
  let config: EffectiveConfig = {
359
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,
360
457
  maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
361
458
  maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
362
459
  environment: [...DEFAULT_ENVIRONMENT],
@@ -382,6 +479,7 @@ export function buildEffectiveConfigFromSources(
382
479
  const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
383
480
  const allow = createRuleAccumulator(DEFAULT_ALLOW);
384
481
  const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
482
+ const deniedPaths = createRuleAccumulator(DEFAULT_DENIED_PATHS);
385
483
  const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
386
484
  const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
387
485
 
@@ -390,6 +488,7 @@ export function buildEffectiveConfigFromSources(
390
488
  applyRuleSetting(environment, settings.autoMode?.environment);
391
489
  applyRuleSetting(allow, settings.autoMode?.allow);
392
490
  applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
491
+ applyRuleSetting(deniedPaths, settings.autoMode?.deniedPaths);
393
492
  applyRuleSetting(
394
493
  softDeny,
395
494
  settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
@@ -405,6 +504,7 @@ export function buildEffectiveConfigFromSources(
405
504
  environment: finalizeRuleSetting(environment),
406
505
  allow: finalizeRuleSetting(allow),
407
506
  protectedPaths: finalizeRuleSetting(protectedPaths),
507
+ deniedPaths: finalizeRuleSetting(deniedPaths),
408
508
  softDeny: finalizeRuleSetting(softDeny),
409
509
  hardDeny: finalizeRuleSetting(hardDeny),
410
510
  };
@@ -179,6 +179,38 @@ export const PROFILE_FILES = new Set([
179
179
 
180
180
  export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
181
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
+
182
214
  /** Default observability log config: off, classifier I/O off. */
183
215
  export const DEFAULT_LOG_CONFIG = {
184
216
  enabled: false,
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_HARD_DENY,
15
15
  DEFAULT_PROTECTED_PATHS,
16
16
  DEFAULT_SOFT_DENY,
17
+ PATH_BEARING_TOOLS,
17
18
  READ_ONLY_TOOLS,
18
19
  } from "./constants.ts";
19
20
  import {
@@ -30,7 +31,15 @@ import {
30
31
  } from "./log.ts";
31
32
  import { formatModelSpec, parseModelSpec } from "./model.ts";
32
33
  import { promptForClassifierModel } from "./model-selector.ts";
33
- 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";
34
43
  import {
35
44
  actionSummary,
36
45
  formatDenials,
@@ -242,7 +251,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
242
251
  // Enforcement order:
243
252
  // 1. permission deny/ask rules,
244
253
  // 2. deterministic hard-deny checks that never consult the model,
245
- // 3. read-only built-in fast path,
254
+ // 3. read-only built-in fast path (skipped when classifyReadOnlyTools is set),
246
255
  // 4. classifier for every remaining action, fail-closed on setup/parse errors.
247
256
  const cfg = effectiveConfig();
248
257
  if (!cfg.enabled) return undefined;
@@ -321,7 +330,73 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
321
330
  }, logCtx);
322
331
  }
323
332
 
324
- 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) {
325
400
  return allow(
326
401
  ctx,
327
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
  }
@@ -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,
@@ -40,6 +40,14 @@ export type AutoModeSettings = {
40
40
  enabled?: boolean;
41
41
  classifierModel?: string;
42
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;
43
51
  maxUserTranscriptTokens?: number;
44
52
  maxToolTranscriptTokens?: number;
45
53
  environment?: unknown;
@@ -76,6 +84,10 @@ export type EffectiveConfig = {
76
84
  enabled: boolean;
77
85
  classifierModel?: string;
78
86
  classifierReasoningLevel?: ClassifierReasoningLevel;
87
+ classifyReadOnlyTools: boolean;
88
+ fastClassifierMaxTokens: number;
89
+ allowInsideWorkingDirectory: boolean;
90
+ deniedPaths: string[];
79
91
  maxUserTranscriptTokens: number;
80
92
  maxToolTranscriptTokens: number;
81
93
  environment: string[];
@@ -108,12 +120,16 @@ export type DenialRecord = {
108
120
  | "permissions.deny"
109
121
  | "permissions.ask"
110
122
  | "deterministic-hard-deny"
123
+ | "deterministic-path-deny"
111
124
  | "classifier"
112
125
  | "setup";
113
126
  };
114
127
 
115
- /** Denial kind plus the read-only fast path, used for decision log entries. */
116
- 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";
117
133
 
118
134
  export type ClassificationDecision = {
119
135
  decision: "allow" | "block";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.9.0",
3
+ "version": "1.11.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.81.1",
45
- "@earendil-works/pi-coding-agent": "^0.81.1",
46
- "@earendil-works/pi-tui": "^0.81.1",
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"