pi-permission-system 0.1.8 → 0.2.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 +20 -0
- package/README.md +41 -11
- package/config.json +4 -0
- package/package.json +60 -59
- package/src/common.ts +82 -82
- package/src/extension-config.ts +106 -0
- package/src/index.ts +345 -18
- package/src/logging.ts +94 -0
- package/src/permission-manager.ts +53 -0
- package/src/test.ts +124 -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
|
+
## [0.2.1] - 2026-03-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Extension configuration system (`config.json`) with `debugLog` and `permissionReviewLog` options
|
|
12
|
+
- JSONL debug logging to `logs/pi-permission-system-debug.jsonl` when `debugLog` is enabled
|
|
13
|
+
- JSONL permission review logging to `logs/pi-permission-system-permission-review.jsonl` for auditing
|
|
14
|
+
- Permission request event emission on `pi-permission-system:permission-request` channel for external consumers
|
|
15
|
+
- New `extension-config.ts` module for config file management and path resolution
|
|
16
|
+
- New `logging.ts` module with `createPermissionSystemLogger` for structured log output
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Replaced `console.warn`/`console.error` calls with structured logging to file
|
|
20
|
+
- Permission forwarding now logs request creation, response received, timeout, and user prompts
|
|
21
|
+
- Updated README documentation to cover extension config, logging, and event emission
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-03-12
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `getToolPermission()` method to retrieve tool-level permission state without evaluating command-level rules, useful for tool injection decisions
|
|
27
|
+
|
|
8
28
|
## [0.1.8] - 2026-03-10
|
|
9
29
|
|
|
10
30
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🔐 pi-permission-system
|
|
2
2
|
|
|
3
|
-
[](package.json)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
6
|
Permission enforcement extension for the Pi coding agent that provides centralized, deterministic permission gates for tool, bash, MCP, skill, and special operations.
|
|
@@ -17,6 +17,8 @@ Permission enforcement extension for the Pi coding agent that provides centraliz
|
|
|
17
17
|
- **Skill Protection** — Controls which skills can be loaded or read from disk
|
|
18
18
|
- **Per-Agent Overrides** — Agent-specific permission policies via YAML frontmatter
|
|
19
19
|
- **Subagent Permission Forwarding** — Forwards `ask` confirmations from non-UI subagents back to the main interactive session
|
|
20
|
+
- **File-Based Review Logging** — Writes permission request/denial review entries to a file by default for later auditing
|
|
21
|
+
- **Optional Debug Logging** — Keeps verbose extension diagnostics in a separate file when enabled in `config.json`
|
|
20
22
|
- **JSON Schema Validation** — Full schema for editor autocomplete and config validation
|
|
21
23
|
|
|
22
24
|
## Installation
|
|
@@ -83,6 +85,26 @@ The extension integrates via Pi's lifecycle hooks:
|
|
|
83
85
|
|
|
84
86
|
## Configuration
|
|
85
87
|
|
|
88
|
+
### Extension Config File
|
|
89
|
+
|
|
90
|
+
**Location:** `~/.pi/agent/extensions/pi-permission-system/config.json`
|
|
91
|
+
|
|
92
|
+
The extension creates this file automatically when it is missing. It controls only extension-local logging behavior:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"debugLog": false,
|
|
97
|
+
"permissionReviewLog": true
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Key | Default | Description |
|
|
102
|
+
|-----|---------|-------------|
|
|
103
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
104
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
|
|
105
|
+
|
|
106
|
+
Both logs write to files only under the extension directory. No debug output is printed to the terminal.
|
|
107
|
+
|
|
86
108
|
### Global Policy File
|
|
87
109
|
|
|
88
110
|
**Location:** `~/.pi/agent/pi-permissions.jsonc`
|
|
@@ -181,18 +203,13 @@ Controls built-in tools by exact name (no wildcards):
|
|
|
181
203
|
|
|
182
204
|
### `bash`
|
|
183
205
|
|
|
184
|
-
Command patterns use `*` wildcards and match against the full command string.
|
|
185
|
-
|
|
186
|
-
1. Fewer wildcards wins
|
|
187
|
-
2. Longer literal text wins
|
|
188
|
-
3. Longer overall pattern wins
|
|
206
|
+
Command patterns use `*` wildcards and match against the full command string. If multiple patterns match, the **last matching rule wins**.
|
|
189
207
|
|
|
190
208
|
```jsonc
|
|
191
209
|
{
|
|
192
210
|
"bash": {
|
|
193
|
-
"git status": "allow",
|
|
194
|
-
"git diff": "allow",
|
|
195
211
|
"git *": "ask",
|
|
212
|
+
"git status": "allow",
|
|
196
213
|
"rm -rf *": "deny"
|
|
197
214
|
}
|
|
198
215
|
}
|
|
@@ -357,12 +374,25 @@ When a delegated or routed subagent runs without direct UI access, `ask` permiss
|
|
|
357
374
|
|
|
358
375
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
|
359
376
|
|
|
377
|
+
### Logging
|
|
378
|
+
|
|
379
|
+
When the extension prompts, denies, or forwards permission requests, it can append structured JSONL entries under:
|
|
380
|
+
|
|
381
|
+
```text
|
|
382
|
+
~/.pi/agent/extensions/pi-permission-system/logs/
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
- `pi-permission-system-permission-review.jsonl` — enabled by default for permission review/audit history
|
|
386
|
+
- `pi-permission-system-debug.jsonl` — disabled by default and intended for troubleshooting
|
|
387
|
+
|
|
360
388
|
### Architecture
|
|
361
389
|
|
|
362
390
|
```
|
|
363
391
|
index.ts → Root Pi entrypoint shim
|
|
364
392
|
src/
|
|
365
|
-
├── index.ts → Extension bootstrap, permission checks, and subagent forwarding
|
|
393
|
+
├── index.ts → Extension bootstrap, permission checks, review logging, and subagent forwarding
|
|
394
|
+
├── extension-config.ts → Extension-local config loading and default creation
|
|
395
|
+
├── logging.ts → File-only debug/review logging helpers
|
|
366
396
|
├── permission-manager.ts → Policy loading, merging, and resolution with caching
|
|
367
397
|
├── bash-filter.ts → Bash command wildcard pattern matching
|
|
368
398
|
├── wildcard-matcher.ts → Shared wildcard pattern compilation and matching
|
|
@@ -371,9 +401,9 @@ src/
|
|
|
371
401
|
├── types.ts → TypeScript type definitions
|
|
372
402
|
└── test.ts → Test runner
|
|
373
403
|
schemas/
|
|
374
|
-
└── permissions.schema.json → JSON Schema for
|
|
404
|
+
└── permissions.schema.json → JSON Schema for policy validation
|
|
375
405
|
config/
|
|
376
|
-
└── config.example.json → Starter
|
|
406
|
+
└── config.example.json → Starter global policy template
|
|
377
407
|
```
|
|
378
408
|
|
|
379
409
|
#### Module Organization
|
package/config.json
ADDED
package/package.json
CHANGED
|
@@ -1,59 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-permission-system",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"index.ts",
|
|
12
|
-
"src",
|
|
13
|
-
"config
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"pi",
|
|
29
|
-
"pi
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"@
|
|
58
|
-
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-permission-system",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"config.json",
|
|
14
|
+
"config/config.example.json",
|
|
15
|
+
"schemas/permissions.schema.json",
|
|
16
|
+
"asset",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
|
|
23
|
+
"lint": "npm run build",
|
|
24
|
+
"test": "bun ./src/test.ts",
|
|
25
|
+
"check": "npm run lint && npm run test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"pi-package",
|
|
29
|
+
"pi",
|
|
30
|
+
"pi-extension",
|
|
31
|
+
"permissions",
|
|
32
|
+
"policy",
|
|
33
|
+
"coding-agent"
|
|
34
|
+
],
|
|
35
|
+
"author": "MasuRii",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/MasuRii/pi-permission-system.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/MasuRii/pi-permission-system#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/MasuRii/pi-permission-system/issues"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"pi": {
|
|
52
|
+
"extensions": [
|
|
53
|
+
"./index.ts"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
58
|
+
"@sinclair/typebox": "*"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/common.ts
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
import type { PermissionState } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
-
return {};
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
return value as Record<string, unknown>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function getNonEmptyString(value: unknown): string | null {
|
|
12
|
-
if (typeof value !== "string") {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const trimmed = value.trim();
|
|
17
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
|
-
return value === "allow" || value === "deny" || value === "ask";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
25
|
-
|
|
26
|
-
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
27
|
-
const root: Record<string, unknown> = {};
|
|
28
|
-
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
29
|
-
|
|
30
|
-
const lines = input.split(/\r?\n/);
|
|
31
|
-
for (const rawLine of lines) {
|
|
32
|
-
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const indent = rawLine.length - rawLine.trimStart().length;
|
|
37
|
-
const line = rawLine.trim();
|
|
38
|
-
const separatorIndex = line.indexOf(":");
|
|
39
|
-
if (separatorIndex <= 0) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const key = line.slice(0, separatorIndex).trim().replace(/^['"]|['"]$/g, "");
|
|
44
|
-
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
45
|
-
|
|
46
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
47
|
-
stack.pop();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const current = stack[stack.length - 1].target;
|
|
51
|
-
|
|
52
|
-
if (!rawValue) {
|
|
53
|
-
const child: Record<string, unknown> = {};
|
|
54
|
-
current[key] = child;
|
|
55
|
-
stack.push({ indent, target: child });
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
let scalar = rawValue;
|
|
60
|
-
if ((scalar.startsWith('"') && scalar.endsWith('"')) || (scalar.startsWith("'") && scalar.endsWith("'"))) {
|
|
61
|
-
scalar = scalar.slice(1, -1);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
current[key] = scalar;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return root;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function extractFrontmatter(markdown: string): string {
|
|
71
|
-
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
72
|
-
if (!normalized.startsWith("---\n")) {
|
|
73
|
-
return "";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const end = normalized.indexOf("\n---", 4);
|
|
77
|
-
if (end === -1) {
|
|
78
|
-
return "";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return normalized.slice(4, end);
|
|
82
|
-
}
|
|
1
|
+
import type { PermissionState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getNonEmptyString(value: unknown): string | null {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
|
+
return value === "allow" || value === "deny" || value === "ask";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
25
|
+
|
|
26
|
+
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
27
|
+
const root: Record<string, unknown> = {};
|
|
28
|
+
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
29
|
+
|
|
30
|
+
const lines = input.split(/\r?\n/);
|
|
31
|
+
for (const rawLine of lines) {
|
|
32
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
37
|
+
const line = rawLine.trim();
|
|
38
|
+
const separatorIndex = line.indexOf(":");
|
|
39
|
+
if (separatorIndex <= 0) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = line.slice(0, separatorIndex).trim().replace(/^['"]|['"]$/g, "");
|
|
44
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
45
|
+
|
|
46
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
47
|
+
stack.pop();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = stack[stack.length - 1].target;
|
|
51
|
+
|
|
52
|
+
if (!rawValue) {
|
|
53
|
+
const child: Record<string, unknown> = {};
|
|
54
|
+
current[key] = child;
|
|
55
|
+
stack.push({ indent, target: child });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let scalar = rawValue;
|
|
60
|
+
if ((scalar.startsWith('"') && scalar.endsWith('"')) || (scalar.startsWith("'") && scalar.endsWith("'"))) {
|
|
61
|
+
scalar = scalar.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current[key] = scalar;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractFrontmatter(markdown: string): string {
|
|
71
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
72
|
+
if (!normalized.startsWith("---\n")) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const end = normalized.indexOf("\n---", 4);
|
|
77
|
+
if (end === -1) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return normalized.slice(4, end);
|
|
82
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { toRecord } from "./common.js";
|
|
6
|
+
|
|
7
|
+
export const EXTENSION_ID = "pi-permission-system";
|
|
8
|
+
|
|
9
|
+
export interface PermissionSystemExtensionConfig {
|
|
10
|
+
debugLog: boolean;
|
|
11
|
+
permissionReviewLog: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PermissionSystemConfigLoadResult {
|
|
15
|
+
config: PermissionSystemExtensionConfig;
|
|
16
|
+
created: boolean;
|
|
17
|
+
warning?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
|
21
|
+
debugLog: false,
|
|
22
|
+
permissionReviewLog: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function resolveExtensionRoot(moduleUrl = import.meta.url): string {
|
|
26
|
+
return join(dirname(fileURLToPath(moduleUrl)), "..");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const EXTENSION_ROOT = resolveExtensionRoot();
|
|
30
|
+
export const CONFIG_PATH = join(EXTENSION_ROOT, "config.json");
|
|
31
|
+
export const LOGS_DIR = join(EXTENSION_ROOT, "logs");
|
|
32
|
+
export const DEBUG_LOG_PATH = join(LOGS_DIR, `${EXTENSION_ID}-debug.jsonl`);
|
|
33
|
+
export const PERMISSION_REVIEW_LOG_PATH = join(LOGS_DIR, `${EXTENSION_ID}-permission-review.jsonl`);
|
|
34
|
+
|
|
35
|
+
function cloneDefaultConfig(): PermissionSystemExtensionConfig {
|
|
36
|
+
return {
|
|
37
|
+
debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
|
|
38
|
+
permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createDefaultConfigContent(): string {
|
|
43
|
+
return `${JSON.stringify(DEFAULT_EXTENSION_CONFIG, null, 2)}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeConfig(raw: unknown): PermissionSystemExtensionConfig {
|
|
47
|
+
const record = toRecord(raw);
|
|
48
|
+
return {
|
|
49
|
+
debugLog: record.debugLog === true,
|
|
50
|
+
permissionReviewLog: record.permissionReviewLog !== false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureConfigDirectory(configPath: string): void {
|
|
55
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ensurePermissionSystemConfig(configPath = CONFIG_PATH): { created: boolean; warning?: string } {
|
|
59
|
+
if (existsSync(configPath)) {
|
|
60
|
+
return { created: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
ensureConfigDirectory(configPath);
|
|
65
|
+
writeFileSync(configPath, createDefaultConfigContent(), "utf-8");
|
|
66
|
+
return { created: true };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
return {
|
|
70
|
+
created: false,
|
|
71
|
+
warning: `Failed to initialize permission-system config at '${configPath}': ${message}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function loadPermissionSystemConfig(configPath = CONFIG_PATH): PermissionSystemConfigLoadResult {
|
|
77
|
+
const ensureResult = ensurePermissionSystemConfig(configPath);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
81
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
82
|
+
const config = normalizeConfig(parsed);
|
|
83
|
+
return {
|
|
84
|
+
config,
|
|
85
|
+
created: ensureResult.created,
|
|
86
|
+
warning: ensureResult.warning,
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
return {
|
|
91
|
+
config: cloneDefaultConfig(),
|
|
92
|
+
created: ensureResult.created,
|
|
93
|
+
warning: ensureResult.warning ?? `Failed to read permission-system config at '${configPath}': ${message}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ensurePermissionSystemLogsDirectory(logsDir = LOGS_DIR): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(logsDir, { recursive: true });
|
|
101
|
+
return undefined;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
return `Failed to create permission-system log directory '${logsDir}': ${message}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { toRecord } from "./common.js";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_EXTENSION_CONFIG,
|
|
9
|
+
loadPermissionSystemConfig,
|
|
10
|
+
type PermissionSystemExtensionConfig,
|
|
11
|
+
} from "./extension-config.js";
|
|
12
|
+
import { createPermissionSystemLogger } from "./logging.js";
|
|
7
13
|
import { PermissionManager } from "./permission-manager.js";
|
|
8
14
|
import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
|
|
9
15
|
import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-registry.js";
|
|
@@ -67,6 +73,66 @@ type PermissionForwardingLocation = {
|
|
|
67
73
|
label: "primary" | "legacy";
|
|
68
74
|
};
|
|
69
75
|
|
|
76
|
+
type PermissionRequestSource = "tool_call" | "skill_input" | "skill_read";
|
|
77
|
+
type PermissionRequestState = "waiting" | "approved" | "denied";
|
|
78
|
+
|
|
79
|
+
type PermissionRequestEvent = {
|
|
80
|
+
requestId: string;
|
|
81
|
+
source: PermissionRequestSource;
|
|
82
|
+
state: PermissionRequestState;
|
|
83
|
+
message: string;
|
|
84
|
+
toolCallId?: string;
|
|
85
|
+
toolName?: string;
|
|
86
|
+
skillName?: string;
|
|
87
|
+
path?: string;
|
|
88
|
+
command?: string;
|
|
89
|
+
target?: string;
|
|
90
|
+
agentName?: string | null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const PERMISSION_REQUEST_EVENT_CHANNEL = "pi-permission-system:permission-request";
|
|
94
|
+
|
|
95
|
+
let extensionConfig: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
|
|
96
|
+
const extensionLogger = createPermissionSystemLogger({
|
|
97
|
+
getConfig: () => extensionConfig,
|
|
98
|
+
});
|
|
99
|
+
const reportedLoggingWarnings = new Set<string>();
|
|
100
|
+
let loggingWarningReporter: ((message: string) => void) | null = null;
|
|
101
|
+
|
|
102
|
+
function setExtensionConfig(config: PermissionSystemExtensionConfig): void {
|
|
103
|
+
extensionConfig = {
|
|
104
|
+
debugLog: config.debugLog,
|
|
105
|
+
permissionReviewLog: config.permissionReviewLog,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setLoggingWarningReporter(reporter: ((message: string) => void) | null): void {
|
|
110
|
+
loggingWarningReporter = reporter;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function reportLoggingWarning(message: string): void {
|
|
114
|
+
if (!loggingWarningReporter || reportedLoggingWarnings.has(message)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
reportedLoggingWarnings.add(message);
|
|
119
|
+
loggingWarningReporter(message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeDebugLog(event: string, details: Record<string, unknown> = {}): void {
|
|
123
|
+
const warning = extensionLogger.debug(event, details);
|
|
124
|
+
if (warning) {
|
|
125
|
+
reportLoggingWarning(warning);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeReviewLog(event: string, details: Record<string, unknown> = {}): void {
|
|
130
|
+
const warning = extensionLogger.review(event, details);
|
|
131
|
+
if (warning) {
|
|
132
|
+
reportLoggingWarning(warning);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
70
136
|
function decodeXml(value: string): string {
|
|
71
137
|
return value
|
|
72
138
|
.replace(/</g, "<")
|
|
@@ -410,6 +476,13 @@ function formatSkillPathDenyReason(skill: SkillPromptEntry, readPath: string, ag
|
|
|
410
476
|
return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
|
|
411
477
|
}
|
|
412
478
|
|
|
479
|
+
function getPermissionLogContext(result: PermissionCheckResult): { command?: string; target?: string } {
|
|
480
|
+
return {
|
|
481
|
+
command: result.command,
|
|
482
|
+
target: result.target,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
413
486
|
function sleep(ms: number): Promise<void> {
|
|
414
487
|
return new Promise((resolve) => {
|
|
415
488
|
setTimeout(resolve, ms);
|
|
@@ -467,21 +540,21 @@ function isErrnoCode(error: unknown, code: string): boolean {
|
|
|
467
540
|
}
|
|
468
541
|
|
|
469
542
|
function logPermissionForwardingWarning(message: string, error?: unknown): void {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
543
|
+
const details = typeof error === "undefined"
|
|
544
|
+
? { message }
|
|
545
|
+
: { message, error: formatUnknownErrorMessage(error) };
|
|
474
546
|
|
|
475
|
-
|
|
547
|
+
writeReviewLog("permission_forwarding.warning", details);
|
|
548
|
+
writeDebugLog("permission_forwarding.warning", details);
|
|
476
549
|
}
|
|
477
550
|
|
|
478
551
|
function logPermissionForwardingError(message: string, error?: unknown): void {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
552
|
+
const details = typeof error === "undefined"
|
|
553
|
+
? { message }
|
|
554
|
+
: { message, error: formatUnknownErrorMessage(error) };
|
|
483
555
|
|
|
484
|
-
|
|
556
|
+
writeReviewLog("permission_forwarding.error", details);
|
|
557
|
+
writeDebugLog("permission_forwarding.error", details);
|
|
485
558
|
}
|
|
486
559
|
|
|
487
560
|
function ensureDirectoryExists(path: string, description: string): boolean {
|
|
@@ -685,6 +758,14 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
|
|
|
685
758
|
const requestPath = join(PERMISSION_FORWARDING_REQUESTS_DIR, `${requestId}.json`);
|
|
686
759
|
const responsePath = join(PERMISSION_FORWARDING_RESPONSES_DIR, `${requestId}.json`);
|
|
687
760
|
|
|
761
|
+
writeReviewLog("forwarded_permission.request_created", {
|
|
762
|
+
requestId,
|
|
763
|
+
requesterAgentName,
|
|
764
|
+
requesterSessionId: request.requesterSessionId,
|
|
765
|
+
requestPath,
|
|
766
|
+
responsePath,
|
|
767
|
+
});
|
|
768
|
+
|
|
688
769
|
try {
|
|
689
770
|
writeJsonFileAtomic(requestPath, request);
|
|
690
771
|
} catch (error) {
|
|
@@ -696,6 +777,12 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
|
|
|
696
777
|
while (Date.now() < deadline) {
|
|
697
778
|
if (existsSync(responsePath)) {
|
|
698
779
|
const response = readForwardedPermissionResponse(responsePath);
|
|
780
|
+
writeReviewLog("forwarded_permission.response_received", {
|
|
781
|
+
requestId,
|
|
782
|
+
approved: response?.approved ?? null,
|
|
783
|
+
responderSessionId: response?.responderSessionId ?? null,
|
|
784
|
+
responsePath,
|
|
785
|
+
});
|
|
699
786
|
safeDeleteFile(responsePath, "forwarded permission response");
|
|
700
787
|
safeDeleteFile(requestPath, "forwarded permission request");
|
|
701
788
|
return Boolean(response?.approved);
|
|
@@ -705,6 +792,11 @@ async function waitForForwardedPermissionApproval(ctx: ExtensionContext, message
|
|
|
705
792
|
}
|
|
706
793
|
|
|
707
794
|
logPermissionForwardingWarning(`Timed out waiting for forwarded permission response '${responsePath}'`);
|
|
795
|
+
writeReviewLog("forwarded_permission.response_timed_out", {
|
|
796
|
+
requestId,
|
|
797
|
+
requesterAgentName,
|
|
798
|
+
responsePath,
|
|
799
|
+
});
|
|
708
800
|
safeDeleteFile(requestPath, "forwarded permission request");
|
|
709
801
|
return false;
|
|
710
802
|
}
|
|
@@ -738,6 +830,14 @@ async function processForwardedPermissionRequests(ctx: ExtensionContext): Promis
|
|
|
738
830
|
continue;
|
|
739
831
|
}
|
|
740
832
|
|
|
833
|
+
writeReviewLog("forwarded_permission.prompted", {
|
|
834
|
+
requestId: request.id,
|
|
835
|
+
source: location.label,
|
|
836
|
+
requesterAgentName: request.requesterAgentName,
|
|
837
|
+
requesterSessionId: request.requesterSessionId,
|
|
838
|
+
requestPath,
|
|
839
|
+
});
|
|
840
|
+
|
|
741
841
|
let approved = false;
|
|
742
842
|
try {
|
|
743
843
|
approved = await ctx.ui.confirm("Permission Required (Subagent)", formatForwardedPermissionPrompt(request));
|
|
@@ -751,6 +851,13 @@ async function processForwardedPermissionRequests(ctx: ExtensionContext): Promis
|
|
|
751
851
|
}
|
|
752
852
|
|
|
753
853
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
854
|
+
writeReviewLog(approved ? "forwarded_permission.approved" : "forwarded_permission.denied", {
|
|
855
|
+
requestId: request.id,
|
|
856
|
+
source: location.label,
|
|
857
|
+
requesterAgentName: request.requesterAgentName,
|
|
858
|
+
requesterSessionId: request.requesterSessionId,
|
|
859
|
+
responsePath,
|
|
860
|
+
});
|
|
754
861
|
try {
|
|
755
862
|
writeJsonFileAtomic(responsePath, {
|
|
756
863
|
approved,
|
|
@@ -788,6 +895,139 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
788
895
|
let permissionForwardingContext: ExtensionContext | null = null;
|
|
789
896
|
let permissionForwardingTimer: NodeJS.Timeout | null = null;
|
|
790
897
|
let isProcessingForwardedRequests = false;
|
|
898
|
+
let runtimeContext: ExtensionContext | null = null;
|
|
899
|
+
let lastConfigWarning: string | null = null;
|
|
900
|
+
|
|
901
|
+
const notifyWarning = (message: string): void => {
|
|
902
|
+
if (!runtimeContext?.hasUI) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
runtimeContext.ui.notify(message, "warning");
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const refreshExtensionConfig = (ctx?: ExtensionContext): void => {
|
|
910
|
+
if (ctx) {
|
|
911
|
+
runtimeContext = ctx;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const result = loadPermissionSystemConfig();
|
|
915
|
+
setExtensionConfig(result.config);
|
|
916
|
+
|
|
917
|
+
if (result.warning && result.warning !== lastConfigWarning) {
|
|
918
|
+
lastConfigWarning = result.warning;
|
|
919
|
+
notifyWarning(result.warning);
|
|
920
|
+
} else if (!result.warning) {
|
|
921
|
+
lastConfigWarning = null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
writeDebugLog("config.loaded", {
|
|
925
|
+
created: result.created,
|
|
926
|
+
warning: result.warning ?? null,
|
|
927
|
+
debugLog: result.config.debugLog,
|
|
928
|
+
permissionReviewLog: result.config.permissionReviewLog,
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
setLoggingWarningReporter(notifyWarning);
|
|
933
|
+
refreshExtensionConfig();
|
|
934
|
+
|
|
935
|
+
const createPermissionRequestId = (prefix: string): string => {
|
|
936
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const emitPermissionRequestEvent = (event: PermissionRequestEvent): void => {
|
|
940
|
+
try {
|
|
941
|
+
pi.events.emit(PERMISSION_REQUEST_EVENT_CHANNEL, event);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
writeDebugLog("permission_request.event_emit_failed", {
|
|
944
|
+
requestId: event.requestId,
|
|
945
|
+
source: event.source,
|
|
946
|
+
state: event.state,
|
|
947
|
+
error: formatUnknownErrorMessage(error),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const reviewPermissionDecision = (
|
|
953
|
+
event: string,
|
|
954
|
+
details: {
|
|
955
|
+
requestId: string;
|
|
956
|
+
source: PermissionRequestSource;
|
|
957
|
+
agentName: string | null;
|
|
958
|
+
message: string;
|
|
959
|
+
toolCallId?: string;
|
|
960
|
+
toolName?: string;
|
|
961
|
+
skillName?: string;
|
|
962
|
+
path?: string;
|
|
963
|
+
command?: string;
|
|
964
|
+
target?: string;
|
|
965
|
+
resolution?: string;
|
|
966
|
+
},
|
|
967
|
+
): void => {
|
|
968
|
+
writeReviewLog(event, {
|
|
969
|
+
requestId: details.requestId,
|
|
970
|
+
source: details.source,
|
|
971
|
+
agentName: details.agentName,
|
|
972
|
+
message: details.message,
|
|
973
|
+
toolCallId: details.toolCallId ?? null,
|
|
974
|
+
toolName: details.toolName ?? null,
|
|
975
|
+
skillName: details.skillName ?? null,
|
|
976
|
+
path: details.path ?? null,
|
|
977
|
+
command: details.command ?? null,
|
|
978
|
+
target: details.target ?? null,
|
|
979
|
+
resolution: details.resolution ?? null,
|
|
980
|
+
});
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const promptPermission = async (
|
|
984
|
+
ctx: ExtensionContext,
|
|
985
|
+
details: {
|
|
986
|
+
requestId: string;
|
|
987
|
+
source: PermissionRequestSource;
|
|
988
|
+
agentName: string | null;
|
|
989
|
+
message: string;
|
|
990
|
+
toolCallId?: string;
|
|
991
|
+
toolName?: string;
|
|
992
|
+
skillName?: string;
|
|
993
|
+
path?: string;
|
|
994
|
+
command?: string;
|
|
995
|
+
target?: string;
|
|
996
|
+
},
|
|
997
|
+
): Promise<boolean> => {
|
|
998
|
+
reviewPermissionDecision("permission_request.waiting", details);
|
|
999
|
+
emitPermissionRequestEvent({
|
|
1000
|
+
requestId: details.requestId,
|
|
1001
|
+
source: details.source,
|
|
1002
|
+
state: "waiting",
|
|
1003
|
+
message: details.message,
|
|
1004
|
+
toolCallId: details.toolCallId,
|
|
1005
|
+
toolName: details.toolName,
|
|
1006
|
+
skillName: details.skillName,
|
|
1007
|
+
path: details.path,
|
|
1008
|
+
command: details.command,
|
|
1009
|
+
target: details.target,
|
|
1010
|
+
agentName: details.agentName,
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
const approved = await confirmPermission(ctx, details.message);
|
|
1014
|
+
reviewPermissionDecision(approved ? "permission_request.approved" : "permission_request.denied", details);
|
|
1015
|
+
emitPermissionRequestEvent({
|
|
1016
|
+
requestId: details.requestId,
|
|
1017
|
+
source: details.source,
|
|
1018
|
+
state: approved ? "approved" : "denied",
|
|
1019
|
+
message: details.message,
|
|
1020
|
+
toolCallId: details.toolCallId,
|
|
1021
|
+
toolName: details.toolName,
|
|
1022
|
+
skillName: details.skillName,
|
|
1023
|
+
path: details.path,
|
|
1024
|
+
command: details.command,
|
|
1025
|
+
target: details.target,
|
|
1026
|
+
agentName: details.agentName,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
return approved;
|
|
1030
|
+
};
|
|
791
1031
|
|
|
792
1032
|
const stopForwardedPermissionPolling = (): void => {
|
|
793
1033
|
if (permissionForwardingTimer) {
|
|
@@ -844,11 +1084,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
844
1084
|
return false;
|
|
845
1085
|
}
|
|
846
1086
|
|
|
847
|
-
|
|
848
|
-
|
|
1087
|
+
// Use tool-level permission check for tool injection decisions
|
|
1088
|
+
// This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
|
|
1089
|
+
// before any command-level permissions are considered
|
|
1090
|
+
const toolPermission = permissionManager.getToolPermission(toolName, agentName ?? undefined);
|
|
1091
|
+
return toolPermission !== "deny";
|
|
849
1092
|
};
|
|
850
1093
|
|
|
851
1094
|
pi.on("session_start", async (_event, ctx) => {
|
|
1095
|
+
runtimeContext = ctx;
|
|
1096
|
+
refreshExtensionConfig(ctx);
|
|
852
1097
|
permissionManager = new PermissionManager();
|
|
853
1098
|
activeSkillEntries = [];
|
|
854
1099
|
lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
@@ -856,16 +1101,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
856
1101
|
});
|
|
857
1102
|
|
|
858
1103
|
pi.on("session_switch", async (_event, ctx) => {
|
|
1104
|
+
runtimeContext = ctx;
|
|
1105
|
+
refreshExtensionConfig(ctx);
|
|
859
1106
|
activeSkillEntries = [];
|
|
860
1107
|
lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
861
1108
|
startForwardedPermissionPolling(ctx);
|
|
862
1109
|
});
|
|
863
1110
|
|
|
864
1111
|
pi.on("session_shutdown", async () => {
|
|
1112
|
+
runtimeContext = null;
|
|
865
1113
|
stopForwardedPermissionPolling();
|
|
866
1114
|
});
|
|
867
1115
|
|
|
868
1116
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
1117
|
+
runtimeContext = ctx;
|
|
1118
|
+
refreshExtensionConfig(ctx);
|
|
869
1119
|
startForwardedPermissionPolling(ctx);
|
|
870
1120
|
const agentName = resolveAgentName(ctx, event.systemPrompt);
|
|
871
1121
|
const allTools = pi.getAllTools();
|
|
@@ -896,6 +1146,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
896
1146
|
});
|
|
897
1147
|
|
|
898
1148
|
pi.on("input", async (event, ctx) => {
|
|
1149
|
+
runtimeContext = ctx;
|
|
899
1150
|
startForwardedPermissionPolling(ctx);
|
|
900
1151
|
const skillName = extractSkillNameFromInput(event.text);
|
|
901
1152
|
if (!skillName) {
|
|
@@ -908,6 +1159,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
908
1159
|
if (ctx.hasUI) {
|
|
909
1160
|
ctx.ui.notify(`Skill '${skillName}' is blocked because active agent context is unavailable.`, "warning");
|
|
910
1161
|
}
|
|
1162
|
+
writeReviewLog("permission_request.blocked", {
|
|
1163
|
+
source: "skill_input",
|
|
1164
|
+
skillName,
|
|
1165
|
+
agentName: null,
|
|
1166
|
+
resolution: "missing_agent_context",
|
|
1167
|
+
});
|
|
911
1168
|
return { action: "handled" };
|
|
912
1169
|
}
|
|
913
1170
|
|
|
@@ -918,15 +1175,35 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
918
1175
|
const resolvedAgent = agentName ?? "none";
|
|
919
1176
|
ctx.ui.notify(`Skill '${skillName}' is not permitted for agent '${resolvedAgent}'.`, "warning");
|
|
920
1177
|
}
|
|
1178
|
+
writeReviewLog("permission_request.blocked", {
|
|
1179
|
+
source: "skill_input",
|
|
1180
|
+
skillName,
|
|
1181
|
+
agentName,
|
|
1182
|
+
resolution: "policy_denied",
|
|
1183
|
+
});
|
|
921
1184
|
return { action: "handled" };
|
|
922
1185
|
}
|
|
923
1186
|
|
|
924
1187
|
if (check.state === "ask") {
|
|
1188
|
+
const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
|
|
925
1189
|
if (!canRequestPermissionConfirmation(ctx)) {
|
|
1190
|
+
writeReviewLog("permission_request.blocked", {
|
|
1191
|
+
source: "skill_input",
|
|
1192
|
+
skillName,
|
|
1193
|
+
agentName,
|
|
1194
|
+
message,
|
|
1195
|
+
resolution: "confirmation_unavailable",
|
|
1196
|
+
});
|
|
926
1197
|
return { action: "handled" };
|
|
927
1198
|
}
|
|
928
1199
|
|
|
929
|
-
const approved = await
|
|
1200
|
+
const approved = await promptPermission(ctx, {
|
|
1201
|
+
requestId: createPermissionRequestId("skill-input"),
|
|
1202
|
+
source: "skill_input",
|
|
1203
|
+
agentName,
|
|
1204
|
+
message,
|
|
1205
|
+
skillName,
|
|
1206
|
+
});
|
|
930
1207
|
if (!approved) {
|
|
931
1208
|
return { action: "handled" };
|
|
932
1209
|
}
|
|
@@ -936,6 +1213,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
936
1213
|
});
|
|
937
1214
|
|
|
938
1215
|
pi.on("tool_call", async (event, ctx) => {
|
|
1216
|
+
runtimeContext = ctx;
|
|
939
1217
|
startForwardedPermissionPolling(ctx);
|
|
940
1218
|
const agentName = resolveAgentName(ctx);
|
|
941
1219
|
const toolName = getEventToolName(event);
|
|
@@ -966,6 +1244,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
966
1244
|
|
|
967
1245
|
if (matchedSkill) {
|
|
968
1246
|
if (matchedSkill.state === "deny") {
|
|
1247
|
+
writeReviewLog("permission_request.blocked", {
|
|
1248
|
+
source: "skill_read",
|
|
1249
|
+
skillName: matchedSkill.name,
|
|
1250
|
+
agentName,
|
|
1251
|
+
path: event.input.path,
|
|
1252
|
+
resolution: "policy_denied",
|
|
1253
|
+
});
|
|
969
1254
|
return {
|
|
970
1255
|
block: true,
|
|
971
1256
|
reason: formatSkillPathDenyReason(matchedSkill, event.input.path, agentName ?? undefined),
|
|
@@ -973,17 +1258,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
973
1258
|
}
|
|
974
1259
|
|
|
975
1260
|
if (matchedSkill.state === "ask") {
|
|
1261
|
+
const message = formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined);
|
|
976
1262
|
if (!canRequestPermissionConfirmation(ctx)) {
|
|
1263
|
+
writeReviewLog("permission_request.blocked", {
|
|
1264
|
+
source: "skill_read",
|
|
1265
|
+
skillName: matchedSkill.name,
|
|
1266
|
+
agentName,
|
|
1267
|
+
path: event.input.path,
|
|
1268
|
+
message,
|
|
1269
|
+
resolution: "confirmation_unavailable",
|
|
1270
|
+
});
|
|
977
1271
|
return {
|
|
978
1272
|
block: true,
|
|
979
1273
|
reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
980
1274
|
};
|
|
981
1275
|
}
|
|
982
1276
|
|
|
983
|
-
const approved = await
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1277
|
+
const approved = await promptPermission(ctx, {
|
|
1278
|
+
requestId: event.toolCallId,
|
|
1279
|
+
source: "skill_read",
|
|
1280
|
+
agentName,
|
|
1281
|
+
message,
|
|
1282
|
+
toolCallId: event.toolCallId,
|
|
1283
|
+
toolName: toolName,
|
|
1284
|
+
skillName: matchedSkill.name,
|
|
1285
|
+
path: event.input.path,
|
|
1286
|
+
});
|
|
987
1287
|
if (!approved) {
|
|
988
1288
|
return { block: true, reason: `User denied access to skill '${matchedSkill.name}'.` };
|
|
989
1289
|
}
|
|
@@ -993,8 +1293,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
993
1293
|
|
|
994
1294
|
const input = getEventInput(event);
|
|
995
1295
|
const check = permissionManager.checkPermission(toolName, input, agentName ?? undefined);
|
|
1296
|
+
const permissionLogContext = getPermissionLogContext(check);
|
|
996
1297
|
|
|
997
1298
|
if (check.state === "deny") {
|
|
1299
|
+
writeReviewLog("permission_request.blocked", {
|
|
1300
|
+
source: "tool_call",
|
|
1301
|
+
toolCallId: event.toolCallId,
|
|
1302
|
+
toolName,
|
|
1303
|
+
agentName,
|
|
1304
|
+
...permissionLogContext,
|
|
1305
|
+
resolution: "policy_denied",
|
|
1306
|
+
});
|
|
998
1307
|
return { block: true, reason: formatDenyReason(check, agentName ?? undefined) };
|
|
999
1308
|
}
|
|
1000
1309
|
|
|
@@ -1005,14 +1314,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1005
1314
|
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
1006
1315
|
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
1007
1316
|
|
|
1317
|
+
const message = formatAskPrompt(check, agentName ?? undefined);
|
|
1008
1318
|
if (!canRequestPermissionConfirmation(ctx)) {
|
|
1319
|
+
writeReviewLog("permission_request.blocked", {
|
|
1320
|
+
source: "tool_call",
|
|
1321
|
+
toolCallId: event.toolCallId,
|
|
1322
|
+
toolName,
|
|
1323
|
+
agentName,
|
|
1324
|
+
message,
|
|
1325
|
+
...permissionLogContext,
|
|
1326
|
+
resolution: "confirmation_unavailable",
|
|
1327
|
+
});
|
|
1009
1328
|
return {
|
|
1010
1329
|
block: true,
|
|
1011
1330
|
reason: unavailableReason,
|
|
1012
1331
|
};
|
|
1013
1332
|
}
|
|
1014
1333
|
|
|
1015
|
-
const approved = await
|
|
1334
|
+
const approved = await promptPermission(ctx, {
|
|
1335
|
+
requestId: event.toolCallId,
|
|
1336
|
+
source: "tool_call",
|
|
1337
|
+
agentName,
|
|
1338
|
+
message,
|
|
1339
|
+
toolCallId: event.toolCallId,
|
|
1340
|
+
toolName,
|
|
1341
|
+
...permissionLogContext,
|
|
1342
|
+
});
|
|
1016
1343
|
if (!approved) {
|
|
1017
1344
|
return { block: true, reason: formatUserDeniedReason(check) };
|
|
1018
1345
|
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEBUG_LOG_PATH,
|
|
5
|
+
EXTENSION_ID,
|
|
6
|
+
LOGS_DIR,
|
|
7
|
+
PERMISSION_REVIEW_LOG_PATH,
|
|
8
|
+
ensurePermissionSystemLogsDirectory,
|
|
9
|
+
type PermissionSystemExtensionConfig,
|
|
10
|
+
} from "./extension-config.js";
|
|
11
|
+
|
|
12
|
+
function safeJsonStringify(value: unknown): string {
|
|
13
|
+
const seen = new WeakSet<object>();
|
|
14
|
+
return JSON.stringify(value, (_key, currentValue) => {
|
|
15
|
+
if (currentValue instanceof Error) {
|
|
16
|
+
return {
|
|
17
|
+
name: currentValue.name,
|
|
18
|
+
message: currentValue.message,
|
|
19
|
+
stack: currentValue.stack,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof currentValue === "bigint") {
|
|
24
|
+
return currentValue.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof currentValue === "object" && currentValue !== null) {
|
|
28
|
+
if (seen.has(currentValue)) {
|
|
29
|
+
return "[Circular]";
|
|
30
|
+
}
|
|
31
|
+
seen.add(currentValue);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return currentValue;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PermissionSystemLogger {
|
|
39
|
+
debug: (event: string, details?: Record<string, unknown>) => string | undefined;
|
|
40
|
+
review: (event: string, details?: Record<string, unknown>) => string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PermissionSystemLoggerOptions {
|
|
44
|
+
getConfig: () => PermissionSystemExtensionConfig;
|
|
45
|
+
debugLogPath?: string;
|
|
46
|
+
reviewLogPath?: string;
|
|
47
|
+
ensureLogsDirectory?: () => string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createPermissionSystemLogger(options: PermissionSystemLoggerOptions): PermissionSystemLogger {
|
|
51
|
+
const debugLogPath = options.debugLogPath ?? DEBUG_LOG_PATH;
|
|
52
|
+
const reviewLogPath = options.reviewLogPath ?? PERMISSION_REVIEW_LOG_PATH;
|
|
53
|
+
const ensureLogsDirectory = options.ensureLogsDirectory ?? (() => ensurePermissionSystemLogsDirectory(LOGS_DIR));
|
|
54
|
+
|
|
55
|
+
const writeLine = (stream: "debug" | "review", path: string, event: string, details: Record<string, unknown>): string | undefined => {
|
|
56
|
+
const directoryError = ensureLogsDirectory();
|
|
57
|
+
if (directoryError) {
|
|
58
|
+
return directoryError;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const line = safeJsonStringify({
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
extension: EXTENSION_ID,
|
|
65
|
+
stream,
|
|
66
|
+
event,
|
|
67
|
+
...details,
|
|
68
|
+
});
|
|
69
|
+
appendFileSync(path, `${line}\n`, "utf-8");
|
|
70
|
+
return undefined;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return `Failed to write permission-system ${stream} log '${path}': ${message}`;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const debug = (event: string, details: Record<string, unknown> = {}): string | undefined => {
|
|
78
|
+
if (!options.getConfig().debugLog) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return writeLine("debug", debugLogPath, event, details);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const review = (event: string, details: Record<string, unknown> = {}): string | undefined => {
|
|
86
|
+
if (!options.getConfig().permissionReviewLog) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return writeLine("review", reviewLogPath, event, details);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { debug, review };
|
|
94
|
+
}
|
|
@@ -585,6 +585,59 @@ export class PermissionManager {
|
|
|
585
585
|
return value;
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Get the tool-level permission state for a tool, without considering command-level rules.
|
|
590
|
+
* This is used for tool injection decisions where we need to know if a tool is allowed/denied
|
|
591
|
+
* at the tool level before checking specific command permissions.
|
|
592
|
+
*
|
|
593
|
+
* @param toolName - The name of the tool (e.g., "bash", "read", "write")
|
|
594
|
+
* @param agentName - Optional agent name to check agent-specific permissions
|
|
595
|
+
* @returns The permission state for the tool at the tool level
|
|
596
|
+
*/
|
|
597
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
598
|
+
const { merged } = this.resolvePermissions(agentName);
|
|
599
|
+
const normalizedToolName = toolName.trim();
|
|
600
|
+
|
|
601
|
+
// Handle special permission keys (doom_loop, external_directory)
|
|
602
|
+
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
603
|
+
return merged.defaultPolicy.special;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Handle skill tool
|
|
607
|
+
if (normalizedToolName === "skill") {
|
|
608
|
+
return merged.defaultPolicy.skills;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// For bash tool, return the tool-level permission (not command-level)
|
|
612
|
+
if (normalizedToolName === "bash") {
|
|
613
|
+
return merged.tools?.bash || merged.defaultPolicy.bash;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Handle mcp tool
|
|
617
|
+
if (normalizedToolName === "mcp") {
|
|
618
|
+
return merged.tools?.mcp || merged.defaultPolicy.mcp;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Handle other tool permission names
|
|
622
|
+
if (TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
|
|
623
|
+
return merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// For MCP tools (qualified names like "server_tool"), check mcp permissions
|
|
627
|
+
if (normalizedToolName.includes("_")) {
|
|
628
|
+
const mcpMatch = findCompiledPermissionMatch(
|
|
629
|
+
compilePermissionPatternsFromSources(this.loadGlobalConfig().mcp, this.loadAgentPermissions(agentName).mcp),
|
|
630
|
+
normalizedToolName
|
|
631
|
+
);
|
|
632
|
+
if (mcpMatch) {
|
|
633
|
+
return mcpMatch.state;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Default to the tools default policy
|
|
638
|
+
return merged.defaultPolicy.tools;
|
|
639
|
+
}
|
|
640
|
+
|
|
588
641
|
checkPermission(toolName: string, input: unknown, agentName?: string): PermissionCheckResult {
|
|
589
642
|
const { agentConfig, merged, compiledSpecial, compiledSkills, compiledMcp, bashFilter } = this.resolvePermissions(agentName);
|
|
590
643
|
const normalizedToolName = toolName.trim();
|
package/src/test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { BashFilter } from "./bash-filter.js";
|
|
7
|
+
import { DEFAULT_EXTENSION_CONFIG, loadPermissionSystemConfig } from "./extension-config.js";
|
|
8
|
+
import { createPermissionSystemLogger } from "./logging.js";
|
|
7
9
|
import { PermissionManager } from "./permission-manager.js";
|
|
8
10
|
import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-registry.js";
|
|
9
11
|
import type { GlobalPermissionConfig } from "./types.js";
|
|
@@ -47,6 +49,61 @@ function runTest(name: string, testFn: () => void): void {
|
|
|
47
49
|
console.log(`[PASS] ${name}`);
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
runTest("Permission-system extension config defaults debug off and review log on", () => {
|
|
53
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-"));
|
|
54
|
+
const configPath = join(baseDir, "config.json");
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = loadPermissionSystemConfig(configPath);
|
|
58
|
+
assert.equal(result.created, true);
|
|
59
|
+
assert.equal(result.warning, undefined);
|
|
60
|
+
assert.deepEqual(result.config, DEFAULT_EXTENSION_CONFIG);
|
|
61
|
+
assert.equal(existsSync(configPath), true);
|
|
62
|
+
|
|
63
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
64
|
+
assert.equal(raw.debugLog, false);
|
|
65
|
+
assert.equal(raw.permissionReviewLog, true);
|
|
66
|
+
} finally {
|
|
67
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
runTest("Permission-system logger respects debug toggle and keeps review log enabled by default", () => {
|
|
72
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-logs-"));
|
|
73
|
+
const logsDir = join(baseDir, "logs");
|
|
74
|
+
const debugLogPath = join(logsDir, "debug.jsonl");
|
|
75
|
+
const reviewLogPath = join(logsDir, "review.jsonl");
|
|
76
|
+
const config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
77
|
+
const logger = createPermissionSystemLogger({
|
|
78
|
+
getConfig: () => config,
|
|
79
|
+
debugLogPath,
|
|
80
|
+
reviewLogPath,
|
|
81
|
+
ensureLogsDirectory: () => {
|
|
82
|
+
mkdirSync(logsDir, { recursive: true });
|
|
83
|
+
return undefined;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const initialDebugWarning = logger.debug("debug.disabled", { sample: true });
|
|
89
|
+
const reviewWarning = logger.review("permission_request.waiting", { toolName: "write" });
|
|
90
|
+
|
|
91
|
+
assert.equal(initialDebugWarning, undefined);
|
|
92
|
+
assert.equal(reviewWarning, undefined);
|
|
93
|
+
assert.equal(existsSync(debugLogPath), false);
|
|
94
|
+
assert.equal(existsSync(reviewLogPath), true);
|
|
95
|
+
assert.match(readFileSync(reviewLogPath, "utf8"), /permission_request\.waiting/);
|
|
96
|
+
|
|
97
|
+
config.debugLog = true;
|
|
98
|
+
const enabledDebugWarning = logger.debug("debug.enabled", { sample: true });
|
|
99
|
+
assert.equal(enabledDebugWarning, undefined);
|
|
100
|
+
assert.equal(existsSync(debugLogPath), true);
|
|
101
|
+
assert.match(readFileSync(debugLogPath, "utf8"), /debug\.enabled/);
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
50
107
|
runTest("BashFilter uses opencode-style last-match hierarchy", () => {
|
|
51
108
|
const filter = new BashFilter(
|
|
52
109
|
{
|
|
@@ -530,4 +587,70 @@ runTest("Tool registry blocks unregistered tools and handles aliases", () => {
|
|
|
530
587
|
assert.equal(missingNameCheck.status, "missing-tool-name");
|
|
531
588
|
});
|
|
532
589
|
|
|
590
|
+
runTest("getToolPermission returns tool-level deny for agent with bash: deny", () => {
|
|
591
|
+
const { manager, cleanup } = createManager(
|
|
592
|
+
{
|
|
593
|
+
defaultPolicy: {
|
|
594
|
+
tools: "ask",
|
|
595
|
+
bash: "ask",
|
|
596
|
+
mcp: "ask",
|
|
597
|
+
skills: "ask",
|
|
598
|
+
special: "ask",
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
orchestrator: `---
|
|
603
|
+
name: orchestrator
|
|
604
|
+
permission:
|
|
605
|
+
tools:
|
|
606
|
+
bash: deny
|
|
607
|
+
read: deny
|
|
608
|
+
task: allow
|
|
609
|
+
---
|
|
610
|
+
`,
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
// Tool-level check for bash should return deny for orchestrator
|
|
616
|
+
const bashPermission = manager.getToolPermission("bash", "orchestrator");
|
|
617
|
+
assert.equal(bashPermission, "deny");
|
|
618
|
+
|
|
619
|
+
// Tool-level check for task should return allow
|
|
620
|
+
const taskPermission = manager.getToolPermission("task", "orchestrator");
|
|
621
|
+
assert.equal(taskPermission, "allow");
|
|
622
|
+
|
|
623
|
+
// Tool-level check for read should return deny
|
|
624
|
+
const readPermission = manager.getToolPermission("read", "orchestrator");
|
|
625
|
+
assert.equal(readPermission, "deny");
|
|
626
|
+
|
|
627
|
+
// When no agent specified, should fall back to default policy
|
|
628
|
+
const defaultBashPermission = manager.getToolPermission("bash");
|
|
629
|
+
assert.equal(defaultBashPermission, "ask");
|
|
630
|
+
|
|
631
|
+
// Global config tools setting should work
|
|
632
|
+
const { manager: manager2, cleanup: cleanup2 } = createManager({
|
|
633
|
+
defaultPolicy: {
|
|
634
|
+
tools: "deny",
|
|
635
|
+
bash: "ask",
|
|
636
|
+
mcp: "ask",
|
|
637
|
+
skills: "ask",
|
|
638
|
+
special: "ask",
|
|
639
|
+
},
|
|
640
|
+
tools: {
|
|
641
|
+
bash: "allow",
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const globalBashPermission = manager2.getToolPermission("bash");
|
|
647
|
+
assert.equal(globalBashPermission, "allow");
|
|
648
|
+
} finally {
|
|
649
|
+
cleanup2();
|
|
650
|
+
}
|
|
651
|
+
} finally {
|
|
652
|
+
cleanup();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
533
656
|
console.log("All permission system tests passed.");
|