pi-permission-system 0.2.0 → 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,21 @@ 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
+
8
23
  ## [0.2.0] - 2026-03-12
9
24
 
10
25
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🔐 pi-permission-system
2
2
 
3
- [![Version](https://img.shields.io/badge/version-0.2.0-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`
@@ -352,12 +374,25 @@ When a delegated or routed subagent runs without direct UI access, `ask` permiss
352
374
 
353
375
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
354
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
+
355
388
  ### Architecture
356
389
 
357
390
  ```
358
391
  index.ts → Root Pi entrypoint shim
359
392
  src/
360
- ├── 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
361
396
  ├── permission-manager.ts → Policy loading, merging, and resolution with caching
362
397
  ├── bash-filter.ts → Bash command wildcard pattern matching
363
398
  ├── wildcard-matcher.ts → Shared wildcard pattern compilation and matching
@@ -366,9 +401,9 @@ src/
366
401
  ├── types.ts → TypeScript type definitions
367
402
  └── test.ts → Test runner
368
403
  schemas/
369
- └── permissions.schema.json → JSON Schema for config validation
404
+ └── permissions.schema.json → JSON Schema for policy validation
370
405
  config/
371
- └── config.example.json → Starter configuration template
406
+ └── config.example.json → Starter global policy template
372
407
  ```
373
408
 
374
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.2.0",
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
+ }
@@ -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) {
@@ -852,6 +1092,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
852
1092
  };
853
1093
 
854
1094
  pi.on("session_start", async (_event, ctx) => {
1095
+ runtimeContext = ctx;
1096
+ refreshExtensionConfig(ctx);
855
1097
  permissionManager = new PermissionManager();
856
1098
  activeSkillEntries = [];
857
1099
  lastKnownActiveAgentName = getActiveAgentName(ctx);
@@ -859,16 +1101,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
859
1101
  });
860
1102
 
861
1103
  pi.on("session_switch", async (_event, ctx) => {
1104
+ runtimeContext = ctx;
1105
+ refreshExtensionConfig(ctx);
862
1106
  activeSkillEntries = [];
863
1107
  lastKnownActiveAgentName = getActiveAgentName(ctx);
864
1108
  startForwardedPermissionPolling(ctx);
865
1109
  });
866
1110
 
867
1111
  pi.on("session_shutdown", async () => {
1112
+ runtimeContext = null;
868
1113
  stopForwardedPermissionPolling();
869
1114
  });
870
1115
 
871
1116
  pi.on("before_agent_start", async (event, ctx) => {
1117
+ runtimeContext = ctx;
1118
+ refreshExtensionConfig(ctx);
872
1119
  startForwardedPermissionPolling(ctx);
873
1120
  const agentName = resolveAgentName(ctx, event.systemPrompt);
874
1121
  const allTools = pi.getAllTools();
@@ -899,6 +1146,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
899
1146
  });
900
1147
 
901
1148
  pi.on("input", async (event, ctx) => {
1149
+ runtimeContext = ctx;
902
1150
  startForwardedPermissionPolling(ctx);
903
1151
  const skillName = extractSkillNameFromInput(event.text);
904
1152
  if (!skillName) {
@@ -911,6 +1159,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
911
1159
  if (ctx.hasUI) {
912
1160
  ctx.ui.notify(`Skill '${skillName}' is blocked because active agent context is unavailable.`, "warning");
913
1161
  }
1162
+ writeReviewLog("permission_request.blocked", {
1163
+ source: "skill_input",
1164
+ skillName,
1165
+ agentName: null,
1166
+ resolution: "missing_agent_context",
1167
+ });
914
1168
  return { action: "handled" };
915
1169
  }
916
1170
 
@@ -921,15 +1175,35 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
921
1175
  const resolvedAgent = agentName ?? "none";
922
1176
  ctx.ui.notify(`Skill '${skillName}' is not permitted for agent '${resolvedAgent}'.`, "warning");
923
1177
  }
1178
+ writeReviewLog("permission_request.blocked", {
1179
+ source: "skill_input",
1180
+ skillName,
1181
+ agentName,
1182
+ resolution: "policy_denied",
1183
+ });
924
1184
  return { action: "handled" };
925
1185
  }
926
1186
 
927
1187
  if (check.state === "ask") {
1188
+ const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
928
1189
  if (!canRequestPermissionConfirmation(ctx)) {
1190
+ writeReviewLog("permission_request.blocked", {
1191
+ source: "skill_input",
1192
+ skillName,
1193
+ agentName,
1194
+ message,
1195
+ resolution: "confirmation_unavailable",
1196
+ });
929
1197
  return { action: "handled" };
930
1198
  }
931
1199
 
932
- 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
+ });
933
1207
  if (!approved) {
934
1208
  return { action: "handled" };
935
1209
  }
@@ -939,6 +1213,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
939
1213
  });
940
1214
 
941
1215
  pi.on("tool_call", async (event, ctx) => {
1216
+ runtimeContext = ctx;
942
1217
  startForwardedPermissionPolling(ctx);
943
1218
  const agentName = resolveAgentName(ctx);
944
1219
  const toolName = getEventToolName(event);
@@ -969,6 +1244,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
969
1244
 
970
1245
  if (matchedSkill) {
971
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
+ });
972
1254
  return {
973
1255
  block: true,
974
1256
  reason: formatSkillPathDenyReason(matchedSkill, event.input.path, agentName ?? undefined),
@@ -976,17 +1258,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
976
1258
  }
977
1259
 
978
1260
  if (matchedSkill.state === "ask") {
1261
+ const message = formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined);
979
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
+ });
980
1271
  return {
981
1272
  block: true,
982
1273
  reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
983
1274
  };
984
1275
  }
985
1276
 
986
- const approved = await confirmPermission(
987
- ctx,
988
- formatSkillPathAskPrompt(matchedSkill, event.input.path, agentName ?? undefined),
989
- );
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
+ });
990
1287
  if (!approved) {
991
1288
  return { block: true, reason: `User denied access to skill '${matchedSkill.name}'.` };
992
1289
  }
@@ -996,8 +1293,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
996
1293
 
997
1294
  const input = getEventInput(event);
998
1295
  const check = permissionManager.checkPermission(toolName, input, agentName ?? undefined);
1296
+ const permissionLogContext = getPermissionLogContext(check);
999
1297
 
1000
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
+ });
1001
1307
  return { block: true, reason: formatDenyReason(check, agentName ?? undefined) };
1002
1308
  }
1003
1309
 
@@ -1008,14 +1314,32 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1008
1314
  ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1009
1315
  : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1010
1316
 
1317
+ const message = formatAskPrompt(check, agentName ?? undefined);
1011
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
+ });
1012
1328
  return {
1013
1329
  block: true,
1014
1330
  reason: unavailableReason,
1015
1331
  };
1016
1332
  }
1017
1333
 
1018
- 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
+ });
1019
1343
  if (!approved) {
1020
1344
  return { block: true, reason: formatUserDeniedReason(check) };
1021
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
+ }
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
  {