@gotgenes/pi-permission-system 1.0.0 → 1.2.0
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 +28 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +0 -11
- package/src/index.ts +7 -0
- package/src/permission-manager.ts +51 -13
- package/tests/permission-system.test.ts +150 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [1.2.0](https://github.com/gotgenes/pi-permission-system/compare/v1.1.0...v1.2.0) (2026-05-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* drop legacy settings.json fallback for MCP server names ([#19](https://github.com/gotgenes/pi-permission-system/issues/19)) ([3978f94](https://github.com/gotgenes/pi-permission-system/commit/3978f94acfe01b32e6e37c4fa0f5ca3b22881208))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan drop legacy settings.json MCP fallback ([#19](https://github.com/gotgenes/pi-permission-system/issues/19)) ([fd88aac](https://github.com/gotgenes/pi-permission-system/commit/fd88aac8e52c0b617b5ce2582c700bbb86b9bf84))
|
|
19
|
+
* **retro:** add retro notes for issue [#18](https://github.com/gotgenes/pi-permission-system/issues/18) ([1bb9cc5](https://github.com/gotgenes/pi-permission-system/commit/1bb9cc52abe159d77b8ab29960bafdb2740c9c98))
|
|
20
|
+
|
|
21
|
+
## [1.1.0](https://github.com/gotgenes/pi-permission-system/compare/v1.0.0...v1.1.0) (2026-05-03)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* emit deprecation warning for special.tool_call_limit ([#18](https://github.com/gotgenes/pi-permission-system/issues/18)) ([1170d40](https://github.com/gotgenes/pi-permission-system/commit/1170d401d3adc438ad3c69bf96f5264b981ed4d5))
|
|
27
|
+
* notify user of deprecated config fields at startup ([#18](https://github.com/gotgenes/pi-permission-system/issues/18)) ([3408672](https://github.com/gotgenes/pi-permission-system/commit/3408672afb94783f173b579e9e33fe088f0971f3))
|
|
28
|
+
* surface config issues from PermissionManager ([#18](https://github.com/gotgenes/pi-permission-system/issues/18)) ([4c8103b](https://github.com/gotgenes/pi-permission-system/commit/4c8103bed99c3d31813f5450a6f4d1938fd74f25))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
* plan drop unread special.tool_call_limit from schema ([#18](https://github.com/gotgenes/pi-permission-system/issues/18)) ([c45f6f7](https://github.com/gotgenes/pi-permission-system/commit/c45f6f7e5a504cac4f6156a97f51ff9588639d94))
|
|
34
|
+
* remove tool_call_limit from schema and README ([#18](https://github.com/gotgenes/pi-permission-system/issues/18)) ([780b414](https://github.com/gotgenes/pi-permission-system/commit/780b41431b1ac06ccf11dca00e1c59575386bbd2))
|
|
35
|
+
|
|
8
36
|
## [1.0.0](https://github.com/gotgenes/pi-permission-system/compare/v0.8.0...v1.0.0) (2026-05-03)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -328,7 +328,6 @@ Reserved permission checks:
|
|
|
328
328
|
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
329
329
|
| `doom_loop` | Controls doom loop detection behavior |
|
|
330
330
|
| `external_directory` | Enforces ask/allow/deny decisions for path-bearing built-in tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory |
|
|
331
|
-
| `tool_call_limit` | _(schema only, not enforced yet)_ |
|
|
332
331
|
|
|
333
332
|
```jsonc
|
|
334
333
|
{
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1569,6 +1569,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1569
1569
|
startForwardedPermissionPolling(ctx);
|
|
1570
1570
|
logResolvedConfigPaths();
|
|
1571
1571
|
|
|
1572
|
+
const policyIssues = permissionManager.getConfigIssues(
|
|
1573
|
+
lastKnownActiveAgentName,
|
|
1574
|
+
);
|
|
1575
|
+
for (const issue of policyIssues) {
|
|
1576
|
+
notifyWarning(issue);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1572
1579
|
if (event.reason === "reload") {
|
|
1573
1580
|
writeDebugLog("lifecycle.reload", {
|
|
1574
1581
|
triggeredBy: "session_start",
|
|
@@ -31,9 +31,6 @@ function defaultGlobalConfigPath(): string {
|
|
|
31
31
|
function defaultAgentsDir(): string {
|
|
32
32
|
return join(getAgentDir(), "agents");
|
|
33
33
|
}
|
|
34
|
-
function defaultLegacyGlobalSettingsPath(): string {
|
|
35
|
-
return join(getAgentDir(), "settings.json");
|
|
36
|
-
}
|
|
37
34
|
function defaultGlobalMcpConfigPath(): string {
|
|
38
35
|
return join(getAgentDir(), "mcp.json");
|
|
39
36
|
}
|
|
@@ -237,8 +234,18 @@ function getConfiguredMcpServerNamesFromPaths(
|
|
|
237
234
|
);
|
|
238
235
|
}
|
|
239
236
|
|
|
240
|
-
|
|
237
|
+
const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
|
|
238
|
+
"tool_call_limit",
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
export interface NormalizeResult {
|
|
242
|
+
permissions: AgentPermissions;
|
|
243
|
+
configIssues: string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function normalizeRawPermission(raw: unknown): NormalizeResult {
|
|
241
247
|
const record = toRecord(raw);
|
|
248
|
+
const configIssues: string[] = [];
|
|
242
249
|
const normalizedTools = normalizePermissionRecord(record.tools);
|
|
243
250
|
|
|
244
251
|
const normalized: AgentPermissions = {
|
|
@@ -250,6 +257,20 @@ function normalizeRawPermission(raw: unknown): AgentPermissions {
|
|
|
250
257
|
special: normalizePermissionRecord(record.special),
|
|
251
258
|
};
|
|
252
259
|
|
|
260
|
+
// Detect deprecated keys in the raw special sub-object before discarding.
|
|
261
|
+
const rawSpecial = toRecord(record.special);
|
|
262
|
+
for (const key of DEPRECATED_SPECIAL_KEYS) {
|
|
263
|
+
if (key in rawSpecial) {
|
|
264
|
+
configIssues.push(
|
|
265
|
+
`special.${key} is deprecated and ignored — remove it from your policy file.`,
|
|
266
|
+
);
|
|
267
|
+
// Ensure the key is stripped even if its value was a valid PermissionState.
|
|
268
|
+
if (normalized.special) {
|
|
269
|
+
delete normalized.special[key];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
253
274
|
for (const [key, value] of Object.entries(record)) {
|
|
254
275
|
if (!isPermissionState(value)) {
|
|
255
276
|
continue;
|
|
@@ -265,7 +286,7 @@ function normalizeRawPermission(raw: unknown): AgentPermissions {
|
|
|
265
286
|
}
|
|
266
287
|
}
|
|
267
288
|
|
|
268
|
-
return normalized;
|
|
289
|
+
return { permissions: normalized, configIssues };
|
|
269
290
|
}
|
|
270
291
|
|
|
271
292
|
function parseQualifiedMcpToolName(
|
|
@@ -500,7 +521,6 @@ export class PermissionManager {
|
|
|
500
521
|
private readonly agentsDir: string;
|
|
501
522
|
private readonly projectGlobalConfigPath: string | null;
|
|
502
523
|
private readonly projectAgentsDir: string | null;
|
|
503
|
-
private readonly legacyGlobalSettingsPath: string;
|
|
504
524
|
private readonly globalMcpConfigPath: string;
|
|
505
525
|
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
506
526
|
private globalConfigCache: FileCacheEntry<GlobalPermissionConfig> | null =
|
|
@@ -522,6 +542,7 @@ export class PermissionManager {
|
|
|
522
542
|
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
523
543
|
readonly string[]
|
|
524
544
|
> | null = null;
|
|
545
|
+
private accumulatedConfigIssues: string[] = [];
|
|
525
546
|
|
|
526
547
|
constructor(
|
|
527
548
|
options: {
|
|
@@ -529,7 +550,6 @@ export class PermissionManager {
|
|
|
529
550
|
agentsDir?: string;
|
|
530
551
|
projectGlobalConfigPath?: string;
|
|
531
552
|
projectAgentsDir?: string;
|
|
532
|
-
legacyGlobalSettingsPath?: string;
|
|
533
553
|
globalMcpConfigPath?: string;
|
|
534
554
|
mcpServerNames?: readonly string[];
|
|
535
555
|
} = {},
|
|
@@ -539,8 +559,6 @@ export class PermissionManager {
|
|
|
539
559
|
this.agentsDir = options.agentsDir || defaultAgentsDir();
|
|
540
560
|
this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
|
|
541
561
|
this.projectAgentsDir = options.projectAgentsDir || null;
|
|
542
|
-
this.legacyGlobalSettingsPath =
|
|
543
|
-
options.legacyGlobalSettingsPath || defaultLegacyGlobalSettingsPath();
|
|
544
562
|
this.globalMcpConfigPath =
|
|
545
563
|
options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
|
|
546
564
|
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
@@ -554,6 +572,20 @@ export class PermissionManager {
|
|
|
554
572
|
: null;
|
|
555
573
|
}
|
|
556
574
|
|
|
575
|
+
private accumulateConfigIssues(issues: string[]): void {
|
|
576
|
+
for (const issue of issues) {
|
|
577
|
+
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
578
|
+
this.accumulatedConfigIssues.push(issue);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
getConfigIssues(agentName?: string): string[] {
|
|
584
|
+
// Trigger a load/resolve to ensure issues are collected.
|
|
585
|
+
this.resolvePermissions(agentName);
|
|
586
|
+
return [...this.accumulatedConfigIssues];
|
|
587
|
+
}
|
|
588
|
+
|
|
557
589
|
private loadGlobalConfig(): GlobalPermissionConfig {
|
|
558
590
|
const stamp = getFileStamp(this.globalConfigPath);
|
|
559
591
|
if (this.globalConfigCache?.stamp === stamp) {
|
|
@@ -564,7 +596,9 @@ export class PermissionManager {
|
|
|
564
596
|
try {
|
|
565
597
|
const raw = readFileSync(this.globalConfigPath, "utf-8");
|
|
566
598
|
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
567
|
-
const normalized =
|
|
599
|
+
const { permissions: normalized, configIssues } =
|
|
600
|
+
normalizeRawPermission(parsed);
|
|
601
|
+
this.accumulateConfigIssues(configIssues);
|
|
568
602
|
|
|
569
603
|
value = {
|
|
570
604
|
defaultPolicy: normalizePolicy(normalized.defaultPolicy),
|
|
@@ -596,7 +630,9 @@ export class PermissionManager {
|
|
|
596
630
|
try {
|
|
597
631
|
const raw = readFileSync(this.projectGlobalConfigPath, "utf-8");
|
|
598
632
|
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
599
|
-
|
|
633
|
+
const result = normalizeRawPermission(parsed);
|
|
634
|
+
value = result.permissions;
|
|
635
|
+
this.accumulateConfigIssues(result.configIssues);
|
|
600
636
|
} catch {
|
|
601
637
|
value = {};
|
|
602
638
|
}
|
|
@@ -629,7 +665,9 @@ export class PermissionManager {
|
|
|
629
665
|
value = {};
|
|
630
666
|
} else {
|
|
631
667
|
const parsed = parseSimpleYamlMap(frontmatter);
|
|
632
|
-
|
|
668
|
+
const result = normalizeRawPermission(parsed.permission);
|
|
669
|
+
value = result.permissions;
|
|
670
|
+
this.accumulateConfigIssues(result.configIssues);
|
|
633
671
|
}
|
|
634
672
|
} catch {
|
|
635
673
|
value = {};
|
|
@@ -795,7 +833,7 @@ export class PermissionManager {
|
|
|
795
833
|
return this.configuredMcpServerNamesOverride;
|
|
796
834
|
}
|
|
797
835
|
|
|
798
|
-
const paths = [this.globalMcpConfigPath
|
|
836
|
+
const paths = [this.globalMcpConfigPath];
|
|
799
837
|
const stamp = paths
|
|
800
838
|
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
801
839
|
.join("|");
|
|
@@ -32,7 +32,10 @@ import {
|
|
|
32
32
|
SUBAGENT_ENV_HINT_KEYS,
|
|
33
33
|
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
34
34
|
} from "../src/permission-forwarding.js";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
normalizeRawPermission,
|
|
37
|
+
PermissionManager,
|
|
38
|
+
} from "../src/permission-manager.js";
|
|
36
39
|
import {
|
|
37
40
|
findSkillPathMatch,
|
|
38
41
|
parseAllSkillPromptSections,
|
|
@@ -44,7 +47,11 @@ import {
|
|
|
44
47
|
checkRequestedToolRegistration,
|
|
45
48
|
getToolNameFromValue,
|
|
46
49
|
} from "../src/tool-registry.js";
|
|
47
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
AgentPermissions,
|
|
52
|
+
GlobalPermissionConfig,
|
|
53
|
+
PermissionState,
|
|
54
|
+
} from "../src/types.js";
|
|
48
55
|
import {
|
|
49
56
|
canResolveAskPermissionRequest,
|
|
50
57
|
shouldAutoApprovePermissionState,
|
|
@@ -904,6 +911,67 @@ test("MCP proxy tool infers server-prefixed aliases from configured server names
|
|
|
904
911
|
}
|
|
905
912
|
});
|
|
906
913
|
|
|
914
|
+
test("MCP server names in settings.json are not used — only mcp.json is consulted", () => {
|
|
915
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
|
|
916
|
+
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
917
|
+
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
918
|
+
const settingsJsonPath = join(baseDir, "settings.json");
|
|
919
|
+
const agentsDir = join(baseDir, "agents");
|
|
920
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
921
|
+
|
|
922
|
+
// Policy: allow any target prefixed with legacy-server, default mcp is ask.
|
|
923
|
+
// If legacy-server were known as a configured server name, a tool named
|
|
924
|
+
// "some_tool_legacy-server" would derive "legacy-server_some_tool_legacy-server"
|
|
925
|
+
// which matches this rule and returns "allow".
|
|
926
|
+
// After the fix, settings.json is ignored, so no server name is derived and the
|
|
927
|
+
// result falls through to the default mcp policy ("ask").
|
|
928
|
+
const config: GlobalPermissionConfig = {
|
|
929
|
+
defaultPolicy: {
|
|
930
|
+
tools: "ask",
|
|
931
|
+
bash: "ask",
|
|
932
|
+
mcp: "ask",
|
|
933
|
+
skills: "ask",
|
|
934
|
+
special: "ask",
|
|
935
|
+
},
|
|
936
|
+
tools: {},
|
|
937
|
+
bash: {},
|
|
938
|
+
mcp: { "legacy-server_*": "allow" },
|
|
939
|
+
skills: {},
|
|
940
|
+
special: {},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
writeFileSync(
|
|
944
|
+
globalConfigPath,
|
|
945
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
946
|
+
"utf8",
|
|
947
|
+
);
|
|
948
|
+
// mcp.json does not know about legacy-server.
|
|
949
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
|
|
950
|
+
// settings.json has legacy-server — the legacy source that must now be ignored.
|
|
951
|
+
writeFileSync(
|
|
952
|
+
settingsJsonPath,
|
|
953
|
+
JSON.stringify({ mcpServers: { "legacy-server": {} } }),
|
|
954
|
+
"utf8",
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const manager = new PermissionManager({
|
|
958
|
+
globalConfigPath,
|
|
959
|
+
agentsDir,
|
|
960
|
+
globalMcpConfigPath: mcpConfigPath,
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
// "legacy-server" must not be derived from settings.json.
|
|
965
|
+
// The bare tool name falls through to the default mcp policy → "ask".
|
|
966
|
+
const result = manager.checkPermission("mcp", {
|
|
967
|
+
tool: "some_tool_legacy-server",
|
|
968
|
+
});
|
|
969
|
+
assert.equal(result.state, "ask");
|
|
970
|
+
} finally {
|
|
971
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
907
975
|
test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
|
|
908
976
|
const { manager, cleanup } = createManager(
|
|
909
977
|
{
|
|
@@ -2357,3 +2425,83 @@ test("getResolvedPolicyPaths returns false for missing files and null for absent
|
|
|
2357
2425
|
rmSync(tempDir, { recursive: true, force: true });
|
|
2358
2426
|
}
|
|
2359
2427
|
});
|
|
2428
|
+
|
|
2429
|
+
// --- tool_call_limit deprecation tests (#18) ---
|
|
2430
|
+
|
|
2431
|
+
test("normalizeRawPermission emits deprecation issue for special.tool_call_limit (integer)", () => {
|
|
2432
|
+
const result = normalizeRawPermission({ special: { tool_call_limit: 5 } });
|
|
2433
|
+
assert.equal(result.configIssues.length, 1);
|
|
2434
|
+
assert.ok(result.configIssues[0].includes("tool_call_limit"));
|
|
2435
|
+
assert.equal(result.permissions.special?.tool_call_limit, undefined);
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
test("normalizeRawPermission emits deprecation issue for special.tool_call_limit (string)", () => {
|
|
2439
|
+
const result = normalizeRawPermission({
|
|
2440
|
+
special: { tool_call_limit: "allow" },
|
|
2441
|
+
});
|
|
2442
|
+
assert.equal(result.configIssues.length, 1);
|
|
2443
|
+
assert.ok(result.configIssues[0].includes("tool_call_limit"));
|
|
2444
|
+
assert.equal(result.permissions.special?.tool_call_limit, undefined);
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
test("normalizeRawPermission emits no issues for valid special keys", () => {
|
|
2448
|
+
const result = normalizeRawPermission({
|
|
2449
|
+
special: { doom_loop: "deny" },
|
|
2450
|
+
});
|
|
2451
|
+
assert.equal(result.configIssues.length, 0);
|
|
2452
|
+
assert.equal(result.permissions.special?.doom_loop, "deny");
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
test("normalizeRawPermission emits no issues when special is absent", () => {
|
|
2456
|
+
const result = normalizeRawPermission({ tools: { read: "allow" } });
|
|
2457
|
+
assert.equal(result.configIssues.length, 0);
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit in global config", () => {
|
|
2461
|
+
const config: GlobalPermissionConfig = {
|
|
2462
|
+
defaultPolicy: {
|
|
2463
|
+
tools: "ask",
|
|
2464
|
+
bash: "ask",
|
|
2465
|
+
mcp: "ask",
|
|
2466
|
+
skills: "ask",
|
|
2467
|
+
special: "ask",
|
|
2468
|
+
},
|
|
2469
|
+
tools: {},
|
|
2470
|
+
bash: {},
|
|
2471
|
+
mcp: {},
|
|
2472
|
+
skills: {},
|
|
2473
|
+
special: { tool_call_limit: "allow" as PermissionState, doom_loop: "deny" },
|
|
2474
|
+
};
|
|
2475
|
+
const { manager, cleanup } = createManager(config);
|
|
2476
|
+
try {
|
|
2477
|
+
const issues = manager.getConfigIssues();
|
|
2478
|
+
assert.equal(issues.length, 1);
|
|
2479
|
+
assert.ok(issues[0].includes("tool_call_limit"));
|
|
2480
|
+
} finally {
|
|
2481
|
+
cleanup();
|
|
2482
|
+
}
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
|
|
2486
|
+
const config: GlobalPermissionConfig = {
|
|
2487
|
+
defaultPolicy: {
|
|
2488
|
+
tools: "ask",
|
|
2489
|
+
bash: "ask",
|
|
2490
|
+
mcp: "ask",
|
|
2491
|
+
skills: "ask",
|
|
2492
|
+
special: "ask",
|
|
2493
|
+
},
|
|
2494
|
+
tools: {},
|
|
2495
|
+
bash: {},
|
|
2496
|
+
mcp: {},
|
|
2497
|
+
skills: {},
|
|
2498
|
+
special: { doom_loop: "deny" },
|
|
2499
|
+
};
|
|
2500
|
+
const { manager, cleanup } = createManager(config);
|
|
2501
|
+
try {
|
|
2502
|
+
const issues = manager.getConfigIssues();
|
|
2503
|
+
assert.equal(issues.length, 0);
|
|
2504
|
+
} finally {
|
|
2505
|
+
cleanup();
|
|
2506
|
+
}
|
|
2507
|
+
});
|