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 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
- [![Version](https://img.shields.io/badge/version-0.1.8-blue.svg)](package.json)
3
+ [![Version](https://img.shields.io/badge/version-0.2.1-blue.svg)](package.json)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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. Patterns are sorted by specificity:
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 config validation
404
+ └── permissions.schema.json → JSON Schema for policy validation
375
405
  config/
376
- └── config.example.json → Starter configuration template
406
+ └── config.example.json → Starter global policy template
377
407
  ```
378
408
 
379
409
  #### Module Organization
package/config.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "debugLog": false,
3
+ "permissionReviewLog": true
4
+ }
package/package.json CHANGED
@@ -1,59 +1,60 @@
1
- {
2
- "name": "pi-permission-system",
3
- "version": "0.1.8",
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/config.example.json",
14
- "schemas/permissions.schema.json",
15
- "asset",
16
- "README.md",
17
- "CHANGELOG.md",
18
- "LICENSE"
19
- ],
20
- "scripts": {
21
- "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
22
- "lint": "npm run build",
23
- "test": "bun ./src/test.ts",
24
- "check": "npm run lint && npm run test"
25
- },
26
- "keywords": [
27
- "pi-package",
28
- "pi",
29
- "pi-extension",
30
- "permissions",
31
- "policy",
32
- "coding-agent"
33
- ],
34
- "author": "MasuRii",
35
- "license": "MIT",
36
- "repository": {
37
- "type": "git",
38
- "url": "git+https://github.com/MasuRii/pi-permission-system.git"
39
- },
40
- "homepage": "https://github.com/MasuRii/pi-permission-system#readme",
41
- "bugs": {
42
- "url": "https://github.com/MasuRii/pi-permission-system/issues"
43
- },
44
- "engines": {
45
- "node": ">=20"
46
- },
47
- "publishConfig": {
48
- "access": "public"
49
- },
50
- "pi": {
51
- "extensions": [
52
- "./index.ts"
53
- ]
54
- },
55
- "peerDependencies": {
56
- "@mariozechner/pi-coding-agent": "*",
57
- "@sinclair/typebox": "*"
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(/&lt;/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
- if (typeof error === "undefined") {
471
- console.warn(`[pi-permission-system] ${message}`);
472
- return;
473
- }
543
+ const details = typeof error === "undefined"
544
+ ? { message }
545
+ : { message, error: formatUnknownErrorMessage(error) };
474
546
 
475
- console.warn(`[pi-permission-system] ${message}: ${formatUnknownErrorMessage(error)}`);
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
- if (typeof error === "undefined") {
480
- console.error(`[pi-permission-system] ${message}`);
481
- return;
482
- }
552
+ const details = typeof error === "undefined"
553
+ ? { message }
554
+ : { message, error: formatUnknownErrorMessage(error) };
483
555
 
484
- console.error(`[pi-permission-system] ${message}: ${formatUnknownErrorMessage(error)}`);
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
- const check = permissionManager.checkPermission(toolName, {}, agentName ?? undefined);
848
- return check.state !== "deny";
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 confirmPermission(ctx, formatSkillAskPrompt(skillName, agentName ?? undefined));
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 confirmPermission(
984
- ctx,
985
- formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined),
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 confirmPermission(ctx, formatAskPrompt(check, agentName ?? undefined));
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.");