@gotgenes/pi-permission-system 18.0.2 → 18.1.1
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 +22 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +213 -0
- package/docs/configuration.md +689 -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 -1
- package/src/access-intent/bash/bash-path-resolver.ts +30 -10
- package/src/access-intent/bash/program.ts +9 -0
- package/src/access-intent/bash/token-classification.ts +39 -9
- package/src/handlers/gates/tool-call-gate-pipeline.ts +13 -2
- package/src/permission-manager.ts +51 -1
- package/src/permission-session.ts +12 -0
- package/src/rule.ts +1 -1
- package/src/types.ts +11 -0
- package/test/access-intent/bash/program.test.ts +57 -0
- package/test/access-intent/bash/token-classification.test.ts +47 -0
- package/test/composition-root.test.ts +73 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +26 -0
- package/test/helpers/gate-fixtures.ts +7 -1
- package/test/helpers/session-fixtures.ts +8 -1
- package/test/permission-manager-unified.test.ts +81 -0
- package/test/permission-resolver.test.ts +8 -1
|
@@ -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.
|
|
3
|
+
"version": "18.1.1",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"test",
|
|
16
16
|
"config/config.example.json",
|
|
17
17
|
"schemas/permissions.schema.json",
|
|
18
|
+
"docs/*.md",
|
|
19
|
+
"docs/guides",
|
|
20
|
+
"docs/migration",
|
|
21
|
+
"docs/assets",
|
|
18
22
|
"README.md",
|
|
19
23
|
"CHANGELOG.md",
|
|
20
24
|
"LICENSE"
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/access-intent/bash/node-text";
|
|
6
6
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
7
|
import {
|
|
8
|
+
classifyPromotedRuleCandidate,
|
|
8
9
|
classifyTokenAsPathCandidate,
|
|
9
10
|
classifyTokenAsRuleCandidate,
|
|
10
11
|
} from "#src/access-intent/bash/token-classification";
|
|
@@ -17,6 +18,10 @@ import {
|
|
|
17
18
|
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
18
19
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
19
20
|
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
21
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
22
|
+
|
|
23
|
+
/** Default promotion predicate: promotes nothing (#509). */
|
|
24
|
+
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
20
25
|
|
|
21
26
|
// ── Internal types ───────────────────────────────────────────────────────────
|
|
22
27
|
|
|
@@ -74,20 +79,29 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
|
74
79
|
/**
|
|
75
80
|
* Resolves the filesystem paths a parsed bash program references.
|
|
76
81
|
*
|
|
77
|
-
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its
|
|
78
|
-
* and answers all platform/cwd-dependent questions through it —
|
|
79
|
-
* folding (`isAbsolute`/`joinBase`), per-candidate resolution
|
|
80
|
-
* `forLiteral`/`resolveBase`), and the outside-cwd boundary
|
|
81
|
-
* walk step re-reads the platform or threads the cwd.
|
|
82
|
+
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its primary
|
|
83
|
+
* collaborator and answers all platform/cwd-dependent questions through it —
|
|
84
|
+
* `cd`-base folding (`isAbsolute`/`joinBase`), per-candidate resolution
|
|
85
|
+
* (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
|
|
86
|
+
* decision — so no walk step re-reads the platform or threads the cwd.
|
|
87
|
+
*
|
|
88
|
+
* Also holds an `isPromotablePathToken` predicate (default: promotes nothing)
|
|
89
|
+
* deciding whether a bare token that fails the broad rule-candidate shape gate
|
|
90
|
+
* should still be promoted because it matches an active, specific `path` rule
|
|
91
|
+
* (#509). The resolver never sees the rules themselves — the predicate is
|
|
92
|
+
* built and owned by `PermissionManager.getPromotablePathTokenMatcher`.
|
|
82
93
|
*
|
|
83
94
|
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
|
84
95
|
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
|
85
96
|
* the `cd`-folding state, and the intermediate path candidates stay private.
|
|
86
97
|
* One instance per parse ({@link BashProgram.parse} constructs it with the
|
|
87
|
-
* session normalizer).
|
|
98
|
+
* session normalizer and promotion predicate).
|
|
88
99
|
*/
|
|
89
100
|
export class BashPathResolver {
|
|
90
|
-
constructor(
|
|
101
|
+
constructor(
|
|
102
|
+
private readonly normalizer: PathNormalizer,
|
|
103
|
+
private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
|
|
104
|
+
) {}
|
|
91
105
|
|
|
92
106
|
/**
|
|
93
107
|
* Resolve a parsed bash program's path references into its external-path and
|
|
@@ -387,8 +401,12 @@ export class BashPathResolver {
|
|
|
387
401
|
* policy lookup values.
|
|
388
402
|
*
|
|
389
403
|
* Filters candidates through the broad path classifier
|
|
390
|
-
* (`classifyTokenAsRuleCandidate`)
|
|
391
|
-
*
|
|
404
|
+
* (`classifyTokenAsRuleCandidate`), falling back to the rule-driven promoted
|
|
405
|
+
* classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
|
|
406
|
+
* broad classifier rejects for shape — promoted only when the injected
|
|
407
|
+
* `isPromotablePathToken` predicate matches an active, specific `path` rule.
|
|
408
|
+
* Pairs each qualifying token with its set of policy values (absolute +
|
|
409
|
+
* project-relative + raw).
|
|
392
410
|
* A token after a non-literal `cd` keeps only its literal value so no
|
|
393
411
|
* spurious absolute rule can match (#393).
|
|
394
412
|
*/
|
|
@@ -399,7 +417,9 @@ export class BashPathResolver {
|
|
|
399
417
|
const result: BashPathRuleCandidate[] = [];
|
|
400
418
|
|
|
401
419
|
for (const { token, base } of candidates) {
|
|
402
|
-
const candidate =
|
|
420
|
+
const candidate =
|
|
421
|
+
classifyTokenAsRuleCandidate(token) ??
|
|
422
|
+
classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
|
|
403
423
|
if (!candidate) continue;
|
|
404
424
|
|
|
405
425
|
const path = this.buildRuleCandidatePath(candidate, base);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "#src/access-intent/bash/command-enumeration";
|
|
10
10
|
import { getParser } from "#src/access-intent/bash/parser";
|
|
11
11
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
12
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
12
13
|
|
|
13
14
|
export type { BashCommand, BashPathRuleCandidate };
|
|
14
15
|
|
|
@@ -37,10 +38,17 @@ export class BashProgram {
|
|
|
37
38
|
* through the injected {@link PathNormalizer} (platform + cwd baked in).
|
|
38
39
|
* Heredoc bodies, comments, and other non-argument content are skipped. An
|
|
39
40
|
* unparseable command yields an empty program.
|
|
41
|
+
*
|
|
42
|
+
* `isPromotablePathToken`, when supplied, promotes a bare filename token
|
|
43
|
+
* (e.g. `id_rsa`) into `pathRuleCandidates()` when it matches an active,
|
|
44
|
+
* specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
|
|
45
|
+
* callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
|
|
46
|
+
* are unaffected.
|
|
40
47
|
*/
|
|
41
48
|
static async parse(
|
|
42
49
|
command: string,
|
|
43
50
|
normalizer: PathNormalizer,
|
|
51
|
+
isPromotablePathToken?: PathRuleTokenMatcher,
|
|
44
52
|
): Promise<BashProgram> {
|
|
45
53
|
const parser = await getParser();
|
|
46
54
|
const tree = parser.parse(command);
|
|
@@ -49,6 +57,7 @@ export class BashProgram {
|
|
|
49
57
|
try {
|
|
50
58
|
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
|
51
59
|
normalizer,
|
|
60
|
+
isPromotablePathToken,
|
|
52
61
|
).resolve(tree.rootNode);
|
|
53
62
|
return new BashProgram(
|
|
54
63
|
collectCommands(tree.rootNode),
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
3
|
*
|
|
4
|
-
* Exports
|
|
4
|
+
* Exports three classifiers consumed by `bash-path-resolver.ts`:
|
|
5
5
|
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
6
|
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
|
+
* - `classifyPromotedRuleCandidate` — rule-driven promotion of a bare filename
|
|
8
|
+
* (e.g. `id_rsa`) that `classifyTokenAsRuleCandidate` rejects for shape, but
|
|
9
|
+
* which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
* the seven rejection cases common to
|
|
10
|
-
* extracted to eliminate).
|
|
11
|
+
* All three classifiers share the private `rejectNonPathToken` predicate that
|
|
12
|
+
* captures the seven rejection cases common to them (the production clone this
|
|
13
|
+
* module was extracted to eliminate).
|
|
11
14
|
*
|
|
12
|
-
* Both
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
|
|
16
|
+
* Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
|
|
17
|
+
* platforms. On POSIX the token resolves as a real in-CWD relative path and is
|
|
18
|
+
* gated by the `path` surface; on Windows the `PathNormalizer` routes it through
|
|
19
|
+
* the absolute-path branch. Shape recognition is platform-independent string
|
|
20
|
+
* matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
|
|
17
21
|
*/
|
|
22
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
18
23
|
|
|
19
24
|
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
20
25
|
|
|
@@ -70,6 +75,31 @@ export function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
|
70
75
|
return null;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Rule-driven promotion classifier for bare filenames (#509).
|
|
80
|
+
*
|
|
81
|
+
* A bare token (`id_rsa`) has none of the shapes `classifyTokenAsRuleCandidate`
|
|
82
|
+
* accepts, so it is dropped before rule evaluation by default — most bash
|
|
83
|
+
* argument tokens are not file paths (subcommands, branch names, search
|
|
84
|
+
* patterns). This classifier promotes a bare token into the rule-candidate
|
|
85
|
+
* surface only when the caller-supplied `isPromotable` predicate says it
|
|
86
|
+
* matches an active, specific `path` deny/ask rule, closing the bypass without
|
|
87
|
+
* treating every bare argument as a path.
|
|
88
|
+
*
|
|
89
|
+
* Still runs the shared `rejectNonPathToken` prelude first, so a flag,
|
|
90
|
+
* env-assignment, URL, `@scope` token, or regex-shaped token is never
|
|
91
|
+
* promoted even if it happens to match a configured pattern.
|
|
92
|
+
*
|
|
93
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
94
|
+
*/
|
|
95
|
+
export function classifyPromotedRuleCandidate(
|
|
96
|
+
token: string,
|
|
97
|
+
isPromotable: PathRuleTokenMatcher,
|
|
98
|
+
): string | null {
|
|
99
|
+
if (rejectNonPathToken(token)) return null;
|
|
100
|
+
return isPromotable(token) ? token : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
// ── Private rejection predicate ────────────────────────────────────────────
|
|
74
104
|
|
|
75
105
|
/**
|