pi-permission-system 0.4.8 → 0.4.9

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
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.9] - 2026-05-05
11
+
12
+ ### Changed
13
+ - Permission-system config parsing now accepts JSONC comments and trailing commas across both policy files and the extension config, and invalid config warnings are emitted once per session as a compact single-line message with explicit fallback behavior.
14
+
15
+ ### Fixed
16
+ - Stopped repeating identical invalid-config warnings in the TUI when the same broken permission policy is re-evaluated during the same session or reload cycle (thanks to @jviel-beta for issue #20).
17
+
10
18
  ## [0.4.8] - 2026-05-04
11
19
 
12
20
  ### Added
package/README.md CHANGED
@@ -246,7 +246,7 @@ The policy file is a JSON object with these sections:
246
246
  | `skills` | Skill name pattern permissions |
247
247
  | `special` | Reserved permission checks such as external directory access |
248
248
 
249
- > **Note:** Trailing commas are **not** supported. If parsing fails, the extension falls back to `ask` for all categories.
249
+ > **Note:** JSONC comments and trailing commas are supported. If parsing still fails, the extension falls back to `ask` for all categories and shows a warning in the TUI when available.
250
250
 
251
251
  ### Global Per-Agent Overrides
252
252
 
@@ -637,7 +637,7 @@ npx --yes ajv-cli@5 validate \
637
637
 
638
638
  | Problem | Cause | Solution |
639
639
  |---------|-------|----------|
640
- | Config not applied (everything asks) | File not found or parse error | Verify the global Pi policy file (default: `~/.pi/agent/pi-permissions.jsonc`, respects `PI_CODING_AGENT_DIR`); check for trailing commas |
640
+ | Config not applied (everything asks) | File not found or parse error | Verify the global Pi policy file (default: `~/.pi/agent/pi-permissions.jsonc`, respects `PI_CODING_AGENT_DIR`); check the TUI warning for the parse location/message |
641
641
  | Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
642
642
  | Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
643
643
  | `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skills` policy (global/project/agent layers). Active agent context is optional in the main session; `ask` still requires UI or forwarded confirmation. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-permission-system",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -64,5 +64,8 @@
64
64
  "@mariozechner/pi-coding-agent": "^0.72.0",
65
65
  "@mariozechner/pi-tui": "^0.72.0",
66
66
  "@sinclair/typebox": "^0.34.49"
67
+ },
68
+ "dependencies": {
69
+ "jsonc-parser": "^3.3.1"
67
70
  }
68
71
  }
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { toRecord } from "./common.js";
6
+ import { formatJsoncConfigLoadWarning, parseJsoncConfig } from "./jsonc-config.js";
6
7
 
7
8
  export const EXTENSION_ID = "pi-permission-system";
8
9
 
@@ -107,7 +108,7 @@ export function loadPermissionSystemConfig(configPath = getPermissionSystemConfi
107
108
 
108
109
  try {
109
110
  const raw = readFileSync(configPath, "utf-8");
110
- const parsed = JSON.parse(raw) as unknown;
111
+ const parsed = parseJsoncConfig(raw, configPath, "permission-system config");
111
112
  const config = normalizePermissionSystemConfig(parsed);
112
113
  return {
113
114
  config,
@@ -115,11 +116,12 @@ export function loadPermissionSystemConfig(configPath = getPermissionSystemConfi
115
116
  warning: ensureResult.warning,
116
117
  };
117
118
  } catch (error) {
118
- const message = error instanceof Error ? error.message : String(error);
119
119
  return {
120
120
  config: cloneDefaultConfig(),
121
121
  created: ensureResult.created,
122
- warning: ensureResult.warning ?? `Failed to read permission-system config at '${configPath}': ${message}`,
122
+ warning: ensureResult.warning
123
+ ?? formatJsoncConfigLoadWarning(configPath, error, "permission-system config", "using default extension config")
124
+ ?? undefined,
123
125
  };
124
126
  }
125
127
  }
package/src/index.ts CHANGED
@@ -1014,20 +1014,23 @@ function derivePiProjectPaths(cwd: string | undefined | null): {
1014
1014
  };
1015
1015
  }
1016
1016
 
1017
- function createPermissionManagerForCwd(cwd: string | undefined | null): PermissionManager {
1017
+ function createPermissionManagerForCwd(
1018
+ cwd: string | undefined | null,
1019
+ onWarning?: (message: string) => void,
1020
+ ): PermissionManager {
1018
1021
  const projectPaths = derivePiProjectPaths(cwd);
1019
1022
  if (!projectPaths) {
1020
- return new PermissionManager();
1023
+ return new PermissionManager({ onWarning });
1021
1024
  }
1022
1025
 
1023
1026
  return new PermissionManager({
1024
1027
  projectGlobalConfigPath: projectPaths.projectGlobalConfigPath,
1025
1028
  projectAgentsDir: projectPaths.projectAgentsDir,
1029
+ onWarning,
1026
1030
  });
1027
1031
  }
1028
1032
 
1029
1033
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1030
- let permissionManager = new PermissionManager();
1031
1034
  let activeSkillEntries: SkillPromptEntry[] = [];
1032
1035
  let lastKnownActiveAgentName: string | null = null;
1033
1036
  let lastActiveToolsCacheKey: string | null = null;
@@ -1037,6 +1040,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1037
1040
  let isProcessingForwardedRequests = false;
1038
1041
  let runtimeContext: ExtensionContext | null = null;
1039
1042
  let lastConfigWarning: string | null = null;
1043
+ const shownWarnings = new Set<string>();
1040
1044
 
1041
1045
  const invalidateAgentStartCache = (): void => {
1042
1046
  activeSkillEntries = [];
@@ -1044,14 +1048,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1044
1048
  lastPromptStateCacheKey = null;
1045
1049
  };
1046
1050
 
1051
+ const resetShownWarnings = (): void => {
1052
+ shownWarnings.clear();
1053
+ };
1054
+
1047
1055
  const notifyWarning = (message: string): void => {
1048
- if (!runtimeContext?.hasUI) {
1056
+ if (!runtimeContext?.hasUI || shownWarnings.has(message)) {
1049
1057
  return;
1050
1058
  }
1051
1059
 
1060
+ shownWarnings.add(message);
1052
1061
  runtimeContext.ui.notify(message, "warning");
1053
1062
  };
1054
1063
 
1064
+ let permissionManager = createPermissionManagerForCwd(undefined, notifyWarning);
1065
+
1055
1066
  const refreshExtensionConfig = (ctx?: ExtensionContext): void => {
1056
1067
  if (ctx) {
1057
1068
  runtimeContext = ctx;
@@ -1366,8 +1377,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1366
1377
 
1367
1378
  const refreshSessionRuntimeState = (ctx: ExtensionContext): void => {
1368
1379
  runtimeContext = ctx;
1380
+ resetShownWarnings();
1369
1381
  refreshExtensionConfig(ctx);
1370
- permissionManager = createPermissionManagerForCwd(ctx.cwd);
1382
+ permissionManager = createPermissionManagerForCwd(ctx.cwd, notifyWarning);
1371
1383
  invalidateAgentStartCache();
1372
1384
  lastKnownActiveAgentName = getActiveAgentName(ctx);
1373
1385
  startForwardedPermissionPolling(ctx);
@@ -1387,7 +1399,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1387
1399
 
1388
1400
  pi.on("resources_discover", async (event, _ctx) => {
1389
1401
  if (event.reason === "reload") {
1390
- permissionManager = runtimeContext ? createPermissionManagerForCwd(runtimeContext.cwd) : new PermissionManager();
1402
+ resetShownWarnings();
1403
+ permissionManager = runtimeContext
1404
+ ? createPermissionManagerForCwd(runtimeContext.cwd, notifyWarning)
1405
+ : createPermissionManagerForCwd(undefined, notifyWarning);
1391
1406
  invalidateAgentStartCache();
1392
1407
  writeDebugLog("lifecycle.reload", {
1393
1408
  triggeredBy: "resources_discover",
@@ -1400,6 +1415,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1400
1415
 
1401
1416
  pi.on("session_shutdown", async () => {
1402
1417
  runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
1418
+ resetShownWarnings();
1403
1419
  runtimeContext = null;
1404
1420
  unregisterPiPermissionSystemRuntimeApi(runtimeApi ?? undefined);
1405
1421
  runtimeApi = null;
@@ -0,0 +1,52 @@
1
+ import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
2
+
3
+ function isNodeErrorWithCode(error: unknown, code: string): boolean {
4
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
5
+ }
6
+
7
+ function formatJsoncParseSummary(input: string, errors: readonly JsoncParseError[]): string {
8
+ const firstError = errors[0];
9
+ if (!firstError) {
10
+ return "unknown parse error";
11
+ }
12
+
13
+ const beforeOffset = input.slice(0, firstError.offset).split("\n");
14
+ const line = beforeOffset.length;
15
+ const column = (beforeOffset.at(-1)?.length ?? 0) + 1;
16
+ const summary = `${printParseErrorCode(firstError.error)} at line ${line}, column ${column}`;
17
+ const additionalErrorCount = errors.length - 1;
18
+
19
+ if (additionalErrorCount <= 0) {
20
+ return summary;
21
+ }
22
+
23
+ return `${summary}; ${additionalErrorCount} more parse error${additionalErrorCount === 1 ? "" : "s"}`;
24
+ }
25
+
26
+ export function parseJsoncConfig(input: string, filePath: string, subject = "config"): unknown {
27
+ const errors: JsoncParseError[] = [];
28
+ const parsed = parseJsonc(input, errors, { allowTrailingComma: true });
29
+
30
+ if (errors.length > 0) {
31
+ throw new Error(`Failed to parse ${subject} at '${filePath}' (${formatJsoncParseSummary(input, errors)})`);
32
+ }
33
+
34
+ return parsed as unknown;
35
+ }
36
+
37
+ export function formatJsoncConfigLoadWarning(
38
+ filePath: string,
39
+ error: unknown,
40
+ subject = "config",
41
+ fallbackMessage?: string,
42
+ ): string | null {
43
+ if (isNodeErrorWithCode(error, "ENOENT")) {
44
+ return null;
45
+ }
46
+
47
+ const baseMessage = error instanceof Error
48
+ ? error.message
49
+ : `Failed to load ${subject} at '${filePath}': ${String(error)}`;
50
+
51
+ return fallbackMessage ? `${baseMessage}; ${fallbackMessage}.` : baseMessage;
52
+ }
@@ -3,6 +3,7 @@ import { readFileSync, statSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
5
  import { extractFrontmatter, getNonEmptyString, isPermissionState, parseSimpleYamlMap, toRecord } from "./common.js";
6
+ import { formatJsoncConfigLoadWarning, parseJsoncConfig } from "./jsonc-config.js";
6
7
  import type {
7
8
  AgentPermissions,
8
9
  BashPermissions,
@@ -50,78 +51,6 @@ const EMPTY_GLOBAL_CONFIG: GlobalPermissionConfig = {
50
51
  special: {},
51
52
  };
52
53
 
53
- function stripJsonComments(input: string): string {
54
- let output = "";
55
- let inString = false;
56
- let stringQuote: '"' | "'" | "" = "";
57
- let escaping = false;
58
- let inLineComment = false;
59
- let inBlockComment = false;
60
-
61
- for (let i = 0; i < input.length; i++) {
62
- const char = input[i];
63
- const next = input[i + 1] || "";
64
-
65
- if (inLineComment) {
66
- if (char === "\n") {
67
- inLineComment = false;
68
- output += char;
69
- }
70
- continue;
71
- }
72
-
73
- if (inBlockComment) {
74
- if (char === "*" && next === "/") {
75
- inBlockComment = false;
76
- i++;
77
- }
78
- continue;
79
- }
80
-
81
- if (!inString && char === "/" && next === "/") {
82
- inLineComment = true;
83
- i++;
84
- continue;
85
- }
86
-
87
- if (!inString && char === "/" && next === "*") {
88
- inBlockComment = true;
89
- i++;
90
- continue;
91
- }
92
-
93
- output += char;
94
-
95
- if (!inString && (char === '"' || char === "'")) {
96
- inString = true;
97
- stringQuote = char;
98
- escaping = false;
99
- continue;
100
- }
101
-
102
- if (!inString) {
103
- continue;
104
- }
105
-
106
- if (escaping) {
107
- escaping = false;
108
- continue;
109
- }
110
-
111
- if (char === "\\") {
112
- escaping = true;
113
- continue;
114
- }
115
-
116
- if (char === stringQuote) {
117
- inString = false;
118
- stringQuote = "";
119
- }
120
- }
121
-
122
- return output;
123
- }
124
-
125
54
  function normalizePolicy(value: unknown): PermissionDefaultPolicy {
126
55
  const record = toRecord(value);
127
56
  return {
@@ -174,7 +103,7 @@ function normalizePermissionRecord(value: unknown): Record<string, PermissionSta
174
103
  function readConfiguredMcpServerNamesFromConfigPath(configPath: string): string[] {
175
104
  try {
176
105
  const raw = readFileSync(configPath, "utf-8");
177
- const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
106
+ const parsed = parseJsoncConfig(raw, configPath, "permission config");
178
107
  const root = toRecord(parsed);
179
108
  const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
180
109
 
@@ -585,6 +514,7 @@ export class PermissionManager {
585
514
  private readonly projectAgentConfigCache = new Map<string, FileCacheEntry<AgentPermissions>>();
586
515
  private readonly resolvedPermissionsCache = new Map<string, FileCacheEntry<ResolvedPermissions>>();
587
516
  private configuredMcpServerNamesCache: FileCacheEntry<readonly string[]> | null = null;
517
+ private readonly onWarning: ((message: string) => void) | null;
588
518
 
589
519
  constructor(
590
520
  options: {
@@ -595,6 +525,7 @@ export class PermissionManager {
595
525
  legacyGlobalSettingsPath?: string;
596
526
  globalMcpConfigPath?: string;
597
527
  mcpServerNames?: readonly string[];
528
+ onWarning?: (message: string) => void;
598
529
  } = {},
599
530
  ) {
600
531
  this.globalConfigPath = options.globalConfigPath || defaultGlobalConfigPath();
@@ -606,6 +537,11 @@ export class PermissionManager {
606
537
  this.configuredMcpServerNamesOverride = options.mcpServerNames
607
538
  ? [...new Set(options.mcpServerNames.map((name) => name.trim()).filter((name) => name.length > 0))]
608
539
  : null;
540
+ this.onWarning = options.onWarning || null;
541
+ }
542
+
543
+ private notifyWarning(message: string): void {
544
+ this.onWarning?.(message);
609
545
  }
610
546
 
611
547
  private loadGlobalConfig(): GlobalPermissionConfig {
@@ -617,7 +553,7 @@ export class PermissionManager {
617
553
  let value: GlobalPermissionConfig;
618
554
  try {
619
555
  const raw = readFileSync(this.globalConfigPath, "utf-8");
620
- const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
556
+ const parsed = parseJsoncConfig(raw, this.globalConfigPath, "permission config");
621
557
  const normalized = normalizeRawPermission(parsed);
622
558
 
623
559
  value = {
@@ -628,7 +564,16 @@ export class PermissionManager {
628
564
  skills: normalized.skills || {},
629
565
  special: normalized.special || {},
630
566
  };
631
- } catch {
567
+ } catch (error) {
568
+ const warning = formatJsoncConfigLoadWarning(
569
+ this.globalConfigPath,
570
+ error,
571
+ "permission config",
572
+ "using ask fallback",
573
+ );
574
+ if (warning) {
575
+ this.notifyWarning(warning);
576
+ }
632
577
  value = EMPTY_GLOBAL_CONFIG;
633
578
  }
634
579
 
@@ -649,9 +594,18 @@ export class PermissionManager {
649
594
  let value: AgentPermissions;
650
595
  try {
651
596
  const raw = readFileSync(this.projectGlobalConfigPath, "utf-8");
652
- const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
597
+ const parsed = parseJsoncConfig(raw, this.projectGlobalConfigPath, "permission config");
653
598
  value = normalizeRawPermission(parsed);
654
- } catch {
599
+ } catch (error) {
600
+ const warning = formatJsoncConfigLoadWarning(
601
+ this.projectGlobalConfigPath,
602
+ error,
603
+ "permission config",
604
+ "ignoring project permission overrides",
605
+ );
606
+ if (warning) {
607
+ this.notifyWarning(warning);
608
+ }
655
609
  value = {};
656
610
  }
657
611
 
@@ -111,6 +111,7 @@ type ExtensionHarnessOptions = {
111
111
  selectResponse?: string;
112
112
  inputResponse?: string;
113
113
  statusUpdates?: Array<{ key: string; value: string | undefined }>;
114
+ notifications?: Array<{ message: string; level: string }>;
114
115
  };
115
116
 
116
117
  const INHERITED_SUBAGENT_ENV_KEYS = [
@@ -223,7 +224,9 @@ function createMockContext(
223
224
  getSessionDir: (): string => cwd,
224
225
  },
225
226
  ui: {
226
- notify: (): void => {},
227
+ notify: (message: string, level: string): void => {
228
+ options.notifications?.push({ message, level });
229
+ },
227
230
  setStatus: (key: string, value: string | undefined): void => {
228
231
  options.statusUpdates?.push({ key, value });
229
232
  },
@@ -297,6 +300,41 @@ await runAsyncTest("Extension exposes a runtime YOLO API for other extensions",
297
300
  assert.equal((globalThis as GlobalWithPermissionSystem).__piPermissionSystem, undefined);
298
301
  });
299
302
 
303
+ await runAsyncTest("Extension dedupes identical permission parse warnings across lifecycle re-entry", async () => {
304
+ const notifications: Array<{ message: string; level: string }> = [];
305
+ const harness = createToolCallHarness(
306
+ { defaultPolicy: { tools: "allow", bash: "allow", mcp: "allow", skills: "allow", special: "allow" } },
307
+ ["read", "write"],
308
+ { hasUI: true, notifications },
309
+ );
310
+
311
+ try {
312
+ mkdirSync(join(harness.cwd, ".pi", "agent"), { recursive: true });
313
+ writeFileSync(
314
+ join(harness.cwd, ".pi", "agent", "pi-permissions.jsonc"),
315
+ `{
316
+ "tools": {
317
+ "read": "allow",,
318
+ }
319
+ }
320
+ `,
321
+ "utf8",
322
+ );
323
+
324
+ const ctx = createMockContext(harness.cwd, harness.prompts, { hasUI: true, notifications });
325
+ await Promise.resolve(harness.handlers.session_start?.({ reason: "startup" }, ctx));
326
+ await Promise.resolve(harness.handlers.before_agent_start?.({ systemPrompt: "" }, ctx));
327
+ await Promise.resolve(harness.handlers.before_agent_start?.({ systemPrompt: "" }, ctx));
328
+
329
+ const warnings = notifications.filter((entry) => entry.level === "warning");
330
+ assert.equal(warnings.length, 1);
331
+ assert.match(warnings[0]?.message || "", /Failed to parse permission config at/);
332
+ assert.equal((warnings[0]?.message || "").includes("\n"), false);
333
+ } finally {
334
+ await harness.cleanup();
335
+ }
336
+ });
337
+
300
338
  runTest("Permission-system extension config defaults debug off, review log on, and yolo mode off", () => {
301
339
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-"));
302
340
  const configPath = join(baseDir, "config.json");
@@ -345,6 +383,63 @@ runTest("Permission-system extension config loads yolo mode when explicitly enab
345
383
  }
346
384
  });
347
385
 
386
+ runTest("Permission-system extension config accepts JSONC comments and trailing commas", () => {
387
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-jsonc-"));
388
+ const configPath = join(baseDir, "config.json");
389
+
390
+ try {
391
+ writeFileSync(
392
+ configPath,
393
+ `{
394
+ // Local extension toggles
395
+ "debugLog": true,
396
+ "permissionReviewLog": false,
397
+ "yoloMode": true,
398
+ }
399
+ `,
400
+ "utf8",
401
+ );
402
+
403
+ const result = loadPermissionSystemConfig(configPath);
404
+ assert.equal(result.created, false);
405
+ assert.equal(result.warning, undefined);
406
+ assert.deepEqual(result.config, {
407
+ debugLog: true,
408
+ permissionReviewLog: false,
409
+ yoloMode: true,
410
+ });
411
+ } finally {
412
+ rmSync(baseDir, { recursive: true, force: true });
413
+ }
414
+ });
415
+
416
+ runTest("Permission-system extension config reports one-line JSONC parse warnings", () => {
417
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-parse-"));
418
+ const configPath = join(baseDir, "config.json");
419
+
420
+ try {
421
+ writeFileSync(
422
+ configPath,
423
+ `{
424
+ "debugLog": true,,
425
+ "permissionReviewLog": false
426
+ }
427
+ `,
428
+ "utf8",
429
+ );
430
+
431
+ const result = loadPermissionSystemConfig(configPath);
432
+ assert.equal(result.created, false);
433
+ assert.deepEqual(result.config, DEFAULT_EXTENSION_CONFIG);
434
+ assert.match(result.warning || "", /Failed to parse permission-system config at/);
435
+ assert.match(result.warning || "", /line 2, column 20/);
436
+ assert.match(result.warning || "", /using default extension config\./);
437
+ assert.equal((result.warning || "").includes("\n"), false);
438
+ } finally {
439
+ rmSync(baseDir, { recursive: true, force: true });
440
+ }
441
+ });
442
+
348
443
  runTest("Permission-system extension config normalizes invalid persisted values back to defaults", () => {
349
444
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-config-invalid-"));
350
445
  const configPath = join(baseDir, "config.json");
@@ -1799,6 +1894,100 @@ runTest("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () =
1799
1894
  }
1800
1895
  });
1801
1896
 
1897
+ runTest("PermissionManager accepts JSONC comments and trailing commas in policy files", () => {
1898
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-jsonc-"));
1899
+ const agentsDir = join(baseDir, "agents");
1900
+ const projectRoot = join(baseDir, "project");
1901
+ const projectGlobalConfigPath = join(projectRoot, "pi-permissions.jsonc");
1902
+ const projectAgentsDir = join(projectRoot, "agents");
1903
+
1904
+ mkdirSync(agentsDir, { recursive: true });
1905
+ mkdirSync(projectAgentsDir, { recursive: true });
1906
+
1907
+ writeFileSync(
1908
+ join(baseDir, "pi-permissions.jsonc"),
1909
+ `{
1910
+ // Global defaults still apply.
1911
+ "defaultPolicy": {
1912
+ "tools": "deny",
1913
+ "bash": "deny",
1914
+ "mcp": "deny",
1915
+ "skills": "deny",
1916
+ "special": "deny",
1917
+ },
1918
+ "tools": {
1919
+ "read": "allow",
1920
+ },
1921
+ }
1922
+ `,
1923
+ "utf8",
1924
+ );
1925
+ writeFileSync(
1926
+ projectGlobalConfigPath,
1927
+ `{
1928
+ "tools": {
1929
+ "write": "allow",
1930
+ },
1931
+ }
1932
+ `,
1933
+ "utf8",
1934
+ );
1935
+
1936
+ try {
1937
+ const manager = new PermissionManager({
1938
+ globalConfigPath: join(baseDir, "pi-permissions.jsonc"),
1939
+ agentsDir,
1940
+ projectGlobalConfigPath,
1941
+ projectAgentsDir,
1942
+ });
1943
+
1944
+ assert.equal(manager.checkPermission("read", {}).state, "allow");
1945
+ assert.equal(manager.checkPermission("write", {}).state, "allow");
1946
+ assert.equal(manager.checkPermission("ls", {}).state, "deny");
1947
+ } finally {
1948
+ rmSync(baseDir, { recursive: true, force: true });
1949
+ }
1950
+ });
1951
+
1952
+ runTest("PermissionManager warns once with a one-line fallback warning when a policy file has invalid JSONC", () => {
1953
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-invalid-jsonc-"));
1954
+ const agentsDir = join(baseDir, "agents");
1955
+ const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
1956
+ const warnings: string[] = [];
1957
+
1958
+ mkdirSync(agentsDir, { recursive: true });
1959
+ writeFileSync(
1960
+ globalConfigPath,
1961
+ `{
1962
+ "tools": {
1963
+ "read": "allow",,
1964
+ }
1965
+ }
1966
+ `,
1967
+ "utf8",
1968
+ );
1969
+
1970
+ try {
1971
+ const manager = new PermissionManager({
1972
+ globalConfigPath,
1973
+ agentsDir,
1974
+ onWarning: (message) => {
1975
+ warnings.push(message);
1976
+ },
1977
+ });
1978
+
1979
+ assert.equal(manager.checkPermission("read", {}).state, "ask");
1980
+ assert.equal(manager.checkPermission("read", {}).state, "ask");
1981
+ assert.equal(warnings.length, 1);
1982
+ assert.match(warnings[0] || "", /Failed to parse permission config at/);
1983
+ assert.match(warnings[0] || "", /line 3, column \d+/);
1984
+ assert.match(warnings[0] || "", /using ask fallback\./);
1985
+ assert.equal((warnings[0] || "").includes("\n"), false);
1986
+ } finally {
1987
+ rmSync(baseDir, { recursive: true, force: true });
1988
+ }
1989
+ });
1990
+
1802
1991
  // ---------------------------------------------------------------------------
1803
1992
  // Skill prompt sanitization - multi-block regression tests
1804
1993
  // ---------------------------------------------------------------------------