@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/README.md CHANGED
@@ -49,7 +49,7 @@ More specific rules override less specific rules:
49
49
 
50
50
  ### Global Configuration
51
51
 
52
- Located in `.pi/agent/pi-permissions.jsonc`.
52
+ Located in `~/.pi/agent/pi-permissions.jsonc`.
53
53
 
54
54
  Example:
55
55
 
@@ -97,9 +97,9 @@ Example:
97
97
 
98
98
  ### Local Configuration
99
99
 
100
- Located in a file in the local project directory root:
100
+ Located in the local Pi config folder:
101
101
 
102
- `pi-permissions.jsonc`
102
+ `<project-root>/.pi/pi-permissions.jsonc`
103
103
 
104
104
  Local configuration overrides global configuration, including `deny` rules.
105
105
 
@@ -120,8 +120,10 @@ Body matching rules:
120
120
 
121
121
  ## Bash Matching Safeguards
122
122
 
123
- Bash matching safeguards against chaining.
123
+ Bash matching safeguards against unsafe shell syntax.
124
124
 
125
- If a bash command contains shell chaining or control syntax (`;`, `&&`, `||`, `|`, `&`, newlines, command substitution, or process substitution), `bash` pattern rules are skipped and the `tools.bash` policy is used instead, falling back to `default`.
125
+ Simple sequences separated only by `;`, `&&`, `||`, or newlines are split and each segment is matched against `bash` rules. The strictest result wins (`deny` > `ask` > `allow`).
126
126
 
127
- "Allow always" for a chained command saves the exact command, not a wildcard.
127
+ If a command contains other chaining or control syntax (`|`, `&`, command substitution, process substitution, or parentheses), `bash` pattern rules are skipped and the `tools.bash` policy is used instead, falling back to `default`.
128
+
129
+ "Allow always" for complex or multi-command sequences saves the exact command, not a wildcard.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davisliu2006/pi-permission-system",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -39,11 +39,13 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": ">=0.78.0",
42
- "@earendil-works/pi-coding-agent": ">=0.78.0"
42
+ "@earendil-works/pi-coding-agent": ">=0.78.0",
43
+ "@earendil-works/pi-tui": ">=0.78.0"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@earendil-works/pi-ai": "^0.78.1",
46
47
  "@earendil-works/pi-coding-agent": "^0.78.1",
48
+ "@earendil-works/pi-tui": "^0.78.1",
47
49
  "@types/node": "^22.10.0",
48
50
  "typescript": "^5.7.3"
49
51
  }
@@ -1,10 +1,19 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { completeText } from "./ai-gen.ts";
3
- import { buildWildcardInstructions, WILDCARD_SYSTEM_PROMPT } from "./prompts.ts";
4
- import { validateWildcardSuggestion } from "./wildcard-validation.ts";
3
+ import {
4
+ buildWildcardInstructions,
5
+ buildWildcardListInstructions,
6
+ WILDCARD_LIST_SYSTEM_PROMPT,
7
+ WILDCARD_SYSTEM_PROMPT,
8
+ } from "./prompts.ts";
9
+ import {
10
+ validateWildcardSuggestion,
11
+ validateWildcardSuggestionList,
12
+ } from "./wildcard-validation.ts";
5
13
 
6
14
  const WILDCARD_TIMEOUT_MS = 8000;
7
15
  const MAX_WILDCARD_TOKENS = 120;
16
+ const MAX_WILDCARD_LIST_TOKENS = 240;
8
17
 
9
18
  /**
10
19
  * Sanitizes against:
@@ -26,7 +35,7 @@ function sanitizeWildcard(value: string): string | undefined {
26
35
  }
27
36
 
28
37
  /**
29
- * Generates the bash wildcard recommendation.
38
+ * Generates one bash wildcard recommendation.
30
39
  */
31
40
  export async function generateBashWildcard(
32
41
  ctx: ExtensionContext,
@@ -49,3 +58,31 @@ export async function generateBashWildcard(
49
58
 
50
59
  return pattern;
51
60
  }
61
+
62
+ /**
63
+ * Generates a list of bash wildcards.
64
+ * Expects semicolon-separated patterns back.
65
+ */
66
+ export async function generateBashWildcards(
67
+ ctx: ExtensionContext,
68
+ commands: readonly string[]
69
+ ): Promise<string[] | undefined> {
70
+ if (commands.length === 0) {return undefined;}
71
+
72
+ const text = await completeText(ctx, {
73
+ systemPrompt: WILDCARD_LIST_SYSTEM_PROMPT,
74
+ userText: buildWildcardListInstructions(commands),
75
+ maxTokens: MAX_WILDCARD_LIST_TOKENS,
76
+ timeoutMs: WILDCARD_TIMEOUT_MS,
77
+ joinWith: "\n",
78
+ });
79
+ if (!text) {return undefined;}
80
+
81
+ const sanitized = sanitizeWildcard(text);
82
+ if (!sanitized) {return undefined;}
83
+
84
+ const validation = validateWildcardSuggestionList(sanitized, commands);
85
+ if (!validation.valid) {return undefined;}
86
+
87
+ return validation.patterns;
88
+ }
package/src/config.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { ASK, PERMISSION_STATES_DESCRIPTION } from "./constants.ts";
2
+ import { ASK } from "./constants.ts";
3
3
  import { parseJsonc } from "./jsonc.ts";
4
4
  import {
5
- DEFAULT_PERMISSION_CONFIG,
6
5
  createDefaultPermissionConfig,
7
6
  toPermissionState,
8
7
  toRecord,
@@ -11,6 +10,14 @@ import {
11
10
  type PermissionSection,
12
11
  type PermissionState,
13
12
  } from "./types.ts";
13
+ import {
14
+ CONFIG_MUST_BE_OBJECT,
15
+ formatConfigParseFailure,
16
+ formatConfigReadFailure,
17
+ formatIgnoredSectionEntryWarning,
18
+ formatIgnoredSectionWarning,
19
+ IGNORED_DEFAULT_WARNING,
20
+ } from "./warning-strings.ts";
14
21
 
15
22
  const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
16
23
 
@@ -29,7 +36,7 @@ function normalizeSection(
29
36
  if (!record) {
30
37
  warnings.push({
31
38
  path: configPath,
32
- message: `Ignoring "${sectionName}": expected an object mapping names to ${PERMISSION_STATES_DESCRIPTION}.`,
39
+ message: formatIgnoredSectionWarning(sectionName),
33
40
  });
34
41
  return {};
35
42
  }
@@ -43,7 +50,7 @@ function normalizeSection(
43
50
  } else {
44
51
  warnings.push({
45
52
  path: configPath,
46
- message: `Ignoring "${sectionName}.${key}": expected ${PERMISSION_STATES_DESCRIPTION}.`,
53
+ message: formatIgnoredSectionEntryWarning(sectionName, key),
47
54
  });
48
55
  }
49
56
  }
@@ -63,7 +70,7 @@ function normalizeConfig(
63
70
  if (!config) {
64
71
  warnings.push({
65
72
  path: configPath,
66
- message: "Permission config must be a JSON object; using defaults.",
73
+ message: CONFIG_MUST_BE_OBJECT,
67
74
  });
68
75
  return createDefaultPermissionConfig();
69
76
  }
@@ -72,7 +79,7 @@ function normalizeConfig(
72
79
  if (config.default !== undefined && parsedDefault === undefined) {
73
80
  warnings.push({
74
81
  path: configPath,
75
- message: `Ignoring "default": expected ${PERMISSION_STATES_DESCRIPTION}.`,
82
+ message: IGNORED_DEFAULT_WARNING,
76
83
  });
77
84
  }
78
85
  const defaultState = parsedDefault ?? ASK;
@@ -105,7 +112,7 @@ async function loadConfigFile(
105
112
  if (code === "ENOENT") {return undefined;} // file does not exist
106
113
  warnings.push({
107
114
  path,
108
- message: `Failed to read permission config: ${error instanceof Error ? error.message : String(error)}`,
115
+ message: formatConfigReadFailure(error),
109
116
  });
110
117
  return undefined;
111
118
  }
@@ -115,7 +122,7 @@ async function loadConfigFile(
115
122
  } catch (error) {
116
123
  warnings.push({
117
124
  path,
118
- message: `Failed to parse permission config: ${error instanceof Error ? error.message : String(error)}`,
125
+ message: formatConfigParseFailure(error),
119
126
  });
120
127
  return undefined;
121
128
  }
@@ -150,7 +157,7 @@ export function mergeConfigs(
150
157
  return createDefaultPermissionConfig();
151
158
  }
152
159
 
153
- globalConfig = globalConfig ?? DEFAULT_PERMISSION_CONFIG;
160
+ globalConfig = globalConfig ?? createDefaultPermissionConfig();
154
161
 
155
162
  return {
156
163
  default: localConfig?.default ?? globalConfig.default,
package/src/constants.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export const EXTENSION_NAME = "pi-permission-system";
2
2
  export const STATUS_KEY = EXTENSION_NAME;
3
- export const CONFIG_WARNING_PREFIX = `${EXTENSION_NAME}: `;
4
3
  export const MAX_CONFIG_WARNINGS = 5;
5
4
 
6
5
  export const CONFIG_FILENAME = "pi-permissions.jsonc";
6
+ /** Local config directory under the cwd. */
7
+ export const LOCAL_CONFIG_DIR_SEGMENTS = [".pi"] as const;
7
8
 
8
9
  export const ALLOW = "allow";
9
10
  export const ASK = "ask";
package/src/extension.ts CHANGED
@@ -2,19 +2,18 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { clearPurposeCache, generateRequestPurpose } from "./ai-purpose.ts";
5
- import { generateBashWildcard } from "./ai-wildcard.ts";
6
5
  import { clearWildcardCache } from "./matcher.ts";
7
6
  import {
8
7
  ALLOW,
9
8
  CONFIG_FILENAME,
10
- CONFIG_WARNING_PREFIX,
11
9
  DENY,
10
+ LOCAL_CONFIG_DIR_SEGMENTS,
12
11
  MAX_CONFIG_WARNINGS,
13
12
  STATUS_KEY,
14
13
  } from "./constants.ts";
15
14
  import { loadMergedConfig } from "./config.ts";
16
15
  import { promptForPermission } from "./permission-dialog.ts";
17
- import { findSessionApproval, resolvePolicy, willGenerateBashWildcard } from "./policy.ts";
16
+ import { findSessionApproval, resolvePolicy } from "./policy.ts";
18
17
  import {
19
18
  buildPurposePromptContext,
20
19
  formatDenyReason,
@@ -24,21 +23,15 @@ import {
24
23
  import { buildRequest } from "./request-builder.ts";
25
24
  import { SessionApprovalStore } from "./session-approvals.ts";
26
25
  import {
27
- BROAD_WILDCARD_TITLE,
28
26
  buildSelectorSummary,
29
- formatBroadWildcardConfirm,
30
- INVALID_WILDCARD_PREFIX,
31
- STATUS_GENERATING_WILDCARD,
32
27
  STATUS_SUMMARIZING_REQUEST,
33
- WILDCARD_EDITOR_TITLE,
34
28
  } from "./ui-strings.ts";
35
- import { validateWildcardSuggestion } from "./wildcard-validation.ts";
36
29
  import {
37
- type PermissionConfig,
38
- type PermissionRequest,
39
- type PolicyResolution,
40
- type AllowAlwaysRule,
41
- } from "./types.ts";
30
+ CONFIG_WARNING_PREFIX,
31
+ formatSuppressedConfigWarnings,
32
+ } from "./warning-strings.ts";
33
+ import { chooseAlwaysRules } from "./wildcard-editor.ts";
34
+ import type { PermissionConfig } from "./types.ts";
42
35
 
43
36
  interface ExtensionState {
44
37
  config?: PermissionConfig;
@@ -60,7 +53,7 @@ function createNewState(): ExtensionState {
60
53
  async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
61
54
  const { config, warnings } = await loadMergedConfig(
62
55
  join(getAgentDir(), CONFIG_FILENAME),
63
- join(ctx.cwd, CONFIG_FILENAME)
56
+ join(ctx.cwd, ...LOCAL_CONFIG_DIR_SEGMENTS, CONFIG_FILENAME)
64
57
  );
65
58
 
66
59
  if (ctx.hasUI) {
@@ -70,7 +63,7 @@ async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
70
63
  if (warnings.length > MAX_CONFIG_WARNINGS) {
71
64
  const suppressed = warnings.length - MAX_CONFIG_WARNINGS;
72
65
  ctx.ui.notify(
73
- `${CONFIG_WARNING_PREFIX}${suppressed} additional config warning(s) suppressed.`,
66
+ `${CONFIG_WARNING_PREFIX}${formatSuppressedConfigWarnings(suppressed)}`,
74
67
  "warning"
75
68
  );
76
69
  }
@@ -79,71 +72,6 @@ async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
79
72
  return config;
80
73
  }
81
74
 
82
- /**
83
- * Selects the "allow always" matching rule for the request.
84
- */
85
- async function chooseAlwaysRule(
86
- ctx: ExtensionContext,
87
- request: PermissionRequest,
88
- policy: PolicyResolution
89
- ): Promise<AllowAlwaysRule | undefined> {
90
- if (request.isExternalDirectory && policy.source === "external") {
91
- return {
92
- scope: "external_directory",
93
- tool: request.toolName,
94
- kind: "exact",
95
- body: request.body,
96
- };
97
- }
98
-
99
- // no wildcard
100
- if (!willGenerateBashWildcard(request, policy)) {
101
- return { scope: "tool", tool: request.toolName, kind: "exact", body: request.body };
102
- }
103
-
104
- // generate wildcard
105
- ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
106
- let suggestion: string | undefined;
107
- try {
108
- suggestion = await generateBashWildcard(ctx, request.body);
109
- } finally {
110
- ctx.ui.setStatus(STATUS_KEY, undefined);
111
- }
112
-
113
- // show wildcard editor
114
- let initial = suggestion ?? request.body;
115
- while (true) {
116
- const edited = await ctx.ui.editor(WILDCARD_EDITOR_TITLE, initial);
117
- if (edited === undefined) {return undefined;}
118
-
119
- const pattern = edited.trim();
120
- const validation = validateWildcardSuggestion(pattern, request.body);
121
- if (!validation.valid) {
122
- ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
123
- initial = pattern || request.body;
124
- continue;
125
- }
126
-
127
- if (validation.broad) {
128
- const confirmed = await ctx.ui.confirm(
129
- BROAD_WILDCARD_TITLE,
130
- formatBroadWildcardConfirm(pattern)
131
- );
132
- if (!confirmed) {
133
- initial = pattern;
134
- continue;
135
- }
136
- }
137
-
138
- return {
139
- scope: "tool",
140
- tool: "bash",
141
- kind: "wildcard",
142
- body: pattern,
143
- };
144
- }
145
- }
146
-
147
75
  /**
148
76
  * Registers the extension.
149
77
  */
@@ -195,15 +123,7 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
195
123
  }
196
124
 
197
125
  // check session approval
198
- const sessionRequest =
199
- policy.source === "external"
200
- ? request
201
- : { ...request, isExternalDirectory: false };
202
- const approval = findSessionApproval(
203
- state.approvals.getRules(),
204
- sessionRequest,
205
- policy.isChainedBash
206
- );
126
+ const approval = findSessionApproval(state.approvals.getRules(), request);
207
127
  if (approval) {return undefined;} // approved, proceed
208
128
 
209
129
  const summary = buildSelectorSummary(request);
@@ -213,7 +133,7 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
213
133
  purpose = await generateRequestPurpose(
214
134
  ctx,
215
135
  request,
216
- buildPurposePromptContext(request, policy)
136
+ buildPurposePromptContext(request)
217
137
  );
218
138
  } finally {
219
139
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -229,12 +149,14 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
229
149
  }
230
150
 
231
151
  // "allow always"
232
- const rule = await chooseAlwaysRule(ctx, request, policy);
233
- if (!rule) {
152
+ const rules = await chooseAlwaysRules(ctx, request, policy);
153
+ if (!rules) {
234
154
  // the user cancelled the wildcard editor, return to the permission dialog
235
155
  continue;
236
156
  }
237
- state.approvals.add(rule);
157
+ for (const rule of rules) {
158
+ state.approvals.add(rule);
159
+ }
238
160
  return undefined;
239
161
  }
240
162
  });
@@ -1,4 +1,6 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { ExtensionSelectorComponent } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Text } from "@earendil-works/pi-tui";
2
4
  import {
3
5
  ALLOW_ALWAYS_EXACT,
4
6
  ALLOW_ALWAYS_WILDCARD,
@@ -17,6 +19,22 @@ import {
17
19
  PURPOSE_PREFIX,
18
20
  } from "./ui-strings.ts";
19
21
 
22
+ function mapChoice(choice: string | undefined | null): PermissionDialogSelection {
23
+ if (choice === ALLOW_ONCE) {return { kind: "once" };}
24
+ if (choice === ALLOW_ALWAYS_EXACT || choice === ALLOW_ALWAYS_WILDCARD) {
25
+ return { kind: "always" };
26
+ }
27
+ return { kind: "reject" };
28
+ }
29
+
30
+ /** Bold + color the `Label:` prefix; warning color for note lines. */
31
+ function styleSummaryLine(theme: Theme, line: string): string {
32
+ const sep = line.indexOf(": ");
33
+ if (sep === -1) {return theme.fg("text", line);}
34
+ return theme.fg("accent", theme.bold(line.slice(0, sep + 1)))
35
+ + theme.fg("text", line.slice(sep + 1));
36
+ }
37
+
20
38
  /**
21
39
  * Shows permission dialog and gets input.
22
40
  */
@@ -27,23 +45,49 @@ export async function promptForPermission(
27
45
  summary: string,
28
46
  purpose?: string
29
47
  ): Promise<PermissionDialogSelection> {
30
- // if there's no ui, just reject
31
- if (!ctx.hasUI) {return { kind: "reject" };}
32
-
33
- const purposeLine = purpose ? `\n${PURPOSE_PREFIX}${purpose}` : "";
34
- const title = `${PERMISSION_DIALOG_TITLE}\n${summary}${purposeLine}\n${PERMISSION_DIALOG_QUESTION}`;
35
48
  const options = [ALLOW_ONCE, formatAllowAlwaysOption(request, policy), REJECT];
36
49
 
37
- let choice: string | undefined;
38
50
  try {
39
- choice = await ctx.ui.select(title, options);
51
+ // non-tui mode fallback
52
+ if (ctx.mode !== "tui") {
53
+ const purposeLine = purpose ? `\n${PURPOSE_PREFIX}${purpose}` : "";
54
+ const title = `${PERMISSION_DIALOG_TITLE}\n${summary}${purposeLine}\n${PERMISSION_DIALOG_QUESTION}`;
55
+ return mapChoice(await ctx.ui.select(title, options));
56
+ }
57
+
58
+ // build custom tui
59
+ const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
60
+ const container = new Container();
61
+ container.addChild(new Text(theme.fg("accent", theme.bold(PERMISSION_DIALOG_TITLE)), 1, 0));
62
+ for (const line of summary.split("\n")) {
63
+ container.addChild(new Text(styleSummaryLine(theme, line), 1, 0));
64
+ }
65
+ if (purpose) {
66
+ container.addChild(new Text(styleSummaryLine(theme, `${PURPOSE_PREFIX}${purpose}`), 1, 0));
67
+ }
68
+
69
+ const selector = new ExtensionSelectorComponent(
70
+ PERMISSION_DIALOG_QUESTION,
71
+ options,
72
+ (option) => done(option),
73
+ () => done(null),
74
+ { tui }
75
+ );
76
+ container.addChild(selector);
77
+
78
+ return {
79
+ render: (width: number) => container.render(width),
80
+ invalidate: () => container.invalidate(),
81
+ handleInput: (data: string) => {
82
+ selector.handleInput(data);
83
+ tui.requestRender();
84
+ },
85
+ dispose: () => selector.dispose(),
86
+ };
87
+ });
88
+
89
+ return mapChoice(choice);
40
90
  } catch {
41
91
  return { kind: "reject" };
42
92
  }
43
-
44
- if (choice === ALLOW_ONCE) {return { kind: "once" };}
45
- if (choice === ALLOW_ALWAYS_EXACT || choice === ALLOW_ALWAYS_WILDCARD) {
46
- return { kind: "always" };
47
- }
48
- return { kind: "reject" };
49
93
  }
@@ -0,0 +1,157 @@
1
+ import { findLastWildcardMatch, matchesWildcard } from "./matcher.ts";
2
+ import { analyzeShellCommand, type ShellCommandAnalysis } from "./shell.ts";
3
+ import type {
4
+ AllowAlwaysMode,
5
+ AllowAlwaysRule,
6
+ PermissionConfig,
7
+ PermissionRequest,
8
+ PermissionState,
9
+ PolicyResolution,
10
+ } from "./types.ts";
11
+
12
+ const STATE_RANK: Record<PermissionState, number> = {
13
+ allow: 0,
14
+ ask: 1,
15
+ deny: 2,
16
+ };
17
+
18
+ /**
19
+ * Prefers the analysis stored on the request.
20
+ * Falls back for hand-built fixtures.
21
+ */
22
+ function bashAnalysisOf(request: PermissionRequest): ShellCommandAnalysis {
23
+ return request.bashAnalysis ?? analyzeShellCommand(request.body);
24
+ }
25
+
26
+ /**
27
+ * The general permission state for bash.
28
+ */
29
+ function generalBashToolState(
30
+ config: PermissionConfig
31
+ ): PermissionState | undefined {
32
+ return Object.hasOwn(config.tools, "bash") ? config.tools.bash : undefined;
33
+ }
34
+
35
+ function bashFallbackResolution(config: PermissionConfig): PolicyResolution {
36
+ const toolState = generalBashToolState(config);
37
+ return {
38
+ state: toolState ?? config.default,
39
+ source: toolState ? "tool" : "default",
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Resolves one simple bash segment against bash rules, then tools.bash / default.
45
+ */
46
+ function resolveBashSegment(
47
+ config: PermissionConfig,
48
+ segment: string
49
+ ): PolicyResolution {
50
+ const match = findLastWildcardMatch(config.bash, segment);
51
+ if (match) {
52
+ return {
53
+ state: match.state,
54
+ source: "bash",
55
+ matchedPattern: match.pattern,
56
+ };
57
+ }
58
+ return bashFallbackResolution(config);
59
+ }
60
+
61
+ /**
62
+ * Worst state wins across simple list segments.
63
+ * When ranks tie, keep the later segment's resolution.
64
+ */
65
+ function combineBashSegmentResolutions(
66
+ resolutions: PolicyResolution[]
67
+ ): PolicyResolution {
68
+ let worst = resolutions[0]!;
69
+ for (let index = 1; index < resolutions.length; index++) {
70
+ const next = resolutions[index]!;
71
+ if (STATE_RANK[next.state] >= STATE_RANK[worst.state]) {
72
+ worst = next;
73
+ }
74
+ }
75
+ return worst;
76
+ }
77
+
78
+ /**
79
+ * Resolves bash policy, including simple list splitting.
80
+ */
81
+ export function resolveBashPolicy(
82
+ config: PermissionConfig,
83
+ request: PermissionRequest
84
+ ): PolicyResolution {
85
+ const analysis = bashAnalysisOf(request);
86
+ if (analysis.isComplex) {
87
+ return bashFallbackResolution(config);
88
+ }
89
+ const resolutions = analysis.segments.map((segment) =>
90
+ resolveBashSegment(config, segment)
91
+ );
92
+ return combineBashSegmentResolutions(resolutions);
93
+ }
94
+
95
+ /**
96
+ * Newest matching bash rule for one command segment.
97
+ */
98
+ function findBashSegmentSessionApproval(
99
+ rules: readonly AllowAlwaysRule[],
100
+ segment: string
101
+ ): AllowAlwaysRule | undefined {
102
+ for (let index = rules.length - 1; index >= 0; index--) {
103
+ const rule = rules[index]!;
104
+ if (rule.tool !== "bash") {continue;}
105
+ if (rule.kind === "exact") {
106
+ if (rule.body === segment) {return rule;}
107
+ continue;
108
+ }
109
+ if (matchesWildcard(rule.body, segment)) {return rule;}
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /**
115
+ * Session approval for bash, including per-segment matching for simple lists.
116
+ */
117
+ export function findBashSessionApproval(
118
+ rules: readonly AllowAlwaysRule[],
119
+ request: PermissionRequest
120
+ ): AllowAlwaysRule | undefined {
121
+ // check full exact match first
122
+ for (let index = rules.length - 1; index >= 0; index--) {
123
+ const rule = rules[index]!;
124
+ if (rule.tool === "bash" && rule.kind === "exact" && rule.body === request.body) {
125
+ return rule;
126
+ }
127
+ }
128
+
129
+ const analysis = bashAnalysisOf(request);
130
+ if (analysis.isComplex) {return undefined;} // complex only has full exact match
131
+
132
+ // check segments with wildcard / exact
133
+ let lastMatch: AllowAlwaysRule | undefined;
134
+ for (const segment of analysis.segments) {
135
+ const match = findBashSegmentSessionApproval(rules, segment);
136
+ if (!match) {return undefined;}
137
+ lastMatch = match;
138
+ }
139
+ return lastMatch;
140
+ }
141
+
142
+ /**
143
+ * How "allow always" should persist this request for the session.
144
+ */
145
+ export function resolveAllowAlwaysMode(
146
+ request: PermissionRequest,
147
+ policy: PolicyResolution
148
+ ): AllowAlwaysMode {
149
+ if (request.isExternalDirectory && policy.source === "external") {return "exact";}
150
+ if (request.toolName !== "bash") {return "exact";}
151
+
152
+ const analysis = bashAnalysisOf(request);
153
+ if (analysis.isComplex) {return "exact";}
154
+ if (analysis.segments.length > 1) {return "wildcard-list";}
155
+ if (analysis.segments.length === 1) {return "wildcard";}
156
+ return "exact";
157
+ }