@gotgenes/pi-permission-system 2.0.0 → 3.0.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -35
  3. package/config/config.example.json +6 -0
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +114 -16
  6. package/src/active-agent.ts +58 -0
  7. package/src/config-loader.ts +398 -0
  8. package/src/config-paths.ts +34 -0
  9. package/src/config-reporter.ts +16 -8
  10. package/src/external-directory.ts +113 -0
  11. package/src/forwarded-permissions/io.ts +328 -0
  12. package/src/forwarded-permissions/polling.ts +334 -0
  13. package/src/index.ts +153 -1095
  14. package/src/permission-manager.ts +25 -111
  15. package/src/permission-prompts.ts +131 -0
  16. package/src/subagent-context.ts +52 -0
  17. package/src/tool-input-preview.ts +206 -0
  18. package/tests/active-agent.test.ts +160 -0
  19. package/tests/bash-filter.test.ts +137 -0
  20. package/tests/common.test.ts +189 -0
  21. package/tests/config-loader.test.ts +364 -0
  22. package/tests/config-paths.test.ts +78 -0
  23. package/tests/config-reporter.test.ts +42 -33
  24. package/tests/extension-config.test.ts +51 -0
  25. package/tests/external-directory.test.ts +250 -0
  26. package/tests/permission-prompts.test.ts +301 -0
  27. package/tests/permission-system.test.ts +9 -26
  28. package/tests/session-start.test.ts +8 -33
  29. package/tests/skill-prompt-sanitizer.test.ts +244 -0
  30. package/tests/subagent-context.test.ts +124 -0
  31. package/tests/system-prompt-sanitizer.test.ts +186 -0
  32. package/tests/tool-input-preview.test.ts +452 -0
  33. package/tests/tool-registry.test.ts +155 -0
  34. package/tests/wildcard-matcher.test.ts +180 -0
  35. package/tests/yolo-mode.test.ts +110 -0
@@ -10,6 +10,8 @@ import {
10
10
  parseSimpleYamlMap,
11
11
  toRecord,
12
12
  } from "./common.js";
13
+ import { loadUnifiedConfig, stripJsonComments } from "./config-loader.js";
14
+ import { getGlobalConfigPath } from "./config-paths.js";
13
15
  import type {
14
16
  AgentPermissions,
15
17
  BashPermissions,
@@ -26,7 +28,7 @@ import {
26
28
  } from "./wildcard-matcher.js";
27
29
 
28
30
  function defaultGlobalConfigPath(): string {
29
- return join(getAgentDir(), "pi-permissions.jsonc");
31
+ return getGlobalConfigPath(getAgentDir());
30
32
  }
31
33
  function defaultAgentsDir(): string {
32
34
  return join(getAgentDir(), "agents");
@@ -61,87 +63,6 @@ const DEFAULT_POLICY: PermissionDefaultPolicy = {
61
63
  special: "ask",
62
64
  };
63
65
 
64
- const EMPTY_GLOBAL_CONFIG: GlobalPermissionConfig = {
65
- defaultPolicy: DEFAULT_POLICY,
66
- tools: {},
67
- bash: {},
68
- mcp: {},
69
- skills: {},
70
- special: {},
71
- };
72
-
73
- function stripJsonComments(input: string): string {
74
- let output = "";
75
- let inString = false;
76
- let stringQuote: '"' | "'" | "" = "";
77
- let escaping = false;
78
- let inLineComment = false;
79
- let inBlockComment = false;
80
-
81
- for (let i = 0; i < input.length; i++) {
82
- const char = input[i];
83
- const next = input[i + 1] || "";
84
-
85
- if (inLineComment) {
86
- if (char === "\n") {
87
- inLineComment = false;
88
- output += char;
89
- }
90
- continue;
91
- }
92
-
93
- if (inBlockComment) {
94
- if (char === "*" && next === "/") {
95
- inBlockComment = false;
96
- i++;
97
- }
98
- continue;
99
- }
100
-
101
- if (!inString && char === "/" && next === "/") {
102
- inLineComment = true;
103
- i++;
104
- continue;
105
- }
106
-
107
- if (!inString && char === "/" && next === "*") {
108
- inBlockComment = true;
109
- i++;
110
- continue;
111
- }
112
-
113
- output += char;
114
-
115
- if (!inString && (char === '"' || char === "'")) {
116
- inString = true;
117
- stringQuote = char;
118
- escaping = false;
119
- continue;
120
- }
121
-
122
- if (!inString) {
123
- continue;
124
- }
125
-
126
- if (escaping) {
127
- escaping = false;
128
- continue;
129
- }
130
-
131
- if (char === "\\") {
132
- escaping = true;
133
- continue;
134
- }
135
-
136
- if (char === stringQuote) {
137
- inString = false;
138
- stringQuote = "";
139
- }
140
- }
141
-
142
- return output;
143
- }
144
-
145
66
  function normalizePolicy(value: unknown): PermissionDefaultPolicy {
146
67
  const record = toRecord(value);
147
68
  return {
@@ -592,25 +513,17 @@ export class PermissionManager {
592
513
  return this.globalConfigCache.value;
593
514
  }
594
515
 
595
- let value: GlobalPermissionConfig;
596
- try {
597
- const raw = readFileSync(this.globalConfigPath, "utf-8");
598
- const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
599
- const { permissions: normalized, configIssues } =
600
- normalizeRawPermission(parsed);
601
- this.accumulateConfigIssues(configIssues);
602
-
603
- value = {
604
- defaultPolicy: normalizePolicy(normalized.defaultPolicy),
605
- tools: normalized.tools || {},
606
- bash: normalized.bash || {},
607
- mcp: normalized.mcp || {},
608
- skills: normalized.skills || {},
609
- special: normalized.special || {},
610
- };
611
- } catch {
612
- value = EMPTY_GLOBAL_CONFIG;
613
- }
516
+ const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
517
+ this.accumulateConfigIssues(issues);
518
+
519
+ const value: GlobalPermissionConfig = {
520
+ defaultPolicy: normalizePolicy(config.defaultPolicy),
521
+ tools: config.tools || {},
522
+ bash: config.bash || {},
523
+ mcp: config.mcp || {},
524
+ skills: config.skills || {},
525
+ special: config.special || {},
526
+ };
614
527
 
615
528
  this.globalConfigCache = { stamp, value };
616
529
  return value;
@@ -626,16 +539,17 @@ export class PermissionManager {
626
539
  return this.projectGlobalConfigCache.value;
627
540
  }
628
541
 
629
- let value: AgentPermissions;
630
- try {
631
- const raw = readFileSync(this.projectGlobalConfigPath, "utf-8");
632
- const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
633
- const result = normalizeRawPermission(parsed);
634
- value = result.permissions;
635
- this.accumulateConfigIssues(result.configIssues);
636
- } catch {
637
- value = {};
638
- }
542
+ const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
543
+ this.accumulateConfigIssues(issues);
544
+
545
+ const value: AgentPermissions = {
546
+ defaultPolicy: config.defaultPolicy,
547
+ tools: config.tools,
548
+ bash: config.bash,
549
+ mcp: config.mcp,
550
+ skills: config.skills,
551
+ special: config.special,
552
+ };
639
553
 
640
554
  this.projectGlobalConfigCache = { stamp, value };
641
555
  return value;
@@ -0,0 +1,131 @@
1
+ import type { SkillPromptEntry } from "./skill-prompt-sanitizer.js";
2
+ import { formatToolInputForPrompt } from "./tool-input-preview.js";
3
+ import type { PermissionCheckResult } from "./types.js";
4
+
5
+ export function formatMissingToolNameReason(): string {
6
+ return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
7
+ }
8
+
9
+ export function formatUnknownToolReason(
10
+ toolName: string,
11
+ availableToolNames: readonly string[],
12
+ ): string {
13
+ const preview = availableToolNames.slice(0, 10);
14
+ const suffix = availableToolNames.length > preview.length ? ", ..." : "";
15
+ const availableList =
16
+ preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
17
+
18
+ const mcpHint =
19
+ toolName === "mcp"
20
+ ? ""
21
+ : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
22
+
23
+ return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
24
+ }
25
+
26
+ export function formatPermissionHardStopHint(
27
+ result: PermissionCheckResult,
28
+ ): string {
29
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
30
+ return "Hard stop: this MCP permission denial is policy-enforced. Do not retry this target, do not run discovery/investigation to bypass it, and report the block to the user.";
31
+ }
32
+
33
+ return "Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.";
34
+ }
35
+
36
+ export function formatDenyReason(
37
+ result: PermissionCheckResult,
38
+ agentName?: string,
39
+ ): string {
40
+ const parts: string[] = [];
41
+
42
+ if (agentName) {
43
+ parts.push(`Agent '${agentName}'`);
44
+ }
45
+
46
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
47
+ parts.push(`is not permitted to run MCP target '${result.target}'`);
48
+ } else {
49
+ parts.push(`is not permitted to run '${result.toolName}'`);
50
+ }
51
+
52
+ if (result.command) {
53
+ parts.push(`command '${result.command}'`);
54
+ }
55
+
56
+ if (result.matchedPattern) {
57
+ parts.push(`(matched '${result.matchedPattern}')`);
58
+ }
59
+
60
+ return `${parts.join(" ")}. ${formatPermissionHardStopHint(result)}`;
61
+ }
62
+
63
+ export function formatUserDeniedReason(
64
+ result: PermissionCheckResult,
65
+ denialReason?: string,
66
+ ): string {
67
+ const base =
68
+ (result.source === "mcp" || result.toolName === "mcp") && result.target
69
+ ? `User denied MCP target '${result.target}'.`
70
+ : result.toolName === "bash" && result.command
71
+ ? `User denied bash command '${result.command}'.`
72
+ : `User denied tool '${result.toolName}'.`;
73
+ const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
74
+
75
+ return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
76
+ }
77
+
78
+ export function formatAskPrompt(
79
+ result: PermissionCheckResult,
80
+ agentName?: string,
81
+ input?: unknown,
82
+ ): string {
83
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
84
+
85
+ if (result.toolName === "bash") {
86
+ const patternInfo = result.matchedPattern
87
+ ? ` (matched '${result.matchedPattern}')`
88
+ : "";
89
+ return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
90
+ }
91
+
92
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
93
+ const patternInfo = result.matchedPattern
94
+ ? ` (matched '${result.matchedPattern}')`
95
+ : "";
96
+ return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
97
+ }
98
+
99
+ const patternInfo = result.matchedPattern
100
+ ? ` (matched '${result.matchedPattern}')`
101
+ : "";
102
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
103
+ const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
104
+ return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
105
+ }
106
+
107
+ export function formatSkillAskPrompt(
108
+ skillName: string,
109
+ agentName?: string,
110
+ ): string {
111
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
112
+ return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
113
+ }
114
+
115
+ export function formatSkillPathAskPrompt(
116
+ skill: SkillPromptEntry,
117
+ readPath: string,
118
+ agentName?: string,
119
+ ): string {
120
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
121
+ return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
122
+ }
123
+
124
+ export function formatSkillPathDenyReason(
125
+ skill: SkillPromptEntry,
126
+ readPath: string,
127
+ agentName?: string,
128
+ ): string {
129
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
130
+ return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
131
+ }
@@ -0,0 +1,52 @@
1
+ import { normalize } from "node:path";
2
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+
4
+ import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding.js";
5
+
6
+ export function normalizeFilesystemPath(pathValue: string): string {
7
+ const normalizedPath = normalize(pathValue);
8
+ return process.platform === "win32"
9
+ ? normalizedPath.toLowerCase()
10
+ : normalizedPath;
11
+ }
12
+
13
+ function isPathWithinDirectoryForSubagent(
14
+ pathValue: string,
15
+ directory: string,
16
+ ): boolean {
17
+ if (!pathValue || !directory) {
18
+ return false;
19
+ }
20
+
21
+ if (pathValue === directory) {
22
+ return true;
23
+ }
24
+
25
+ const sep = process.platform === "win32" ? "\\" : "/";
26
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
27
+ return pathValue.startsWith(prefix);
28
+ }
29
+
30
+ export function isSubagentExecutionContext(
31
+ ctx: ExtensionContext,
32
+ subagentSessionsDir: string,
33
+ ): boolean {
34
+ for (const key of SUBAGENT_ENV_HINT_KEYS) {
35
+ const value = process.env[key];
36
+ if (typeof value === "string" && value.trim()) {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ const sessionDir = ctx.sessionManager.getSessionDir();
42
+ if (!sessionDir) {
43
+ return false;
44
+ }
45
+
46
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
47
+ const normalizedSubagentRoot = normalizeFilesystemPath(subagentSessionsDir);
48
+ return isPathWithinDirectoryForSubagent(
49
+ normalizedSessionDir,
50
+ normalizedSubagentRoot,
51
+ );
52
+ }
@@ -0,0 +1,206 @@
1
+ import { getNonEmptyString, toRecord } from "./common.js";
2
+ import { safeJsonStringify } from "./logging.js";
3
+ import type { PermissionCheckResult } from "./types.js";
4
+
5
+ export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
6
+ export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
7
+ export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
8
+
9
+ export function truncateInlineText(value: string, maxLength: number): string {
10
+ return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
11
+ }
12
+
13
+ export function sanitizeInlineText(
14
+ value: string,
15
+ maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
16
+ ): string {
17
+ const normalized = value.replace(/\s+/g, " ").trim();
18
+ return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
19
+ }
20
+
21
+ export function countTextLines(value: string): number {
22
+ if (!value) {
23
+ return 0;
24
+ }
25
+
26
+ return value.split(/\r\n|\r|\n/).length;
27
+ }
28
+
29
+ export function formatCount(
30
+ value: number,
31
+ singular: string,
32
+ plural: string,
33
+ ): string {
34
+ return `${value} ${value === 1 ? singular : plural}`;
35
+ }
36
+
37
+ export function getPromptPath(input: Record<string, unknown>): string | null {
38
+ return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
39
+ }
40
+
41
+ export function formatEditInputForPrompt(
42
+ input: Record<string, unknown>,
43
+ ): string {
44
+ const path = getPromptPath(input);
45
+ const rawEdits = Array.isArray(input.edits)
46
+ ? input.edits
47
+ : typeof input.oldText === "string" && typeof input.newText === "string"
48
+ ? [{ oldText: input.oldText, newText: input.newText }]
49
+ : [];
50
+
51
+ const edits = rawEdits
52
+ .map((edit) => toRecord(edit))
53
+ .filter(
54
+ (edit) =>
55
+ typeof edit.oldText === "string" && typeof edit.newText === "string",
56
+ );
57
+
58
+ const pathPart = path ? `for '${path}'` : "";
59
+ if (edits.length === 0) {
60
+ return pathPart ? `${pathPart} with edit input` : "with edit input";
61
+ }
62
+
63
+ const firstEdit = edits[0];
64
+ const oldText = String(firstEdit.oldText);
65
+ const newText = String(firstEdit.newText);
66
+ const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
67
+ const extraEdits =
68
+ edits.length > 1
69
+ ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
70
+ : "";
71
+ const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
72
+ return pathPart ? `${pathPart} ${summary}` : summary;
73
+ }
74
+
75
+ export function formatWriteInputForPrompt(
76
+ input: Record<string, unknown>,
77
+ ): string {
78
+ const path = getPromptPath(input);
79
+ const content = typeof input.content === "string" ? input.content : "";
80
+ const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
81
+ return path ? `for '${path}' ${summary}` : summary;
82
+ }
83
+
84
+ export function formatReadInputForPrompt(
85
+ input: Record<string, unknown>,
86
+ ): string {
87
+ const path = getPromptPath(input);
88
+ const parts = path ? [`path '${path}'`] : [];
89
+ if (typeof input.offset === "number") {
90
+ parts.push(`offset ${input.offset}`);
91
+ }
92
+ if (typeof input.limit === "number") {
93
+ parts.push(`limit ${input.limit}`);
94
+ }
95
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
96
+ }
97
+
98
+ export function formatSearchInputForPrompt(
99
+ toolName: string,
100
+ input: Record<string, unknown>,
101
+ ): string {
102
+ const parts: string[] = [];
103
+ const path = getPromptPath(input);
104
+ const pattern = getNonEmptyString(input.pattern);
105
+ const glob = getNonEmptyString(input.glob);
106
+
107
+ if (pattern) {
108
+ parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
109
+ }
110
+ if (glob) {
111
+ parts.push(`glob '${sanitizeInlineText(glob)}'`);
112
+ }
113
+ if (path) {
114
+ parts.push(`path '${path}'`);
115
+ } else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
116
+ parts.push("current working directory");
117
+ }
118
+
119
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
120
+ }
121
+
122
+ export function serializeToolInputPreview(input: unknown): string {
123
+ const serialized = safeJsonStringify(input);
124
+ if (!serialized || serialized === "{}" || serialized === "null") {
125
+ return "";
126
+ }
127
+
128
+ return serialized.replace(/\s+/g, " ").trim();
129
+ }
130
+
131
+ export function formatJsonInputForPrompt(input: unknown): string {
132
+ const inline = serializeToolInputPreview(input);
133
+ return inline
134
+ ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
135
+ : "";
136
+ }
137
+
138
+ export function formatToolInputForPrompt(
139
+ toolName: string,
140
+ input: unknown,
141
+ ): string {
142
+ const inputRecord = toRecord(input);
143
+
144
+ switch (toolName) {
145
+ case "edit":
146
+ return formatEditInputForPrompt(inputRecord);
147
+ case "write":
148
+ return formatWriteInputForPrompt(inputRecord);
149
+ case "read":
150
+ return formatReadInputForPrompt(inputRecord);
151
+ case "find":
152
+ case "grep":
153
+ case "ls":
154
+ return formatSearchInputForPrompt(toolName, inputRecord);
155
+ default:
156
+ return formatJsonInputForPrompt(input);
157
+ }
158
+ }
159
+
160
+ export function formatGenericToolInputForLog(
161
+ input: unknown,
162
+ ): string | undefined {
163
+ const inline = serializeToolInputPreview(input);
164
+ return inline
165
+ ? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
166
+ : undefined;
167
+ }
168
+
169
+ export function getToolInputPreviewForLog(
170
+ result: PermissionCheckResult,
171
+ input: unknown,
172
+ pathBearingTools: ReadonlySet<string>,
173
+ ): string | undefined {
174
+ if (
175
+ result.toolName === "bash" ||
176
+ result.toolName === "mcp" ||
177
+ result.source === "mcp"
178
+ ) {
179
+ return undefined;
180
+ }
181
+
182
+ if (pathBearingTools.has(result.toolName)) {
183
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
184
+ return inputPreview
185
+ ? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
186
+ : undefined;
187
+ }
188
+
189
+ return formatGenericToolInputForLog(input);
190
+ }
191
+
192
+ export function getPermissionLogContext(
193
+ result: PermissionCheckResult,
194
+ input: unknown,
195
+ pathBearingTools: ReadonlySet<string>,
196
+ ): { command?: string; target?: string; toolInputPreview?: string } {
197
+ return {
198
+ command: result.command,
199
+ target: result.target,
200
+ toolInputPreview: getToolInputPreviewForLog(
201
+ result,
202
+ input,
203
+ pathBearingTools,
204
+ ),
205
+ };
206
+ }