@davisliu2006/pi-permission-system 0.2.0 → 0.3.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
@@ -29,7 +29,7 @@ Reject
29
29
 
30
30
  ## Configuration
31
31
 
32
- The supported top-level keys are: `default`, `tools`, `bash`, and `external_directory`.
32
+ The supported top-level keys are: `default`, `tools`, `bash`, `external_directory`, and `settings`.
33
33
 
34
34
  `bash` body matching supports wildcard:
35
35
  - `*` matches any sequence and `?` matches one character
@@ -39,6 +39,8 @@ The supported top-level keys are: `default`, `tools`, `bash`, and `external_dire
39
39
 
40
40
  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
41
 
42
+ `settings` holds optional non-permission settings.
43
+
42
44
  ### Rule Precedence
43
45
 
44
46
  More specific rules override less specific rules:
@@ -90,7 +92,8 @@ Example:
90
92
  "rg*": "allow"
91
93
  },
92
94
  "external_directory": {
93
- "write": "ask"
95
+ "write": "ask",
96
+ "edit": "ask"
94
97
  }
95
98
  }
96
99
  ```
@@ -103,6 +106,16 @@ Located in the local Pi config folder:
103
106
 
104
107
  Local configuration overrides global configuration, including `deny` rules.
105
108
 
109
+ ### Optional Settings
110
+
111
+ ```jsonc
112
+ {
113
+ "settings": {
114
+ "ai-model": "provider/model-id"
115
+ }
116
+ }
117
+ ```
118
+
106
119
  ## "Allow Always" Matching
107
120
 
108
121
  Matching is done as `(tool, body)`.
@@ -122,8 +135,8 @@ Body matching rules:
122
135
 
123
136
  Bash matching safeguards against unsafe shell syntax.
124
137
 
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`).
138
+ Sequences separated by `;`, `&&`, `||`, or newlines are split and each segment is matched against `bash` rules. The strictest result wins (`deny` > `ask` > `allow`).
126
139
 
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`.
140
+ If a command contains other chaining (`|`, `&`, command/process substitution, or parentheses), `bash` pattern rules (except for `deny`) are skipped and the `tools.bash` policy is used instead, falling back to `default`.
128
141
 
129
- "Allow always" for complex or multi-command sequences saves the exact command, not a wildcard.
142
+ "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.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "index.ts",
12
12
  "src",
13
- "README.md"
13
+ "README.md",
14
+ "LICENSE"
14
15
  ],
15
16
  "scripts": {
16
17
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -21,7 +22,8 @@
21
22
  "pi-package",
22
23
  "pi",
23
24
  "pi-extension",
24
- "permissions"
25
+ "permissions",
26
+ "pi-permissions"
25
27
  ],
26
28
  "author": "davisliu2006",
27
29
  "license": "MIT",
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,9 @@ import {
16
18
  formatConfigReadFailure,
17
19
  formatIgnoredSectionEntryWarning,
18
20
  formatIgnoredSectionWarning,
21
+ IGNORED_AI_MODEL_WARNING,
19
22
  IGNORED_DEFAULT_WARNING,
23
+ IGNORED_SETTINGS_WARNING,
20
24
  } from "./warning-strings.ts";
21
25
 
22
26
  const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -57,11 +61,44 @@ function normalizeSection(
57
61
  return result;
58
62
  }
59
63
 
64
+ /**
65
+ * Helper used by normalizeConfig.
66
+ * Ensures settings is a valid PermissionSettings object.
67
+ */
68
+ function normalizeSettings(
69
+ raw: unknown,
70
+ configPath: string | undefined,
71
+ warnings: ConfigWarning[]
72
+ ): PermissionSettings {
73
+ if (raw === undefined) {return {};}
74
+ const record = toRecord(raw);
75
+ if (!record) {
76
+ warnings.push({
77
+ path: configPath,
78
+ message: IGNORED_SETTINGS_WARNING,
79
+ });
80
+ return {};
81
+ }
82
+
83
+ const settings: PermissionSettings = {};
84
+ if (record.ai_model !== undefined) {
85
+ if (typeof record.ai_model === "string" && parseAiModelRef(record.ai_model)) {
86
+ settings.ai_model = record.ai_model.trim();
87
+ } else {
88
+ warnings.push({
89
+ path: configPath,
90
+ message: IGNORED_AI_MODEL_WARNING,
91
+ });
92
+ }
93
+ }
94
+ return settings;
95
+ }
96
+
60
97
  /**
61
98
  * Convert the raw config to a PermissionConfig.
62
99
  * Ensures the config is valid.
63
100
  */
64
- function normalizeConfig(
101
+ export function normalizeConfig(
65
102
  rawConfig: unknown,
66
103
  configPath: string | undefined,
67
104
  warnings: ConfigWarning[]
@@ -94,6 +131,7 @@ function normalizeConfig(
94
131
  configPath,
95
132
  warnings
96
133
  ),
134
+ settings: normalizeSettings(config.settings, configPath, warnings),
97
135
  };
98
136
  }
99
137
 
@@ -167,6 +205,10 @@ export function mergeConfigs(
167
205
  ...globalConfig.external_directory,
168
206
  ...(localConfig?.external_directory ?? {}),
169
207
  },
208
+ settings: {
209
+ ...globalConfig.settings,
210
+ ...(localConfig?.settings ?? {}),
211
+ },
170
212
  };
171
213
  }
172
214
 
package/src/extension.ts CHANGED
@@ -22,10 +22,7 @@ import {
22
22
  } from "./prompts.ts";
23
23
  import { buildRequest } from "./request-builder.ts";
24
24
  import { SessionApprovalStore } from "./session-approvals.ts";
25
- import {
26
- buildSelectorSummary,
27
- STATUS_SUMMARIZING_REQUEST,
28
- } from "./ui-strings.ts";
25
+ import { STATUS_SUMMARIZING_REQUEST } from "./ui-strings.ts";
29
26
  import {
30
27
  CONFIG_WARNING_PREFIX,
31
28
  formatSuppressedConfigWarnings,
@@ -126,21 +123,21 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
126
123
  const approval = findSessionApproval(state.approvals.getRules(), request);
127
124
  if (approval) {return undefined;} // approved, proceed
128
125
 
129
- const summary = buildSelectorSummary(request);
130
126
  ctx.ui.setStatus(STATUS_KEY, STATUS_SUMMARIZING_REQUEST);
131
127
  let purpose: string | undefined;
132
128
  try {
133
129
  purpose = await generateRequestPurpose(
134
130
  ctx,
135
131
  request,
136
- buildPurposePromptContext(request)
132
+ buildPurposePromptContext(request),
133
+ config.settings.ai_model
137
134
  );
138
135
  } finally {
139
136
  ctx.ui.setStatus(STATUS_KEY, undefined);
140
137
  }
141
138
 
142
139
  while (true) {
143
- const choice = await promptForPermission(ctx, request, policy, summary, purpose);
140
+ const choice = await promptForPermission(ctx, request, policy, purpose);
144
141
  if (choice.kind === "once") { // approved, proceed
145
142
  return;
146
143
  }
@@ -149,7 +146,12 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
149
146
  }
150
147
 
151
148
  // "allow always"
152
- const rules = await chooseAlwaysRules(ctx, request, policy);
149
+ const rules = await chooseAlwaysRules(
150
+ ctx,
151
+ request,
152
+ policy,
153
+ config.settings.ai_model
154
+ );
153
155
  if (!rules) {
154
156
  // the user cancelled the wildcard editor, return to the permission dialog
155
157
  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(
@@ -75,6 +75,26 @@ function combineBashSegmentResolutions(
75
75
  return worst;
76
76
  }
77
77
 
78
+ /**
79
+ * "Deny" rule can do raw wildcard matching on complex segments.
80
+ */
81
+ function resolveComplexBashDeny(
82
+ config: PermissionConfig,
83
+ segments: readonly string[]
84
+ ): PolicyResolution | undefined {
85
+ let denied: PolicyResolution | undefined;
86
+ for (const segment of segments) {
87
+ const match = findLastWildcardMatch(config.bash, segment);
88
+ if (match?.state !== "deny") {continue;}
89
+ denied = {
90
+ state: "deny",
91
+ source: "bash",
92
+ matchedPattern: match.pattern,
93
+ };
94
+ }
95
+ return denied;
96
+ }
97
+
78
98
  /**
79
99
  * Resolves bash policy, including simple list splitting.
80
100
  */
@@ -84,7 +104,10 @@ export function resolveBashPolicy(
84
104
  ): PolicyResolution {
85
105
  const analysis = bashAnalysisOf(request);
86
106
  if (analysis.isComplex) {
87
- return bashFallbackResolution(config);
107
+ return (
108
+ resolveComplexBashDeny(config, analysis.segments) ??
109
+ bashFallbackResolution(config)
110
+ );
88
111
  }
89
112
  const resolutions = analysis.segments.map((segment) =>
90
113
  resolveBashSegment(config, segment)
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,37 @@ export function toPermissionState(value: unknown): PermissionState | undefined {
21
21
 
22
22
  export type PermissionSection = Record<string, PermissionState>;
23
23
 
24
+ export interface PermissionSettings {
25
+ /** `provider/model-id` override for small helper completions. */
26
+ ai_model?: string;
27
+ }
28
+
24
29
  export interface PermissionConfig {
25
30
  default: PermissionState;
26
31
  tools: PermissionSection;
27
32
  bash: PermissionSection;
28
33
  external_directory: PermissionSection;
34
+ settings: PermissionSettings;
29
35
  }
30
36
 
31
37
  export function createDefaultPermissionConfig(): PermissionConfig {
32
- return { default: ASK, tools: {}, bash: {}, external_directory: {} };
38
+ return { default: ASK, tools: {}, bash: {}, external_directory: {}, settings: {} };
39
+ }
40
+
41
+ /**
42
+ * Parses `provider/model-id`.
43
+ * Splits using the first '/'.
44
+ */
45
+ export function parseAiModelRef(
46
+ value: string
47
+ ): { provider: string; modelId: string } | undefined {
48
+ const trimmed = value.trim();
49
+ const slash = trimmed.indexOf("/");
50
+ if (slash <= 0 || slash >= trimmed.length - 1) {return undefined;}
51
+ const provider = trimmed.slice(0, slash).trim();
52
+ const modelId = trimmed.slice(slash + 1).trim();
53
+ if (!provider || !modelId) {return undefined;}
54
+ return { provider, modelId };
33
55
  }
34
56
 
35
57
  // 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,
@@ -10,7 +11,6 @@ export const STATUS_GENERATING_WILDCARD = "Generating bash wildcard...";
10
11
 
11
12
  export const PERMISSION_DIALOG_TITLE = "Permission Required";
12
13
  export const PERMISSION_DIALOG_QUESTION = "What should the agent be allowed to do?";
13
- export const PURPOSE_PREFIX = "Purpose: ";
14
14
 
15
15
  export const WILDCARD_EDITOR_TITLE = "Review bash wildcard";
16
16
  export const WILDCARD_LIST_EDITOR_TITLE = "Review bash wildcards (semicolon-separated)";
@@ -19,34 +19,75 @@ export const BROAD_WILDCARD_TITLE = "Very broad wildcard";
19
19
  const CHAINED_BASH_NOTE = "Shell chaining detected.";
20
20
  const EXTERNAL_DIRECTORY_NOTE = "Outside the current working directory.";
21
21
 
22
+ type SummaryLine = {
23
+ label?: string;
24
+ value: string;
25
+ };
26
+
22
27
  function preview(value: string, limit: number): string {
23
28
  const normalized = value.replace(/\r\n/g, "\n");
24
29
  return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
25
30
  }
26
31
 
27
- /**
28
- * Summary shown in the permission select dialog.
29
- */
30
- export function buildSelectorSummary(request: PermissionRequest): string {
31
- const lines = [
32
- `Tool: ${request.toolName}`,
32
+ function buildSelectorSummaryLines(
33
+ request: PermissionRequest,
34
+ purpose?: string
35
+ ): SummaryLine[] {
36
+ const lines: SummaryLine[] = [
37
+ { label: "Tool", value: request.toolName },
33
38
  ];
34
-
35
39
  if (request.bodyKind === "command") {
36
- lines.push(`Command: ${preview(request.body, 600)}`);
40
+ lines.push({ label: "Command", value: preview(request.body, 600) });
37
41
  if (request.bashAnalysis?.isComplex) {
38
- lines.push(CHAINED_BASH_NOTE);
42
+ lines.push({ value: CHAINED_BASH_NOTE });
39
43
  }
40
44
  } else if (request.bodyKind === "path") {
41
- lines.push(`Path: ${preview(request.body, 400)}`);
45
+ lines.push({ label: "Path", value: preview(request.body, 400) });
42
46
  if (request.isExternalDirectory) {
43
- lines.push(EXTERNAL_DIRECTORY_NOTE);
47
+ lines.push({ value: EXTERNAL_DIRECTORY_NOTE });
44
48
  }
45
49
  } else {
46
- lines.push(`Input: ${preview(request.body, 600)}`);
50
+ lines.push({ label: "Input", value: preview(request.body, 600) });
47
51
  }
52
+ if (purpose) {
53
+ lines.push({ label: "Purpose", value: purpose });
54
+ }
55
+ return lines;
56
+ }
57
+
58
+ /**
59
+ * Plain summary shown in the non-TUI permission dialog.
60
+ */
61
+ export function buildPlainSelectorSummary(
62
+ request: PermissionRequest,
63
+ purpose?: string
64
+ ): string {
65
+ return buildSelectorSummaryLines(request, purpose)
66
+ .map((line) => (
67
+ line.label ? `${line.label}: ${line.value}` : line.value)
68
+ )
69
+ .join("\n");
70
+ }
48
71
 
49
- return lines.join("\n");
72
+ /**
73
+ * Styled summary lines shown in the TUI permission dialog.
74
+ */
75
+ export function buildStyledSelectorSummary(
76
+ theme: Theme,
77
+ request: PermissionRequest,
78
+ purpose?: string
79
+ ): string[] {
80
+ return buildSelectorSummaryLines(request, purpose).map((line) => {
81
+ if (!line.label) {
82
+ return theme.fg("muted", line.value);
83
+ }
84
+ if (line.label === "Purpose") {
85
+ return theme.fg("accent", theme.bold(`${line.label}:`))
86
+ + theme.fg("muted", ` ${line.value}`);
87
+ }
88
+ return theme.fg("accent", theme.bold(`${line.label}:`))
89
+ + theme.fg("text", ` ${line.value}`);
90
+ });
50
91
  }
51
92
 
52
93
  /**
@@ -7,6 +7,10 @@ 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".';
10
14
 
11
15
  export const WILDCARD_LENGTH_REASON =
12
16
  "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);}