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 +8 -0
- package/README.md +2 -2
- package/package.json +4 -1
- package/src/extension-config.ts +5 -3
- package/src/index.ts +22 -6
- package/src/jsonc-config.ts +52 -0
- package/src/permission-manager.ts +31 -77
- package/tests/permission-system.test.ts +190 -1
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:**
|
|
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
|
|
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.
|
|
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
|
}
|
package/src/extension-config.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
// ---------------------------------------------------------------------------
|