@davisliu2006/pi-permission-system 0.1.0 → 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/src/policy.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { findLastWildcardMatch, matchesWildcard } from "./matcher.ts";
1
+ import {
2
+ findBashSessionApproval,
3
+ resolveBashPolicy,
4
+ } from "./policy-bash.ts";
2
5
  import type {
3
6
  PermissionConfig,
4
7
  PermissionRequest,
@@ -7,6 +10,8 @@ import type {
7
10
  AllowAlwaysRule,
8
11
  } from "./types.ts";
9
12
 
13
+ export { resolveAllowAlwaysMode } from "./policy-bash.ts";
14
+
10
15
  /**
11
16
  * Returns the permission for that tool if specified in the config.
12
17
  */
@@ -17,13 +22,6 @@ function configuredToolState(
17
22
  return Object.hasOwn(config.tools, toolName) ? config.tools[toolName] : undefined;
18
23
  }
19
24
 
20
- /**
21
- * Returns the permission for that tool or the default if not specified.
22
- */
23
- function fallbackState(config: PermissionConfig, toolName: string): PermissionState {
24
- return configuredToolState(config, toolName) ?? config.default;
25
- }
26
-
27
25
  /**
28
26
  * Returns the policy for the request using the correct precedence rules.
29
27
  */
@@ -40,36 +38,12 @@ export function resolvePolicy(
40
38
  state: externalState,
41
39
  source: "external",
42
40
  matchedPattern: request.toolName,
43
- isChainedBash: false,
44
41
  };
45
42
  }
46
43
  }
47
44
 
48
- if (request.toolName === "bash") { // bash
49
- const chained = request.isChainedBash === true;
50
- if (chained) { // chained bash - can't match wildcard
51
- return {
52
- state: fallbackState(config, "bash"),
53
- source: configuredToolState(config, "bash") ? "tool" : "default",
54
- isChainedBash: true,
55
- };
56
- }
57
-
58
- const match = findLastWildcardMatch(config.bash, request.body);
59
- if (match) { // matched with wildcard
60
- return {
61
- state: match.state,
62
- source: "bash",
63
- matchedPattern: match.pattern,
64
- isChainedBash: false,
65
- };
66
- }
67
-
68
- return { // fallback to default
69
- state: fallbackState(config, "bash"),
70
- source: configuredToolState(config, "bash") ? "tool" : "default",
71
- isChainedBash: false,
72
- };
45
+ if (request.toolName === "bash") {
46
+ return resolveBashPolicy(config, request);
73
47
  }
74
48
 
75
49
  // other tool
@@ -77,7 +51,6 @@ export function resolvePolicy(
77
51
  return {
78
52
  state: toolState ?? config.default,
79
53
  source: toolState ? "tool" : "default",
80
- isChainedBash: false,
81
54
  };
82
55
  }
83
56
 
@@ -86,32 +59,19 @@ export function resolvePolicy(
86
59
  */
87
60
  export function findSessionApproval(
88
61
  rules: readonly AllowAlwaysRule[],
89
- request: PermissionRequest,
90
- isChainedBash: boolean
62
+ request: PermissionRequest
91
63
  ): AllowAlwaysRule | undefined {
92
- const isExternal = request.isExternalDirectory === true;
64
+ if (request.toolName === "bash") {
65
+ return findBashSessionApproval(rules, request);
66
+ }
93
67
 
94
- for (let index = rules.length - 1; index >= 0; index -= 1) {
68
+ for (let index = rules.length - 1; index >= 0; index--) {
95
69
  const rule = rules[index]!;
96
-
97
- if (rule.scope === "external_directory") {
98
- if (!isExternal) {continue;}
99
- if (rule.tool !== request.toolName) {continue;}
100
- if (rule.body === request.body) {return rule;}
101
- continue;
102
- }
103
-
104
- if (isExternal) {continue;}
105
- if (rule.tool !== request.toolName) {continue;}
106
-
107
- if (rule.kind === "exact") {
108
- if (rule.body === request.body) {return rule;}
109
- continue;
110
- }
111
-
112
- if (rule.tool !== "bash" || request.bodyKind !== "command") {continue;}
113
- if (isChainedBash) {continue;}
114
- if (matchesWildcard(rule.body, request.body)) {return rule;}
70
+ if (
71
+ rule.kind === "exact"
72
+ && rule.tool === request.toolName
73
+ && rule.body === request.body
74
+ ) {return rule;}
115
75
  }
116
76
 
117
77
  return undefined;
@@ -129,17 +89,3 @@ export function describePolicy(policy: PolicyResolution): string {
129
89
  }
130
90
  return `${policy.state} (default)`;
131
91
  }
132
-
133
- /**
134
- * True when "Allow Always" will generate a bash wildcard instead of an exact rule.
135
- * Mirrors the branching in chooseAlwaysRule.
136
- */
137
- export function willGenerateBashWildcard(
138
- request: PermissionRequest,
139
- policy: PolicyResolution
140
- ): boolean {
141
- if (request.isExternalDirectory && policy.source === "external") {return false;}
142
- if (request.toolName !== "bash") {return false;}
143
- if (policy.isChainedBash) {return false;}
144
- return true;
145
- }
package/src/prompts.ts CHANGED
@@ -20,7 +20,7 @@ export function describeBlockedSubject(
20
20
  return `bash command ${JSON.stringify(request.body)}`;
21
21
  }
22
22
  if (request.bodyKind === "path") {
23
- return `access to ${JSON.stringify(request.body)}`;
23
+ return `${request.toolName} access to ${JSON.stringify(request.body)}`;
24
24
  }
25
25
  return includeGenericSubject
26
26
  ? `tool ${JSON.stringify(request.toolName)} with input ${request.body}`
@@ -30,20 +30,13 @@ export function describeBlockedSubject(
30
30
  /**
31
31
  * Builds context for the ai-generated purpose.
32
32
  */
33
- export function buildPurposePromptContext(
34
- request: PermissionRequest,
35
- policy: PolicyResolution
36
- ): string {
33
+ export function buildPurposePromptContext(request: PermissionRequest): string {
37
34
  const lines = [
38
35
  `Tool: ${request.toolName}`,
39
- `Policy: ${describePolicy(policy)}`,
40
36
  ];
41
37
 
42
38
  if (request.bodyKind === "command") {
43
39
  lines.push(`Command:\n${preview(request.body, 600)}`);
44
- if (policy.isChainedBash) {
45
- lines.push(CHAINED_BASH_NOTE);
46
- }
47
40
  } else if (request.bodyKind === "path") {
48
41
  lines.push(`Path: ${preview(request.body, 400)}`);
49
42
  if (request.isExternalDirectory) {
@@ -63,6 +56,14 @@ export const PURPOSE_SYSTEM_PROMPT = [
63
56
 
64
57
  export function buildPurposeInstructions(context: string): string {
65
58
  return [
59
+ "Examples:",
60
+ "Tool: bash",
61
+ `Command: git commit -m "Add feature" && git log`,
62
+ "Commits the changes to the repository and lists the commit history.",
63
+ "Tool: bash",
64
+ `Command: grep -rnEI --color=never --exclude-dir={node_modules} --include=\\*.py -B 2 -A 3 "^[[:space:]]*[^/]*\\b(func_1|func_2)\\s*(" .`,
65
+ "Searches the project for instances of func_1 or func_2.",
66
+ "",
66
67
  "Explain why the following request is being made in a single sentence.",
67
68
  "Do not use markdown or quotes.",
68
69
  "",
@@ -75,31 +76,56 @@ export const WILDCARD_SYSTEM_PROMPT = [
75
76
  "Return only the pattern itself with no explanation or markdown.",
76
77
  ].join(" ");
77
78
 
79
+ export const WILDCARD_LIST_SYSTEM_PROMPT = [
80
+ "You generate shell command permission patterns for the user to approve.",
81
+ "Return only semicolon-separated patterns with no explanation or markdown.",
82
+ ].join(" ");
83
+
84
+ const WILDCARD_RULES = [
85
+ "Rules for each pattern:",
86
+ "- Use wildcard syntax, not regular expressions.",
87
+ "- * matches any sequence of characters.",
88
+ "- ? matches exactly one character.",
89
+ "- \\* and \\? match literal characters.",
90
+ "- All other characters are literal.",
91
+ ];
92
+
78
93
  export function buildWildcardInstructions(command: string): string {
79
94
  return [
80
95
  "Create a permission wildcard for this command.",
81
96
  "Select one that is general enough to be useful but narrow enough to not pose security risks.",
82
97
  "",
83
- "Rules for the pattern:",
84
- "- Use wildcard syntax, not regular expressions.",
85
- "- * matches any sequence of characters.",
86
- "- ? matches exactly one character.",
87
- "- \\* and \\? match literal characters.",
88
- "- All other characters are literal.",
98
+ ...WILDCARD_RULES,
89
99
  "The pattern must match the given command.",
90
100
  "",
91
101
  `Command: ${JSON.stringify(command)}`,
92
102
  ].join("\n");
93
103
  }
94
104
 
105
+ /**
106
+ * Builds instructions for generating one wildcard per command in a simple list.
107
+ */
108
+ export function buildWildcardListInstructions(commands: readonly string[]): string {
109
+ return [
110
+ "Create a permission wildcard for each command below.",
111
+ "Select patterns that are general enough to be useful but narrow enough to not pose security risks.",
112
+ "Return exactly one pattern per command, separated by semicolons, in the same order.",
113
+ "",
114
+ ...WILDCARD_RULES,
115
+ "Each pattern must match its corresponding command.",
116
+ "",
117
+ `Commands: ${commands.join("; ")}`,
118
+ ].join("\n");
119
+ }
120
+
95
121
  /**
96
122
  * Prompt given to the agent on deny.
97
123
  */
98
124
  export function formatDenyReason(request: PermissionRequest, policy: PolicyResolution): string {
99
125
  const subject = describeBlockedSubject(request, true);
100
- const chainedNote = policy.isChainedBash ? ` ${CHAINED_BASH_NOTE}` : "";
126
+ const chainedNote = request.bashAnalysis?.isComplex ? ` ${CHAINED_BASH_NOTE}` : "";
101
127
 
102
- return `Permission denied for ${subject}. Policy: ${describePolicy(policy)}.${chainedNote} Hard stop: do not retry or investigate a bypass; report the denial to the user.`;
128
+ return `Permission denied for ${subject}. Policy: ${describePolicy(policy)}.${chainedNote} Hard stop: do not retry or investigate a bypass. Report the denial to the user.`;
103
129
  }
104
130
 
105
131
  /**
@@ -114,5 +140,5 @@ export function formatNoUiReason(request: PermissionRequest, policy: PolicyResol
114
140
  */
115
141
  export function formatRejectReason(request: PermissionRequest): string {
116
142
  const subject = describeBlockedSubject(request, false);
117
- return `User rejected ${subject}. Do not retry the rejected request. Ask the user what to do next.`;
143
+ return `User rejected ${subject}. Pause the task immediately (do not do further analysis or run more tool requests) and concisely ask the user what to do next.`;
118
144
  }
@@ -1,5 +1,5 @@
1
1
  import { isPathTool, resolveToolPath } from "./paths.ts";
2
- import { hasShellChaining } from "./shell.ts";
2
+ import { analyzeShellCommand } from "./shell.ts";
3
3
  import type { PiToolInput } from "./types-pi.ts";
4
4
  import { toRecord, type PermissionRequest } from "./types.ts";
5
5
 
@@ -45,7 +45,7 @@ export async function buildRequest(
45
45
  body: command,
46
46
  bodyKind: "command",
47
47
  cwd,
48
- isChainedBash: hasShellChaining(command),
48
+ bashAnalysis: analyzeShellCommand(command),
49
49
  };
50
50
  }
51
51
 
@@ -1,9 +1,18 @@
1
1
  import type { AllowAlwaysRule } from "./types.ts";
2
2
 
3
+ function ruleEq(a: AllowAlwaysRule, b: AllowAlwaysRule): boolean {
4
+ return a.kind === b.kind && a.tool === b.tool && a.body === b.body;
5
+ }
6
+
3
7
  export class SessionApprovalStore {
8
+ /**
9
+ * Stored as an array rather than a set because JS
10
+ * objects don't compare by value.
11
+ */
4
12
  private readonly rules: AllowAlwaysRule[] = [];
5
13
 
6
14
  add(rule: AllowAlwaysRule): void {
15
+ if (this.rules.some((existing) => ruleEq(existing, rule))) {return;}
7
16
  this.rules.push(rule);
8
17
  }
9
18
 
package/src/shell.ts CHANGED
@@ -1,15 +1,39 @@
1
1
  /**
2
- * Conservative shell-chaining detector.
3
- * This is not a parser and is biased toward returning true.
4
- *
5
- * Guards against:
2
+ * Result of a conservative shell scan.
3
+ *
4
+ * When `isComplex` is false, `segments` are commands separated only by `;`,
5
+ * `&&`, `||`, or newlines (or a single command). When true, `segments` is empty.
6
+ */
7
+ export type ShellCommandAnalysis = {
8
+ isComplex: boolean;
9
+ segments: string[];
10
+ };
11
+
12
+ const COMPLEX: ShellCommandAnalysis = { isComplex: true, segments: [] };
13
+
14
+ /**
15
+ * Conservative shell command scanner.
16
+ * This is not a full parser and is biased toward `isComplex: true`.
17
+ *
18
+ * Splits on:
19
+ * - `;`, `&&`, `||`, and newlines outside quotes
20
+ *
21
+ * Treats as complex:
6
22
  * - Substitutions like `command` and $(command)
7
- * - Chaining like ;, &&, ||, \n, and \r
23
+ * - Pipes `|` and background `&`
8
24
  * - Parentheses
9
25
  */
10
- export function hasShellChaining(command: string): boolean {
26
+ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
11
27
  let quote: "'" | '"' | undefined;
12
28
  let index = 0;
29
+ let current = "";
30
+ const segments: string[] = [];
31
+
32
+ const pushCurrent = (): void => {
33
+ const trimmed = current.trim();
34
+ if (trimmed) {segments.push(trimmed);}
35
+ current = "";
36
+ };
13
37
 
14
38
  while (index < command.length) {
15
39
  const character = command[index]!;
@@ -17,52 +41,89 @@ export function hasShellChaining(command: string): boolean {
17
41
  // inside quote
18
42
  if (quote === "'") {
19
43
  if (character === "'") {quote = undefined;}
44
+ current += character;
20
45
  index++;
21
46
  continue;
22
47
  }
23
48
  if (quote === '"') {
24
49
  if (character === "\\") {
50
+ current += character + (command[index + 1] ?? "");
25
51
  index += 2;
26
52
  continue;
27
53
  }
28
54
  if (character === '"') {
29
55
  quote = undefined;
56
+ current += character;
30
57
  index++;
31
58
  continue;
32
59
  }
33
- if (character === "`") {return true;}
34
- if (character === "$" && command[index + 1] === "(") {return true;}
60
+ if (character === "`") {return COMPLEX;}
61
+ if (character === "$" && command[index + 1] === "(") {return COMPLEX;}
62
+ current += character;
35
63
  index++;
36
64
  continue;
37
65
  }
38
66
 
39
67
  // escape character
40
68
  if (character === "\\") {
69
+ current += character + (command[index + 1] ?? "");
41
70
  index += 2;
42
71
  continue;
43
72
  }
44
-
45
- // strat quote
73
+
74
+ // start quote
46
75
  if (character === "'") {
47
76
  quote = "'";
77
+ current += character;
48
78
  index++;
49
79
  continue;
50
80
  }
51
81
  if (character === '"') {
52
82
  quote = '"';
83
+ current += character;
53
84
  index++;
54
85
  continue;
55
86
  }
56
87
 
57
- if (character === "`") {return true;}
58
- if (character === "$" && command[index + 1] === "(") {return true;}
59
- if (character === ";" || character === "|" || character === "&") {return true;}
60
- if (character === "\n" || character === "\r") {return true;}
61
- if (character === "(" || character === ")") {return true;}
88
+ if (character === "`") {return COMPLEX;}
89
+ if (character === "$" && command[index + 1] === "(") {return COMPLEX;}
90
+ if (character === "(" || character === ")") {return COMPLEX;}
62
91
 
92
+ // simple separators
93
+ if (character === ";") {
94
+ pushCurrent();
95
+ index++;
96
+ continue;
97
+ }
98
+ if (character === "&") {
99
+ if (command[index+1] !== "&") {return COMPLEX;}
100
+ pushCurrent();
101
+ index += 2;
102
+ continue;
103
+ }
104
+ if (character === "|") {
105
+ if (command[index+1] !== "|") {return COMPLEX;}
106
+ pushCurrent();
107
+ index += 2;
108
+ continue;
109
+ }
110
+ if (character === "\n" || character === "\r") {
111
+ pushCurrent();
112
+ if (character === "\r" && command[index + 1] === "\n") {index += 2;}
113
+ else {index++;}
114
+ continue;
115
+ }
116
+
117
+ current += character;
63
118
  index++;
64
119
  }
65
120
 
66
121
  // Unterminated quotes are unsafe to reason about.
67
- return quote !== undefined;
122
+ if (quote !== undefined) {return COMPLEX;}
123
+
124
+ pushCurrent();
125
+ return {
126
+ isComplex: false,
127
+ segments: segments.length > 0 ? segments : [""],
128
+ };
68
129
  }
package/src/types-pi.ts CHANGED
@@ -23,12 +23,3 @@ export type PiToolInput =
23
23
  | PathToolInput
24
24
  | BashToolInput
25
25
  | Record<string, unknown>;
26
-
27
- export function validatePathToolInput(input: PiToolInput): PathToolInput | undefined {
28
- if (!("path" in input) || typeof(input.path) !== "string") {return undefined;}
29
- return input as PathToolInput;
30
- }
31
- export function validateBashToolInput(input: PiToolInput): BashToolInput | undefined {
32
- if (!("command" in input) || typeof(input.command) !== "string") {return undefined;}
33
- return input as BashToolInput;
34
- }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ASK, PERMISSION_STATES } from "./constants.ts";
2
+ import type { ShellCommandAnalysis } from "./shell.ts";
2
3
  import type { PiToolInput } from "./types-pi.ts";
3
4
 
4
5
  // utility functions
@@ -31,11 +32,9 @@ export function createDefaultPermissionConfig(): PermissionConfig {
31
32
  return { default: ASK, tools: {}, bash: {}, external_directory: {} };
32
33
  }
33
34
 
34
- export const DEFAULT_PERMISSION_CONFIG = createDefaultPermissionConfig();
35
-
36
35
  // permission request
37
36
 
38
- export type RequestBodyKind = "command" | "path" | "generic";
37
+ export type RequestBodyKind = "path" | "command" | "generic";
39
38
 
40
39
  /**
41
40
  * Request for a tool.
@@ -44,6 +43,8 @@ export type RequestBodyKind = "command" | "path" | "generic";
44
43
  * @property body - The body for the permission request.
45
44
  * @property bodyKind - The meaning of the body.
46
45
  * @property cwd - The current working directory.
46
+ * @property isExternalDirectory - Whether the request uses path to an external directory.
47
+ * @property bashAnalysis - Shell scan for bash requests.
47
48
  */
48
49
  export interface PermissionRequest {
49
50
  toolName: string;
@@ -52,12 +53,12 @@ export interface PermissionRequest {
52
53
  bodyKind: RequestBodyKind;
53
54
  cwd: string;
54
55
  isExternalDirectory?: boolean;
55
- isChainedBash?: boolean;
56
+ bashAnalysis?: ShellCommandAnalysis;
56
57
  }
57
58
 
58
59
  // resolved policy
59
60
 
60
- export type PolicySource = "bash" | "tool" | "default" | "external";
61
+ export type PolicySource = "default" | "tool" | "bash" | "external";
61
62
 
62
63
  /**
63
64
  * A resolved policy.
@@ -66,18 +67,17 @@ export interface PolicyResolution {
66
67
  state: PermissionState;
67
68
  source: PolicySource;
68
69
  matchedPattern?: string;
69
- isChainedBash: boolean;
70
70
  }
71
71
 
72
+ export type AllowAlwaysMode = "wildcard" | "wildcard-list" | "exact";
73
+
72
74
  // other types
73
75
 
74
76
  export type AllowAlwaysRule = {
75
- scope: "tool" | "external_directory";
76
77
  kind: "exact";
77
78
  tool: string;
78
79
  body: string;
79
80
  } | {
80
- scope: "tool";
81
81
  kind: "wildcard";
82
82
  tool: "bash";
83
83
  body: string;
package/src/ui-strings.ts CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  ALLOW_ALWAYS_EXACT,
3
3
  ALLOW_ALWAYS_WILDCARD,
4
4
  } from "./constants.ts";
5
- import { willGenerateBashWildcard } from "./policy.ts";
5
+ import { resolveAllowAlwaysMode } from "./policy-bash.ts";
6
6
  import type { PermissionRequest, PolicyResolution } from "./types.ts";
7
7
 
8
8
  export const STATUS_SUMMARIZING_REQUEST = "Summarizing permission request...";
@@ -13,8 +13,8 @@ export const PERMISSION_DIALOG_QUESTION = "What should the agent be allowed to d
13
13
  export const PURPOSE_PREFIX = "Purpose: ";
14
14
 
15
15
  export const WILDCARD_EDITOR_TITLE = "Review bash wildcard";
16
+ export const WILDCARD_LIST_EDITOR_TITLE = "Review bash wildcards (semicolon-separated)";
16
17
  export const BROAD_WILDCARD_TITLE = "Very broad wildcard";
17
- export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
18
18
 
19
19
  const CHAINED_BASH_NOTE = "Shell chaining detected.";
20
20
  const EXTERNAL_DIRECTORY_NOTE = "Outside the current working directory.";
@@ -33,8 +33,8 @@ export function buildSelectorSummary(request: PermissionRequest): string {
33
33
  ];
34
34
 
35
35
  if (request.bodyKind === "command") {
36
- lines.push(`Command: \`${preview(request.body, 600)}\``);
37
- if (request.isChainedBash) {
36
+ lines.push(`Command: ${preview(request.body, 600)}`);
37
+ if (request.bashAnalysis?.isComplex) {
38
38
  lines.push(CHAINED_BASH_NOTE);
39
39
  }
40
40
  } else if (request.bodyKind === "path") {
@@ -50,17 +50,21 @@ export function buildSelectorSummary(request: PermissionRequest): string {
50
50
  }
51
51
 
52
52
  /**
53
- * Label for the "Allow Always" select option.
53
+ * Label for the "allow always" select option.
54
54
  */
55
55
  export function formatAllowAlwaysOption(
56
56
  request: PermissionRequest,
57
57
  policy: PolicyResolution
58
58
  ): string {
59
- return willGenerateBashWildcard(request, policy)
60
- ? ALLOW_ALWAYS_WILDCARD
61
- : ALLOW_ALWAYS_EXACT;
59
+ return resolveAllowAlwaysMode(request, policy) === "exact"
60
+ ? ALLOW_ALWAYS_EXACT
61
+ : ALLOW_ALWAYS_WILDCARD;
62
62
  }
63
63
 
64
64
  export function formatBroadWildcardConfirm(pattern: string): string {
65
65
  return `This pattern can match many commands:\n\n${pattern}\n\nSave it for this session?`;
66
66
  }
67
+
68
+ export function formatBroadWildcardListConfirm(patterns: readonly string[]): string {
69
+ return `These patterns can match many commands:\n\n${patterns.join("\n")}\n\nSave them for this session?`;
70
+ }
@@ -0,0 +1,54 @@
1
+ import { EXTENSION_NAME, PERMISSION_STATES_DESCRIPTION } from "./constants.ts";
2
+
3
+ export const CONFIG_WARNING_PREFIX = `${EXTENSION_NAME}: `;
4
+ export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
5
+
6
+ export const CONFIG_MUST_BE_OBJECT =
7
+ "Permission config must be a JSON object; using defaults.";
8
+ export const IGNORED_DEFAULT_WARNING =
9
+ `Ignoring "default": expected ${PERMISSION_STATES_DESCRIPTION}.`;
10
+
11
+ export const WILDCARD_LENGTH_REASON =
12
+ "Pattern must be between 1 and 500 characters.";
13
+ export const WILDCARD_NO_MATCH_REASON =
14
+ "Pattern does not match the current command.";
15
+
16
+ export function formatIgnoredSectionWarning(sectionName: string): string {
17
+ return `Ignoring "${sectionName}": expected an object mapping names to ${PERMISSION_STATES_DESCRIPTION}.`;
18
+ }
19
+
20
+ export function formatIgnoredSectionEntryWarning(
21
+ sectionName: string,
22
+ key: string
23
+ ): string {
24
+ return `Ignoring "${sectionName}.${key}": expected ${PERMISSION_STATES_DESCRIPTION}.`;
25
+ }
26
+
27
+ export function formatConfigReadFailure(error: unknown): string {
28
+ const detail = error instanceof Error ? error.message : String(error);
29
+ return `Failed to read permission config: ${detail}`;
30
+ }
31
+
32
+ export function formatConfigParseFailure(error: unknown): string {
33
+ const detail = error instanceof Error ? error.message : String(error);
34
+ return `Failed to parse permission config: ${detail}`;
35
+ }
36
+
37
+ export function formatSuppressedConfigWarnings(suppressed: number): string {
38
+ return `${suppressed} additional config warning(s) suppressed.`;
39
+ }
40
+
41
+ export function formatWildcardListCountReason(
42
+ expected: number,
43
+ actual: number
44
+ ): string {
45
+ return `Expected ${expected} semicolon-separated pattern(s), got ${actual}.`;
46
+ }
47
+
48
+ export function formatWildcardListItemReason(
49
+ index: number,
50
+ pattern: string,
51
+ reason: string
52
+ ): string {
53
+ return `Pattern ${index} (${JSON.stringify(pattern)}): ${reason}`;
54
+ }