@czottmann/pi-automode 1.2.0 → 1.4.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
@@ -37,33 +37,43 @@ pi -e ./extensions/auto-mode.ts
37
37
  /automode defaults # print the built-in rule lists
38
38
  /automode config # current effective config + diagnostics
39
39
  /automode denials # denial history for this session
40
- /automode model # open classifier model selector
41
- /automode model provider/model-id # set classifier directly
40
+ /automode model # open classifier model selector and save to ~/.pi/agent/automode.json
41
+ /automode model provider/model-id # save classifier model to ~/.pi/agent/automode.json
42
42
  ```
43
43
 
44
44
  `/auto-mode` is an alias.
45
45
 
46
+ ## Docs
47
+
48
+ - [Defaults and rule-list behavior](docs/defaults.md)
49
+ - [Auto-mode classifier flow](docs/automode-classifier-flow.md)
50
+
46
51
  ## Configuration
47
52
 
48
53
  The extension follows Claude Code's documented config model where Pi can support it.
49
54
 
50
55
  It reads `autoMode` from Pi-owned config only:
51
56
 
52
- - `~/.pi/automode.json`
57
+ - `~/.pi/agent/automode.json`
53
58
  - `.pi/automode.local.json`
54
59
  - `PI_AUTOMODE_SETTINGS_JSON`
55
60
 
56
61
  It deliberately does not read `autoMode` from shared project `.pi/automode.json`, because a checked-in repo should not be able to weaken auto-mode rules. Shared project config may still contribute `permissions.deny` and `permissions.ask`.
57
62
 
63
+ Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
64
+
58
65
  Example:
59
66
 
60
67
  ```json
61
68
  {
62
69
  "autoMode": {
70
+ "classifierModel": "provider/model-id",
63
71
  "environment": [
64
72
  "$defaults",
65
73
  "Source control: github.example.com/acme-corp and all repos under it",
66
- "Trusted internal domains: *.corp.example.com"
74
+ "Trusted internal domains: *.corp.example.com, git.example.com",
75
+ "Trusted cloud buckets: s3://acme-dev-artifacts, gs://acme-ci-cache",
76
+ "Key internal services: staging deploy API at deploy.corp.example.com"
67
77
  ],
68
78
  "allow": ["$defaults"],
69
79
  "protectedPaths": ["$defaults"],
@@ -82,105 +92,7 @@ Example:
82
92
 
83
93
  ### `$defaults`
84
94
 
85
- `$defaults` expands to this plugin's built-in entries for the section where it appears. It is section-local: `$defaults` in `allow` means the built-in allow rules, not the built-in hard-deny rules.
86
-
87
- #### `environment`
88
-
89
- `$defaults` expands to:
90
-
91
- - trusted repo: the repository Pi started in and its configured git remotes
92
- - source control: the trusted repo and its configured remotes only
93
- - trusted internal domains: none configured
94
- - trusted cloud buckets: none configured
95
- - key internal services: none configured
96
-
97
- #### `allow`
98
-
99
- `$defaults` expands to allow exceptions for:
100
-
101
- - read-only operations: inspecting files, listing directories, searching, GET requests, and state queries that do not expose secrets
102
- - local development inside the working tree: creating, editing, building, testing, linting, formatting, and deleting files created during the current task
103
- - installing dependencies already declared in package manifests or lockfiles
104
- - using standard credentials only with their intended configured providers
105
- - pushing to the current non-default working branch or a new branch created for the task
106
- - bootstrapping language/toolchain installers from official sources
107
-
108
- These are exceptions to `soft_deny`, not to `hard_deny`.
109
-
110
- #### `protectedPaths`
111
-
112
- `$defaults` expands to paths where writes are never auto-approved — they always go to the classifier, regardless of `allow` rules. This matches Claude Code's protected-paths behavior.
113
-
114
- Protected directories: `.git`, `.config/git`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.pi`.
115
-
116
- 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`.
117
-
118
- Read-only tools (`read`, `grep`, `find`, `ls`) bypass this check — reads to protected paths are always allowed. Only `write` and `edit` are affected.
119
-
120
- #### `soft_deny`
121
-
122
- `$defaults` expands to soft blocks for:
123
-
124
- - force push, remote branch deletion, remote history rewrite, or direct push to `main`, `master`, or the default branch
125
- - downloading and executing external code, including `curl | bash` and `wget | sh`
126
- - production deploys, production database migrations, or production infrastructure changes
127
- - deleting, truncating, or overwriting pre-existing local files unless the user named the specific target
128
- - mass modification/deletion in cloud storage, ticketing systems, collaboration tools, or external APIs
129
- - writing through remote shells such as `ssh`, `kubectl exec`, or `docker exec` on shared or production hosts
130
- - changing live/shared infrastructure without a preview, plan, dry-run, or review when one normally exists
131
- - killing other users' processes, cancelling shared jobs, or disrupting shared infrastructure
132
- - exposing local files, sockets, credentials, or internal services through a network service
133
- - outbound messages, purchases, bookings, subscriptions, or other real-world transactions
134
-
135
- Soft blocks can be overridden by a matching `allow` exception or by direct, specific user intent.
136
-
137
- #### `hard_deny`
138
-
139
- `$defaults` expands to unconditional blocks for:
140
-
141
- - weakening TLS, certificate validation, authentication, authorization, audit logging, or other security controls
142
- - adding persistence such as SSH keys, shell profile edits, cron jobs, launch agents, login items, or background agents
143
- - granting admin, owner, IAM, RBAC, repository, or production access
144
- - exfiltrating repository contents, secrets, credentials, private keys, tokens, or internal data to untrusted places
145
- - scanning for credentials, tokens, private keys, or secrets beyond what the task requires
146
- - tampering with logs, audit trails, security monitoring, permission rules, auto-mode config, or safety-control files
147
- - creating services, endpoints, workflows, or autonomous agents that execute arbitrary code without meaningful approval
148
- - posting or updating public/external content that is fabricated, misleading, impersonating a user, or claiming approval/action that did not happen
149
-
150
- Hard-deny rules cannot be overridden by `allow` or by user intent.
151
-
152
- #### Replacement behavior
153
-
154
- Use `$defaults` when you want to keep the built-ins and add your own entries:
155
-
156
- ```json
157
- {
158
- "autoMode": {
159
- "allow": [
160
- "$defaults",
161
- "Running the staging deploy script is allowed."
162
- ]
163
- }
164
- }
165
- ```
166
-
167
- That means: use all built-in `allow` entries, plus the staging rule.
168
-
169
- If you omit `$defaults`, you replace the built-ins for that section:
170
-
171
- ```json
172
- {
173
- "autoMode": {
174
- "allow": [
175
- "Running the staging deploy script is allowed."
176
- ]
177
- }
178
- }
179
- ```
180
-
181
- That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, `protectedPaths`, or `environment`.
182
-
183
- `$defaults` is not used in `permissions.deny` or `permissions.ask`. Those lists contain only explicit Pi tool patterns.
95
+ 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.
184
96
 
185
97
  ### Permission patterns
186
98
 
@@ -201,7 +113,7 @@ The extension blocks these before any allow or classifier decision:
201
113
 
202
114
  Read-only Pi tools (`read`, `grep`, `find`, `ls`) are allowed after those checks.
203
115
 
204
- Writes to [protected paths](#protectedpaths) (shell profiles, tool configs, `.git`, `.vscode`, `.pi`, etc.) always go to the classifier, even if an `allow` rule matches. The classifier decides whether to permit the write.
116
+ Writes to [protected paths](docs/defaults.md#protectedpaths) (shell profiles, tool configs, `.git`, `.vscode`, `.pi`, etc.) always go to the classifier, even if an `allow` rule matches. The classifier decides whether to permit the write.
205
117
 
206
118
  Everything else goes to the classifier. If the classifier is missing, fails, or returns invalid JSON, the action is blocked.
207
119
 
@@ -209,6 +121,10 @@ Everything else goes to the classifier. If the classifier is missing, fails, or
209
121
 
210
122
  - `examples/automode.local.json`: copy to `.pi/automode.local.json` in a project and edit the domains, buckets, and source-control org.
211
123
 
124
+ ## Known limits
125
+
126
+ Claude Code's real classifier and exact built-in rules are private. This package implements the documented precedence and configuration behavior, with a local classifier prompt and deterministic hard-deny checks.
127
+
212
128
  ## Development
213
129
 
214
130
  ```bash
@@ -219,6 +135,17 @@ npm pack --dry-run
219
135
 
220
136
  The tests cover the risky parts: scoped permission matching, config-source precedence, `$defaults` behavior, config diagnostics, deterministic hard-deny checks, shell parsing for risky bash fragments, classifier JSON parsing, hook-level blocking/allowing, classifier mocking, and protected-path enforcement.
221
137
 
222
- ## Known limits
138
+ ## Publishing
223
139
 
224
- Claude Code's real classifier and exact built-in rules are private. This package implements the documented precedence and configuration behavior, with a local classifier prompt and deterministic hard-deny checks.
140
+ GitHub Actions publishes the package to npm when a GitHub Release is published. The release tag must match `package.json` exactly, with or without a leading `v` (`v1.0.0` and `1.0.0` both work for version `1.0.0`).
141
+
142
+ The workflow uses npm Trusted Publishing, so it does not need an npm token secret. Configure this package on npm with this repository and workflow file (`.github/workflows/publish.yml`). The workflow builds the package, runs `npm run check`, and publishes with npm provenance.
143
+
144
+ ## Author
145
+
146
+ Carlo Zottmann, <carlo@zottmann.dev>
147
+
148
+ - Website: https://actions.work
149
+ - GitHub: https://github.com/czottmann
150
+ - Bluesky: https://bsky.app/profile/zottmann.dev
151
+ - Mastodon: https://norden.social/@czottmann
@@ -0,0 +1,320 @@
1
+ # Auto-mode classifier flow
2
+
3
+ This document describes how `pi-automode` decides whether an agent tool call can run. The classifier is only one part of the flow. Several checks happen before any model call, and some tool calls never reach the classifier at all.
4
+
5
+ ## Short version
6
+
7
+ For each Pi `tool_call` event, the extension does this:
8
+
9
+ 1. Load the effective auto-mode config for the current session.
10
+ 2. Ignore the call if auto-mode is disabled.
11
+ 3. Block immediately if the agent turn was cancelled.
12
+ 4. Check `permissions.deny` rules.
13
+ 5. Check `permissions.ask` rules and ask the user when needed.
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 to the classifier.
17
+ 9. Allow or block based on the classifier JSON response.
18
+ 10. Persist state and update the UI status/denial history.
19
+
20
+ The default posture is fail-closed. If the classifier cannot be resolved, has no API key, errors, or returns invalid JSON, the action is blocked.
21
+
22
+ ## Diagram
23
+
24
+ ```mermaid
25
+ flowchart TD
26
+ A[Pi emits tool_call] --> B[Build effective config]
27
+ B --> C{Auto-mode enabled?}
28
+ C -- no --> Z[Let tool run]
29
+ C -- yes --> D{ctx.signal aborted?}
30
+ D -- yes --> X[Block: cancelled]
31
+ D -- no --> E[Summarize action]
32
+
33
+ E --> F{Matches permissions.deny?}
34
+ F -- yes --> F1[Block locally]
35
+ F -- no --> G{Matches permissions.ask?}
36
+
37
+ G -- yes --> H{UI available?}
38
+ H -- no --> H1[Block locally]
39
+ H -- yes --> I[Ask user]
40
+ I -- declined --> I1[Block locally]
41
+ I -- allowed --> J[Continue]
42
+ G -- no --> J
43
+
44
+ J --> K{Deterministic hard-deny?}
45
+ K -- yes --> K1[Block locally]
46
+ K -- no --> L{Read-only built-in tool?}
47
+
48
+ L -- yes --> L1[Allow locally]
49
+ L -- no --> M{write/edit protected path?}
50
+ M -- yes --> N[Classify action]
51
+ M -- no --> N[Classify action]
52
+
53
+ N --> O{Classifier available and valid JSON?}
54
+ O -- no --> O1[Block: fail closed]
55
+ O -- yes --> P{decision == allow?}
56
+ P -- yes --> Q[Allow tool]
57
+ P -- no --> R[Block with classifier reason]
58
+
59
+ X --> S[Persist state + update UI]
60
+ F1 --> S
61
+ H1 --> S
62
+ I1 --> S
63
+ K1 --> S
64
+ O1 --> S
65
+ R --> S
66
+ L1 --> T[Persist allow state + update UI]
67
+ Q --> T
68
+ ```
69
+
70
+ ## Config loading
71
+
72
+ Config is loaded on `session_start` and can be reloaded with `/automode reload`.
73
+
74
+ The effective config combines these sources:
75
+
76
+ - `~/.pi/agent/automode.json`
77
+ - `.pi/automode.local.json`
78
+ - `PI_AUTOMODE_SETTINGS_JSON`
79
+ - shared `.pi/automode.json`, but only for `permissions.deny` and `permissions.ask`
80
+
81
+ Shared project `.pi/automode.json` cannot change `autoMode` rules. That is deliberate: a checked-in repo must not be able to weaken auto-mode. It may still add Pi permission rules.
82
+
83
+ List settings such as `allow`, `soft_deny`, `hard_deny`, `environment`, and `protectedPaths` support `$defaults`. Omitting `$defaults` replaces the built-ins for that section only. See [Defaults and rule-list behavior](defaults.md).
84
+
85
+ ## Context captured before classification
86
+
87
+ On `before_agent_start`, the extension appends `AUTO_MODE_GUIDANCE` to the main agent's system prompt. This reminds the main agent that auto-mode is active and tells it not to bypass or weaken the controls.
88
+
89
+ The same hook also extracts loaded context files from Pi's `systemPromptOptions.contextFiles`. That extracted text becomes `loadedContext`, which is later sent to the classifier. Each context file is formatted as:
90
+
91
+ ```text
92
+ # path/to/file
93
+ <truncated content>
94
+ ```
95
+
96
+ Each file's content is truncated in the middle to 4000 characters.
97
+
98
+ ## Local checks before the classifier
99
+
100
+ ### `permissions.deny`
101
+
102
+ `permissions.deny` is checked first. A matching rule blocks immediately. The classifier is not consulted.
103
+
104
+ Example rule:
105
+
106
+ ```json
107
+ "bash(git push --force*)"
108
+ ```
109
+
110
+ Permission patterns are scoped to the Pi tool and its primary argument. For `bash`, the primary argument is `input.command`. For file tools such as `read`, `write`, and `edit`, it is the resolved path normalized for matching.
111
+
112
+ ### `permissions.ask`
113
+
114
+ `permissions.ask` runs after `permissions.deny`.
115
+
116
+ If a rule matches and no UI is available, the action is blocked. If UI is available, the user sees a confirmation dialog with the matched rule and the action summary.
117
+
118
+ Approving that dialog does not run the tool directly. It only lets the action continue to the normal auto-mode checks, including deterministic hard-deny checks and classifier review.
119
+
120
+ ### Deterministic hard-deny checks
121
+
122
+ Some actions are too risky to leave to the classifier. These are blocked locally, before any classifier call.
123
+
124
+ Current deterministic blocks include:
125
+
126
+ - writes to shell profile files;
127
+ - writes to `~/.ssh/authorized_keys`;
128
+ - edits to auto-mode or Pi permission safety-control files;
129
+ - TLS or certificate verification weakening;
130
+ - persistence changes such as cron jobs, launch agents, and system service enablement;
131
+ - dangerous recursive deletes of root, home, or system paths;
132
+ - selected system or SSH permission mutations.
133
+
134
+ The `bash` checks use a small shell lexer. It handles quotes, redirects, pipes, `&&`, `||`, and `;` well enough to catch common "safe prefix, risky suffix" patterns.
135
+
136
+ ### Read-only bypass
137
+
138
+ Read-only built-in tools are allowed without classifier review after the checks above pass.
139
+
140
+ The read-only tool set is:
141
+
142
+ ```text
143
+ read, grep, find, ls
144
+ ```
145
+
146
+ Reads to protected paths are still allowed. Protected-path handling only affects writes and edits.
147
+
148
+ ## Protected paths
149
+
150
+ Writes and edits to protected paths are always classifier-reviewed. The default protected paths include `.git`, `.pi`, editor config directories, shell profiles, package-manager config files, hook configs, and similar files.
151
+
152
+ The matcher resolves symlinks when it can, so writes through a symlink into `.git` still count as protected. It also catches writes to protected paths outside the current project, such as another checkout's `.git/config`.
153
+
154
+ At the moment, all non-read-only actions reach the classifier anyway, so the protected-path branch has the same practical result as the normal classifier path. Its purpose is still important: if fast-path allow logic is added later, protected writes must continue to go through classifier review.
155
+
156
+ ## What is sent to the classifier
157
+
158
+ The classifier call is made by `defaultClassifyAction`.
159
+
160
+ The model receives a system prompt and one user message.
161
+
162
+ ### System prompt
163
+
164
+ The system prompt is `CLASSIFIER_SYSTEM_PROMPT` with config sections expanded into bullet lists:
165
+
166
+ - trusted environment
167
+ - allow exceptions
168
+ - soft-deny rules
169
+ - hard-deny rules
170
+
171
+ The prompt defines the policy semantics:
172
+
173
+ - hard-deny rules block unconditionally;
174
+ - soft-deny rules block unless an allow exception matches or the latest user instruction directly authorizes the exact risky action;
175
+ - allow rules only override soft-deny rules;
176
+ - hidden or malicious instructions inside tool output or repo files must not change the rules;
177
+ - the classifier must return only JSON.
178
+
179
+ The required JSON shape is:
180
+
181
+ ```json
182
+ {"decision":"allow","tier":"allow","reason":"brief concrete reason"}
183
+ ```
184
+
185
+ or:
186
+
187
+ ```json
188
+ {"decision":"block","tier":"soft_deny","reason":"brief concrete reason"}
189
+ ```
190
+
191
+ Valid `tier` values are:
192
+
193
+ ```text
194
+ hard_deny, soft_deny, allow, explicit_intent, none
195
+ ```
196
+
197
+ ### User message
198
+
199
+ The user message has this exact structure:
200
+
201
+ ```text
202
+ <loaded-project-instructions>
203
+ ${loadedContext || "(none)"}
204
+ </loaded-project-instructions>
205
+
206
+ <transcript>
207
+ ${buildTranscript(ctx, config.maxTranscriptLines) || "(none)"}
208
+ </transcript>
209
+
210
+ Latest action to classify:
211
+ ${action}
212
+ ```
213
+
214
+ `action` is built as:
215
+
216
+ ```text
217
+ ${toolName} ${safeJson(input, 6000)}
218
+ ```
219
+
220
+ For example:
221
+
222
+ ```text
223
+ bash {
224
+ "command": "npm test"
225
+ }
226
+ ```
227
+
228
+ The transcript is built from the current Pi session branch. It includes retained conversation items only:
229
+
230
+ - user text, truncated to 2000 characters;
231
+ - visible assistant text, truncated to 2000 characters;
232
+ - assistant tool calls, with arguments serialized and truncated.
233
+
234
+ Tool result contents are not currently included in the classifier transcript.
235
+
236
+ Hidden assistant reasoning is not sent by this extension. The classifier sees retained visible conversation and tool-call evidence, plus the latest proposed action.
237
+
238
+ ## Classifier model resolution
239
+
240
+ The classifier model is selected in this order:
241
+
242
+ 1. `autoMode.classifierModel` from config;
243
+ 2. the current Pi session model.
244
+
245
+ `/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.
246
+
247
+ 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:
248
+
249
+ ```text
250
+ No classifier model/API key available; auto mode fails closed.
251
+ ```
252
+
253
+ Classifier calls use:
254
+
255
+ ```text
256
+ temperature: 0
257
+ maxTokens: 700
258
+ signal: ctx.signal
259
+ ```
260
+
261
+ ## Parsing the classifier result
262
+
263
+ The parser accepts JSON returned directly, JSON inside a fenced code block, or the first JSON-looking object in the response.
264
+
265
+ A valid response must contain:
266
+
267
+ - `decision: "allow"` or `decision: "block"`
268
+ - `reason` as a string
269
+
270
+ If `tier` is missing, it defaults to `none`.
271
+
272
+ If parsing fails, the action is blocked with this reason:
273
+
274
+ ```text
275
+ Classifier response was not valid decision JSON; auto mode fails closed.
276
+ ```
277
+
278
+ If the model call throws, the action is blocked with a classifier failure message.
279
+
280
+ ## State, UI, and denial history
281
+
282
+ Every checked action increments `checkedActions`.
283
+
284
+ Allowed actions store:
285
+
286
+ - `lastDecision: "allow"`
287
+ - `lastReason`
288
+
289
+ Blocked actions also increment `blockedActions` and add a denial record. Denial records keep:
290
+
291
+ - timestamp;
292
+ - tool name;
293
+ - reason;
294
+ - action summary;
295
+ - denial kind.
296
+
297
+ Recent denial history is capped at 12 entries. State is persisted with `pi.appendEntry("pi-automode-state", state)` so it survives reloads and session restoration.
298
+
299
+ When UI is available, the extension updates the footer status and shows a warning notification for blocked actions.
300
+
301
+ ## Command interactions
302
+
303
+ The classifier flow can be inspected or changed through slash commands:
304
+
305
+ ```text
306
+ /automode status
307
+ /automode on
308
+ /automode off
309
+ /automode reload
310
+ /automode reset
311
+ /automode defaults
312
+ /automode config
313
+ /automode denials
314
+ /automode model
315
+ /automode model provider/model-id
316
+ ```
317
+
318
+ `/auto-mode` is an alias.
319
+
320
+ `/automode off` disables the whole flow for the current session. `/automode on` re-enables it. `/automode model` saves the classifier model to `~/.pi/agent/automode.json`.
@@ -0,0 +1,121 @@
1
+ # Defaults and rule-list behavior
2
+
3
+ ## `$defaults`
4
+
5
+ `$defaults` expands to this plugin's built-in entries for the section where it appears. It is section-local: `$defaults` in `allow` means the built-in allow rules, not the built-in hard-deny rules.
6
+
7
+ ### `environment`
8
+
9
+ `$defaults` expands to:
10
+
11
+ - trusted repo: the repository Pi started in and its configured git remotes
12
+ - source control: the trusted repo and its configured remotes only
13
+ - trusted internal domains: none configured
14
+ - trusted cloud buckets: none configured
15
+ - key internal services: none configured
16
+
17
+ Add your own entries when the classifier should know about trusted company infrastructure:
18
+
19
+ ```json
20
+ {
21
+ "autoMode": {
22
+ "environment": [
23
+ "$defaults",
24
+ "Trusted internal domains: *.corp.example.com, git.example.com",
25
+ "Trusted cloud buckets: s3://acme-dev-artifacts, gs://acme-ci-cache",
26
+ "Key internal services: staging deploy API at deploy.corp.example.com"
27
+ ]
28
+ }
29
+ }
30
+ ```
31
+
32
+ These entries give the classifier context. They do not bypass `hard_deny` or automatically allow every action involving those services.
33
+
34
+ ### `allow`
35
+
36
+ `$defaults` expands to allow exceptions for:
37
+
38
+ - read-only operations: inspecting files, listing directories, searching, GET requests, and state queries that do not expose secrets
39
+ - local development inside the working tree: creating, editing, building, testing, linting, formatting, and deleting files created during the current task
40
+ - installing dependencies already declared in package manifests or lockfiles
41
+ - using standard credentials only with their intended configured providers
42
+ - pushing to the current non-default working branch or a new branch created for the task
43
+ - bootstrapping language/toolchain installers from official sources
44
+
45
+ These are exceptions to `soft_deny`, not to `hard_deny`.
46
+
47
+ ### `protectedPaths`
48
+
49
+ `$defaults` expands to paths where writes are never auto-approved — they always go to the classifier, regardless of `allow` rules. This matches Claude Code's protected-paths behavior.
50
+
51
+ Protected directories: `.git`, `.config/git`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.pi`.
52
+
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
+
55
+ Read-only tools (`read`, `grep`, `find`, `ls`) bypass this check — reads to protected paths are always allowed. Only `write` and `edit` are affected.
56
+
57
+ ### `soft_deny`
58
+
59
+ `$defaults` expands to soft blocks for:
60
+
61
+ - force push, remote branch deletion, remote history rewrite, or direct push to `main`, `master`, or the default branch
62
+ - downloading and executing external code, including `curl | bash` and `wget | sh`
63
+ - production deploys, production database migrations, or production infrastructure changes
64
+ - deleting, truncating, or overwriting pre-existing local files unless the user named the specific target
65
+ - mass modification/deletion in cloud storage, ticketing systems, collaboration tools, or external APIs
66
+ - writing through remote shells such as `ssh`, `kubectl exec`, or `docker exec` on shared or production hosts
67
+ - changing live/shared infrastructure without a preview, plan, dry-run, or review when one normally exists
68
+ - killing other users' processes, cancelling shared jobs, or disrupting shared infrastructure
69
+ - exposing local files, sockets, credentials, or internal services through a network service
70
+ - outbound messages, purchases, bookings, subscriptions, or other real-world transactions
71
+
72
+ Soft blocks can be overridden by a matching `allow` exception or by direct, specific user intent.
73
+
74
+ ### `hard_deny`
75
+
76
+ `$defaults` expands to unconditional blocks for:
77
+
78
+ - weakening TLS, certificate validation, authentication, authorization, audit logging, or other security controls
79
+ - adding persistence such as SSH keys, shell profile edits, cron jobs, launch agents, login items, or background agents
80
+ - granting admin, owner, IAM, RBAC, repository, or production access
81
+ - exfiltrating repository contents, secrets, credentials, private keys, tokens, or internal data to untrusted places
82
+ - scanning for credentials, tokens, private keys, or secrets beyond what the task requires
83
+ - tampering with logs, audit trails, security monitoring, permission rules, auto-mode config, or safety-control files
84
+ - creating services, endpoints, workflows, or autonomous agents that execute arbitrary code without meaningful approval
85
+ - posting or updating public/external content that is fabricated, misleading, impersonating a user, or claiming approval/action that did not happen
86
+
87
+ Hard-deny rules cannot be overridden by `allow` or by user intent.
88
+
89
+ ### Replacement behavior
90
+
91
+ Use `$defaults` when you want to keep the built-ins and add your own entries:
92
+
93
+ ```json
94
+ {
95
+ "autoMode": {
96
+ "allow": [
97
+ "$defaults",
98
+ "Running the staging deploy script is allowed."
99
+ ]
100
+ }
101
+ }
102
+ ```
103
+
104
+ That means: use all built-in `allow` entries, plus the staging rule.
105
+
106
+ If you omit `$defaults`, you replace the built-ins for that section:
107
+
108
+ ```json
109
+ {
110
+ "autoMode": {
111
+ "allow": [
112
+ "Running the staging deploy script is allowed."
113
+ ]
114
+ }
115
+ }
116
+ ```
117
+
118
+ That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, `protectedPaths`, or `environment`.
119
+
120
+ `$defaults` is not used in `permissions.deny` or `permissions.ask`. Those lists contain only explicit Pi tool patterns.
121
+
@@ -55,6 +55,24 @@ async function resolveClassifier(
55
55
  return { model, apiKey: auth.apiKey, headers: auth.headers };
56
56
  }
57
57
 
58
+ export type ClassifierCompletionFn = (
59
+ model: Model<any>,
60
+ options: { systemPrompt: string; messages: UserMessage[] },
61
+ callOptions: {
62
+ apiKey?: string;
63
+ headers?: Record<string, string>;
64
+ signal?: AbortSignal;
65
+ maxTokens: number;
66
+ temperature: number;
67
+ },
68
+ ) => Promise<AssistantMessage>;
69
+
70
+ export type RetryOptions = {
71
+ maxAttempts?: number;
72
+ maxTokens?: number;
73
+ temperature?: number;
74
+ };
75
+
58
76
  /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
59
77
  export function parseClassifierDecision(
60
78
  message: AssistantMessage,
@@ -90,6 +108,66 @@ export function parseClassifierDecision(
90
108
  return undefined;
91
109
  }
92
110
 
111
+ /**
112
+ * Call the classifier model and parse its decision, retrying when the model
113
+ * returns malformed or truncated output. Small classifier models occasionally
114
+ * ramble into the token cap (stopReason "length") or emit prose instead of
115
+ * JSON; a single retry recovers most of these transient failures.
116
+ *
117
+ * Safety is preserved: an "allow" is only returned when a response actually
118
+ * parses to a valid allow decision. If every attempt fails to parse, the
119
+ * function fails closed with a block decision. Thrown errors (network/auth)
120
+ * are not retried — they fail closed immediately, matching prior behavior.
121
+ */
122
+ export async function classifyWithRetry(
123
+ completeFn: ClassifierCompletionFn,
124
+ classifier: {
125
+ model: Model<any>;
126
+ apiKey?: string;
127
+ headers?: Record<string, string>;
128
+ },
129
+ prompt: { systemPrompt: string; messages: UserMessage[] },
130
+ signal: AbortSignal | undefined,
131
+ options: RetryOptions = {},
132
+ ): Promise<ClassificationDecision> {
133
+ const maxAttempts = options.maxAttempts ?? 2;
134
+ const maxTokens = options.maxTokens ?? 1200;
135
+ const temperature = options.temperature ?? 0;
136
+ let lastReason =
137
+ "Classifier response was not valid decision JSON; auto mode fails closed.";
138
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
139
+ let response: AssistantMessage;
140
+ try {
141
+ response = await completeFn(
142
+ classifier.model,
143
+ prompt,
144
+ {
145
+ apiKey: classifier.apiKey,
146
+ headers: classifier.headers,
147
+ signal,
148
+ maxTokens,
149
+ temperature,
150
+ },
151
+ );
152
+ } catch (error) {
153
+ return {
154
+ decision: "block",
155
+ tier: "none",
156
+ reason: `Classifier failed; auto mode fails closed: ${
157
+ error instanceof Error ? error.message : String(error)
158
+ }`,
159
+ };
160
+ }
161
+ const decision = parseClassifierDecision(response);
162
+ if (decision) return decision;
163
+ lastReason =
164
+ response.stopReason === "length"
165
+ ? "Classifier response was truncated before producing valid decision JSON; auto mode fails closed."
166
+ : "Classifier response was not valid decision JSON; auto mode fails closed.";
167
+ }
168
+ return { decision: "block", tier: "none", reason: lastReason };
169
+ }
170
+
93
171
  export const defaultClassifyAction: ClassifyAction = async (
94
172
  ctx,
95
173
  config,
@@ -120,33 +198,10 @@ export const defaultClassifyAction: ClassifyAction = async (
120
198
  timestamp: Date.now(),
121
199
  };
122
200
 
123
- try {
124
- const response = await complete(
125
- classifier.model,
126
- { systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
127
- {
128
- apiKey: classifier.apiKey,
129
- headers: classifier.headers,
130
- signal: ctx.signal,
131
- maxTokens: 700,
132
- temperature: 0,
133
- },
134
- );
135
- return (
136
- parseClassifierDecision(response) ?? {
137
- decision: "block",
138
- tier: "none",
139
- reason:
140
- "Classifier response was not valid decision JSON; auto mode fails closed.",
141
- }
142
- );
143
- } catch (error) {
144
- return {
145
- decision: "block",
146
- tier: "none",
147
- reason: `Classifier failed; auto mode fails closed: ${
148
- error instanceof Error ? error.message : String(error)
149
- }`,
150
- };
151
- }
201
+ return classifyWithRetry(
202
+ complete,
203
+ classifier,
204
+ { systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
205
+ ctx.signal,
206
+ );
152
207
  };
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
3
  import {
4
4
  DEFAULT_ALLOW,
5
5
  DEFAULT_ENVIRONMENT,
@@ -397,3 +397,38 @@ export function loadEffectiveConfigWithDiagnostics(
397
397
  export function loadEffectiveConfig(cwd: string): EffectiveConfig {
398
398
  return loadEffectiveConfigWithDiagnostics(cwd).config;
399
399
  }
400
+
401
+ function readWritableSettingsFile(path: string): SettingsFile {
402
+ if (!existsSync(path)) return {};
403
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
404
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
405
+ throw new Error(`${path}: root must be a JSON object`);
406
+ }
407
+ const settings = parsed as SettingsFile;
408
+ if (
409
+ settings.autoMode !== undefined &&
410
+ (!settings.autoMode ||
411
+ typeof settings.autoMode !== "object" ||
412
+ Array.isArray(settings.autoMode))
413
+ ) {
414
+ throw new Error(`${path}: autoMode must be a JSON object`);
415
+ }
416
+ return settings;
417
+ }
418
+
419
+ /** Persist the global default classifier model while preserving other settings. */
420
+ export function writeGlobalClassifierModel(
421
+ classifierModel: string,
422
+ path = PI_GLOBAL_SETTINGS[0],
423
+ ): void {
424
+ const settings = readWritableSettingsFile(path);
425
+ const next: SettingsFile = {
426
+ ...settings,
427
+ autoMode: {
428
+ ...settings.autoMode,
429
+ classifierModel,
430
+ },
431
+ };
432
+ mkdirSync(dirname(path), { recursive: true });
433
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
434
+ }
@@ -149,7 +149,7 @@ Examples:
149
149
  Respond with JSON exactly matching:
150
150
  {"decision":"allow"|"block","tier":"hard_deny"|"soft_deny"|"allow"|"explicit_intent"|"none","reason":"brief concrete reason"}`;
151
151
 
152
- export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/automode.json")];
152
+ export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
153
153
  export const PI_PROJECT_LOCAL_SETTINGS = [".pi/automode.local.json"];
154
154
  export const PI_PROJECT_SHARED_SETTINGS = [".pi/automode.json"];
155
155
 
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  loadEffectiveConfig,
18
18
  loadEffectiveConfigWithDiagnostics,
19
+ writeGlobalClassifierModel,
19
20
  } from "./config.ts";
20
21
  import { deterministicHardDeny } from "./hard-deny.ts";
21
22
  import { formatModelSpec, parseModelSpec } from "./model.ts";
@@ -45,6 +46,8 @@ export type PiAutomodeOptions = {
45
46
  loadConfig?: (cwd: string) => EffectiveConfig;
46
47
  /** Override classifier calls in tests so unit tests never need a real LLM/API key. */
47
48
  classifyAction?: ClassifyAction;
49
+ /** Override classifier-model persistence in tests. Runtime code writes ~/.pi/agent/automode.json. */
50
+ saveClassifierModel?: (classifierModel: string) => void;
48
51
  };
49
52
 
50
53
  /** Create a Pi extension instance. Default export uses production dependencies. */
@@ -56,6 +59,8 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
56
59
  })
57
60
  : loadEffectiveConfigWithDiagnostics;
58
61
  const classify = options.classifyAction ?? defaultClassifyAction;
62
+ const saveClassifierModel = options.saveClassifierModel ??
63
+ writeGlobalClassifierModel;
59
64
 
60
65
  return function piAutomode(pi: ExtensionAPI) {
61
66
  let loadResult = loadConfigWithDiagnostics(process.cwd());
@@ -72,8 +77,6 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
72
77
  return {
73
78
  ...config,
74
79
  enabled: state.enabledOverride ?? config.enabled,
75
- classifierModel: state.classifierModelOverride ??
76
- config.classifierModel,
77
80
  };
78
81
  }
79
82
 
@@ -294,7 +297,6 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
294
297
  blockedActions: 0,
295
298
  recentDenials: [],
296
299
  enabledOverride: state.enabledOverride,
297
- classifierModelOverride: state.classifierModelOverride,
298
300
  };
299
301
  persist();
300
302
  updateUi(ctx);
@@ -335,36 +337,20 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
335
337
  return;
336
338
  }
337
339
  if (command === "model") {
338
- if (!remainder) {
339
- const selected = await promptForClassifierModel(
340
- ctx,
341
- effectiveConfig().classifierModel ?? state.classifierModelOverride,
342
- );
343
- if (!selected) {
344
- ctx.ui.notify("Classifier model unchanged", "info");
345
- return;
346
- }
347
- const parsed = parseModelSpec(selected);
348
- const model = parsed
349
- ? ctx.modelRegistry.find(parsed.provider, parsed.id)
350
- : undefined;
351
- if (model) {
352
- state.classifierModelOverride = selected;
353
- persist();
354
- updateUi(ctx);
355
- ctx.ui.notify(
356
- `pi-automode classifier set for this session: ${selected}`,
357
- "info",
358
- );
359
- }
340
+ const selected = remainder || await promptForClassifierModel(
341
+ ctx,
342
+ effectiveConfig().classifierModel,
343
+ );
344
+ if (!selected) {
345
+ ctx.ui.notify("Classifier model unchanged", "info");
360
346
  return;
361
347
  }
362
- const parsed = parseModelSpec(remainder);
348
+ const parsed = parseModelSpec(selected);
363
349
  const model = parsed
364
350
  ? ctx.modelRegistry.find(parsed.provider, parsed.id)
365
351
  : undefined;
366
352
  if (!model) {
367
- ctx.ui.notify(`Model not found: ${remainder}`, "error");
353
+ ctx.ui.notify(`Model not found: ${selected}`, "error");
368
354
  return;
369
355
  }
370
356
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
@@ -372,11 +358,29 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
372
358
  ctx.ui.notify(auth.error, "error");
373
359
  return;
374
360
  }
375
- state.classifierModelOverride = formatModelSpec(model);
361
+ const modelSpec = formatModelSpec(model);
362
+ try {
363
+ saveClassifierModel(modelSpec);
364
+ } catch (error) {
365
+ ctx.ui.notify(
366
+ `Failed to save classifier model: ${
367
+ error instanceof Error ? error.message : String(error)
368
+ }`,
369
+ "error",
370
+ );
371
+ return;
372
+ }
373
+ loadResult = loadConfigWithDiagnostics(ctx.cwd);
374
+ config = loadResult.config;
375
+ configDiagnostics = loadResult.diagnostics;
376
376
  persist();
377
377
  updateUi(ctx);
378
+ const active = effectiveConfig().classifierModel ??
379
+ "current session model";
378
380
  ctx.ui.notify(
379
- `pi-automode classifier set for this session: ${state.classifierModelOverride}`,
381
+ active === modelSpec
382
+ ? `pi-automode classifier saved globally: ${modelSpec}`
383
+ : `pi-automode classifier saved globally: ${modelSpec}; current config uses ${active}`,
380
384
  "info",
381
385
  );
382
386
  return;
@@ -32,10 +32,7 @@ export function statusText(
32
32
  ): string {
33
33
  return [
34
34
  `enabled: ${(state.enabledOverride ?? config.enabled) ? "yes" : "no"}`,
35
- `classifier: ${
36
- state.classifierModelOverride ?? config.classifierModel ??
37
- "current session model"
38
- }`,
35
+ `classifier: ${config.classifierModel ?? "current session model"}`,
39
36
  `checked actions: ${state.checkedActions}`,
40
37
  `blocked actions: ${state.blockedActions}`,
41
38
  `permissions.deny rules: ${config.permissionDeny.length}`,
@@ -89,7 +86,6 @@ export function restoreState(ctx: ExtensionContext): AutoModeState {
89
86
  }
90
87
  return {
91
88
  enabledOverride: entry.data.enabledOverride,
92
- classifierModelOverride: entry.data.classifierModelOverride,
93
89
  lastDecision: entry.data.lastDecision,
94
90
  lastReason: entry.data.lastReason,
95
91
  checkedActions: entry.data.checkedActions ?? 0,
@@ -48,7 +48,6 @@ export type EffectiveConfig = {
48
48
 
49
49
  export type AutoModeState = {
50
50
  enabledOverride?: boolean;
51
- classifierModelOverride?: string;
52
51
  lastDecision?: "allow" | "block";
53
52
  lastReason?: string;
54
53
  checkedActions: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"
@@ -25,6 +25,7 @@
25
25
  "test": "node --import tsx --test tests/*.test.ts"
26
26
  },
27
27
  "files": [
28
+ "docs",
28
29
  "extensions",
29
30
  "examples",
30
31
  "README.md"
@@ -36,7 +37,8 @@
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-ai": "*",
39
- "@earendil-works/pi-coding-agent": "*"
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
40
42
  },
41
43
  "devDependencies": {
42
44
  "@earendil-works/pi-ai": "^0.79.3",