pi-permission-system 0.1.8 → 0.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 +5 -0
- package/README.md +3 -8
- package/package.json +1 -1
- package/src/common.ts +82 -82
- package/src/index.ts +5 -2
- package/src/permission-manager.ts +53 -0
- package/src/test.ts +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ 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.0] - 2026-03-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `getToolPermission()` method to retrieve tool-level permission state without evaluating command-level rules, useful for tool injection decisions
|
|
12
|
+
|
|
8
13
|
## [0.1.8] - 2026-03-10
|
|
9
14
|
|
|
10
15
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🔐 pi-permission-system
|
|
2
2
|
|
|
3
|
-
[](package.json)
|
|
4
4
|
[](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.
|
|
@@ -181,18 +181,13 @@ Controls built-in tools by exact name (no wildcards):
|
|
|
181
181
|
|
|
182
182
|
### `bash`
|
|
183
183
|
|
|
184
|
-
Command patterns use `*` wildcards and match against the full command string.
|
|
185
|
-
|
|
186
|
-
1. Fewer wildcards wins
|
|
187
|
-
2. Longer literal text wins
|
|
188
|
-
3. Longer overall pattern wins
|
|
184
|
+
Command patterns use `*` wildcards and match against the full command string. If multiple patterns match, the **last matching rule wins**.
|
|
189
185
|
|
|
190
186
|
```jsonc
|
|
191
187
|
{
|
|
192
188
|
"bash": {
|
|
193
|
-
"git status": "allow",
|
|
194
|
-
"git diff": "allow",
|
|
195
189
|
"git *": "ask",
|
|
190
|
+
"git status": "allow",
|
|
196
191
|
"rm -rf *": "deny"
|
|
197
192
|
}
|
|
198
193
|
}
|
package/package.json
CHANGED
package/src/common.ts
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
import type { PermissionState } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
-
return {};
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
return value as Record<string, unknown>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function getNonEmptyString(value: unknown): string | null {
|
|
12
|
-
if (typeof value !== "string") {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const trimmed = value.trim();
|
|
17
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
|
-
return value === "allow" || value === "deny" || value === "ask";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
25
|
-
|
|
26
|
-
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
27
|
-
const root: Record<string, unknown> = {};
|
|
28
|
-
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
29
|
-
|
|
30
|
-
const lines = input.split(/\r?\n/);
|
|
31
|
-
for (const rawLine of lines) {
|
|
32
|
-
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const indent = rawLine.length - rawLine.trimStart().length;
|
|
37
|
-
const line = rawLine.trim();
|
|
38
|
-
const separatorIndex = line.indexOf(":");
|
|
39
|
-
if (separatorIndex <= 0) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const key = line.slice(0, separatorIndex).trim().replace(/^['"]|['"]$/g, "");
|
|
44
|
-
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
45
|
-
|
|
46
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
47
|
-
stack.pop();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const current = stack[stack.length - 1].target;
|
|
51
|
-
|
|
52
|
-
if (!rawValue) {
|
|
53
|
-
const child: Record<string, unknown> = {};
|
|
54
|
-
current[key] = child;
|
|
55
|
-
stack.push({ indent, target: child });
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
let scalar = rawValue;
|
|
60
|
-
if ((scalar.startsWith('"') && scalar.endsWith('"')) || (scalar.startsWith("'") && scalar.endsWith("'"))) {
|
|
61
|
-
scalar = scalar.slice(1, -1);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
current[key] = scalar;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return root;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function extractFrontmatter(markdown: string): string {
|
|
71
|
-
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
72
|
-
if (!normalized.startsWith("---\n")) {
|
|
73
|
-
return "";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const end = normalized.indexOf("\n---", 4);
|
|
77
|
-
if (end === -1) {
|
|
78
|
-
return "";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return normalized.slice(4, end);
|
|
82
|
-
}
|
|
1
|
+
import type { PermissionState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getNonEmptyString(value: unknown): string | null {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
|
+
return value === "allow" || value === "deny" || value === "ask";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
25
|
+
|
|
26
|
+
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
27
|
+
const root: Record<string, unknown> = {};
|
|
28
|
+
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
29
|
+
|
|
30
|
+
const lines = input.split(/\r?\n/);
|
|
31
|
+
for (const rawLine of lines) {
|
|
32
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
37
|
+
const line = rawLine.trim();
|
|
38
|
+
const separatorIndex = line.indexOf(":");
|
|
39
|
+
if (separatorIndex <= 0) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = line.slice(0, separatorIndex).trim().replace(/^['"]|['"]$/g, "");
|
|
44
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
45
|
+
|
|
46
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
47
|
+
stack.pop();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = stack[stack.length - 1].target;
|
|
51
|
+
|
|
52
|
+
if (!rawValue) {
|
|
53
|
+
const child: Record<string, unknown> = {};
|
|
54
|
+
current[key] = child;
|
|
55
|
+
stack.push({ indent, target: child });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let scalar = rawValue;
|
|
60
|
+
if ((scalar.startsWith('"') && scalar.endsWith('"')) || (scalar.startsWith("'") && scalar.endsWith("'"))) {
|
|
61
|
+
scalar = scalar.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current[key] = scalar;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractFrontmatter(markdown: string): string {
|
|
71
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
72
|
+
if (!normalized.startsWith("---\n")) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const end = normalized.indexOf("\n---", 4);
|
|
77
|
+
if (end === -1) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return normalized.slice(4, end);
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -844,8 +844,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
844
844
|
return false;
|
|
845
845
|
}
|
|
846
846
|
|
|
847
|
-
|
|
848
|
-
|
|
847
|
+
// Use tool-level permission check for tool injection decisions
|
|
848
|
+
// This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
|
|
849
|
+
// before any command-level permissions are considered
|
|
850
|
+
const toolPermission = permissionManager.getToolPermission(toolName, agentName ?? undefined);
|
|
851
|
+
return toolPermission !== "deny";
|
|
849
852
|
};
|
|
850
853
|
|
|
851
854
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -585,6 +585,59 @@ export class PermissionManager {
|
|
|
585
585
|
return value;
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Get the tool-level permission state for a tool, without considering command-level rules.
|
|
590
|
+
* This is used for tool injection decisions where we need to know if a tool is allowed/denied
|
|
591
|
+
* at the tool level before checking specific command permissions.
|
|
592
|
+
*
|
|
593
|
+
* @param toolName - The name of the tool (e.g., "bash", "read", "write")
|
|
594
|
+
* @param agentName - Optional agent name to check agent-specific permissions
|
|
595
|
+
* @returns The permission state for the tool at the tool level
|
|
596
|
+
*/
|
|
597
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
598
|
+
const { merged } = this.resolvePermissions(agentName);
|
|
599
|
+
const normalizedToolName = toolName.trim();
|
|
600
|
+
|
|
601
|
+
// Handle special permission keys (doom_loop, external_directory)
|
|
602
|
+
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
603
|
+
return merged.defaultPolicy.special;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Handle skill tool
|
|
607
|
+
if (normalizedToolName === "skill") {
|
|
608
|
+
return merged.defaultPolicy.skills;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// For bash tool, return the tool-level permission (not command-level)
|
|
612
|
+
if (normalizedToolName === "bash") {
|
|
613
|
+
return merged.tools?.bash || merged.defaultPolicy.bash;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Handle mcp tool
|
|
617
|
+
if (normalizedToolName === "mcp") {
|
|
618
|
+
return merged.tools?.mcp || merged.defaultPolicy.mcp;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Handle other tool permission names
|
|
622
|
+
if (TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
|
|
623
|
+
return merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// For MCP tools (qualified names like "server_tool"), check mcp permissions
|
|
627
|
+
if (normalizedToolName.includes("_")) {
|
|
628
|
+
const mcpMatch = findCompiledPermissionMatch(
|
|
629
|
+
compilePermissionPatternsFromSources(this.loadGlobalConfig().mcp, this.loadAgentPermissions(agentName).mcp),
|
|
630
|
+
normalizedToolName
|
|
631
|
+
);
|
|
632
|
+
if (mcpMatch) {
|
|
633
|
+
return mcpMatch.state;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Default to the tools default policy
|
|
638
|
+
return merged.defaultPolicy.tools;
|
|
639
|
+
}
|
|
640
|
+
|
|
588
641
|
checkPermission(toolName: string, input: unknown, agentName?: string): PermissionCheckResult {
|
|
589
642
|
const { agentConfig, merged, compiledSpecial, compiledSkills, compiledMcp, bashFilter } = this.resolvePermissions(agentName);
|
|
590
643
|
const normalizedToolName = toolName.trim();
|
package/src/test.ts
CHANGED
|
@@ -530,4 +530,70 @@ runTest("Tool registry blocks unregistered tools and handles aliases", () => {
|
|
|
530
530
|
assert.equal(missingNameCheck.status, "missing-tool-name");
|
|
531
531
|
});
|
|
532
532
|
|
|
533
|
+
runTest("getToolPermission returns tool-level deny for agent with bash: deny", () => {
|
|
534
|
+
const { manager, cleanup } = createManager(
|
|
535
|
+
{
|
|
536
|
+
defaultPolicy: {
|
|
537
|
+
tools: "ask",
|
|
538
|
+
bash: "ask",
|
|
539
|
+
mcp: "ask",
|
|
540
|
+
skills: "ask",
|
|
541
|
+
special: "ask",
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
orchestrator: `---
|
|
546
|
+
name: orchestrator
|
|
547
|
+
permission:
|
|
548
|
+
tools:
|
|
549
|
+
bash: deny
|
|
550
|
+
read: deny
|
|
551
|
+
task: allow
|
|
552
|
+
---
|
|
553
|
+
`,
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
// Tool-level check for bash should return deny for orchestrator
|
|
559
|
+
const bashPermission = manager.getToolPermission("bash", "orchestrator");
|
|
560
|
+
assert.equal(bashPermission, "deny");
|
|
561
|
+
|
|
562
|
+
// Tool-level check for task should return allow
|
|
563
|
+
const taskPermission = manager.getToolPermission("task", "orchestrator");
|
|
564
|
+
assert.equal(taskPermission, "allow");
|
|
565
|
+
|
|
566
|
+
// Tool-level check for read should return deny
|
|
567
|
+
const readPermission = manager.getToolPermission("read", "orchestrator");
|
|
568
|
+
assert.equal(readPermission, "deny");
|
|
569
|
+
|
|
570
|
+
// When no agent specified, should fall back to default policy
|
|
571
|
+
const defaultBashPermission = manager.getToolPermission("bash");
|
|
572
|
+
assert.equal(defaultBashPermission, "ask");
|
|
573
|
+
|
|
574
|
+
// Global config tools setting should work
|
|
575
|
+
const { manager: manager2, cleanup: cleanup2 } = createManager({
|
|
576
|
+
defaultPolicy: {
|
|
577
|
+
tools: "deny",
|
|
578
|
+
bash: "ask",
|
|
579
|
+
mcp: "ask",
|
|
580
|
+
skills: "ask",
|
|
581
|
+
special: "ask",
|
|
582
|
+
},
|
|
583
|
+
tools: {
|
|
584
|
+
bash: "allow",
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const globalBashPermission = manager2.getToolPermission("bash");
|
|
590
|
+
assert.equal(globalBashPermission, "allow");
|
|
591
|
+
} finally {
|
|
592
|
+
cleanup2();
|
|
593
|
+
}
|
|
594
|
+
} finally {
|
|
595
|
+
cleanup();
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
533
599
|
console.log("All permission system tests passed.");
|