@davisliu2006/pi-permission-system 0.2.0 → 0.4.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 ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.4.0] — 2026-11-11
4
+
5
+ ### Added
6
+
7
+ - Added experimental auto-classifier, `auto` permission, and `settings.auto_allow_criteria`.
8
+ - Added `settings.ignore_global` to the local config to ignore global permission rules.
9
+
10
+ ## [0.3.0] — 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - Added `settings` to the config schema for non-permission settings.
15
+ - Added `settings.ai_model` config field to optionally override the model for small helper completions.
16
+
17
+ ### Changed
18
+
19
+ - `deny` rules can now check complex bash segments.
20
+
21
+ ## [0.2.0] — 2026-11-03
22
+
23
+ ### Added
24
+
25
+ - Added bash splitting. A chained command like `cd dir && git status` now checks `cd dir` and `git status` separately. Bash wildcard can now generate multiple patterns at once (eg. `cd *; git status`).
26
+
27
+ ### Changed
28
+
29
+ - Local permission config now loads from `.pi/pi-permissions.jsonc` instead of the project root, which is consistent with Pi local config conventions.
30
+ - Made `ai-purpose` responses more concise by adding examples to the prompt.
31
+ - Improved permission dialog UI by putting details in a custom TUI display instead of the selector title.
32
+
33
+ ## [0.1.0] — 2026-11-01
34
+
35
+ Initial version.
package/README.md CHANGED
@@ -4,6 +4,13 @@ Permissions management for the `pi` coding agent.
4
4
 
5
5
  Roughly based on [MasuRii/pi-permission-system](https://github.com/MasuRii/pi-permission-system).
6
6
 
7
+ <img src="https://raw.githubusercontent.com/davisliu2006/pi-permission-system/main/images/image.webp">
8
+
9
+ ### Key Features
10
+
11
+ - [Bash splitting](#bash-matching-safeguards). Splits chained commands (eg. `cd ~ && pwd`) into parts and matches rules for each segment.
12
+ - AI-generated request purpose and bash rule wildcard.
13
+ - Experimental auto-classifier. Supports configurable auto-allow critera.
7
14
 
8
15
  ## Permissions Overview
9
16
 
@@ -11,8 +18,9 @@ A permission can be one of the following:
11
18
 
12
19
  ```jsonc
13
20
  "allow" // allows the action
14
- "ask" // asks the user for permission
15
- "deny" // automatically denies the action
21
+ "auto" // uses an AI classifier to choose between allow and ask
22
+ "ask" // asks the user for permission
23
+ "deny" // automatically denies the action
16
24
  ```
17
25
 
18
26
  Ask states the purpose of the request and gives the following options:
@@ -29,7 +37,7 @@ Reject
29
37
 
30
38
  ## Configuration
31
39
 
32
- The supported top-level keys are: `default`, `tools`, `bash`, and `external_directory`.
40
+ The supported top-level keys are: `default`, `tools`, `bash`, `external_directory`, and `settings`.
33
41
 
34
42
  `bash` body matching supports wildcard:
35
43
  - `*` matches any sequence and `?` matches one character
@@ -39,6 +47,8 @@ The supported top-level keys are: `default`, `tools`, `bash`, and `external_dire
39
47
 
40
48
  The builtin path tools in pi are: `read`, `write`, `edit`, `find`, `grep`, `ls`. `external_directory` overrides the settings for these path tools. The primary use is to override `write` and `edit` to a stronger policy for external directories.
41
49
 
50
+ `settings` holds optional non-permission settings.
51
+
42
52
  ### Rule Precedence
43
53
 
44
54
  More specific rules override less specific rules:
@@ -90,7 +100,8 @@ Example:
90
100
  "rg*": "allow"
91
101
  },
92
102
  "external_directory": {
93
- "write": "ask"
103
+ "write": "ask",
104
+ "edit": "ask"
94
105
  }
95
106
  }
96
107
  ```
@@ -103,6 +114,24 @@ Located in the local Pi config folder:
103
114
 
104
115
  Local configuration overrides global configuration, including `deny` rules.
105
116
 
117
+ Set `settings.ignore_global` to `true` in the local config to ignore global permission sections entirely.
118
+
119
+ ### Optional Settings
120
+
121
+ ```jsonc
122
+ {
123
+ "settings": {
124
+ "ignore_global": true,
125
+ "ai_model": "provider/model-id",
126
+ "auto_allow_criteria": "read-only or easily reversible"
127
+ }
128
+ }
129
+ ```
130
+
131
+ - `ignore_global: boolean`: Skips global permission sections if placed in a local config. Has no effect if placed in a global config.
132
+ - `ai_model: string`: Overrides the model used for helper completions.
133
+ - `auto_allow_criteria: string`: Specify the auto-classifer criteria. Defaults to `"read-only or easily reversible"`.
134
+
106
135
  ## "Allow Always" Matching
107
136
 
108
137
  Matching is done as `(tool, body)`.
@@ -122,8 +151,8 @@ Body matching rules:
122
151
 
123
152
  Bash matching safeguards against unsafe shell syntax.
124
153
 
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`).
154
+ Sequences separated by `;`, `&&`, `||`, or newlines are split and each segment is matched against `bash` rules. The strictest result wins (`deny` > `ask` > `auto` > `allow`).
126
155
 
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`.
156
+ If a command contains other chaining (`|`, `&`, command/process substitution, or parentheses), `bash` pattern rules for `allow` are skipped, allowing other segments to take priority and successively falling back to `tools.bash` and `default`.
128
157
 
129
- "Allow always" for complex or multi-command sequences saves the exact command, not a wildcard.
158
+ "Allow always" for complex 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.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -10,7 +10,9 @@
10
10
  "files": [
11
11
  "index.ts",
12
12
  "src",
13
- "README.md"
13
+ "README.md",
14
+ "LICENSE",
15
+ "CHANGELOG.md"
14
16
  ],
15
17
  "scripts": {
16
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -21,7 +23,8 @@
21
23
  "pi-package",
22
24
  "pi",
23
25
  "pi-extension",
24
- "permissions"
26
+ "permissions",
27
+ "pi-permissions"
25
28
  ],
26
29
  "author": "davisliu2006",
27
30
  "license": "MIT",
@@ -32,7 +35,8 @@
32
35
  "pi": {
33
36
  "extensions": [
34
37
  "./index.ts"
35
- ]
38
+ ],
39
+ "image": "https://raw.githubusercontent.com/davisliu2006/pi-permission-system/main/images/image.webp"
36
40
  },
37
41
  "engines": {
38
42
  "node": ">=20"
@@ -0,0 +1,53 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { completeText } from "./ai-gen.ts";
3
+ import { ALLOW, ASK, DEFAULT_AUTO_ALLOW_CRITERIA } from "./constants.ts";
4
+ import {
5
+ buildClassifierInstructions,
6
+ buildClassifierPromptContext,
7
+ CLASSIFIER_SYSTEM_PROMPT,
8
+ } from "./prompts.ts";
9
+ import type { PermissionRequest } from "./types.ts";
10
+
11
+ const CLASSIFIER_TIMEOUT_MS = 5000;
12
+ const MAX_CLASSIFIER_TOKENS = 16;
13
+
14
+ export type AutoDecision = typeof ALLOW | typeof ASK;
15
+
16
+ /**
17
+ * Parses a classifier reply.
18
+ * Sanitizes against escape sequences, newlines, and punctuation.
19
+ */
20
+ export function parseAutoDecision(text: string | undefined): AutoDecision {
21
+ if (!text) {return ASK;}
22
+ const line = text
23
+ .replace(/\u001b\[[0-9;]*m/g, "")
24
+ .replace(/\s+/g, " ")
25
+ .trim()
26
+ .toLowerCase()
27
+ .replace(/^["'`]+|["'`.,!:;]+$/g, "");
28
+ return line === ALLOW ? ALLOW : ASK;
29
+ }
30
+
31
+ /**
32
+ * Classifies whether an "auto" request should auto-allow or ask the user.
33
+ * Leans towards "ask" in case of uncertainty.
34
+ */
35
+ export async function classifyAutoPermission(
36
+ ctx: ExtensionContext,
37
+ request: PermissionRequest,
38
+ aiModel?: string,
39
+ allowCriteria?: string
40
+ ): Promise<AutoDecision> {
41
+ const criteria = allowCriteria?.trim() || DEFAULT_AUTO_ALLOW_CRITERIA;
42
+ const text = await completeText(ctx, {
43
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
44
+ userText: buildClassifierInstructions(
45
+ buildClassifierPromptContext(request),
46
+ criteria
47
+ ),
48
+ maxTokens: MAX_CLASSIFIER_TOKENS,
49
+ timeoutMs: CLASSIFIER_TIMEOUT_MS,
50
+ aiModel,
51
+ });
52
+ return parseAutoDecision(text);
53
+ }
package/src/ai-gen.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { complete, type Message } from "@earendil-works/pi-ai";
1
+ import { complete, type Api, type Message, type Model } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { parseAiModelRef } from "./types.ts";
3
4
 
4
5
  export interface CompleteTextOptions {
5
6
  systemPrompt: string;
@@ -8,6 +9,8 @@ export interface CompleteTextOptions {
8
9
  timeoutMs: number;
9
10
  /** How to join multiple text blocks. Defaults to a single space. */
10
11
  joinWith?: string;
12
+ /** Optional `provider/model-id` override. Falls back to the session model. */
13
+ aiModel?: string;
11
14
  }
12
15
 
13
16
  /**
@@ -23,17 +26,37 @@ function timeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): Abor
23
26
  }
24
27
 
25
28
  /**
26
- * Runs a single-turn text completion against the extension model.
29
+ * Resolves the model for helper completions.
30
+ */
31
+ export function resolveCompletionModel(
32
+ ctx: ExtensionContext,
33
+ aiModel?: string
34
+ ): Model<Api> | undefined {
35
+ if (aiModel) {
36
+ const parsed = parseAiModelRef(aiModel);
37
+ if (parsed) {
38
+ const configured = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
39
+ if (configured && ctx.modelRegistry.hasConfiguredAuth(configured)) {
40
+ return configured;
41
+ }
42
+ }
43
+ }
44
+ return ctx.model;
45
+ }
46
+
47
+ /**
48
+ * Runs a single-turn text completion against the extension helper model.
27
49
  * Returns undefined when the model/auth is unavailable, the call aborts, or no text is returned.
28
50
  */
29
51
  export async function completeText(
30
52
  ctx: ExtensionContext,
31
53
  options: CompleteTextOptions
32
54
  ): Promise<string | undefined> {
33
- if (!ctx.model) {return undefined;}
55
+ const model = resolveCompletionModel(ctx, options.aiModel);
56
+ if (!model) {return undefined;}
34
57
 
35
58
  try {
36
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
59
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
37
60
  if (!auth.ok || !auth.apiKey) {return undefined;}
38
61
 
39
62
  // prompt
@@ -50,7 +73,7 @@ export async function completeText(
50
73
 
51
74
  // call model
52
75
  const response = await complete(
53
- ctx.model,
76
+ model,
54
77
  {
55
78
  systemPrompt: options.systemPrompt,
56
79
  messages: [message],
package/src/ai-purpose.ts CHANGED
@@ -41,7 +41,8 @@ function sanitizePurpose(value: string): string | undefined {
41
41
  export async function generateRequestPurpose(
42
42
  ctx: ExtensionContext,
43
43
  request: PermissionRequest,
44
- context: string
44
+ context: string,
45
+ aiModel?: string
45
46
  ): Promise<string | undefined> {
46
47
  const key = cacheKey(request);
47
48
  const cached = purposeCache.get(key);
@@ -52,6 +53,7 @@ export async function generateRequestPurpose(
52
53
  userText: buildPurposeInstructions(context),
53
54
  maxTokens: MAX_PURPOSE_TOKENS,
54
55
  timeoutMs: PURPOSE_TIMEOUT_MS,
56
+ aiModel,
55
57
  });
56
58
  if (!text) {return undefined;}
57
59
 
@@ -39,7 +39,8 @@ function sanitizeWildcard(value: string): string | undefined {
39
39
  */
40
40
  export async function generateBashWildcard(
41
41
  ctx: ExtensionContext,
42
- command: string
42
+ command: string,
43
+ aiModel?: string
43
44
  ): Promise<string | undefined> {
44
45
  const text = await completeText(ctx, {
45
46
  systemPrompt: WILDCARD_SYSTEM_PROMPT,
@@ -47,6 +48,7 @@ export async function generateBashWildcard(
47
48
  maxTokens: MAX_WILDCARD_TOKENS,
48
49
  timeoutMs: WILDCARD_TIMEOUT_MS,
49
50
  joinWith: "\n",
51
+ aiModel,
50
52
  });
51
53
  if (!text) {return undefined;}
52
54
 
@@ -65,7 +67,8 @@ export async function generateBashWildcard(
65
67
  */
66
68
  export async function generateBashWildcards(
67
69
  ctx: ExtensionContext,
68
- commands: readonly string[]
70
+ commands: readonly string[],
71
+ aiModel?: string
69
72
  ): Promise<string[] | undefined> {
70
73
  if (commands.length === 0) {return undefined;}
71
74
 
@@ -75,6 +78,7 @@ export async function generateBashWildcards(
75
78
  maxTokens: MAX_WILDCARD_LIST_TOKENS,
76
79
  timeoutMs: WILDCARD_TIMEOUT_MS,
77
80
  joinWith: "\n",
81
+ aiModel,
78
82
  });
79
83
  if (!text) {return undefined;}
80
84
 
package/src/config.ts CHANGED
@@ -3,11 +3,13 @@ import { ASK } from "./constants.ts";
3
3
  import { parseJsonc } from "./jsonc.ts";
4
4
  import {
5
5
  createDefaultPermissionConfig,
6
+ parseAiModelRef,
6
7
  toPermissionState,
7
8
  toRecord,
8
9
  type ConfigWarning,
9
10
  type PermissionConfig,
10
11
  type PermissionSection,
12
+ type PermissionSettings,
11
13
  type PermissionState,
12
14
  } from "./types.ts";
13
15
  import {
@@ -16,7 +18,12 @@ import {
16
18
  formatConfigReadFailure,
17
19
  formatIgnoredSectionEntryWarning,
18
20
  formatIgnoredSectionWarning,
21
+ IGNORED_AI_MODEL_WARNING,
22
+ IGNORED_AUTO_ALLOW_CRITERIA_WARNING,
19
23
  IGNORED_DEFAULT_WARNING,
24
+ IGNORED_IGNORE_GLOBAL_LOCAL_ONLY_WARNING,
25
+ IGNORED_IGNORE_GLOBAL_WARNING,
26
+ IGNORED_SETTINGS_WARNING,
20
27
  } from "./warning-strings.ts";
21
28
 
22
29
  const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -57,14 +64,77 @@ function normalizeSection(
57
64
  return result;
58
65
  }
59
66
 
67
+ /**
68
+ * Helper used by normalizeConfig.
69
+ * Ensures settings is a valid PermissionSettings object.
70
+ */
71
+ function normalizeSettings(
72
+ raw: unknown,
73
+ configPath: string | undefined,
74
+ warnings: ConfigWarning[],
75
+ isLocal: boolean
76
+ ): PermissionSettings {
77
+ if (raw === undefined) {return {};}
78
+ const record = toRecord(raw);
79
+ if (!record) {
80
+ warnings.push({
81
+ path: configPath,
82
+ message: IGNORED_SETTINGS_WARNING,
83
+ });
84
+ return {};
85
+ }
86
+
87
+ const settings: PermissionSettings = {};
88
+ if (record.ignore_global !== undefined) {
89
+ if (!isLocal) {
90
+ warnings.push({
91
+ path: configPath,
92
+ message: IGNORED_IGNORE_GLOBAL_LOCAL_ONLY_WARNING,
93
+ });
94
+ } else if (typeof record.ignore_global === "boolean") {
95
+ settings.ignore_global = record.ignore_global;
96
+ } else {
97
+ warnings.push({
98
+ path: configPath,
99
+ message: IGNORED_IGNORE_GLOBAL_WARNING,
100
+ });
101
+ }
102
+ }
103
+ if (record.ai_model !== undefined) {
104
+ if (typeof record.ai_model === "string" && parseAiModelRef(record.ai_model)) {
105
+ settings.ai_model = record.ai_model.trim();
106
+ } else {
107
+ warnings.push({
108
+ path: configPath,
109
+ message: IGNORED_AI_MODEL_WARNING,
110
+ });
111
+ }
112
+ }
113
+ if (record.auto_allow_criteria !== undefined) {
114
+ if (
115
+ typeof record.auto_allow_criteria === "string"
116
+ && record.auto_allow_criteria.trim()
117
+ ) {
118
+ settings.auto_allow_criteria = record.auto_allow_criteria.trim();
119
+ } else {
120
+ warnings.push({
121
+ path: configPath,
122
+ message: IGNORED_AUTO_ALLOW_CRITERIA_WARNING,
123
+ });
124
+ }
125
+ }
126
+ return settings;
127
+ }
128
+
60
129
  /**
61
130
  * Convert the raw config to a PermissionConfig.
62
131
  * Ensures the config is valid.
63
132
  */
64
- function normalizeConfig(
133
+ export function normalizeConfig(
65
134
  rawConfig: unknown,
66
135
  configPath: string | undefined,
67
- warnings: ConfigWarning[]
136
+ warnings: ConfigWarning[],
137
+ isLocal: boolean
68
138
  ): PermissionConfig {
69
139
  const config = toRecord(rawConfig);
70
140
  if (!config) {
@@ -94,6 +164,7 @@ function normalizeConfig(
94
164
  configPath,
95
165
  warnings
96
166
  ),
167
+ settings: normalizeSettings(config.settings, configPath, warnings, isLocal),
97
168
  };
98
169
  }
99
170
 
@@ -102,7 +173,8 @@ function normalizeConfig(
102
173
  */
103
174
  async function loadConfigFile(
104
175
  path: string,
105
- warnings: ConfigWarning[]
176
+ warnings: ConfigWarning[],
177
+ isLocal: boolean
106
178
  ): Promise<PermissionConfig | undefined> {
107
179
  let raw: string;
108
180
  try {
@@ -118,7 +190,7 @@ async function loadConfigFile(
118
190
  }
119
191
 
120
192
  try {
121
- return normalizeConfig(parseJsonc(raw), path, warnings);
193
+ return normalizeConfig(parseJsonc(raw), path, warnings, isLocal);
122
194
  } catch (error) {
123
195
  warnings.push({
124
196
  path,
@@ -148,6 +220,8 @@ function mergeBashSections(
148
220
 
149
221
  /**
150
222
  * Merges configs such that local config overrides global config.
223
+ * If local `settings.ignore_global` is true, global permission
224
+ * sections are ignored.
151
225
  */
152
226
  export function mergeConfigs(
153
227
  globalConfig: PermissionConfig | undefined,
@@ -158,6 +232,20 @@ export function mergeConfigs(
158
232
  }
159
233
 
160
234
  globalConfig = globalConfig ?? createDefaultPermissionConfig();
235
+ const mergedSettings: PermissionSettings = {
236
+ ...globalConfig.settings,
237
+ ...(localConfig?.settings ?? {}),
238
+ };
239
+
240
+ if (localConfig?.settings.ignore_global === true) {
241
+ return {
242
+ default: localConfig.default,
243
+ tools: { ...localConfig.tools },
244
+ bash: { ...localConfig.bash },
245
+ external_directory: { ...localConfig.external_directory },
246
+ settings: mergedSettings,
247
+ };
248
+ }
161
249
 
162
250
  return {
163
251
  default: localConfig?.default ?? globalConfig.default,
@@ -167,6 +255,7 @@ export function mergeConfigs(
167
255
  ...globalConfig.external_directory,
168
256
  ...(localConfig?.external_directory ?? {}),
169
257
  },
258
+ settings: mergedSettings,
170
259
  };
171
260
  }
172
261
 
@@ -178,8 +267,8 @@ export async function loadMergedConfig(
178
267
  localPath: string
179
268
  ): Promise<{ config: PermissionConfig; warnings: ConfigWarning[] }> {
180
269
  const warnings: ConfigWarning[] = [];
181
- const globalConfig = await loadConfigFile(globalPath, warnings);
182
- const localConfig = await loadConfigFile(localPath, warnings);
270
+ const globalConfig = await loadConfigFile(globalPath, warnings, false);
271
+ const localConfig = await loadConfigFile(localPath, warnings, true);
183
272
  return {
184
273
  config: mergeConfigs(globalConfig, localConfig),
185
274
  warnings,
package/src/constants.ts CHANGED
@@ -7,10 +7,14 @@ export const CONFIG_FILENAME = "pi-permissions.jsonc";
7
7
  export const LOCAL_CONFIG_DIR_SEGMENTS = [".pi"] as const;
8
8
 
9
9
  export const ALLOW = "allow";
10
+ export const AUTO = "auto";
10
11
  export const ASK = "ask";
11
12
  export const DENY = "deny";
12
- export const PERMISSION_STATES = [ALLOW, ASK, DENY] as const;
13
- export const PERMISSION_STATES_DESCRIPTION = "allow, ask, or deny";
13
+ export const PERMISSION_STATES = [ALLOW, AUTO, ASK, DENY] as const;
14
+ export const PERMISSION_STATES_DESCRIPTION = "allow, auto, ask, or deny";
15
+
16
+ /** Default criteria used by "auto" classifier. */
17
+ export const DEFAULT_AUTO_ALLOW_CRITERIA = "read-only or easily reversible";
14
18
 
15
19
  export const ALLOW_ONCE = "Allow Once";
16
20
  export const ALLOW_ALWAYS_EXACT = "Allow Always (exact match)";
package/src/extension.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { join } from "node:path";
3
3
 
4
+ import { classifyAutoPermission } from "./ai-classifier.ts";
4
5
  import { clearPurposeCache, generateRequestPurpose } from "./ai-purpose.ts";
5
6
  import { clearWildcardCache } from "./matcher.ts";
6
7
  import {
7
8
  ALLOW,
9
+ AUTO,
8
10
  CONFIG_FILENAME,
9
11
  DENY,
10
12
  LOCAL_CONFIG_DIR_SEGMENTS,
@@ -23,7 +25,7 @@ import {
23
25
  import { buildRequest } from "./request-builder.ts";
24
26
  import { SessionApprovalStore } from "./session-approvals.ts";
25
27
  import {
26
- buildSelectorSummary,
28
+ STATUS_CLASSIFYING_REQUEST,
27
29
  STATUS_SUMMARIZING_REQUEST,
28
30
  } from "./ui-strings.ts";
29
31
  import {
@@ -118,29 +120,49 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
118
120
  if (policy.state === ALLOW) { // auto allow
119
121
  return undefined;
120
122
  }
121
- if (!ctx.hasUI) { // no ui, auto deny
122
- return { block: true, reason: formatNoUiReason(request, policy) };
123
- }
124
123
 
125
124
  // check session approval
126
125
  const approval = findSessionApproval(state.approvals.getRules(), request);
127
126
  if (approval) {return undefined;} // approved, proceed
128
127
 
129
- const summary = buildSelectorSummary(request);
128
+ if (policy.state === AUTO) { // use ai classifier
129
+ if (ctx.hasUI) {
130
+ ctx.ui.setStatus(STATUS_KEY, STATUS_CLASSIFYING_REQUEST);
131
+ }
132
+ try {
133
+ const decision = await classifyAutoPermission(
134
+ ctx,
135
+ request,
136
+ config.settings.ai_model,
137
+ config.settings.auto_allow_criteria
138
+ );
139
+ if (decision === ALLOW) {return undefined;}
140
+ } finally {
141
+ if (ctx.hasUI) {
142
+ ctx.ui.setStatus(STATUS_KEY, undefined);
143
+ }
144
+ }
145
+ }
146
+
147
+ if (!ctx.hasUI) { // no ui, deny (same path as ask)
148
+ return { block: true, reason: formatNoUiReason(request, policy) };
149
+ }
150
+
130
151
  ctx.ui.setStatus(STATUS_KEY, STATUS_SUMMARIZING_REQUEST);
131
152
  let purpose: string | undefined;
132
153
  try {
133
154
  purpose = await generateRequestPurpose(
134
155
  ctx,
135
156
  request,
136
- buildPurposePromptContext(request)
157
+ buildPurposePromptContext(request),
158
+ config.settings.ai_model
137
159
  );
138
160
  } finally {
139
161
  ctx.ui.setStatus(STATUS_KEY, undefined);
140
162
  }
141
163
 
142
164
  while (true) {
143
- const choice = await promptForPermission(ctx, request, policy, summary, purpose);
165
+ const choice = await promptForPermission(ctx, request, policy, purpose);
144
166
  if (choice.kind === "once") { // approved, proceed
145
167
  return;
146
168
  }
@@ -149,7 +171,12 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
149
171
  }
150
172
 
151
173
  // "allow always"
152
- const rules = await chooseAlwaysRules(ctx, request, policy);
174
+ const rules = await chooseAlwaysRules(
175
+ ctx,
176
+ request,
177
+ policy,
178
+ config.settings.ai_model
179
+ );
153
180
  if (!rules) {
154
181
  // the user cancelled the wildcard editor, return to the permission dialog
155
182
  continue;
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { ExtensionSelectorComponent } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, Text } from "@earendil-works/pi-tui";
4
4
  import {
@@ -13,10 +13,11 @@ import type {
13
13
  PolicyResolution,
14
14
  } from "./types.ts";
15
15
  import {
16
+ buildPlainSelectorSummary,
17
+ buildStyledSelectorSummary,
16
18
  formatAllowAlwaysOption,
17
19
  PERMISSION_DIALOG_QUESTION,
18
20
  PERMISSION_DIALOG_TITLE,
19
- PURPOSE_PREFIX,
20
21
  } from "./ui-strings.ts";
21
22
 
22
23
  function mapChoice(choice: string | undefined | null): PermissionDialogSelection {
@@ -27,14 +28,6 @@ function mapChoice(choice: string | undefined | null): PermissionDialogSelection
27
28
  return { kind: "reject" };
28
29
  }
29
30
 
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
-
38
31
  /**
39
32
  * Shows permission dialog and gets input.
40
33
  */
@@ -42,7 +35,6 @@ export async function promptForPermission(
42
35
  ctx: ExtensionContext,
43
36
  request: PermissionRequest,
44
37
  policy: PolicyResolution,
45
- summary: string,
46
38
  purpose?: string
47
39
  ): Promise<PermissionDialogSelection> {
48
40
  const options = [ALLOW_ONCE, formatAllowAlwaysOption(request, policy), REJECT];
@@ -50,8 +42,8 @@ export async function promptForPermission(
50
42
  try {
51
43
  // non-tui mode fallback
52
44
  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}`;
45
+ const summary = buildPlainSelectorSummary(request, purpose);
46
+ const title = `${PERMISSION_DIALOG_TITLE}\n${summary}\n${PERMISSION_DIALOG_QUESTION}`;
55
47
  return mapChoice(await ctx.ui.select(title, options));
56
48
  }
57
49
 
@@ -59,11 +51,8 @@ export async function promptForPermission(
59
51
  const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
60
52
  const container = new Container();
61
53
  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));
54
+ for (const line of buildStyledSelectorSummary(theme, request, purpose)) {
55
+ container.addChild(new Text(line, 1, 0));
67
56
  }
68
57
 
69
58
  const selector = new ExtensionSelectorComponent(
@@ -11,10 +11,15 @@ import type {
11
11
 
12
12
  const STATE_RANK: Record<PermissionState, number> = {
13
13
  allow: 0,
14
- ask: 1,
15
- deny: 2,
14
+ auto: 1,
15
+ ask: 2,
16
+ deny: 3,
16
17
  };
17
18
 
19
+ function appliesWhenComplex(state: PermissionState): boolean {
20
+ return state === "deny" || state === "ask" || state === "auto";
21
+ }
22
+
18
23
  /**
19
24
  * Prefers the analysis stored on the request.
20
25
  * Falls back for hand-built fixtures.
@@ -41,30 +46,37 @@ function bashFallbackResolution(config: PermissionConfig): PolicyResolution {
41
46
  }
42
47
 
43
48
  /**
44
- * Resolves one simple bash segment against bash rules, then tools.bash / default.
49
+ * Resolves one bash segment against bash rules.
50
+ * Returns undefined if a complex segment fails to match a rule.
45
51
  */
46
52
  function resolveBashSegment(
47
53
  config: PermissionConfig,
48
- segment: string
49
- ): PolicyResolution {
54
+ segment: string,
55
+ isComplex: boolean
56
+ ): PolicyResolution | undefined {
50
57
  const match = findLastWildcardMatch(config.bash, segment);
51
- if (match) {
52
- return {
53
- state: match.state,
54
- source: "bash",
55
- matchedPattern: match.pattern,
56
- };
58
+ if (!match) {
59
+ return isComplex ? undefined : bashFallbackResolution(config);
60
+ }
61
+ if (isComplex && !appliesWhenComplex(match.state)) {
62
+ return undefined;
57
63
  }
58
- return bashFallbackResolution(config);
64
+ return {
65
+ state: match.state,
66
+ source: "bash",
67
+ matchedPattern: match.pattern,
68
+ };
59
69
  }
60
70
 
61
71
  /**
62
- * Worst state wins across simple list segments.
72
+ * Worst state wins across segment resolutions.
63
73
  * When ranks tie, keep the later segment's resolution.
74
+ * Returns undefined when there are no votes.
64
75
  */
65
76
  function combineBashSegmentResolutions(
66
- resolutions: PolicyResolution[]
67
- ): PolicyResolution {
77
+ resolutions: readonly PolicyResolution[]
78
+ ): PolicyResolution | undefined {
79
+ if (resolutions.length === 0) {return undefined;}
68
80
  let worst = resolutions[0]!;
69
81
  for (let index = 1; index < resolutions.length; index++) {
70
82
  const next = resolutions[index]!;
@@ -76,20 +88,19 @@ function combineBashSegmentResolutions(
76
88
  }
77
89
 
78
90
  /**
79
- * Resolves bash policy, including simple list splitting.
91
+ * Resolves bash policy, including command splitting.
80
92
  */
81
93
  export function resolveBashPolicy(
82
94
  config: PermissionConfig,
83
95
  request: PermissionRequest
84
96
  ): PolicyResolution {
85
97
  const analysis = bashAnalysisOf(request);
86
- if (analysis.isComplex) {
87
- return bashFallbackResolution(config);
98
+ const votes: PolicyResolution[] = [];
99
+ for (const segment of analysis.segments) {
100
+ const resolution = resolveBashSegment(config, segment, analysis.isComplex);
101
+ if (resolution) {votes.push(resolution);}
88
102
  }
89
- const resolutions = analysis.segments.map((segment) =>
90
- resolveBashSegment(config, segment)
91
- );
92
- return combineBashSegmentResolutions(resolutions);
103
+ return combineBashSegmentResolutions(votes) ?? bashFallbackResolution(config);
93
104
  }
94
105
 
95
106
  /**
package/src/prompts.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { describePolicy } from "./policy.ts";
2
2
  import type { PermissionRequest, PolicyResolution } from "./types.ts";
3
3
 
4
- const CHAINED_BASH_NOTE = "Shell chaining detected: bash pattern rules were skipped.";
5
4
  const EXTERNAL_DIRECTORY_NOTE = "Outside the working directory.";
6
5
 
7
6
  function preview(value: string, limit: number): string {
@@ -27,6 +26,32 @@ export function describeBlockedSubject(
27
26
  : `tool ${JSON.stringify(request.toolName)}`;
28
27
  }
29
28
 
29
+ /**
30
+ * Builds context for the "auto" permission classifier.
31
+ */
32
+ export function buildClassifierPromptContext(request: PermissionRequest): string {
33
+ const lines = [
34
+ `Tool: ${request.toolName}`,
35
+ `Working directory: ${request.cwd}`,
36
+ ];
37
+
38
+ if (request.bodyKind === "command") {
39
+ lines.push(`Command:\n${preview(request.body, 600)}`);
40
+ } else if (request.bodyKind === "path") {
41
+ lines.push(`Path: ${preview(request.body, 400)}`);
42
+ } else {
43
+ lines.push(`Input: ${preview(request.body, 600)}`);
44
+ }
45
+
46
+ if (request.isExternalDirectory !== undefined) {
47
+ lines.push(
48
+ `Outside working directory: ${request.isExternalDirectory ? "yes" : "no"}`
49
+ );
50
+ }
51
+
52
+ return lines.join("\n");
53
+ }
54
+
30
55
  /**
31
56
  * Builds context for the ai-generated purpose.
32
57
  */
@@ -49,6 +74,23 @@ export function buildPurposePromptContext(request: PermissionRequest): string {
49
74
  return lines.join("\n");
50
75
  }
51
76
 
77
+ export const CLASSIFIER_SYSTEM_PROMPT = [
78
+ "You decide whether a coding-agent tool request can be auto-allowed.",
79
+ "Reply with exactly one word, with no punctuation: \"allow\" or \"ask\".",
80
+ "If uncertain, reply \"ask\".",
81
+ ].join(" ");
82
+
83
+ export function buildClassifierInstructions(
84
+ context: string,
85
+ allowCriteria: string
86
+ ): string {
87
+ return [
88
+ `A request can be \"allow\" if it meets this criteria: ${allowCriteria}.`,
89
+ "",
90
+ `Classify the following request:\n${context}`,
91
+ ].join("\n");
92
+ }
93
+
52
94
  export const PURPOSE_SYSTEM_PROMPT = [
53
95
  "You summarize the purpose of coding-agent tool requests for a permission prompt.",
54
96
  "Be as concise and neutral as possible.",
@@ -102,9 +144,6 @@ export function buildWildcardInstructions(command: string): string {
102
144
  ].join("\n");
103
145
  }
104
146
 
105
- /**
106
- * Builds instructions for generating one wildcard per command in a simple list.
107
- */
108
147
  export function buildWildcardListInstructions(commands: readonly string[]): string {
109
148
  return [
110
149
  "Create a permission wildcard for each command below.",
@@ -123,9 +162,7 @@ export function buildWildcardListInstructions(commands: readonly string[]): stri
123
162
  */
124
163
  export function formatDenyReason(request: PermissionRequest, policy: PolicyResolution): string {
125
164
  const subject = describeBlockedSubject(request, true);
126
- const chainedNote = request.bashAnalysis?.isComplex ? ` ${CHAINED_BASH_NOTE}` : "";
127
-
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.`;
165
+ return `Permission denied for ${subject}. Policy: ${describePolicy(policy)}. Hard stop: do not retry or investigate a bypass. Report the denial to the user.`;
129
166
  }
130
167
 
131
168
  /**
package/src/shell.ts CHANGED
@@ -2,7 +2,11 @@
2
2
  * Result of a conservative shell scan.
3
3
  *
4
4
  * When `isComplex` is false, `segments` are commands separated only by `;`,
5
- * `&&`, `||`, or newlines (or a single command). When true, `segments` is empty.
5
+ * `&&`, `||`, or newlines (or a single command).
6
+ *
7
+ * When `isComplex` is true because of `|` / `|&`, `segments` are still filled
8
+ * so deny rules can be checked per stage. Other complex syntax returns empty
9
+ * `segments`.
6
10
  */
7
11
  export type ShellCommandAnalysis = {
8
12
  isComplex: boolean;
@@ -17,6 +21,7 @@ const COMPLEX: ShellCommandAnalysis = { isComplex: true, segments: [] };
17
21
  *
18
22
  * Splits on:
19
23
  * - `;`, `&&`, `||`, and newlines outside quotes
24
+ * - '|' and '|&' outside quotes (also treated as complex)
20
25
  *
21
26
  * Treats as complex:
22
27
  * - Substitutions like `command` and $(command)
@@ -27,6 +32,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
27
32
  let quote: "'" | '"' | undefined;
28
33
  let index = 0;
29
34
  let current = "";
35
+ let isComplex = false;
30
36
  const segments: string[] = [];
31
37
 
32
38
  const pushCurrent = (): void => {
@@ -47,7 +53,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
47
53
  }
48
54
  if (quote === '"') {
49
55
  if (character === "\\") {
50
- current += character + (command[index + 1] ?? "");
56
+ current += character + (command[index+1] ?? "");
51
57
  index += 2;
52
58
  continue;
53
59
  }
@@ -58,7 +64,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
58
64
  continue;
59
65
  }
60
66
  if (character === "`") {return COMPLEX;}
61
- if (character === "$" && command[index + 1] === "(") {return COMPLEX;}
67
+ if (character === "$" && command[index+1] === "(") {return COMPLEX;}
62
68
  current += character;
63
69
  index++;
64
70
  continue;
@@ -66,7 +72,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
66
72
 
67
73
  // escape character
68
74
  if (character === "\\") {
69
- current += character + (command[index + 1] ?? "");
75
+ current += character + (command[index+1] ?? "");
70
76
  index += 2;
71
77
  continue;
72
78
  }
@@ -86,7 +92,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
86
92
  }
87
93
 
88
94
  if (character === "`") {return COMPLEX;}
89
- if (character === "$" && command[index + 1] === "(") {return COMPLEX;}
95
+ if (character === "$" && command[index+1] === "(") {return COMPLEX;}
90
96
  if (character === "(" || character === ")") {return COMPLEX;}
91
97
 
92
98
  // simple separators
@@ -102,14 +108,18 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
102
108
  continue;
103
109
  }
104
110
  if (character === "|") {
105
- if (command[index+1] !== "|") {return COMPLEX;}
106
111
  pushCurrent();
107
- index += 2;
112
+ if (command[index+1] === "|") {
113
+ index += 2;
114
+ continue;
115
+ }
116
+ isComplex = true;
117
+ index += command[index+1] === "&" ? 2 : 1;
108
118
  continue;
109
119
  }
110
120
  if (character === "\n" || character === "\r") {
111
121
  pushCurrent();
112
- if (character === "\r" && command[index + 1] === "\n") {index += 2;}
122
+ if (character === "\r" && command[index+1] === "\n") {index += 2;}
113
123
  else {index++;}
114
124
  continue;
115
125
  }
@@ -123,7 +133,7 @@ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
123
133
 
124
134
  pushCurrent();
125
135
  return {
126
- isComplex: false,
136
+ isComplex,
127
137
  segments: segments.length > 0 ? segments : [""],
128
138
  };
129
139
  }
package/src/types.ts CHANGED
@@ -21,15 +21,41 @@ export function toPermissionState(value: unknown): PermissionState | undefined {
21
21
 
22
22
  export type PermissionSection = Record<string, PermissionState>;
23
23
 
24
+ export interface PermissionSettings {
25
+ /** Tells the local config to ignore global permission sections. */
26
+ ignore_global?: boolean;
27
+ /** `provider/model-id` override for small helper completions. */
28
+ ai_model?: string;
29
+ /** Criteria prompt override for what "auto" may allow. */
30
+ auto_allow_criteria?: string;
31
+ }
32
+
24
33
  export interface PermissionConfig {
25
34
  default: PermissionState;
26
35
  tools: PermissionSection;
27
36
  bash: PermissionSection;
28
37
  external_directory: PermissionSection;
38
+ settings: PermissionSettings;
29
39
  }
30
40
 
31
41
  export function createDefaultPermissionConfig(): PermissionConfig {
32
- return { default: ASK, tools: {}, bash: {}, external_directory: {} };
42
+ return { default: ASK, tools: {}, bash: {}, external_directory: {}, settings: {} };
43
+ }
44
+
45
+ /**
46
+ * Parses `provider/model-id`.
47
+ * Splits using the first '/'.
48
+ */
49
+ export function parseAiModelRef(
50
+ value: string
51
+ ): { provider: string; modelId: string } | undefined {
52
+ const trimmed = value.trim();
53
+ const slash = trimmed.indexOf("/");
54
+ if (slash <= 0 || slash >= trimmed.length - 1) {return undefined;}
55
+ const provider = trimmed.slice(0, slash).trim();
56
+ const modelId = trimmed.slice(slash + 1).trim();
57
+ if (!provider || !modelId) {return undefined;}
58
+ return { provider, modelId };
33
59
  }
34
60
 
35
61
  // permission request
package/src/ui-strings.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
1
2
  import {
2
3
  ALLOW_ALWAYS_EXACT,
3
4
  ALLOW_ALWAYS_WILDCARD,
@@ -6,11 +7,11 @@ import { resolveAllowAlwaysMode } from "./policy-bash.ts";
6
7
  import type { PermissionRequest, PolicyResolution } from "./types.ts";
7
8
 
8
9
  export const STATUS_SUMMARIZING_REQUEST = "Summarizing permission request...";
10
+ export const STATUS_CLASSIFYING_REQUEST = "Classifying permission request...";
9
11
  export const STATUS_GENERATING_WILDCARD = "Generating bash wildcard...";
10
12
 
11
13
  export const PERMISSION_DIALOG_TITLE = "Permission Required";
12
14
  export const PERMISSION_DIALOG_QUESTION = "What should the agent be allowed to do?";
13
- export const PURPOSE_PREFIX = "Purpose: ";
14
15
 
15
16
  export const WILDCARD_EDITOR_TITLE = "Review bash wildcard";
16
17
  export const WILDCARD_LIST_EDITOR_TITLE = "Review bash wildcards (semicolon-separated)";
@@ -19,34 +20,75 @@ export const BROAD_WILDCARD_TITLE = "Very broad wildcard";
19
20
  const CHAINED_BASH_NOTE = "Shell chaining detected.";
20
21
  const EXTERNAL_DIRECTORY_NOTE = "Outside the current working directory.";
21
22
 
23
+ type SummaryLine = {
24
+ label?: string;
25
+ value: string;
26
+ };
27
+
22
28
  function preview(value: string, limit: number): string {
23
29
  const normalized = value.replace(/\r\n/g, "\n");
24
30
  return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
25
31
  }
26
32
 
27
- /**
28
- * Summary shown in the permission select dialog.
29
- */
30
- export function buildSelectorSummary(request: PermissionRequest): string {
31
- const lines = [
32
- `Tool: ${request.toolName}`,
33
+ function buildSelectorSummaryLines(
34
+ request: PermissionRequest,
35
+ purpose?: string
36
+ ): SummaryLine[] {
37
+ const lines: SummaryLine[] = [
38
+ { label: "Tool", value: request.toolName },
33
39
  ];
34
-
35
40
  if (request.bodyKind === "command") {
36
- lines.push(`Command: ${preview(request.body, 600)}`);
41
+ lines.push({ label: "Command", value: preview(request.body, 600) });
37
42
  if (request.bashAnalysis?.isComplex) {
38
- lines.push(CHAINED_BASH_NOTE);
43
+ lines.push({ value: CHAINED_BASH_NOTE });
39
44
  }
40
45
  } else if (request.bodyKind === "path") {
41
- lines.push(`Path: ${preview(request.body, 400)}`);
46
+ lines.push({ label: "Path", value: preview(request.body, 400) });
42
47
  if (request.isExternalDirectory) {
43
- lines.push(EXTERNAL_DIRECTORY_NOTE);
48
+ lines.push({ value: EXTERNAL_DIRECTORY_NOTE });
44
49
  }
45
50
  } else {
46
- lines.push(`Input: ${preview(request.body, 600)}`);
51
+ lines.push({ label: "Input", value: preview(request.body, 600) });
47
52
  }
53
+ if (purpose) {
54
+ lines.push({ label: "Purpose", value: purpose });
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ /**
60
+ * Plain summary shown in the non-TUI permission dialog.
61
+ */
62
+ export function buildPlainSelectorSummary(
63
+ request: PermissionRequest,
64
+ purpose?: string
65
+ ): string {
66
+ return buildSelectorSummaryLines(request, purpose)
67
+ .map((line) => (
68
+ line.label ? `${line.label}: ${line.value}` : line.value)
69
+ )
70
+ .join("\n");
71
+ }
48
72
 
49
- return lines.join("\n");
73
+ /**
74
+ * Styled summary lines shown in the TUI permission dialog.
75
+ */
76
+ export function buildStyledSelectorSummary(
77
+ theme: Theme,
78
+ request: PermissionRequest,
79
+ purpose?: string
80
+ ): string[] {
81
+ return buildSelectorSummaryLines(request, purpose).map((line) => {
82
+ if (!line.label) {
83
+ return theme.fg("muted", line.value);
84
+ }
85
+ if (line.label === "Purpose") {
86
+ return theme.fg("accent", theme.bold(`${line.label}:`))
87
+ + theme.fg("muted", ` ${line.value}`);
88
+ }
89
+ return theme.fg("accent", theme.bold(`${line.label}:`))
90
+ + theme.fg("text", ` ${line.value}`);
91
+ });
50
92
  }
51
93
 
52
94
  /**
@@ -7,6 +7,16 @@ export const CONFIG_MUST_BE_OBJECT =
7
7
  "Permission config must be a JSON object; using defaults.";
8
8
  export const IGNORED_DEFAULT_WARNING =
9
9
  `Ignoring "default": expected ${PERMISSION_STATES_DESCRIPTION}.`;
10
+ export const IGNORED_SETTINGS_WARNING =
11
+ 'Ignoring "settings": expected an object.';
12
+ export const IGNORED_AI_MODEL_WARNING =
13
+ 'Ignoring "settings.ai_model": expected a string like "provider/model-id".';
14
+ export const IGNORED_AUTO_ALLOW_CRITERIA_WARNING =
15
+ 'Ignoring "settings.auto_allow_criteria": expected a non-empty string.';
16
+ export const IGNORED_IGNORE_GLOBAL_WARNING =
17
+ 'Ignoring "settings.ignore_global": expected a boolean.';
18
+ export const IGNORED_IGNORE_GLOBAL_LOCAL_ONLY_WARNING =
19
+ 'Ignoring "settings.ignore_global": this setting is only valid in local config.';
10
20
 
11
21
  export const WILDCARD_LENGTH_REASON =
12
22
  "Pattern must be between 1 and 500 characters.";
@@ -28,12 +28,13 @@ import type {
28
28
  */
29
29
  async function chooseSingleWildcard(
30
30
  ctx: ExtensionContext,
31
- command: string
31
+ command: string,
32
+ aiModel?: string
32
33
  ): Promise<AllowAlwaysRule | undefined> {
33
34
  ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
34
35
  let suggestion: string | undefined;
35
36
  try {
36
- suggestion = await generateBashWildcard(ctx, command);
37
+ suggestion = await generateBashWildcard(ctx, command, aiModel);
37
38
  } finally {
38
39
  ctx.ui.setStatus(STATUS_KEY, undefined);
39
40
  }
@@ -71,12 +72,13 @@ async function chooseSingleWildcard(
71
72
  */
72
73
  async function chooseWildcardListRules(
73
74
  ctx: ExtensionContext,
74
- commands: readonly string[]
75
+ commands: readonly string[],
76
+ aiModel?: string
75
77
  ): Promise<AllowAlwaysRule[] | undefined> {
76
78
  ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
77
79
  let suggestion: string[] | undefined;
78
80
  try {
79
- suggestion = await generateBashWildcards(ctx, commands);
81
+ suggestion = await generateBashWildcards(ctx, commands, aiModel);
80
82
  } finally {
81
83
  ctx.ui.setStatus(STATUS_KEY, undefined);
82
84
  }
@@ -118,18 +120,19 @@ async function chooseWildcardListRules(
118
120
  export async function chooseAlwaysRules(
119
121
  ctx: ExtensionContext,
120
122
  request: PermissionRequest,
121
- policy: PolicyResolution
123
+ policy: PolicyResolution,
124
+ aiModel?: string
122
125
  ): Promise<AllowAlwaysRule[] | undefined> {
123
126
  const mode = resolveAllowAlwaysMode(request, policy);
124
127
 
125
128
  if (mode === "wildcard") {
126
- const rule = await chooseSingleWildcard(ctx, request.body);
129
+ const rule = await chooseSingleWildcard(ctx, request.body, aiModel);
127
130
  return rule ? [rule] : undefined;
128
131
  }
129
132
 
130
133
  if (mode === "wildcard-list") {
131
134
  const analysis = request.bashAnalysis ?? analyzeShellCommand(request.body);
132
- return chooseWildcardListRules(ctx, analysis.segments);
135
+ return chooseWildcardListRules(ctx, analysis.segments, aiModel);
133
136
  }
134
137
 
135
138
  return [{ kind: "exact", tool: request.toolName, body: request.body }];
@@ -76,7 +76,7 @@ export function validateWildcardSuggestionList(
76
76
  if (!result.valid) {
77
77
  return {
78
78
  valid: false,
79
- reason: formatWildcardListItemReason(index + 1, pattern, result.reason),
79
+ reason: formatWildcardListItemReason(index+1, pattern, result.reason),
80
80
  };
81
81
  }
82
82
  if (result.broad) {broadPatterns.push(pattern);}