pi-permission-system 0.4.7 → 0.4.8
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 +20 -0
- package/README.md +162 -38
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/config-modal.ts +3 -80
- package/src/index.ts +97 -12
- package/src/permission-manager.ts +31 -18
- package/src/yolo-mode-api.ts +43 -0
- package/tests/config-modal.test.ts +50 -112
- package/tests/permission-system.test.ts +145 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.8] - 2026-05-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added runtime yolo-mode control through the `/permission-system` settings modal and `globalThis.__piPermissionSystem` for other extensions.
|
|
14
|
+
- Added wildcard support for `permission.tools` so direct tools from any extension can be controlled without adapter-specific hardcoding.
|
|
15
|
+
- Added `PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR` so global policy and global agent override lookup can be pointed at a different agent root when needed.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Simplified `/permission-system` to only open the settings modal, removing the previous `show`, `path`, `reset`, `help`, and `yolo ...` subcommands.
|
|
19
|
+
- Expanded the README with an OpenCode-to-Pi migration guide covering agent file placement, frontmatter structure, permission mapping, Pi-specific `tools` vs `mcp` behavior, and policy-root overrides.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Removed artifact validation that forced local runtime `config.json` values to match release defaults.
|
|
23
|
+
- Restored requested bash command text in permission review log entries while keeping prompts and generic tool payload previews redacted.
|
|
24
|
+
- Clarified last-declared-match wildcard precedence in the README and fixed wildcard-order examples/recipes so documented `tools` and `mcp` examples now match runtime behavior (thanks to @Nateowami for issue #17 and @gotgenes for the rule-order diagnosis).
|
|
25
|
+
- Replaced the removed `session_switch` lifecycle subscription with a single supported `session_start` refresh path so session changes rebuild runtime permission state without duplicate handlers (thanks to @gotgenes for PR #11).
|
|
26
|
+
- Removed stale README wording around exact-name tool matching and refreshed the documented architecture/module list to match the current extension layout.
|
|
27
|
+
|
|
8
28
|
## [0.4.7] - 2026-05-04
|
|
9
29
|
|
|
10
30
|
### Added
|
package/README.md
CHANGED
|
@@ -6,6 +6,87 @@ Permission enforcement extension for the Pi coding agent that provides centraliz
|
|
|
6
6
|
|
|
7
7
|
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/3e85190a-17fa-4d94-ac8e-efa54337df5d" />
|
|
8
8
|
|
|
9
|
+
## Coming from OpenCode?
|
|
10
|
+
|
|
11
|
+
Yes — this extension was designed so OpenCode-style agent permission policies can be ported into Pi with minimal friction.
|
|
12
|
+
|
|
13
|
+
### Start here
|
|
14
|
+
|
|
15
|
+
| If you have this in OpenCode | In Pi, use this |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Agent markdown file | `~/.pi/agent/agents/<agent-name>.md` (respects `PI_CODING_AGENT_DIR`) |
|
|
18
|
+
| YAML frontmatter | Same place: top of the markdown file |
|
|
19
|
+
| Agent instructions / system prompt body | Same file, below frontmatter |
|
|
20
|
+
| Agent permission rules | `permission:` inside that same frontmatter |
|
|
21
|
+
|
|
22
|
+
### Important compatibility notes
|
|
23
|
+
|
|
24
|
+
- **Agents are still markdown files with YAML frontmatter.**
|
|
25
|
+
- **Wildcard permissions still use last-match-wins ordering.**
|
|
26
|
+
- **Keep frontmatter simple when porting.** This extension intentionally supports `key: value` scalars and nested maps, not full YAML features like arrays, anchors, or multiline scalars.
|
|
27
|
+
|
|
28
|
+
### Minimal Pi agent example
|
|
29
|
+
|
|
30
|
+
```md
|
|
31
|
+
---
|
|
32
|
+
name: my-agent
|
|
33
|
+
mode: primary
|
|
34
|
+
description: My ported agent
|
|
35
|
+
permission:
|
|
36
|
+
tools:
|
|
37
|
+
read: allow
|
|
38
|
+
grep: allow
|
|
39
|
+
bash:
|
|
40
|
+
"*": ask
|
|
41
|
+
mcp:
|
|
42
|
+
"*": ask
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
Your agent instructions go here.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Compatibility matrix
|
|
49
|
+
|
|
50
|
+
| OpenCode concept | Pi equivalent with this extension | Compatibility | Porting notes |
|
|
51
|
+
|---|---|---:|---|
|
|
52
|
+
| Agent markdown files with YAML frontmatter | `~/.pi/agent/agents/<agent-name>.md` | High | Your agent-local `permission:` frontmatter pattern carries over cleanly. |
|
|
53
|
+
| Wildcard precedence | Same last-declared-match-wins behavior | High | Broad rules first, specific overrides later. |
|
|
54
|
+
| `bash` permission rules | `permission.bash` | High | Command-pattern gating ports cleanly. |
|
|
55
|
+
| Per-tool permission rules like `read`, `grep`, `list`, `task`, or arbitrary extension tool names | `permission.tools` | Medium-High | Pi groups registered tool names under `tools`, including built-ins and extension tools. |
|
|
56
|
+
| `external_directory` | `permission.special.external_directory` | Medium | Same idea, different location. |
|
|
57
|
+
| `doom_loop` | `permission.special.doom_loop` | Medium | Same idea, different location. |
|
|
58
|
+
| `skill` permission rules | `permission.skills` | Medium | Same purpose, but Pi uses a dedicated plural `skills` section. |
|
|
59
|
+
| MCP-related access | `permission.mcp` for proxy targets, `permission.tools` for direct registered tools | Medium | This is the biggest Pi-specific difference: proxy MCP targets and direct tool names are intentionally split. |
|
|
60
|
+
| OpenCode-specific permissions like `webfetch`, `websearch`, `question`, `lsp`, `todowrite` | Usually extension-specific Pi tool names under `permission.tools` | Low-Medium | These do not have universal built-in one-to-one Pi names; map them to the actual registered tools available in your Pi setup. |
|
|
61
|
+
|
|
62
|
+
### Most important difference
|
|
63
|
+
|
|
64
|
+
In OpenCode, many permission names live in one broad permission namespace. In Pi with this extension, there is a deliberate split:
|
|
65
|
+
|
|
66
|
+
| Use this when... | Put the rule here |
|
|
67
|
+
|---|---|
|
|
68
|
+
| You are targeting the registered **`mcp` proxy tool** and its internal server/tool targets | `permission.mcp` |
|
|
69
|
+
| You are targeting an actual registered tool name, including direct extension tools like `context7_*`, `github_*`, or `exa_*` | `permission.tools` |
|
|
70
|
+
|
|
71
|
+
### Fast porting guide
|
|
72
|
+
|
|
73
|
+
| If your OpenCode agent has... | In Pi, do this |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `permission.bash` rules | Move them into `permission.bash` |
|
|
76
|
+
| `permission.external_directory` | Move it to `permission.special.external_directory` |
|
|
77
|
+
| `permission.doom_loop` | Move it to `permission.special.doom_loop` |
|
|
78
|
+
| `permission.skill` rules | Move them to `permission.skills` |
|
|
79
|
+
| Tool-ish permissions like `read`, `grep`, `list`, `task`, or third-party tool names | Put them in `permission.tools` |
|
|
80
|
+
| MCP server/tool target logic | Put proxy-target rules in `permission.mcp` |
|
|
81
|
+
|
|
82
|
+
### Practical takeaway
|
|
83
|
+
|
|
84
|
+
If you are coming from OpenCode, you usually do **not** need to rewrite your whole agent. In most cases, porting is just:
|
|
85
|
+
|
|
86
|
+
1. Keep the agent markdown/frontmatter structure.
|
|
87
|
+
2. Move OpenCode-style tool permissions into Pi's `tools` section.
|
|
88
|
+
3. Move `external_directory` and `doom_loop` into `special`.
|
|
89
|
+
4. Split MCP proxy target rules into `mcp` and direct registered tool rules into `tools`.
|
|
9
90
|
|
|
10
91
|
## Features
|
|
11
92
|
|
|
@@ -17,6 +98,7 @@ Permission enforcement extension for the Pi coding agent that provides centraliz
|
|
|
17
98
|
- **Skill Protection** — Controls which skills can be loaded or read from disk, including multi-block prompt sanitization
|
|
18
99
|
- **Per-Agent Overrides** — Agent-specific permission policies via YAML frontmatter
|
|
19
100
|
- **Subagent Permission Forwarding** — Forwards `ask` confirmations from non-UI subagents back to the main interactive session
|
|
101
|
+
- **Runtime YOLO Control** — Lets users toggle yolo mode from the settings modal and lets other extensions toggle it through the runtime API
|
|
20
102
|
- **File-Based Review Logging** — Writes permission request/denial review entries to a file by default for later auditing
|
|
21
103
|
- **Optional Debug Logging** — Keeps verbose extension diagnostics in a separate file when enabled in `config.json`
|
|
22
104
|
- **JSON Schema Validation** — Full schema for editor autocomplete and config validation
|
|
@@ -41,7 +123,7 @@ Place this folder in one of the following locations:
|
|
|
41
123
|
|
|
42
124
|
Pi auto-discovers extensions in these paths.
|
|
43
125
|
|
|
44
|
-
> **Tip:** All `~/.pi/agent` paths shown in this document are defaults. If the `PI_CODING_AGENT_DIR` environment variable is set, pi uses that directory instead. The extension automatically follows pi's `getAgentDir()` helper
|
|
126
|
+
> **Tip:** All `~/.pi/agent` paths shown in this document are defaults. If the `PI_CODING_AGENT_DIR` environment variable is set, pi uses that directory instead. The extension automatically follows pi's `getAgentDir()` helper for extension installation, session directories, and extension-local config paths. If you need policy lookup to come from a different global agent root, set `PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR`.
|
|
45
127
|
|
|
46
128
|
## Usage
|
|
47
129
|
|
|
@@ -90,10 +172,10 @@ The extension integrates via Pi's lifecycle hooks:
|
|
|
90
172
|
**Additional behaviors:**
|
|
91
173
|
- Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
|
|
92
174
|
- The `Available tools:` system prompt section is rewritten to match the filtered active tool set
|
|
93
|
-
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled
|
|
175
|
+
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled through the same registered-tool permission layer instead of private built-in hardcodes
|
|
94
176
|
- When a subagent hits an `ask` permission without direct UI access, the request can be forwarded to the main interactive session for confirmation
|
|
95
177
|
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries instead of raw multiline JSON
|
|
96
|
-
- Permission review logs include redacted prompt/input metadata for auditing without writing raw prompts
|
|
178
|
+
- Permission review logs include requested bash command text plus redacted prompt/input metadata for auditing without writing raw prompts or generic tool payload previews
|
|
97
179
|
- Path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) evaluate `special.external_directory` before their normal tool permission when an explicit path points outside `ctx.cwd`
|
|
98
180
|
|
|
99
181
|
## Configuration
|
|
@@ -104,7 +186,7 @@ The extension integrates via Pi's lifecycle hooks:
|
|
|
104
186
|
|
|
105
187
|
Set `PI_PERMISSION_SYSTEM_CONFIG_PATH` to point this extension at a specific config file when the default global path is not appropriate.
|
|
106
188
|
|
|
107
|
-
The extension creates this file automatically when it is missing. It controls
|
|
189
|
+
The extension creates this file automatically when it is missing. It controls extension-local logging behavior and yolo mode defaults:
|
|
108
190
|
|
|
109
191
|
```json
|
|
110
192
|
{
|
|
@@ -122,16 +204,43 @@ The extension creates this file automatically when it is missing. It controls on
|
|
|
122
204
|
|
|
123
205
|
Both logs write to files only under the extension directory by default. Set `PI_PERMISSION_SYSTEM_LOGS_DIR` to redirect review/debug logs to a specific directory. No debug output is printed to the terminal.
|
|
124
206
|
|
|
207
|
+
### Runtime YOLO Control
|
|
208
|
+
|
|
209
|
+
Use `/permission-system` to open the settings modal and inspect or change yolo mode interactively.
|
|
210
|
+
|
|
211
|
+
Other extensions can toggle yolo mode immediately through the shared runtime API:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
type PermissionSystemGlobal = typeof globalThis & {
|
|
215
|
+
__piPermissionSystem?: {
|
|
216
|
+
toggleYoloMode(options?: { persist?: boolean; source?: string }): { error?: string };
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
pi.registerShortcut("f8", {
|
|
221
|
+
description: "Toggle pi-permission-system YOLO mode",
|
|
222
|
+
handler: () => {
|
|
223
|
+
const permissionSystem = (globalThis as PermissionSystemGlobal).__piPermissionSystem;
|
|
224
|
+
const result = permissionSystem?.toggleYoloMode({ source: "my-extension" });
|
|
225
|
+
if (result?.error) {
|
|
226
|
+
// Notify or log the error in your extension.
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The runtime API exposes `getYoloMode()`, `setYoloMode(enabled, options?)`, and `toggleYoloMode(options?)`. Runtime updates persist to `config.json` by default; pass `{ persist: false }` for a current-session-only toggle.
|
|
233
|
+
|
|
125
234
|
### Global Policy File
|
|
126
235
|
|
|
127
|
-
**Location:** global Pi policy file (default: `~/.pi/agent/pi-permissions.jsonc`, respects `PI_CODING_AGENT_DIR`)
|
|
236
|
+
**Location:** global Pi policy file (default: `~/.pi/agent/pi-permissions.jsonc`, respects `PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR` when set and otherwise follows `PI_CODING_AGENT_DIR`)
|
|
128
237
|
|
|
129
238
|
The policy file is a JSON object with these sections:
|
|
130
239
|
|
|
131
240
|
| Section | Description |
|
|
132
241
|
|-----------------|-----------------------------------------------------|
|
|
133
242
|
| `defaultPolicy` | Fallback permissions per category |
|
|
134
|
-
| `tools` |
|
|
243
|
+
| `tools` | Pattern-based tool permissions for registered tools |
|
|
135
244
|
| `bash` | Command pattern permissions |
|
|
136
245
|
| `mcp` | MCP server/tool permissions for calls routed through a registered `mcp` tool |
|
|
137
246
|
| `skills` | Skill name pattern permissions |
|
|
@@ -141,7 +250,7 @@ The policy file is a JSON object with these sections:
|
|
|
141
250
|
|
|
142
251
|
### Global Per-Agent Overrides
|
|
143
252
|
|
|
144
|
-
Override global permissions for specific agents via YAML frontmatter in the global Pi agents directory (default: `~/.pi/agent/agents/<agent>.md`, respects `PI_CODING_AGENT_DIR`):
|
|
253
|
+
Override global permissions for specific agents via YAML frontmatter in the global Pi agents directory (default: `~/.pi/agent/agents/<agent-name>.md`, respects `PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR` when set and otherwise follows `PI_CODING_AGENT_DIR`):
|
|
145
254
|
|
|
146
255
|
```yaml
|
|
147
256
|
---
|
|
@@ -164,7 +273,7 @@ permission:
|
|
|
164
273
|
|
|
165
274
|
**MCP behavior:** `permission.tools.mcp` is the coarse entry/fallback permission for a registered `mcp` tool when one is available. More specific `permission.mcp` target rules override that fallback when they match.
|
|
166
275
|
|
|
167
|
-
**Limitations:** The frontmatter parser is intentionally minimal. Use only `key: value` scalars and nested maps. Avoid arrays, multi-line scalars, and YAML anchors.
|
|
276
|
+
**Limitations:** The frontmatter parser is intentionally minimal. Use only `key: value` scalars and nested maps. Avoid arrays, multi-line scalars, and YAML anchors. If you are porting from OpenCode, simplify richer YAML frontmatter before expecting a clean migration.
|
|
168
277
|
|
|
169
278
|
### Project-Level Policy Files
|
|
170
279
|
|
|
@@ -173,7 +282,7 @@ The extension can also layer project-local permission files relative to the acti
|
|
|
173
282
|
| Scope | Path |
|
|
174
283
|
|-------|------|
|
|
175
284
|
| Project policy | `<cwd>/.pi/agent/pi-permissions.jsonc` |
|
|
176
|
-
| Project agent override | `<cwd>/.pi/agent/agents/<agent>.md` |
|
|
285
|
+
| Project agent override | `<cwd>/.pi/agent/agents/<agent-name>.md` |
|
|
177
286
|
|
|
178
287
|
Project-local files use the same formats as the global policy file and global agent frontmatter. These project files are resolved from Pi's current session `cwd`, so they are workspace-specific and do **not** move under `PI_CODING_AGENT_DIR`.
|
|
179
288
|
|
|
@@ -183,7 +292,7 @@ Project-local files use the same formats as the global policy file and global ag
|
|
|
183
292
|
3. Global agent frontmatter
|
|
184
293
|
4. Project agent frontmatter
|
|
185
294
|
|
|
186
|
-
Later trusted layers override earlier layers within the same permission category, and project-local layers can tighten policy by adding `deny` rules. Project-local policy cannot relax a `deny` from the global policy file or global agent frontmatter: an `allow` or `ask` in a project policy is ignored when the latest matching trusted layer is `deny`. For wildcard-based sections like `bash`, `mcp`, `skills`, and `special`, matching still follows **last matching rule wins** within the applicable trust boundary, with global/system `deny` rules acting as floors for project-local overrides.
|
|
295
|
+
Later trusted layers override earlier layers within the same permission category, and project-local layers can tighten policy by adding `deny` rules. Project-local policy cannot relax a `deny` from the global policy file or global agent frontmatter: an `allow` or `ask` in a project policy is ignored when the latest matching trusted layer is `deny`. For wildcard-based sections like `tools`, `bash`, `mcp`, `skills`, and `special`, matching still follows **last matching rule wins** within the applicable trust boundary, with global/system `deny` rules acting as floors for project-local overrides.
|
|
187
296
|
|
|
188
297
|
---
|
|
189
298
|
|
|
@@ -207,7 +316,7 @@ Sets fallback permissions when no specific rule matches:
|
|
|
207
316
|
|
|
208
317
|
### `tools`
|
|
209
318
|
|
|
210
|
-
Controls tools by
|
|
319
|
+
Controls tools by registered name pattern. This is the recommended standalone format for **all** tool entries, including Pi built-ins and arbitrary third-party extension tools. Patterns use `*` wildcards and follow last-declared-match semantics, so put broad fallbacks first and specific overrides later.
|
|
211
320
|
|
|
212
321
|
| Tool name example | Description |
|
|
213
322
|
|-----------------------|-------------|
|
|
@@ -216,29 +325,33 @@ Controls tools by exact registered name (no wildcards). This is the recommended
|
|
|
216
325
|
| `mcp` | Registered MCP proxy tool entry/fallback when available |
|
|
217
326
|
| `task` | Delegation tool handled like any other registered extension tool |
|
|
218
327
|
| `third_party_tool` | Arbitrary registered extension tool |
|
|
328
|
+
| `context7_*` | Wildcard for direct tools registered by another extension |
|
|
329
|
+
| `*` | Fallback for every registered tool not matched by a later rule |
|
|
219
330
|
|
|
220
331
|
```jsonc
|
|
221
332
|
{
|
|
222
333
|
"tools": {
|
|
223
|
-
"
|
|
224
|
-
"
|
|
334
|
+
"*": "ask",
|
|
335
|
+
"context7_*": "ask",
|
|
336
|
+
"third_party_tool": "ask",
|
|
225
337
|
"mcp": "allow",
|
|
226
|
-
"
|
|
338
|
+
"read": "allow",
|
|
339
|
+
"write": "deny"
|
|
227
340
|
}
|
|
228
341
|
}
|
|
229
342
|
```
|
|
230
343
|
|
|
231
|
-
Unknown or absent tools are not required in the config. If another extension is not installed, its tool simply will not be registered at runtime, and this extension will block attempts to call that missing tool before permission checks run.
|
|
344
|
+
Unknown or absent tools are not required in the config. If another extension is not installed, its tool simply will not be registered at runtime, and this extension will block attempts to call that missing tool before permission checks run. Wildcard `tools` rules apply to direct tools from any extension; no adapter-specific naming is required.
|
|
232
345
|
|
|
233
346
|
> **Note:** Setting `tools.bash` affects the *default* for bash commands, but `bash` patterns can provide command-level overrides.
|
|
234
347
|
>
|
|
235
|
-
> **Note:** Setting `tools.mcp` controls coarse access to a registered `mcp` tool when one is available. Specific `mcp` rules still override it when a target pattern matches.
|
|
348
|
+
> **Note:** Setting `tools.mcp` controls coarse access to a registered `mcp` proxy tool when one is available. Specific `mcp` rules still override it when a proxy target pattern matches. Direct MCP tools registered by extensions are regular registered tools and should be controlled with `tools` patterns such as `context7_*` or `github_*`.
|
|
236
349
|
>
|
|
237
350
|
> **Note:** Top-level shorthand is only supported for the canonical Pi built-ins (`bash`, `read`, `write`, `edit`, `grep`, `find`, `ls`) in agent frontmatter. Use `permission.tools.<name>` for `mcp`, `task`, and any third-party tool.
|
|
238
351
|
|
|
239
352
|
### `bash`
|
|
240
353
|
|
|
241
|
-
Command patterns use `*` wildcards and match against the full command string. If multiple patterns match, the **last matching rule wins**.
|
|
354
|
+
Command patterns use `*` wildcards and match against the full command string. If multiple patterns match, the **last declared matching rule wins**. Put broad fallback rules first and more specific overrides later.
|
|
242
355
|
|
|
243
356
|
```jsonc
|
|
244
357
|
{
|
|
@@ -264,9 +377,10 @@ MCP permissions match against derived targets from tool input. These rules are m
|
|
|
264
377
|
```jsonc
|
|
265
378
|
{
|
|
266
379
|
"mcp": {
|
|
380
|
+
"*": "ask",
|
|
381
|
+
"myServer:*": "ask",
|
|
267
382
|
"mcp_status": "allow",
|
|
268
383
|
"mcp_list": "allow",
|
|
269
|
-
"myServer:*": "ask",
|
|
270
384
|
"dangerousServer": "deny"
|
|
271
385
|
}
|
|
272
386
|
}
|
|
@@ -362,10 +476,10 @@ Reserved permission checks:
|
|
|
362
476
|
{
|
|
363
477
|
"defaultPolicy": { "tools": "ask", "bash": "deny", "mcp": "ask", "skills": "ask", "special": "ask" },
|
|
364
478
|
"bash": {
|
|
479
|
+
"git *": "ask",
|
|
365
480
|
"git status": "allow",
|
|
366
481
|
"git diff": "allow",
|
|
367
|
-
"git log *": "allow"
|
|
368
|
-
"git *": "ask"
|
|
482
|
+
"git log *": "allow"
|
|
369
483
|
}
|
|
370
484
|
}
|
|
371
485
|
```
|
|
@@ -376,11 +490,11 @@ Reserved permission checks:
|
|
|
376
490
|
{
|
|
377
491
|
"defaultPolicy": { "tools": "ask", "bash": "ask", "mcp": "ask", "skills": "ask", "special": "ask" },
|
|
378
492
|
"mcp": {
|
|
493
|
+
"*": "ask",
|
|
379
494
|
"mcp_status": "allow",
|
|
380
495
|
"mcp_list": "allow",
|
|
381
496
|
"mcp_search": "allow",
|
|
382
|
-
"mcp_describe": "allow"
|
|
383
|
-
"*": "ask"
|
|
497
|
+
"mcp_describe": "allow"
|
|
384
498
|
}
|
|
385
499
|
}
|
|
386
500
|
```
|
|
@@ -441,26 +555,36 @@ Override logs directory: $PI_PERMISSION_SYSTEM_LOGS_DIR when set
|
|
|
441
555
|
### Architecture
|
|
442
556
|
|
|
443
557
|
```
|
|
444
|
-
index.ts
|
|
558
|
+
index.ts → Root Pi entrypoint shim
|
|
445
559
|
src/
|
|
446
|
-
├── index.ts
|
|
447
|
-
├──
|
|
448
|
-
├──
|
|
449
|
-
├──
|
|
450
|
-
├──
|
|
451
|
-
├──
|
|
452
|
-
├──
|
|
453
|
-
├──
|
|
454
|
-
├──
|
|
455
|
-
|
|
560
|
+
├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
|
|
561
|
+
├── before-agent-start-cache.ts → Caches prompt/tool filtering state between before_agent_start runs
|
|
562
|
+
├── bash-filter.ts → Bash command wildcard pattern matching
|
|
563
|
+
├── common.ts → Shared utilities (YAML parsing, type guards, etc.)
|
|
564
|
+
├── config-modal.ts → `/permission-system` modal registration and settings UI wiring
|
|
565
|
+
├── extension-config.ts → Extension-local config loading and default creation
|
|
566
|
+
├── logging.ts → File-only debug/review logging helpers
|
|
567
|
+
├── model-option-compatibility.ts → Guards unsupported provider/model options
|
|
568
|
+
├── permission-dialog.ts → Interactive permission approval UI helpers
|
|
569
|
+
├── permission-forwarding.ts → Subagent-to-parent permission forwarding utilities
|
|
570
|
+
├── permission-manager.ts → Global/project policy loading, merging, and resolution with caching
|
|
571
|
+
├── skill-prompt-sanitizer.ts → Skill prompt parsing, multi-block sanitization, and skill-read path matching
|
|
572
|
+
├── status.ts → Status line integration for runtime yolo state
|
|
573
|
+
├── system-prompt-sanitizer.ts → Available-tools prompt filtering helpers
|
|
574
|
+
├── tool-registry.ts → Registered tool name resolution
|
|
575
|
+
├── types.ts → TypeScript type definitions
|
|
576
|
+
├── wildcard-matcher.ts → Shared wildcard pattern compilation and matching
|
|
577
|
+
├── yolo-mode.ts → Runtime yolo approval helpers
|
|
578
|
+
├── yolo-mode-api.ts → Shared global runtime API for yolo toggling
|
|
579
|
+
└── zellij-modal.ts → Reusable modal/settings UI components
|
|
456
580
|
tests/
|
|
457
|
-
├── permission-system.test.ts
|
|
458
|
-
├── config-modal.test.ts
|
|
459
|
-
└── test-harness.ts
|
|
581
|
+
├── permission-system.test.ts → Core permission, layering, forwarding, and policy tests
|
|
582
|
+
├── config-modal.test.ts → Modal command behavior tests
|
|
583
|
+
└── test-harness.ts → Shared lightweight test helpers
|
|
460
584
|
schemas/
|
|
461
|
-
└── permissions.schema.json
|
|
585
|
+
└── permissions.schema.json → JSON Schema for policy validation
|
|
462
586
|
config/
|
|
463
|
-
└── config.example.json
|
|
587
|
+
└── config.example.json → Starter global policy template
|
|
464
588
|
```
|
|
465
589
|
|
|
466
590
|
#### Module Organization
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"tools": {
|
|
34
|
-
"description": "
|
|
34
|
+
"description": "Pattern-based permissions for registered tools. Use exact names or `*` wildcards for canonical Pi built-ins and any extension-provided or third-party tools.",
|
|
35
35
|
"$ref": "#/$defs/permissionMap"
|
|
36
36
|
},
|
|
37
37
|
"bash": {
|
package/src/config-modal.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { SettingItem } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { PermissionSystemExtensionConfig } from "./extension-config.js";
|
|
5
5
|
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
|
|
6
6
|
|
|
7
7
|
interface PermissionSystemConfigController {
|
|
@@ -15,42 +15,11 @@ interface SettingValueSyncTarget {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const ON_OFF = ["on", "off"];
|
|
18
|
-
const COMMAND_ARGUMENTS = [
|
|
19
|
-
{
|
|
20
|
-
value: "show",
|
|
21
|
-
label: "Show active settings",
|
|
22
|
-
description: "Display the current permission-system config summary",
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
value: "path",
|
|
26
|
-
label: "Show config path",
|
|
27
|
-
description: "Display the config.json path used by pi-permission-system",
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
value: "reset",
|
|
31
|
-
label: "Reset defaults",
|
|
32
|
-
description: "Restore default yolo/logging settings and persist them",
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
value: "help",
|
|
36
|
-
label: "Show help",
|
|
37
|
-
description: "Display command usage",
|
|
38
|
-
},
|
|
39
|
-
] as const;
|
|
40
|
-
const USAGE_TEXT = "Usage: /permission-system [show|path|reset|help] (or run /permission-system with no args to open settings modal)";
|
|
41
18
|
|
|
42
19
|
function toOnOff(value: boolean): string {
|
|
43
20
|
return value ? "on" : "off";
|
|
44
21
|
}
|
|
45
22
|
|
|
46
|
-
function summarizeConfig(config: PermissionSystemExtensionConfig): string {
|
|
47
|
-
return [
|
|
48
|
-
`yoloMode=${toOnOff(config.yoloMode)}`,
|
|
49
|
-
`permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
|
|
50
|
-
`debugLog=${toOnOff(config.debugLog)}`,
|
|
51
|
-
].join(", ");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
23
|
function buildSettingItems(config: PermissionSystemExtensionConfig): SettingItem[] {
|
|
55
24
|
return [
|
|
56
25
|
{
|
|
@@ -100,16 +69,6 @@ function syncSettingValues(settingsList: SettingValueSyncTarget, config: Permiss
|
|
|
100
69
|
settingsList.updateValue("debugLog", toOnOff(config.debugLog));
|
|
101
70
|
}
|
|
102
71
|
|
|
103
|
-
function getArgumentCompletions(argumentPrefix: string): Array<{ value: string; label: string; description: string }> | null {
|
|
104
|
-
const normalized = argumentPrefix.trim().toLowerCase();
|
|
105
|
-
if (normalized.includes(" ")) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const filtered = COMMAND_ARGUMENTS.filter((item) => item.value.startsWith(normalized));
|
|
110
|
-
return filtered.length > 0 ? [...filtered] : null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
72
|
async function openSettingsModal(ctx: ExtensionCommandContext, controller: PermissionSystemConfigController): Promise<void> {
|
|
114
73
|
const overlayOptions = { anchor: "center" as const, width: 82, maxHeight: "85%" as const, margin: 1 };
|
|
115
74
|
|
|
@@ -132,7 +91,7 @@ async function openSettingsModal(ctx: ExtensionCommandContext, controller: Permi
|
|
|
132
91
|
}
|
|
133
92
|
},
|
|
134
93
|
onClose: () => done(),
|
|
135
|
-
helpText:
|
|
94
|
+
helpText: `Config file: ${controller.getConfigPath()}`,
|
|
136
95
|
enableSearch: true,
|
|
137
96
|
},
|
|
138
97
|
theme,
|
|
@@ -172,46 +131,10 @@ async function openSettingsModal(ctx: ExtensionCommandContext, controller: Permi
|
|
|
172
131
|
);
|
|
173
132
|
}
|
|
174
133
|
|
|
175
|
-
function handleArgs(args: string, ctx: ExtensionCommandContext, controller: PermissionSystemConfigController): boolean {
|
|
176
|
-
const normalized = args.trim().toLowerCase();
|
|
177
|
-
if (!normalized) {
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (normalized === "show") {
|
|
182
|
-
ctx.ui.notify(`permission-system: ${summarizeConfig(controller.getConfig())}`, "info");
|
|
183
|
-
return true;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (normalized === "path") {
|
|
187
|
-
ctx.ui.notify(`permission-system config: ${controller.getConfigPath()}`, "info");
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (normalized === "reset") {
|
|
192
|
-
controller.setConfig(cloneDefaultConfig(), ctx);
|
|
193
|
-
ctx.ui.notify("Permission system settings reset to defaults.", "info");
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (normalized === "help") {
|
|
198
|
-
ctx.ui.notify(USAGE_TEXT, "info");
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
ctx.ui.notify(USAGE_TEXT, "warning");
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
134
|
export function registerPermissionSystemCommand(pi: ExtensionAPI, controller: PermissionSystemConfigController): void {
|
|
207
135
|
pi.registerCommand("permission-system", {
|
|
208
136
|
description: "Configure pi-permission-system logging and yolo-mode behavior",
|
|
209
|
-
|
|
210
|
-
handler: async (args, ctx) => {
|
|
211
|
-
if (handleArgs(args, ctx, controller)) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
137
|
+
handler: async (_args, ctx) => {
|
|
215
138
|
if (!ctx.hasUI) {
|
|
216
139
|
ctx.ui.notify("/permission-system requires interactive TUI mode.", "warning");
|
|
217
140
|
return;
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,13 @@ import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-reg
|
|
|
47
47
|
import type { PermissionCheckResult } from "./types.js";
|
|
48
48
|
import { PERMISSION_SYSTEM_STATUS_KEY, syncPermissionSystemStatus } from "./status.js";
|
|
49
49
|
import { canResolveAskPermissionRequest, shouldAutoApprovePermissionState } from "./yolo-mode.js";
|
|
50
|
+
import {
|
|
51
|
+
registerPiPermissionSystemRuntimeApi,
|
|
52
|
+
unregisterPiPermissionSystemRuntimeApi,
|
|
53
|
+
type PiPermissionSystemRuntimeApi,
|
|
54
|
+
type YoloModeControlOptions,
|
|
55
|
+
type YoloModeControlResult,
|
|
56
|
+
} from "./yolo-mode-api.js";
|
|
50
57
|
import { registerModelOptionCompatibilityGuard } from "./model-option-compatibility.js";
|
|
51
58
|
|
|
52
59
|
const PI_AGENT_DIR = getAgentDir();
|
|
@@ -82,6 +89,7 @@ const PERMISSION_REQUEST_EVENT_CHANNEL = "pi-permission-system:permission-reques
|
|
|
82
89
|
const PATH_BEARING_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"]);
|
|
83
90
|
|
|
84
91
|
let extensionConfig: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
|
|
92
|
+
let runtimeApi: PiPermissionSystemRuntimeApi | null = null;
|
|
85
93
|
const extensionLogger = createPermissionSystemLogger({
|
|
86
94
|
getConfig: () => extensionConfig,
|
|
87
95
|
});
|
|
@@ -514,8 +522,14 @@ function createSensitiveLogMetadata(value: string | undefined): SensitiveLogMeta
|
|
|
514
522
|
function getPermissionLogContext(
|
|
515
523
|
result: PermissionCheckResult,
|
|
516
524
|
input: unknown,
|
|
517
|
-
): {
|
|
525
|
+
): {
|
|
526
|
+
command?: string;
|
|
527
|
+
commandMetadata: SensitiveLogMetadata | null;
|
|
528
|
+
target?: string;
|
|
529
|
+
toolInputPreviewMetadata: SensitiveLogMetadata | null;
|
|
530
|
+
} {
|
|
518
531
|
return {
|
|
532
|
+
command: result.toolName === "bash" && result.command ? result.command : undefined,
|
|
519
533
|
commandMetadata: createSensitiveLogMetadata(result.command),
|
|
520
534
|
target: result.target,
|
|
521
535
|
toolInputPreviewMetadata: createSensitiveLogMetadata(getToolInputPreviewForLog(result, input)),
|
|
@@ -1066,6 +1080,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1066
1080
|
});
|
|
1067
1081
|
};
|
|
1068
1082
|
|
|
1083
|
+
const syncPermissionSystemStatusWhenPossible = (
|
|
1084
|
+
config: PermissionSystemExtensionConfig,
|
|
1085
|
+
ctx?: ExtensionCommandContext | ExtensionContext,
|
|
1086
|
+
): void => {
|
|
1087
|
+
if (ctx) {
|
|
1088
|
+
syncPermissionSystemStatus(ctx, config);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (runtimeContext?.hasUI) {
|
|
1093
|
+
syncPermissionSystemStatus(runtimeContext, config);
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1069
1097
|
const saveExtensionConfig = (next: PermissionSystemExtensionConfig, ctx: ExtensionCommandContext): void => {
|
|
1070
1098
|
const normalized = normalizePermissionSystemConfig(next);
|
|
1071
1099
|
const saved = savePermissionSystemConfig(normalized);
|
|
@@ -1077,7 +1105,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1077
1105
|
}
|
|
1078
1106
|
|
|
1079
1107
|
setExtensionConfig(normalized);
|
|
1080
|
-
|
|
1108
|
+
syncPermissionSystemStatusWhenPossible(normalized, ctx);
|
|
1081
1109
|
lastConfigWarning = null;
|
|
1082
1110
|
|
|
1083
1111
|
writeDebugLog("config.saved", {
|
|
@@ -1087,8 +1115,62 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1087
1115
|
});
|
|
1088
1116
|
};
|
|
1089
1117
|
|
|
1118
|
+
const setYoloModeFromRuntimeApi = (enabled: boolean, options: YoloModeControlOptions = {}): YoloModeControlResult => {
|
|
1119
|
+
if (typeof enabled !== "boolean") {
|
|
1120
|
+
return {
|
|
1121
|
+
yoloMode: extensionConfig.yoloMode,
|
|
1122
|
+
changed: false,
|
|
1123
|
+
persisted: false,
|
|
1124
|
+
error: "setYoloMode(enabled) requires a boolean value.",
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const normalized = normalizePermissionSystemConfig({ ...extensionConfig, yoloMode: enabled });
|
|
1129
|
+
const persisted = options.persist !== false;
|
|
1130
|
+
const changed = extensionConfig.yoloMode !== normalized.yoloMode;
|
|
1131
|
+
|
|
1132
|
+
if (persisted) {
|
|
1133
|
+
const saved = savePermissionSystemConfig(normalized);
|
|
1134
|
+
if (!saved.success) {
|
|
1135
|
+
const error = saved.error ?? "Failed to persist pi-permission-system config.";
|
|
1136
|
+
writeDebugLog("yolo_mode.update_failed", {
|
|
1137
|
+
error,
|
|
1138
|
+
requestedYoloMode: normalized.yoloMode,
|
|
1139
|
+
source: getNonEmptyString(options.source) ?? "runtime-api",
|
|
1140
|
+
});
|
|
1141
|
+
return {
|
|
1142
|
+
yoloMode: extensionConfig.yoloMode,
|
|
1143
|
+
changed: false,
|
|
1144
|
+
persisted: false,
|
|
1145
|
+
error,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
lastConfigWarning = null;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
setExtensionConfig(normalized);
|
|
1152
|
+
syncPermissionSystemStatusWhenPossible(normalized);
|
|
1153
|
+
writeDebugLog("yolo_mode.updated", {
|
|
1154
|
+
changed,
|
|
1155
|
+
persisted,
|
|
1156
|
+
source: getNonEmptyString(options.source) ?? "runtime-api",
|
|
1157
|
+
yoloMode: normalized.yoloMode,
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
yoloMode: normalized.yoloMode,
|
|
1162
|
+
changed,
|
|
1163
|
+
persisted,
|
|
1164
|
+
};
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1090
1167
|
setLoggingWarningReporter(notifyWarning);
|
|
1091
1168
|
refreshExtensionConfig();
|
|
1169
|
+
runtimeApi = registerPiPermissionSystemRuntimeApi({
|
|
1170
|
+
getYoloMode: () => extensionConfig.yoloMode,
|
|
1171
|
+
setYoloMode: setYoloModeFromRuntimeApi,
|
|
1172
|
+
toggleYoloMode: (options?: YoloModeControlOptions) => setYoloModeFromRuntimeApi(!extensionConfig.yoloMode, options),
|
|
1173
|
+
});
|
|
1092
1174
|
registerModelOptionCompatibilityGuard(pi);
|
|
1093
1175
|
|
|
1094
1176
|
registerPermissionSystemCommand(pi, {
|
|
@@ -1125,6 +1207,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1125
1207
|
toolName?: string;
|
|
1126
1208
|
skillName?: string;
|
|
1127
1209
|
path?: string;
|
|
1210
|
+
command?: string;
|
|
1128
1211
|
commandMetadata?: SensitiveLogMetadata | null;
|
|
1129
1212
|
target?: string;
|
|
1130
1213
|
toolInputPreviewMetadata?: SensitiveLogMetadata | null;
|
|
@@ -1141,6 +1224,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1141
1224
|
toolName: details.toolName ?? null,
|
|
1142
1225
|
skillName: details.skillName ?? null,
|
|
1143
1226
|
path: details.path ?? null,
|
|
1227
|
+
command: details.command ?? null,
|
|
1144
1228
|
commandMetadata: details.commandMetadata ?? null,
|
|
1145
1229
|
target: details.target ?? null,
|
|
1146
1230
|
toolInputPreviewMetadata: details.toolInputPreviewMetadata ?? null,
|
|
@@ -1160,6 +1244,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1160
1244
|
toolName?: string;
|
|
1161
1245
|
skillName?: string;
|
|
1162
1246
|
path?: string;
|
|
1247
|
+
command?: string;
|
|
1163
1248
|
commandMetadata?: SensitiveLogMetadata | null;
|
|
1164
1249
|
target?: string;
|
|
1165
1250
|
toolInputPreviewMetadata?: SensitiveLogMetadata | null;
|
|
@@ -1176,6 +1261,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1176
1261
|
toolName: details.toolName,
|
|
1177
1262
|
skillName: details.skillName,
|
|
1178
1263
|
path: details.path,
|
|
1264
|
+
command: details.command,
|
|
1179
1265
|
target: details.target,
|
|
1180
1266
|
agentName: details.agentName,
|
|
1181
1267
|
});
|
|
@@ -1192,6 +1278,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1192
1278
|
toolName: details.toolName,
|
|
1193
1279
|
skillName: details.skillName,
|
|
1194
1280
|
path: details.path,
|
|
1281
|
+
command: details.command,
|
|
1195
1282
|
target: details.target,
|
|
1196
1283
|
agentName: details.agentName,
|
|
1197
1284
|
});
|
|
@@ -1211,6 +1298,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1211
1298
|
toolName: details.toolName,
|
|
1212
1299
|
skillName: details.skillName,
|
|
1213
1300
|
path: details.path,
|
|
1301
|
+
command: details.command,
|
|
1214
1302
|
target: details.target,
|
|
1215
1303
|
agentName: details.agentName,
|
|
1216
1304
|
});
|
|
@@ -1276,13 +1364,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1276
1364
|
return toolPermission !== "deny";
|
|
1277
1365
|
};
|
|
1278
1366
|
|
|
1279
|
-
|
|
1367
|
+
const refreshSessionRuntimeState = (ctx: ExtensionContext): void => {
|
|
1280
1368
|
runtimeContext = ctx;
|
|
1281
1369
|
refreshExtensionConfig(ctx);
|
|
1282
1370
|
permissionManager = createPermissionManagerForCwd(ctx.cwd);
|
|
1283
1371
|
invalidateAgentStartCache();
|
|
1284
1372
|
lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
1285
1373
|
startForwardedPermissionPolling(ctx);
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1377
|
+
refreshSessionRuntimeState(ctx);
|
|
1286
1378
|
|
|
1287
1379
|
if (event.reason === "reload") {
|
|
1288
1380
|
writeDebugLog("lifecycle.reload", {
|
|
@@ -1293,15 +1385,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1293
1385
|
}
|
|
1294
1386
|
});
|
|
1295
1387
|
|
|
1296
|
-
pi.on("session_switch", async (_event, ctx) => {
|
|
1297
|
-
runtimeContext = ctx;
|
|
1298
|
-
refreshExtensionConfig(ctx);
|
|
1299
|
-
permissionManager = createPermissionManagerForCwd(ctx.cwd);
|
|
1300
|
-
invalidateAgentStartCache();
|
|
1301
|
-
lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
1302
|
-
startForwardedPermissionPolling(ctx);
|
|
1303
|
-
});
|
|
1304
|
-
|
|
1305
1388
|
pi.on("resources_discover", async (event, _ctx) => {
|
|
1306
1389
|
if (event.reason === "reload") {
|
|
1307
1390
|
permissionManager = runtimeContext ? createPermissionManagerForCwd(runtimeContext.cwd) : new PermissionManager();
|
|
@@ -1318,6 +1401,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1318
1401
|
pi.on("session_shutdown", async () => {
|
|
1319
1402
|
runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
|
1320
1403
|
runtimeContext = null;
|
|
1404
|
+
unregisterPiPermissionSystemRuntimeApi(runtimeApi ?? undefined);
|
|
1405
|
+
runtimeApi = null;
|
|
1321
1406
|
invalidateAgentStartCache();
|
|
1322
1407
|
stopForwardedPermissionPolling();
|
|
1323
1408
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { extractFrontmatter, getNonEmptyString, isPermissionState, parseSimpleYamlMap, toRecord } from "./common.js";
|
|
6
6
|
import type {
|
|
@@ -17,10 +17,17 @@ import {
|
|
|
17
17
|
type CompiledWildcardPattern,
|
|
18
18
|
} from "./wildcard-matcher.js";
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
function
|
|
23
|
-
|
|
20
|
+
const PERMISSION_POLICY_AGENT_DIR_ENV_KEY = "PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR";
|
|
21
|
+
|
|
22
|
+
function defaultPolicyAgentDir(): string {
|
|
23
|
+
const override = process.env[PERMISSION_POLICY_AGENT_DIR_ENV_KEY]?.trim();
|
|
24
|
+
return override ? resolve(override) : getAgentDir();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function defaultGlobalConfigPath(): string { return join(defaultPolicyAgentDir(), "pi-permissions.jsonc"); }
|
|
28
|
+
function defaultAgentsDir(): string { return join(defaultPolicyAgentDir(), "agents"); }
|
|
29
|
+
function defaultLegacyGlobalSettingsPath(): string { return join(defaultPolicyAgentDir(), "settings.json"); }
|
|
30
|
+
function defaultGlobalMcpConfigPath(): string { return join(defaultPolicyAgentDir(), "mcp.json"); }
|
|
24
31
|
|
|
25
32
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set(["bash", "read", "write", "edit", "grep", "find", "ls"]);
|
|
26
33
|
const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
|
|
@@ -384,6 +391,7 @@ type ResolvedPermissions = {
|
|
|
384
391
|
agentConfig: AgentPermissions;
|
|
385
392
|
merged: GlobalPermissionConfig;
|
|
386
393
|
layers: readonly PermissionLayer[];
|
|
394
|
+
compiledTools: CompiledPermissionPatterns;
|
|
387
395
|
compiledSpecial: CompiledPermissionPatterns;
|
|
388
396
|
compiledSkills: CompiledPermissionPatterns;
|
|
389
397
|
compiledMcp: CompiledPermissionPatterns;
|
|
@@ -754,6 +762,7 @@ export class PermissionManager {
|
|
|
754
762
|
agentConfig,
|
|
755
763
|
merged,
|
|
756
764
|
layers,
|
|
765
|
+
compiledTools: compilePermissionPatternsFromLayers("tools", layers),
|
|
757
766
|
compiledSpecial: compilePermissionPatternsFromLayers("special", layers),
|
|
758
767
|
compiledSkills: compilePermissionPatternsFromLayers("skills", layers),
|
|
759
768
|
compiledMcp: compilePermissionPatternsFromLayers("mcp", layers),
|
|
@@ -798,7 +807,7 @@ export class PermissionManager {
|
|
|
798
807
|
* @returns The permission state for the tool at the tool level
|
|
799
808
|
*/
|
|
800
809
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
801
|
-
const { layers } = this.resolvePermissions(agentName);
|
|
810
|
+
const { layers, compiledTools } = this.resolvePermissions(agentName);
|
|
802
811
|
const normalizedToolName = toolName.trim();
|
|
803
812
|
|
|
804
813
|
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
@@ -809,26 +818,29 @@ export class PermissionManager {
|
|
|
809
818
|
return resolveLayeredDefaultPermission(layers, "skills")?.state ?? DEFAULT_POLICY.skills;
|
|
810
819
|
}
|
|
811
820
|
|
|
821
|
+
const toolMatch = findCompiledPermissionMatch(compiledTools, normalizedToolName);
|
|
822
|
+
|
|
812
823
|
if (normalizedToolName === "bash") {
|
|
813
|
-
return
|
|
824
|
+
return toolMatch?.state
|
|
814
825
|
?? resolveLayeredDefaultPermission(layers, "bash")?.state
|
|
815
826
|
?? DEFAULT_POLICY.bash;
|
|
816
827
|
}
|
|
817
828
|
|
|
818
829
|
if (normalizedToolName === "mcp") {
|
|
819
|
-
return
|
|
830
|
+
return toolMatch?.state
|
|
820
831
|
?? resolveLayeredDefaultPermission(layers, "mcp")?.state
|
|
821
832
|
?? DEFAULT_POLICY.mcp;
|
|
822
833
|
}
|
|
823
834
|
|
|
824
|
-
return
|
|
835
|
+
return toolMatch?.state
|
|
825
836
|
?? resolveLayeredDefaultPermission(layers, "tools")?.state
|
|
826
837
|
?? DEFAULT_POLICY.tools;
|
|
827
838
|
}
|
|
828
839
|
|
|
829
840
|
checkPermission(toolName: string, input: unknown, agentName?: string): PermissionCheckResult {
|
|
830
|
-
const { merged, layers, compiledSpecial, compiledSkills, compiledMcp, compiledBash } = this.resolvePermissions(agentName);
|
|
841
|
+
const { merged, layers, compiledTools, compiledSpecial, compiledSkills, compiledMcp, compiledBash } = this.resolvePermissions(agentName);
|
|
831
842
|
const normalizedToolName = toolName.trim();
|
|
843
|
+
const toolMatch = findCompiledPermissionMatch(compiledTools, normalizedToolName);
|
|
832
844
|
|
|
833
845
|
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
834
846
|
const result = findCompiledPermissionMatch(compiledSpecial, normalizedToolName);
|
|
@@ -867,7 +879,7 @@ export class PermissionManager {
|
|
|
867
879
|
return {
|
|
868
880
|
toolName,
|
|
869
881
|
state: result?.state
|
|
870
|
-
??
|
|
882
|
+
?? toolMatch?.state
|
|
871
883
|
?? resolveLayeredDefaultPermission(layers, "bash")?.state
|
|
872
884
|
?? DEFAULT_POLICY.bash,
|
|
873
885
|
command,
|
|
@@ -879,7 +891,6 @@ export class PermissionManager {
|
|
|
879
891
|
if (normalizedToolName === "mcp") {
|
|
880
892
|
const mcpTargets = [...createMcpPermissionTargets(input, this.getConfiguredMcpServerNames()), "mcp"];
|
|
881
893
|
const fallbackTarget = mcpTargets[0] || "mcp";
|
|
882
|
-
const toolLevelMcpState = resolveLayeredRecordPermission(layers, "tools", "mcp")?.state;
|
|
883
894
|
const defaultMcpState = resolveLayeredDefaultPermission(layers, "mcp")?.state ?? DEFAULT_POLICY.mcp;
|
|
884
895
|
|
|
885
896
|
const mcpMatch = findCompiledPermissionMatchForNames(compiledMcp, mcpTargets);
|
|
@@ -893,10 +904,11 @@ export class PermissionManager {
|
|
|
893
904
|
};
|
|
894
905
|
}
|
|
895
906
|
|
|
896
|
-
if (
|
|
907
|
+
if (toolMatch) {
|
|
897
908
|
return {
|
|
898
909
|
toolName,
|
|
899
|
-
state:
|
|
910
|
+
state: toolMatch.state,
|
|
911
|
+
matchedPattern: toolMatch.matchedPattern,
|
|
900
912
|
target: fallbackTarget,
|
|
901
913
|
source: "tool",
|
|
902
914
|
};
|
|
@@ -923,21 +935,22 @@ export class PermissionManager {
|
|
|
923
935
|
};
|
|
924
936
|
}
|
|
925
937
|
|
|
926
|
-
const explicitToolPermission = resolveLayeredRecordPermission(layers, "tools", normalizedToolName);
|
|
927
938
|
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
|
|
928
939
|
return {
|
|
929
940
|
toolName,
|
|
930
|
-
state:
|
|
941
|
+
state: toolMatch?.state
|
|
931
942
|
?? resolveLayeredDefaultPermission(layers, "tools")?.state
|
|
932
943
|
?? DEFAULT_POLICY.tools,
|
|
944
|
+
matchedPattern: toolMatch?.matchedPattern,
|
|
933
945
|
source: "tool",
|
|
934
946
|
};
|
|
935
947
|
}
|
|
936
948
|
|
|
937
|
-
if (
|
|
949
|
+
if (toolMatch) {
|
|
938
950
|
return {
|
|
939
951
|
toolName,
|
|
940
|
-
state:
|
|
952
|
+
state: toolMatch.state,
|
|
953
|
+
matchedPattern: toolMatch.matchedPattern,
|
|
941
954
|
source: "tool",
|
|
942
955
|
};
|
|
943
956
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface YoloModeControlOptions {
|
|
2
|
+
persist?: boolean;
|
|
3
|
+
source?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface YoloModeControlResult {
|
|
7
|
+
yoloMode: boolean;
|
|
8
|
+
changed: boolean;
|
|
9
|
+
persisted: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PiPermissionSystemRuntimeApi {
|
|
14
|
+
getYoloMode(): boolean;
|
|
15
|
+
setYoloMode(enabled: boolean, options?: YoloModeControlOptions): YoloModeControlResult;
|
|
16
|
+
toggleYoloMode(options?: YoloModeControlOptions): YoloModeControlResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type GlobalWithPermissionSystemRuntimeApi = typeof globalThis & {
|
|
20
|
+
__piPermissionSystem?: PiPermissionSystemRuntimeApi;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function registerPiPermissionSystemRuntimeApi(
|
|
24
|
+
api: PiPermissionSystemRuntimeApi,
|
|
25
|
+
): PiPermissionSystemRuntimeApi {
|
|
26
|
+
const globalScope = globalThis as GlobalWithPermissionSystemRuntimeApi;
|
|
27
|
+
globalScope.__piPermissionSystem = api;
|
|
28
|
+
return api;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unregisterPiPermissionSystemRuntimeApi(api?: PiPermissionSystemRuntimeApi): void {
|
|
32
|
+
const globalScope = globalThis as GlobalWithPermissionSystemRuntimeApi;
|
|
33
|
+
if (api !== undefined && globalScope.__piPermissionSystem !== undefined && globalScope.__piPermissionSystem !== api) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
delete globalScope.__piPermissionSystem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getPiPermissionSystemRuntimeApi(): PiPermissionSystemRuntimeApi | null {
|
|
41
|
+
const globalScope = globalThis as GlobalWithPermissionSystemRuntimeApi;
|
|
42
|
+
return globalScope.__piPermissionSystem ?? null;
|
|
43
|
+
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
2
|
import { mock } from "bun:test";
|
|
6
3
|
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_EXTENSION_CONFIG,
|
|
9
|
-
loadPermissionSystemConfig,
|
|
10
|
-
savePermissionSystemConfig,
|
|
11
|
-
type PermissionSystemExtensionConfig,
|
|
12
|
-
} from "../src/extension-config.js";
|
|
4
|
+
import type { PermissionSystemExtensionConfig } from "../src/extension-config.js";
|
|
13
5
|
import { runAsyncTest, runTest } from "./test-harness.js";
|
|
14
6
|
|
|
15
7
|
mock.module("@mariozechner/pi-coding-agent", () => ({
|
|
@@ -90,122 +82,68 @@ function getRegisteredDefinition(definition: RegisteredCommandDefinition | null)
|
|
|
90
82
|
return definition;
|
|
91
83
|
}
|
|
92
84
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const configPath = join(baseDir, "config.json");
|
|
96
|
-
let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
|
|
85
|
+
function registerForTest(config: PermissionSystemExtensionConfig): RegisteredCommandDefinition {
|
|
86
|
+
let definition: RegisteredCommandDefinition | null = null;
|
|
97
87
|
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
registerPermissionSystemCommand(
|
|
89
|
+
{
|
|
90
|
+
registerCommand(_name: string, nextDefinition: RegisteredCommandDefinition) {
|
|
91
|
+
definition = nextDefinition;
|
|
92
|
+
},
|
|
93
|
+
} as never,
|
|
94
|
+
{
|
|
100
95
|
getConfig: () => config,
|
|
101
96
|
setConfig: (next: PermissionSystemExtensionConfig) => {
|
|
102
97
|
config = next;
|
|
103
98
|
},
|
|
104
|
-
getConfigPath: () =>
|
|
105
|
-
}
|
|
99
|
+
getConfigPath: () => "C:/tmp/pi-permission-system/config.json",
|
|
100
|
+
} as never,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return getRegisteredDefinition(definition);
|
|
104
|
+
}
|
|
106
105
|
|
|
107
|
-
|
|
106
|
+
runTest("permission-system command exposes no subcommand completions", () => {
|
|
107
|
+
const registeredDefinition = registerForTest({
|
|
108
|
+
debugLog: false,
|
|
109
|
+
permissionReviewLog: true,
|
|
110
|
+
yoloMode: false,
|
|
111
|
+
});
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
registerCommand(_name: string, nextDefinition: RegisteredCommandDefinition) {
|
|
112
|
-
definition = nextDefinition;
|
|
113
|
-
},
|
|
114
|
-
} as never,
|
|
115
|
-
controller as never,
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
const registeredDefinition = getRegisteredDefinition(definition);
|
|
119
|
-
assert.ok(typeof registeredDefinition.getArgumentCompletions === "function");
|
|
120
|
-
|
|
121
|
-
const topLevel = registeredDefinition.getArgumentCompletions("");
|
|
122
|
-
assert.ok(Array.isArray(topLevel));
|
|
123
|
-
assert.ok(topLevel.some((item) => item.value === "show"));
|
|
124
|
-
assert.ok(topLevel.some((item) => item.value === "reset"));
|
|
125
|
-
|
|
126
|
-
const filtered = registeredDefinition.getArgumentCompletions("pa");
|
|
127
|
-
assert.deepEqual(filtered?.map((item) => item.value), ["path"]);
|
|
128
|
-
assert.equal(registeredDefinition.getArgumentCompletions("path extra"), null);
|
|
129
|
-
assert.equal(registeredDefinition.getArgumentCompletions("zzz"), null);
|
|
130
|
-
} finally {
|
|
131
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
132
|
-
}
|
|
113
|
+
assert.equal(registeredDefinition.getArgumentCompletions, undefined);
|
|
133
114
|
});
|
|
134
115
|
|
|
135
|
-
await runAsyncTest("permission-system command
|
|
136
|
-
const
|
|
137
|
-
const configPath = join(baseDir, "config.json");
|
|
138
|
-
let config: PermissionSystemExtensionConfig = {
|
|
116
|
+
await runAsyncTest("permission-system command only opens the settings modal", async () => {
|
|
117
|
+
const config: PermissionSystemExtensionConfig = {
|
|
139
118
|
debugLog: true,
|
|
140
119
|
permissionReviewLog: false,
|
|
141
120
|
yoloMode: true,
|
|
142
121
|
};
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
},
|
|
169
|
-
} as never,
|
|
170
|
-
controller as never,
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
assert.equal(registeredName, "permission-system");
|
|
174
|
-
const registeredDefinition = getRegisteredDefinition(definition);
|
|
175
|
-
assert.ok(registeredDefinition.description.includes("Configure pi-permission-system"));
|
|
176
|
-
|
|
177
|
-
const infoCtx = createCommandContext(true);
|
|
178
|
-
await registeredDefinition.handler("show", infoCtx.ctx);
|
|
179
|
-
assert.ok(lastNotification(infoCtx.notifications).message.includes("yoloMode=on"));
|
|
180
|
-
assert.ok(lastNotification(infoCtx.notifications).message.includes("debugLog=on"));
|
|
181
|
-
|
|
182
|
-
await registeredDefinition.handler("path", infoCtx.ctx);
|
|
183
|
-
assert.equal(lastNotification(infoCtx.notifications).message, `permission-system config: ${configPath}`);
|
|
184
|
-
|
|
185
|
-
await registeredDefinition.handler("help", infoCtx.ctx);
|
|
186
|
-
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /permission-system"));
|
|
187
|
-
|
|
188
|
-
await registeredDefinition.handler("reset", infoCtx.ctx);
|
|
189
|
-
assert.deepEqual(config, DEFAULT_EXTENSION_CONFIG);
|
|
190
|
-
assert.equal(lastNotification(infoCtx.notifications).message, "Permission system settings reset to defaults.");
|
|
191
|
-
|
|
192
|
-
const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
193
|
-
assert.deepEqual(persisted, DEFAULT_EXTENSION_CONFIG);
|
|
194
|
-
|
|
195
|
-
await registeredDefinition.handler("unknown", infoCtx.ctx);
|
|
196
|
-
assert.equal(lastNotification(infoCtx.notifications).level, "warning");
|
|
197
|
-
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /permission-system"));
|
|
198
|
-
|
|
199
|
-
const headlessCtx = createCommandContext(false);
|
|
200
|
-
await registeredDefinition.handler("", headlessCtx.ctx);
|
|
201
|
-
assert.equal(lastNotification(headlessCtx.notifications).message, "/permission-system requires interactive TUI mode.");
|
|
202
|
-
|
|
203
|
-
const modalCtx = createCommandContext(true);
|
|
204
|
-
await registeredDefinition.handler("", modalCtx.ctx);
|
|
205
|
-
assert.equal(modalCtx.getCustomCalls(), 1);
|
|
206
|
-
} finally {
|
|
207
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
208
|
-
}
|
|
122
|
+
const registeredDefinition = registerForTest(config);
|
|
123
|
+
|
|
124
|
+
assert.ok(registeredDefinition.description.includes("Configure pi-permission-system"));
|
|
125
|
+
|
|
126
|
+
const headlessCtx = createCommandContext(false);
|
|
127
|
+
await registeredDefinition.handler("", headlessCtx.ctx);
|
|
128
|
+
assert.equal(lastNotification(headlessCtx.notifications).message, "/permission-system requires interactive TUI mode.");
|
|
129
|
+
assert.equal(headlessCtx.getCustomCalls(), 0);
|
|
130
|
+
|
|
131
|
+
const modalCtx = createCommandContext(true);
|
|
132
|
+
await registeredDefinition.handler("", modalCtx.ctx);
|
|
133
|
+
assert.equal(modalCtx.getCustomCalls(), 1);
|
|
134
|
+
assert.equal(modalCtx.notifications.length, 0);
|
|
135
|
+
|
|
136
|
+
const subcommandCtx = createCommandContext(true);
|
|
137
|
+
await registeredDefinition.handler("yolo off", subcommandCtx.ctx);
|
|
138
|
+
await registeredDefinition.handler("show", subcommandCtx.ctx);
|
|
139
|
+
await registeredDefinition.handler("reset", subcommandCtx.ctx);
|
|
140
|
+
assert.equal(subcommandCtx.getCustomCalls(), 3);
|
|
141
|
+
assert.equal(subcommandCtx.notifications.length, 0);
|
|
142
|
+
assert.deepEqual(config, {
|
|
143
|
+
debugLog: true,
|
|
144
|
+
permissionReviewLog: false,
|
|
145
|
+
yoloMode: true,
|
|
146
|
+
});
|
|
209
147
|
});
|
|
210
148
|
|
|
211
149
|
console.log("All permission-system config-modal tests passed.");
|
|
@@ -85,10 +85,21 @@ type MockHandler = (
|
|
|
85
85
|
ctx: Record<string, unknown>,
|
|
86
86
|
) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
87
87
|
|
|
88
|
+
type PermissionSystemRuntimeApi = {
|
|
89
|
+
getYoloMode(): boolean;
|
|
90
|
+
setYoloMode(enabled: boolean, options?: { persist?: boolean; source?: string }): { yoloMode: boolean; changed: boolean; persisted: boolean; error?: string };
|
|
91
|
+
toggleYoloMode(options?: { persist?: boolean; source?: string }): { yoloMode: boolean; changed: boolean; persisted: boolean; error?: string };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type GlobalWithPermissionSystem = typeof globalThis & {
|
|
95
|
+
__piPermissionSystem?: PermissionSystemRuntimeApi;
|
|
96
|
+
};
|
|
97
|
+
|
|
88
98
|
type ExtensionHarness = {
|
|
89
99
|
baseDir: string;
|
|
90
100
|
cwd: string;
|
|
91
101
|
handlers: Record<string, MockHandler>;
|
|
102
|
+
registeredEvents: string[];
|
|
92
103
|
prompts: string[];
|
|
93
104
|
reviewLogPath: string;
|
|
94
105
|
cleanup: () => Promise<void>;
|
|
@@ -99,6 +110,7 @@ type ExtensionHarnessOptions = {
|
|
|
99
110
|
hasUI?: boolean;
|
|
100
111
|
selectResponse?: string;
|
|
101
112
|
inputResponse?: string;
|
|
113
|
+
statusUpdates?: Array<{ key: string; value: string | undefined }>;
|
|
102
114
|
};
|
|
103
115
|
|
|
104
116
|
const INHERITED_SUBAGENT_ENV_KEYS = [
|
|
@@ -135,6 +147,7 @@ function createToolCallHarness(
|
|
|
135
147
|
const cwd = options.cwd || baseDir;
|
|
136
148
|
const prompts: string[] = [];
|
|
137
149
|
const handlers: Record<string, MockHandler> = {};
|
|
150
|
+
const registeredEvents: string[] = [];
|
|
138
151
|
const extensionConfigPath = join(baseDir, "extension-config.json");
|
|
139
152
|
const logsDir = join(baseDir, "extension-logs");
|
|
140
153
|
const reviewLogPath = join(logsDir, "pi-permission-system-permission-review.jsonl");
|
|
@@ -153,6 +166,7 @@ function createToolCallHarness(
|
|
|
153
166
|
try {
|
|
154
167
|
piPermissionSystemExtension({
|
|
155
168
|
on: (name: string, handler: MockHandler): void => {
|
|
169
|
+
registeredEvents.push(name);
|
|
156
170
|
handlers[name] = handler;
|
|
157
171
|
},
|
|
158
172
|
registerCommand: (): void => {},
|
|
@@ -175,6 +189,7 @@ function createToolCallHarness(
|
|
|
175
189
|
baseDir,
|
|
176
190
|
cwd,
|
|
177
191
|
handlers,
|
|
192
|
+
registeredEvents,
|
|
178
193
|
prompts,
|
|
179
194
|
reviewLogPath,
|
|
180
195
|
cleanup: async (): Promise<void> => {
|
|
@@ -209,7 +224,9 @@ function createMockContext(
|
|
|
209
224
|
},
|
|
210
225
|
ui: {
|
|
211
226
|
notify: (): void => {},
|
|
212
|
-
setStatus: (): void => {
|
|
227
|
+
setStatus: (key: string, value: string | undefined): void => {
|
|
228
|
+
options.statusUpdates?.push({ key, value });
|
|
229
|
+
},
|
|
213
230
|
select: async (title: string): Promise<string | undefined> => {
|
|
214
231
|
prompts.push(title);
|
|
215
232
|
return options.selectResponse ?? "Yes";
|
|
@@ -233,6 +250,53 @@ async function runToolCall(
|
|
|
233
250
|
return (result ?? {}) as Record<string, unknown>;
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
await runAsyncTest("Extension registers only one supported session_start lifecycle handler", async () => {
|
|
254
|
+
const harness = createToolCallHarness({ defaultPolicy: { tools: "allow", bash: "allow", mcp: "allow", skills: "allow", special: "allow" } }, []);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
assert.equal(harness.registeredEvents.includes("session_switch"), false);
|
|
258
|
+
assert.equal(harness.registeredEvents.filter((eventName) => eventName === "session_start").length, 1);
|
|
259
|
+
} finally {
|
|
260
|
+
await harness.cleanup();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await runAsyncTest("Extension exposes a runtime YOLO API for other extensions", async () => {
|
|
265
|
+
const statusUpdates: Array<{ key: string; value: string | undefined }> = [];
|
|
266
|
+
const harness = createToolCallHarness(
|
|
267
|
+
{ defaultPolicy: { tools: "allow", bash: "allow", mcp: "allow", skills: "allow", special: "allow" } },
|
|
268
|
+
[],
|
|
269
|
+
{ hasUI: true, statusUpdates },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const globalScope = globalThis as GlobalWithPermissionSystem;
|
|
274
|
+
const api = globalScope.__piPermissionSystem;
|
|
275
|
+
assert.ok(api);
|
|
276
|
+
assert.equal(api.getYoloMode(), false);
|
|
277
|
+
|
|
278
|
+
await Promise.resolve(harness.handlers.session_start?.({ reason: "startup" }, createMockContext(harness.cwd, harness.prompts, { hasUI: true, statusUpdates })));
|
|
279
|
+
|
|
280
|
+
const transient = api.toggleYoloMode({ persist: false, source: "test-extension" });
|
|
281
|
+
assert.deepEqual(transient, { yoloMode: true, changed: true, persisted: false });
|
|
282
|
+
assert.equal(loadPermissionSystemConfig().config.yoloMode, false);
|
|
283
|
+
const enabledStatus = statusUpdates.at(-1);
|
|
284
|
+
assert.equal(enabledStatus?.key, "pi-permission-system");
|
|
285
|
+
assert.equal(enabledStatus?.value, "yolo");
|
|
286
|
+
|
|
287
|
+
const persisted = api.setYoloMode(false, { source: "test-extension" });
|
|
288
|
+
assert.deepEqual(persisted, { yoloMode: false, changed: true, persisted: true });
|
|
289
|
+
assert.equal(loadPermissionSystemConfig().config.yoloMode, false);
|
|
290
|
+
const disabledStatus = statusUpdates.at(-1);
|
|
291
|
+
assert.equal(disabledStatus?.key, "pi-permission-system");
|
|
292
|
+
assert.equal(disabledStatus?.value, undefined);
|
|
293
|
+
} finally {
|
|
294
|
+
await harness.cleanup();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
assert.equal((globalThis as GlobalWithPermissionSystem).__piPermissionSystem, undefined);
|
|
298
|
+
});
|
|
299
|
+
|
|
236
300
|
runTest("Permission-system extension config defaults debug off, review log on, and yolo mode off", () => {
|
|
237
301
|
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-"));
|
|
238
302
|
const configPath = join(baseDir, "config.json");
|
|
@@ -729,6 +793,63 @@ runTest("Arbitrary extension tools use exact-name tool permissions instead of MC
|
|
|
729
793
|
}
|
|
730
794
|
});
|
|
731
795
|
|
|
796
|
+
runTest("Tool permissions support wildcard patterns for extension tools", () => {
|
|
797
|
+
const { manager, cleanup } = createManager({
|
|
798
|
+
defaultPolicy: {
|
|
799
|
+
tools: "deny",
|
|
800
|
+
bash: "ask",
|
|
801
|
+
mcp: "ask",
|
|
802
|
+
skills: "ask",
|
|
803
|
+
special: "ask",
|
|
804
|
+
},
|
|
805
|
+
tools: {
|
|
806
|
+
"*": "ask",
|
|
807
|
+
"context7_*": "allow",
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
const context7 = manager.checkPermission("context7_query-docs", {});
|
|
813
|
+
assert.equal(context7.state, "allow");
|
|
814
|
+
assert.equal(context7.source, "tool");
|
|
815
|
+
assert.equal(context7.matchedPattern, "context7_*");
|
|
816
|
+
assert.equal(manager.getToolPermission("context7_query-docs"), "allow");
|
|
817
|
+
|
|
818
|
+
const unknown = manager.checkPermission("unknown_extension_tool", {});
|
|
819
|
+
assert.equal(unknown.state, "ask");
|
|
820
|
+
assert.equal(unknown.source, "tool");
|
|
821
|
+
assert.equal(unknown.matchedPattern, "*");
|
|
822
|
+
assert.equal(manager.getToolPermission("unknown_extension_tool"), "ask");
|
|
823
|
+
} finally {
|
|
824
|
+
cleanup();
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
runTest("Tool permission wildcards use last matching rule wins", () => {
|
|
829
|
+
const { manager, cleanup } = createManager({
|
|
830
|
+
defaultPolicy: {
|
|
831
|
+
tools: "deny",
|
|
832
|
+
bash: "ask",
|
|
833
|
+
mcp: "ask",
|
|
834
|
+
skills: "ask",
|
|
835
|
+
special: "ask",
|
|
836
|
+
},
|
|
837
|
+
tools: {
|
|
838
|
+
"context7_*": "allow",
|
|
839
|
+
"*": "ask",
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
try {
|
|
844
|
+
const context7 = manager.checkPermission("context7_query-docs", {});
|
|
845
|
+
assert.equal(context7.state, "ask");
|
|
846
|
+
assert.equal(context7.source, "tool");
|
|
847
|
+
assert.equal(context7.matchedPattern, "*");
|
|
848
|
+
} finally {
|
|
849
|
+
cleanup();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
732
853
|
runTest("Skill permission matching", () => {
|
|
733
854
|
const { manager, cleanup } = createManager({
|
|
734
855
|
defaultPolicy: {
|
|
@@ -2096,6 +2217,29 @@ await runAsyncTest("generic ask prompts include serialized tool input for inform
|
|
|
2096
2217
|
}
|
|
2097
2218
|
});
|
|
2098
2219
|
|
|
2220
|
+
await runAsyncTest("permission review logs include requested bash commands", async () => {
|
|
2221
|
+
const harness = createToolCallHarness(
|
|
2222
|
+
{
|
|
2223
|
+
defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "ask", special: "ask" },
|
|
2224
|
+
},
|
|
2225
|
+
["bash"],
|
|
2226
|
+
);
|
|
2227
|
+
|
|
2228
|
+
try {
|
|
2229
|
+
const result = await runToolCall(harness, {
|
|
2230
|
+
toolName: "bash",
|
|
2231
|
+
toolCallId: "review-bash-command",
|
|
2232
|
+
input: { command: "git status --short" },
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
assert.equal(result.block, true);
|
|
2236
|
+
const reviewLog = readFileSync(harness.reviewLogPath, "utf8");
|
|
2237
|
+
assert.match(reviewLog, /\"command\":\"git status --short\"/);
|
|
2238
|
+
} finally {
|
|
2239
|
+
await harness.cleanup();
|
|
2240
|
+
}
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2099
2243
|
await runAsyncTest("permission review logs redact raw prompts and tool input previews", async () => {
|
|
2100
2244
|
const harness = createToolCallHarness(
|
|
2101
2245
|
{
|