@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.
- package/CHANGELOG.md +23 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +213 -0
- package/docs/configuration.md +700 -0
- package/docs/cross-extension-api.md +525 -0
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +201 -0
- package/docs/guides/upstream-issue-template.md +113 -0
- package/docs/migration/legacy-to-flat.md +365 -0
- package/docs/opencode-compatibility.md +213 -0
- package/docs/session-approvals.md +68 -0
- package/docs/subagent-integration.md +102 -0
- package/docs/troubleshooting.md +53 -0
- package/package.json +5 -2
- package/src/access-intent/access-path.ts +29 -4
- package/src/access-intent/bash/bash-path-resolver.ts +39 -18
- package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
- package/src/path-normalizer.ts +105 -2
- package/test/access-intent/access-path.test.ts +0 -277
- package/test/access-intent/bash/node-text.test.ts +0 -148
- package/test/access-intent/bash/parser.test.ts +0 -19
- package/test/access-intent/bash/program.test.ts +0 -673
- package/test/access-intent/bash/token-classification.test.ts +0 -363
- package/test/access-intent/bash/token-collection.test.ts +0 -300
- package/test/active-agent.test.ts +0 -155
- package/test/async-cache.test.ts +0 -48
- package/test/bash-arity.test.ts +0 -144
- package/test/bash-external-directory.test.ts +0 -1022
- package/test/builtin-tool-input-formatters.test.ts +0 -109
- package/test/canonicalize-path.test.ts +0 -119
- package/test/composition-root.test.ts +0 -698
- package/test/config-loader.test.ts +0 -740
- package/test/config-modal.test.ts +0 -320
- package/test/config-paths.test.ts +0 -83
- package/test/config-pipeline.test.ts +0 -90
- package/test/config-reporter.test.ts +0 -147
- package/test/config-store.test.ts +0 -466
- package/test/decision-audit.test.ts +0 -72
- package/test/decision-reporter.test.ts +0 -112
- package/test/denial-messages.test.ts +0 -714
- package/test/detect-permissive-bash-fallback.test.ts +0 -56
- package/test/expand-home.test.ts +0 -93
- package/test/extension-config.test.ts +0 -129
- package/test/extension-paths.test.ts +0 -108
- package/test/forwarded-permissions/io.test.ts +0 -251
- package/test/forwarding-manager.test.ts +0 -200
- package/test/handlers/before-agent-start.test.ts +0 -314
- package/test/handlers/external-directory-integration.test.ts +0 -515
- package/test/handlers/external-directory-session-dedup.test.ts +0 -175
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
- package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
- package/test/handlers/gates/bash-command.test.ts +0 -257
- package/test/handlers/gates/bash-external-directory.test.ts +0 -268
- package/test/handlers/gates/bash-path.test.ts +0 -346
- package/test/handlers/gates/candidate-check.test.ts +0 -52
- package/test/handlers/gates/external-directory-messages.test.ts +0 -85
- package/test/handlers/gates/external-directory-policy.test.ts +0 -134
- package/test/handlers/gates/external-directory.test.ts +0 -267
- package/test/handlers/gates/helpers.test.ts +0 -165
- package/test/handlers/gates/path.test.ts +0 -369
- package/test/handlers/gates/runner.test.ts +0 -408
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
- package/test/handlers/gates/skill-input.test.ts +0 -128
- package/test/handlers/gates/skill-read.test.ts +0 -161
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
- package/test/handlers/gates/tool.test.ts +0 -244
- package/test/handlers/input-events.test.ts +0 -168
- package/test/handlers/input.test.ts +0 -199
- package/test/handlers/lifecycle.test.ts +0 -221
- package/test/handlers/tool-call-boundary.test.ts +0 -145
- package/test/handlers/tool-call-events.test.ts +0 -277
- package/test/handlers/tool-call.test.ts +0 -395
- package/test/handlers/validate-requested-tool.test.ts +0 -92
- package/test/helpers/external-directory-fixtures.ts +0 -269
- package/test/helpers/gate-fixtures.ts +0 -316
- package/test/helpers/handler-fixtures.ts +0 -335
- package/test/helpers/make-fake-pi.ts +0 -100
- package/test/helpers/manager-harness.ts +0 -112
- package/test/helpers/session-fixtures.ts +0 -199
- package/test/input-normalizer.test.ts +0 -325
- package/test/logging.test.ts +0 -51
- package/test/mcp-targets.test.ts +0 -233
- package/test/node-modules-discovery.test.ts +0 -97
- package/test/normalize.test.ts +0 -247
- package/test/path-containment.test.ts +0 -161
- package/test/path-normalization.test.ts +0 -233
- package/test/path-normalizer.test.ts +0 -196
- package/test/path-surfaces.test.ts +0 -55
- package/test/pattern-suggest.test.ts +0 -248
- package/test/permission-dialog.test.ts +0 -205
- package/test/permission-event-rpc.test.ts +0 -560
- package/test/permission-events.test.ts +0 -400
- package/test/permission-forwarder.test.ts +0 -370
- package/test/permission-forwarding.test.ts +0 -315
- package/test/permission-gate.test.ts +0 -269
- package/test/permission-manager-unified.test.ts +0 -3714
- package/test/permission-merge.test.ts +0 -61
- package/test/permission-prompter.test.ts +0 -518
- package/test/permission-prompts.test.ts +0 -363
- package/test/permission-resolver.test.ts +0 -277
- package/test/permission-session.test.ts +0 -404
- package/test/permission-ui-prompt.test.ts +0 -146
- package/test/permissions-service.test.ts +0 -192
- package/test/pi-infrastructure-read.test.ts +0 -432
- package/test/policy-loader.test.ts +0 -561
- package/test/prompting-gateway.test.ts +0 -231
- package/test/rule.test.ts +0 -650
- package/test/safe-system-paths.test.ts +0 -46
- package/test/scope-merge.test.ts +0 -116
- package/test/service-lifecycle.test.ts +0 -163
- package/test/service.test.ts +0 -261
- package/test/session-approval.test.ts +0 -75
- package/test/session-logger.test.ts +0 -200
- package/test/session-rules.test.ts +0 -321
- package/test/session-start.test.ts +0 -112
- package/test/skill-prompt-sanitizer.test.ts +0 -418
- package/test/status.test.ts +0 -10
- package/test/subagent-context.test.ts +0 -372
- package/test/subagent-lifecycle-events.test.ts +0 -132
- package/test/subagent-registry.test.ts +0 -145
- package/test/synthesize.test.ts +0 -302
- package/test/system-prompt-sanitizer.test.ts +0 -382
- package/test/tool-access-extractor-registry.test.ts +0 -77
- package/test/tool-input-formatter-registry.test.ts +0 -75
- package/test/tool-input-path.test.ts +0 -84
- package/test/tool-input-preview.test.ts +0 -129
- package/test/tool-input-prompt-formatters.test.ts +0 -115
- package/test/tool-preview-formatter.test.ts +0 -458
- package/test/tool-registry.test.ts +0 -197
- package/test/value-guards.test.ts +0 -193
- package/test/wildcard-matcher.test.ts +0 -424
- package/test/yaml-frontmatter.test.ts +0 -91
- package/test/yolo-mode.test.ts +0 -188
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# OpenCode Compatibility
|
|
2
|
+
|
|
3
|
+
This extension's flat permission format and evaluation semantics were directly inspired by [OpenCode's permission model](https://opencode.ai/docs/permissions/) (v1.1.x permission rework).
|
|
4
|
+
If you are familiar with OpenCode's permission system, most concepts transfer directly — the same mental model applies.
|
|
5
|
+
|
|
6
|
+
> **Point-in-time reference.**
|
|
7
|
+
> This comparison reflects OpenCode as of May 2026.
|
|
8
|
+
> See the [official OpenCode permissions docs](https://opencode.ai/docs/permissions/) for the latest upstream behavior.
|
|
9
|
+
|
|
10
|
+
## What Transfers Directly
|
|
11
|
+
|
|
12
|
+
The following concepts are shared between OpenCode and this extension:
|
|
13
|
+
|
|
14
|
+
| Concept | Description |
|
|
15
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| Three actions | `allow` / `ask` / `deny` — identical semantics |
|
|
17
|
+
| Flat `permission` object | Top-level key in config; surface names as keys |
|
|
18
|
+
| `"*"` universal fallback | Sets the default action when no surface-specific rule matches |
|
|
19
|
+
| Granular object syntax | Surface key → string (catch-all) or `{ pattern: action }` map |
|
|
20
|
+
| Last-match-wins | When multiple patterns match, the last one in config order wins |
|
|
21
|
+
| `*` wildcard | Matches zero or more of any character (including path separators) |
|
|
22
|
+
| `?` wildcard | Matches exactly one character |
|
|
23
|
+
| Home directory expansion | `~/` and `$HOME/` expand to the OS home directory in patterns |
|
|
24
|
+
| `external_directory` surface | Gates access to paths outside the working directory |
|
|
25
|
+
| `bash` surface | Command patterns matched against shell commands |
|
|
26
|
+
| `skill` surface | Skill name patterns matched against skill invocations |
|
|
27
|
+
| `task` surface | Gates subagent/delegation tool calls |
|
|
28
|
+
| Session-scoped approvals | `once` / `always` / `reject` from the ask dialog; `always` adds a session rule |
|
|
29
|
+
| Per-agent overrides | Override global permissions for specific agents |
|
|
30
|
+
| Tool hiding | Denied tools are removed before the agent starts (no wasted turns probing) |
|
|
31
|
+
| Bash path extraction | Tree-sitter AST parsing to detect external paths in shell commands (see [details below](#bash-path-extraction)) |
|
|
32
|
+
| Bash arity table | Generates smart approval pattern suggestions (e.g., `git checkout *` not `git *`) |
|
|
33
|
+
| Trailing wildcard optionality | `"ls *"` matches bare `"ls"` — the trailing `*` is optional |
|
|
34
|
+
|
|
35
|
+
If your OpenCode config uses these features, the equivalent works in this extension with minimal translation (see [Porting Guide](#porting-an-opencode-config) below).
|
|
36
|
+
|
|
37
|
+
## Where They Diverge
|
|
38
|
+
|
|
39
|
+
### Summary Table
|
|
40
|
+
|
|
41
|
+
| Area | OpenCode | This extension |
|
|
42
|
+
| -------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| Default fallback | `"*": "allow"` (permissive) | `"*": "ask"` (least privilege) |
|
|
44
|
+
| `.env` file protection | Built-in `read` rules deny/ask `.env` files | No built-in rules; user configures with the cross-cutting `path` surface or per-tool path patterns (see [porting guide](#porting-an-opencode-config)) |
|
|
45
|
+
| Cross-cutting `path` gate | No equivalent — `.env` protection is per-tool only | `path` surface denies/asks across all tools and bash at once; a `path` deny cannot be overridden by a per-tool allow |
|
|
46
|
+
| OpenCode-only surfaces | `lsp`, `question`, `webfetch`, `websearch`, `todowrite`, `doom_loop` | Not applicable — Pi does not expose these tools or events |
|
|
47
|
+
| File mutation surfaces | `edit` covers `edit`, `write`, `apply_patch` | Separate `write` and `edit` surfaces |
|
|
48
|
+
| Search/discovery surfaces | `glob`, `grep`, `list` | `find`, `grep`, `ls` (Pi tool names) |
|
|
49
|
+
| `mcp` surface | Not a documented permission surface | First-class with server/tool-level granularity |
|
|
50
|
+
| Top-level string shorthand | `"permission": "allow"` sets all surfaces | Not supported; must use an object |
|
|
51
|
+
| Per-agent config location | `agent` key in config JSON or YAML frontmatter | YAML frontmatter in agent `.md` files only |
|
|
52
|
+
| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/extensions/pi-permission-system/config.json` |
|
|
53
|
+
| Subagent prompt forwarding | Not documented | `ask` policies work in non-UI subagent contexts |
|
|
54
|
+
| Infrastructure auto-allow | N/A | Read-only tools to Pi infra dirs bypass the gate |
|
|
55
|
+
| Permission review log | No equivalent documented | Writes decisions to a JSONL audit log |
|
|
56
|
+
|
|
57
|
+
### Notable Differences Explained
|
|
58
|
+
|
|
59
|
+
#### Default Fallback: `allow` vs `ask`
|
|
60
|
+
|
|
61
|
+
OpenCode defaults to permissive — most tools work without configuration.
|
|
62
|
+
This extension defaults to least privilege — omitting `"*"` gives you `"ask"` for everything.
|
|
63
|
+
|
|
64
|
+
If you want OpenCode-like permissiveness:
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
{
|
|
68
|
+
"permission": {
|
|
69
|
+
"*": "allow",
|
|
70
|
+
"external_directory": "ask"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### File Mutation Surfaces
|
|
76
|
+
|
|
77
|
+
OpenCode unifies all file writes under a single `edit` permission.
|
|
78
|
+
This extension exposes Pi's actual tool names: `write` (create/overwrite) and `edit` (targeted replacement).
|
|
79
|
+
|
|
80
|
+
To replicate OpenCode's unified behavior, set both to the same action:
|
|
81
|
+
|
|
82
|
+
```jsonc
|
|
83
|
+
{
|
|
84
|
+
"permission": {
|
|
85
|
+
"write": "ask",
|
|
86
|
+
"edit": "ask"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### MCP Surface (Pi-Only)
|
|
92
|
+
|
|
93
|
+
This extension provides a first-class `mcp` permission surface with granular server and tool-level control:
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
{
|
|
97
|
+
"permission": {
|
|
98
|
+
"mcp": {
|
|
99
|
+
"*": "ask",
|
|
100
|
+
"mcp_status": "allow",
|
|
101
|
+
"myServer:*": "ask",
|
|
102
|
+
"dangerousServer": "deny"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
OpenCode does not expose MCP as a configurable permission surface.
|
|
109
|
+
|
|
110
|
+
#### Bash Path Extraction
|
|
111
|
+
|
|
112
|
+
Both systems use `web-tree-sitter` + `tree-sitter-bash` to parse shell commands into an AST for `external_directory` path detection, but the extraction strategies differ significantly:
|
|
113
|
+
|
|
114
|
+
**OpenCode** only extracts paths from a hardcoded allowlist of file-manipulating commands (`rm`, `cp`, `mv`, `mkdir`, `touch`, `chmod`, `chown`, `cat`, plus PowerShell equivalents).
|
|
115
|
+
Commands not in the list — including `sed`, `awk`, `grep` — get no path extraction at all.
|
|
116
|
+
For allowlisted commands, all non-flag positional arguments are assumed to be paths.
|
|
117
|
+
|
|
118
|
+
**This extension** extracts path candidates from all commands generically, then applies additional intelligence:
|
|
119
|
+
|
|
120
|
+
- A `PATTERN_FIRST_COMMANDS` map understands flag arity for `sed`, `awk`, `grep`, `rg`, and similar tools, distinguishing inline patterns/scripts from file arguments to avoid false positives.
|
|
121
|
+
- Redirect destinations (`> /path/to/file`) are extracted.
|
|
122
|
+
- Heredoc bodies, comments, and variable assignments are skipped.
|
|
123
|
+
|
|
124
|
+
The result is broader coverage (paths detected in any command, not just a curated list) with fewer false positives on pattern-first commands (no spurious prompts for sed regexes or grep patterns that happen to contain `/`).
|
|
125
|
+
|
|
126
|
+
## Porting an OpenCode Config
|
|
127
|
+
|
|
128
|
+
### Before (OpenCode)
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"$schema": "https://opencode.ai/config.json",
|
|
133
|
+
"permission": {
|
|
134
|
+
"*": "allow",
|
|
135
|
+
"bash": {
|
|
136
|
+
"*": "ask",
|
|
137
|
+
"git *": "allow",
|
|
138
|
+
"npm *": "allow",
|
|
139
|
+
"rm *": "deny"
|
|
140
|
+
},
|
|
141
|
+
"edit": {
|
|
142
|
+
"*": "ask",
|
|
143
|
+
"src/*.ts": "allow"
|
|
144
|
+
},
|
|
145
|
+
"external_directory": {
|
|
146
|
+
"~/projects/*": "allow"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### After (this extension)
|
|
153
|
+
|
|
154
|
+
```jsonc
|
|
155
|
+
{
|
|
156
|
+
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
|
|
157
|
+
"permission": {
|
|
158
|
+
"*": "allow",
|
|
159
|
+
"bash": {
|
|
160
|
+
"*": "ask",
|
|
161
|
+
"git *": "allow",
|
|
162
|
+
"npm *": "allow",
|
|
163
|
+
"rm *": "deny"
|
|
164
|
+
},
|
|
165
|
+
"write": "ask",
|
|
166
|
+
"edit": "ask",
|
|
167
|
+
"external_directory": {
|
|
168
|
+
"*": "ask",
|
|
169
|
+
"~/projects/*": "allow"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Key Translation Steps
|
|
176
|
+
|
|
177
|
+
1. **Replace `"permission": "allow"`** (top-level string) with `"permission": { "*": "allow" }`.
|
|
178
|
+
2. **Split `edit`** into separate `write` and `edit` entries if you need different policies for create vs. modify.
|
|
179
|
+
If not, set both to the same action.
|
|
180
|
+
3. **Rename search surfaces**: `glob` → `find`, `list` → `ls`.
|
|
181
|
+
4. **Add `.env` rules manually** if you relied on OpenCode's built-in protection.
|
|
182
|
+
The `path` surface is the recommended approach — it covers all tools and bash in one rule:
|
|
183
|
+
|
|
184
|
+
```jsonc
|
|
185
|
+
{
|
|
186
|
+
"permission": {
|
|
187
|
+
"path": {
|
|
188
|
+
"*": "allow",
|
|
189
|
+
"*.env": "deny",
|
|
190
|
+
"*.env.*": "deny",
|
|
191
|
+
"*.env.example": "allow"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Alternatively, use per-tool patterns if you only need to protect specific tools (e.g., `read`):
|
|
198
|
+
|
|
199
|
+
```jsonc
|
|
200
|
+
{
|
|
201
|
+
"permission": {
|
|
202
|
+
"read": {
|
|
203
|
+
"*": "allow",
|
|
204
|
+
"*.env": "deny",
|
|
205
|
+
"*.env.*": "deny",
|
|
206
|
+
"*.env.example": "allow"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
5. **Remove OpenCode-only surfaces** (`lsp`, `question`, `webfetch`, `websearch`, `todowrite`, `doom_loop`) — they have no effect in this extension.
|
|
213
|
+
6. **Add `mcp` rules** if you use MCP servers — OpenCode has no equivalent, so this is new configuration.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Session-Scoped Approvals
|
|
2
|
+
|
|
3
|
+
When any permission resolves to `ask`, the permission dialog offers four options:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Yes | Yes, allow "<pattern>" for this session | No | No, provide reason
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Selecting **Yes, allow "\<pattern\>" for this session** approves the current request and records the suggested wildcard pattern as a session rule.
|
|
10
|
+
Subsequent requests that match the pattern skip the prompt for the remainder of the session.
|
|
11
|
+
|
|
12
|
+
Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
|
|
13
|
+
|
|
14
|
+
## Suggested Patterns
|
|
15
|
+
|
|
16
|
+
The suggested pattern is surface-specific:
|
|
17
|
+
|
|
18
|
+
| Surface | Example request | Suggested session pattern |
|
|
19
|
+
| ------------------------------- | ---------------------------- | ------------------------- |
|
|
20
|
+
| bash | `git status --short` | `git status *` |
|
|
21
|
+
| mcp (qualified) | `exa:search` | `exa:*` |
|
|
22
|
+
| mcp (munged) | `exa_search` | `exa_*` |
|
|
23
|
+
| skill | `librarian` | `librarian` |
|
|
24
|
+
| path | `src/.env` | `src/*` |
|
|
25
|
+
| tool with path (read, write, …) | `read` for `src/foo.ts` | `src/*` |
|
|
26
|
+
| tool catch-all | `read` (no extractable path) | `*` |
|
|
27
|
+
| external_directory | `/other/project/src/foo.ts` | `/other/project/src/*` |
|
|
28
|
+
|
|
29
|
+
## Bash Arity Table
|
|
30
|
+
|
|
31
|
+
Bash pattern suggestions use a curated arity dictionary (`src/bash-arity.ts`) to determine how many tokens define the "human-understandable subcommand."
|
|
32
|
+
Longest matching prefix wins, so `npm run` (arity 3) takes precedence over `npm` (arity 2).
|
|
33
|
+
Unknown commands default to arity 1 (first word only).
|
|
34
|
+
|
|
35
|
+
| Example command | Arity entry matched | Suggested pattern |
|
|
36
|
+
| --------------------- | -------------------- | --------------------- |
|
|
37
|
+
| `git checkout main` | `git` → 2 | `git checkout *` |
|
|
38
|
+
| `npm run dev` | `npm run` → 3 | `npm run dev*` |
|
|
39
|
+
| `npm install lodash` | `npm` → 2 | `npm install *` |
|
|
40
|
+
| `docker compose up` | `docker compose` → 3 | `docker compose up *` |
|
|
41
|
+
| `rm -rf node_modules` | `rm` → 1 | `rm *` |
|
|
42
|
+
| `mytool --verbose` | (unknown) → 1 | `mytool *` |
|
|
43
|
+
|
|
44
|
+
The arity table covers common CLI tools including git, npm/pnpm/yarn/bun, docker, cargo, go, kubectl, gh, and others.
|
|
45
|
+
To add an entry, open `src/bash-arity.ts` and add a key/arity pair to the `ARITY` object.
|
|
46
|
+
Put the most specific multi-word prefix first (e.g. `"npm run": 3`) before the shorter fallback (`"npm": 2`).
|
|
47
|
+
|
|
48
|
+
## Review Log Entries
|
|
49
|
+
|
|
50
|
+
The review log records session approval decisions:
|
|
51
|
+
|
|
52
|
+
- `resolution: "approved_for_session"` — when the user approves with the session pattern
|
|
53
|
+
- `resolution: "session_approved"` — when a later request is matched by an existing session rule
|
|
54
|
+
|
|
55
|
+
## Permission Prompt Summaries
|
|
56
|
+
|
|
57
|
+
When a tool permission resolves to `ask`, the prompt is designed to be readable enough for an informed approval decision:
|
|
58
|
+
|
|
59
|
+
- `bash` prompts show the command and matched bash pattern when available.
|
|
60
|
+
- `mcp` prompts show the derived MCP target and matched rule when available.
|
|
61
|
+
- Built-in file tools show concise summaries, such as the target path and edit/write line counts, instead of raw multiline JSON.
|
|
62
|
+
- Unknown or third-party extension tools show a bounded single-line JSON preview of the input so users are not asked to approve a blind tool name.
|
|
63
|
+
|
|
64
|
+
Example edit approval prompt:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
|
|
68
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Subagent Integration
|
|
2
|
+
|
|
3
|
+
## Native integration with `@gotgenes/pi-subagents`
|
|
4
|
+
|
|
5
|
+
[`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) is the only subagent extension with native permission-system integration.
|
|
6
|
+
It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
|
|
7
|
+
Because the event bus dispatches synchronously, the synchronous registration completes before binding proceeds.
|
|
8
|
+
This inverts the former dependency direction: the core no longer looks up this package's service ([ADR-0002] / pi-subagents [#261]).
|
|
9
|
+
|
|
10
|
+
The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/subagent-registry.ts`.
|
|
11
|
+
This is necessary because each session's `ResourceLoader` creates its own `pi.events` bus: the parent emits `subagents:child:session-created` on the parent's bus, and only the parent's permission-system instance receives it.
|
|
12
|
+
The child's jiti instance runs on a separate bus and never receives the event — but because both instances call `getSubagentSessionRegistry()`, they share the same store, so the parent's registration is visible to the child.
|
|
13
|
+
|
|
14
|
+
The integration enables:
|
|
15
|
+
|
|
16
|
+
1. **Deterministic child detection** — `isSubagentExecutionContext()` hits the process-global registry on the first check, no env-var or filesystem heuristics needed.
|
|
17
|
+
2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
|
|
18
|
+
3. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
|
|
19
|
+
The parent approves or denies, and the child resumes.
|
|
20
|
+
|
|
21
|
+
No configuration is required - the integration is automatic when both extensions are installed.
|
|
22
|
+
When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents` emits its lifecycle events with no subscriber - a harmless no-op.
|
|
23
|
+
|
|
24
|
+
## Permission Forwarding
|
|
25
|
+
|
|
26
|
+
When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories.
|
|
27
|
+
The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
28
|
+
|
|
29
|
+
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
|
30
|
+
|
|
31
|
+
For in-process child sessions, detection and forwarding use the event-driven registration described above.
|
|
32
|
+
The [Prompt Forwarding RPC](cross-extension-api.md#prompt-forwarding-rpc) remains available as an alternative for extensions that cannot use the service accessor.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Coexistence with Other Subagent Extensions
|
|
37
|
+
|
|
38
|
+
Subagent extensions implement their own tool restriction mechanisms.
|
|
39
|
+
These compose correctly with the permission system because the two operate at different layers: **visibility** (subagent extension) and **policy** (permission system).
|
|
40
|
+
|
|
41
|
+
### The Two-Layer Model
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
┌─────────────────────────────────────────────────────┐
|
|
45
|
+
│ Layer 1 - Visibility (subagent extension) │
|
|
46
|
+
│ Controls which tools are registered / active │
|
|
47
|
+
│ before the agent session starts. │
|
|
48
|
+
├─────────────────────────────────────────────────────┤
|
|
49
|
+
│ Layer 2 - Policy (pi-permission-system) │
|
|
50
|
+
│ Controls allow / ask / deny decisions on every │
|
|
51
|
+
│ tool call, bash command, MCP operation, etc. │
|
|
52
|
+
└─────────────────────────────────────────────────────┘
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Known Subagent Extensions
|
|
56
|
+
|
|
57
|
+
| Extension | Type | Permission integration | Frontmatter key |
|
|
58
|
+
| ----------------------------------------------------------------------------------- | ---------- | -------------------------------- | ---------------------------------- |
|
|
59
|
+
| [@gotgenes/pi-subagents](https://github.com/gotgenes/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) |
|
|
60
|
+
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) |
|
|
61
|
+
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | subprocess | ✗ Missing env vars | `tools:` (CSV allowlist) |
|
|
62
|
+
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | subprocess | ✗ Missing env vars | `deny-tools:` (CSV denylist) |
|
|
63
|
+
|
|
64
|
+
Process-based subagent extensions (nicobailon, HazAT) spawn child processes but do not set the `PI_SUBAGENT_PARENT_SESSION` env var that the permission system needs for `ask`-state forwarding.
|
|
65
|
+
Without that env var, `ask` permissions in child processes are auto-denied.
|
|
66
|
+
See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the convention that subagent extension authors should follow.
|
|
67
|
+
|
|
68
|
+
The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) does not publish the `subagents:child:session-created` lifecycle event, so it lacks deterministic child detection and `ask`-state forwarding.
|
|
69
|
+
|
|
70
|
+
### Interaction Rules
|
|
71
|
+
|
|
72
|
+
1. **Hidden tool → permission system never sees it.**
|
|
73
|
+
If a subagent extension removes a tool from the active set, the permission system receives no registration or call event for that tool.
|
|
74
|
+
The permission policy for that tool is irrelevant - it is already gone.
|
|
75
|
+
|
|
76
|
+
2. **Denied tool → hidden regardless of the subagent extension's allowlist.**
|
|
77
|
+
If the permission system denies a tool (via `deny` policy or tool filtering), it is removed from the active set before the agent starts.
|
|
78
|
+
A `tools:` allowlist in a subagent extension cannot restore a tool that the permission system has already hidden.
|
|
79
|
+
|
|
80
|
+
3. **The two denylist mechanisms are additive, not conflicting.**
|
|
81
|
+
A tool blocked by either layer stays blocked.
|
|
82
|
+
Neither layer can silently re-enable what the other has blocked.
|
|
83
|
+
|
|
84
|
+
### `permission:` Frontmatter is Exclusive to This Extension
|
|
85
|
+
|
|
86
|
+
The `permission:` key in an agent's YAML frontmatter is read exclusively by `pi-permission-system`.
|
|
87
|
+
It has no interaction with the `tools:`, `disallowed_tools:`, or `deny-tools:` keys consumed by subagent extensions.
|
|
88
|
+
You can freely use both in the same agent file:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
---
|
|
92
|
+
# Subagent extension: allow only bash and read in the child session
|
|
93
|
+
tools: bash,read
|
|
94
|
+
# pi-permission-system: still enforce ask on bash within those allowed tools
|
|
95
|
+
permission:
|
|
96
|
+
bash: ask
|
|
97
|
+
---
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
In this example the subagent extension restricts visibility to `bash` and `read`, and the permission system then gates every `bash` call with an `ask` prompt - both rules apply independently.
|
|
101
|
+
|
|
102
|
+
[ADR-0002]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## Common Issues
|
|
4
|
+
|
|
5
|
+
| Problem | Cause | Solution |
|
|
6
|
+
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
|
|
8
|
+
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
|
|
9
|
+
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
|
|
10
|
+
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
|
|
11
|
+
| External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
|
|
12
|
+
| Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
|
|
13
|
+
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
|
|
14
|
+
|
|
15
|
+
## Diagnostic Logging
|
|
16
|
+
|
|
17
|
+
Enable `"debugLog": true` in your config to write verbose diagnostics to `logs/pi-permission-system-debug.jsonl`.
|
|
18
|
+
|
|
19
|
+
On every session start, the extension emits a `config.resolved` entry to both logs listing the resolved config paths and whether each exists.
|
|
20
|
+
This makes it easy to verify which files the extension actually loaded:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"event": "config.resolved",
|
|
25
|
+
"globalConfigPath": "/…/.pi/agent/extensions/pi-permission-system/config.json",
|
|
26
|
+
"globalConfigExists": true,
|
|
27
|
+
"projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/config.json",
|
|
28
|
+
"projectConfigExists": false,
|
|
29
|
+
"agentsDir": "/…/.pi/agent/agents",
|
|
30
|
+
"agentsDirExists": true,
|
|
31
|
+
"projectAgentsDir": "/…/my-project/.pi/agents",
|
|
32
|
+
"projectAgentsDirExists": false,
|
|
33
|
+
"legacyGlobalPolicyDetected": false,
|
|
34
|
+
"legacyProjectPolicyDetected": false,
|
|
35
|
+
"legacyExtensionConfigDetected": false
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Threat Model
|
|
40
|
+
|
|
41
|
+
**Goal:** Enforce policy at the host level, not the model level.
|
|
42
|
+
|
|
43
|
+
**What this stops:**
|
|
44
|
+
|
|
45
|
+
- Agent calling tools it shouldn't use (e.g., `write`, dangerous `bash`)
|
|
46
|
+
- Tool switching attempts (calling non-existent tool names)
|
|
47
|
+
- Accidental escalation via skill loading
|
|
48
|
+
- Unapproved path-bearing tool access outside the active working directory when `external_directory` is `ask` or `deny`
|
|
49
|
+
|
|
50
|
+
**Limitations:**
|
|
51
|
+
|
|
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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "18.1.
|
|
3
|
+
"version": "18.1.2",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"test",
|
|
16
15
|
"config/config.example.json",
|
|
17
16
|
"schemas/permissions.schema.json",
|
|
17
|
+
"docs/*.md",
|
|
18
|
+
"docs/guides",
|
|
19
|
+
"docs/migration",
|
|
20
|
+
"docs/assets",
|
|
18
21
|
"README.md",
|
|
19
22
|
"CHANGELOG.md",
|
|
20
23
|
"LICENSE"
|
|
@@ -118,10 +118,35 @@ export class AccessPath {
|
|
|
118
118
|
* unknown (a relative bash token after a non-literal `cd`).
|
|
119
119
|
*
|
|
120
120
|
* Carries no canonical alias and no absolute resolution — `matchValues()` is
|
|
121
|
-
* `[literal]` (or `[]` when empty) and `boundaryValue()` is
|
|
122
|
-
* spurious absolute or symlink-resolved rule can match (#393).
|
|
121
|
+
* `[literal, ...matchAliases]` (or `[]` when empty) and `boundaryValue()` is
|
|
122
|
+
* `""` — so no spurious absolute or symlink-resolved rule can match (#393).
|
|
123
|
+
*
|
|
124
|
+
* `matchAliases` supplies extra match-only forms that do not change the
|
|
125
|
+
* display value: a win32 Git Bash POSIX absolute carries a backslash-separated
|
|
126
|
+
* alias so the separator-folding path matcher can match a `/tmp/*` rule (#533).
|
|
127
|
+
*/
|
|
128
|
+
static forLiteral(
|
|
129
|
+
literal: string,
|
|
130
|
+
matchAliases: readonly string[] = [],
|
|
131
|
+
): AccessPath {
|
|
132
|
+
if (!literal) return new AccessPath("", [], "");
|
|
133
|
+
const aliases = [...new Set([literal, ...matchAliases.filter(Boolean)])];
|
|
134
|
+
return new AccessPath(literal, aliases, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build an `AccessPath` for a Git Bash/MSYS device path (`/dev/null`,
|
|
139
|
+
* `/dev/std{in,out,err}`) seen in a bash command on a win32 host.
|
|
140
|
+
*
|
|
141
|
+
* The token names an MSYS runtime device, not a filesystem path, so it is
|
|
142
|
+
* preserved verbatim across all three representations — `value()`,
|
|
143
|
+
* `boundaryValue()`, and `matchValues()` are the device path itself, never
|
|
144
|
+
* `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and
|
|
145
|
+
* canonical forms let the boundary check reach `isSafeSystemPath` (so the
|
|
146
|
+
* device never triggers `external_directory`) while a config rule still
|
|
147
|
+
* matches the path as typed.
|
|
123
148
|
*/
|
|
124
|
-
static
|
|
125
|
-
return new AccessPath(
|
|
149
|
+
static forDevice(devicePath: string): AccessPath {
|
|
150
|
+
return new AccessPath(devicePath, [devicePath], devicePath);
|
|
126
151
|
}
|
|
127
152
|
}
|
|
@@ -315,23 +315,34 @@ export class BashPathResolver {
|
|
|
315
315
|
* Returns `base` unchanged unless the command is `cd`:
|
|
316
316
|
*
|
|
317
317
|
* - `cd /abs` (absolute literal) → a fresh known base, recovering from an
|
|
318
|
-
* earlier unknown base.
|
|
318
|
+
* earlier unknown base. On win32 a drive-mount target (`cd /c/x`) folds to
|
|
319
|
+
* its translated Windows base, while a non-mount POSIX absolute
|
|
320
|
+
* (`cd /tmp`) is not deterministically resolvable and yields unknown (#533).
|
|
319
321
|
* - `cd rel` (relative literal) → fold into a known base, or stay unknown if
|
|
320
322
|
* the base was already unknown.
|
|
321
323
|
* - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
|
|
322
324
|
* unknown.
|
|
325
|
+
*
|
|
326
|
+
* The target's platform/MSYS interpretation is delegated to the
|
|
327
|
+
* {@link PathNormalizer}; this method owns only the base-folding state.
|
|
323
328
|
*/
|
|
324
329
|
private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
|
|
325
330
|
if (extractCommandName(commandNode) !== "cd") return base;
|
|
326
331
|
const target = cdLiteralTarget(commandNode);
|
|
327
332
|
if (target === null) return UNKNOWN_BASE;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
const interpreted = this.normalizer.interpretBashCdTarget(target);
|
|
334
|
+
switch (interpreted.kind) {
|
|
335
|
+
case "absolute":
|
|
336
|
+
return { kind: "known", offset: interpreted.value };
|
|
337
|
+
case "unknown":
|
|
338
|
+
return UNKNOWN_BASE;
|
|
339
|
+
case "relative":
|
|
340
|
+
if (base.kind === "unknown") return UNKNOWN_BASE;
|
|
341
|
+
return {
|
|
342
|
+
kind: "known",
|
|
343
|
+
offset: this.normalizer.joinBase(base.offset, target),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
335
346
|
}
|
|
336
347
|
|
|
337
348
|
// ── Projection ─────────────────────────────────────────────────────────
|
|
@@ -376,19 +387,29 @@ export class BashPathResolver {
|
|
|
376
387
|
base.kind === "known"
|
|
377
388
|
? this.normalizer.resolveBase(base.offset)
|
|
378
389
|
: undefined;
|
|
379
|
-
const accessPath = this.normalizer.
|
|
390
|
+
const accessPath = this.normalizer.forBashToken(candidate, {
|
|
391
|
+
resolveBase,
|
|
392
|
+
});
|
|
380
393
|
const lexical = accessPath.value();
|
|
381
394
|
if (!lexical) continue;
|
|
382
395
|
// The boundary decision and dedup identity use the canonical
|
|
383
|
-
// (symlink-resolved) form
|
|
384
|
-
// config patterns match the path as the user
|
|
396
|
+
// (symlink-resolved) form the AccessPath already derived, but the returned
|
|
397
|
+
// value is the lexical form so config patterns match the path as the user
|
|
398
|
+
// typed it (#418). A win32 device path preserves `/dev/null` as its
|
|
399
|
+
// boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
|
|
400
|
+
// safe-path exclusion (#533).
|
|
385
401
|
const canonical = accessPath.boundaryValue();
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
402
|
+
// A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
|
|
403
|
+
// has no canonical form; it is foreign to the win32 cwd, so it is always
|
|
404
|
+
// external. Its lexical value is the dedup identity so two distinct
|
|
405
|
+
// literal-only paths do not collapse (#533).
|
|
406
|
+
const isExternal = canonical
|
|
407
|
+
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
|
408
|
+
: true;
|
|
409
|
+
const dedupKey = canonical || lexical;
|
|
410
|
+
|
|
411
|
+
if (isExternal && !seen.has(dedupKey)) {
|
|
412
|
+
seen.add(dedupKey);
|
|
392
413
|
externalPaths.push(accessPath);
|
|
393
414
|
}
|
|
394
415
|
}
|
|
@@ -450,7 +471,7 @@ export class BashPathResolver {
|
|
|
450
471
|
base.kind === "known"
|
|
451
472
|
? this.normalizer.resolveBase(base.offset)
|
|
452
473
|
: undefined;
|
|
453
|
-
return this.normalizer.
|
|
474
|
+
return this.normalizer.forBashToken(candidate, { resolveBase });
|
|
454
475
|
}
|
|
455
476
|
|
|
456
477
|
/**
|