@davisliu2006/pi-permission-system 0.3.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:
@@ -106,16 +114,24 @@ Located in the local Pi config folder:
106
114
 
107
115
  Local configuration overrides global configuration, including `deny` rules.
108
116
 
117
+ Set `settings.ignore_global` to `true` in the local config to ignore global permission sections entirely.
118
+
109
119
  ### Optional Settings
110
120
 
111
121
  ```jsonc
112
122
  {
113
123
  "settings": {
114
- "ai-model": "provider/model-id"
124
+ "ignore_global": true,
125
+ "ai_model": "provider/model-id",
126
+ "auto_allow_criteria": "read-only or easily reversible"
115
127
  }
116
128
  }
117
129
  ```
118
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
+
119
135
  ## "Allow Always" Matching
120
136
 
121
137
  Matching is done as `(tool, body)`.
@@ -135,8 +151,8 @@ Body matching rules:
135
151
 
136
152
  Bash matching safeguards against unsafe shell syntax.
137
153
 
138
- Sequences separated 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`).
139
155
 
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`.
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`.
141
157
 
142
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.3.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",
@@ -11,7 +11,8 @@
11
11
  "index.ts",
12
12
  "src",
13
13
  "README.md",
14
- "LICENSE"
14
+ "LICENSE",
15
+ "CHANGELOG.md"
15
16
  ],
16
17
  "scripts": {
17
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -34,7 +35,8 @@
34
35
  "pi": {
35
36
  "extensions": [
36
37
  "./index.ts"
37
- ]
38
+ ],
39
+ "image": "https://raw.githubusercontent.com/davisliu2006/pi-permission-system/main/images/image.webp"
38
40
  },
39
41
  "engines": {
40
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/config.ts CHANGED
@@ -19,7 +19,10 @@ import {
19
19
  formatIgnoredSectionEntryWarning,
20
20
  formatIgnoredSectionWarning,
21
21
  IGNORED_AI_MODEL_WARNING,
22
+ IGNORED_AUTO_ALLOW_CRITERIA_WARNING,
22
23
  IGNORED_DEFAULT_WARNING,
24
+ IGNORED_IGNORE_GLOBAL_LOCAL_ONLY_WARNING,
25
+ IGNORED_IGNORE_GLOBAL_WARNING,
23
26
  IGNORED_SETTINGS_WARNING,
24
27
  } from "./warning-strings.ts";
25
28
 
@@ -68,7 +71,8 @@ function normalizeSection(
68
71
  function normalizeSettings(
69
72
  raw: unknown,
70
73
  configPath: string | undefined,
71
- warnings: ConfigWarning[]
74
+ warnings: ConfigWarning[],
75
+ isLocal: boolean
72
76
  ): PermissionSettings {
73
77
  if (raw === undefined) {return {};}
74
78
  const record = toRecord(raw);
@@ -81,6 +85,21 @@ function normalizeSettings(
81
85
  }
82
86
 
83
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
+ }
84
103
  if (record.ai_model !== undefined) {
85
104
  if (typeof record.ai_model === "string" && parseAiModelRef(record.ai_model)) {
86
105
  settings.ai_model = record.ai_model.trim();
@@ -91,6 +110,19 @@ function normalizeSettings(
91
110
  });
92
111
  }
93
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
+ }
94
126
  return settings;
95
127
  }
96
128
 
@@ -101,7 +133,8 @@ function normalizeSettings(
101
133
  export function normalizeConfig(
102
134
  rawConfig: unknown,
103
135
  configPath: string | undefined,
104
- warnings: ConfigWarning[]
136
+ warnings: ConfigWarning[],
137
+ isLocal: boolean
105
138
  ): PermissionConfig {
106
139
  const config = toRecord(rawConfig);
107
140
  if (!config) {
@@ -131,7 +164,7 @@ export function normalizeConfig(
131
164
  configPath,
132
165
  warnings
133
166
  ),
134
- settings: normalizeSettings(config.settings, configPath, warnings),
167
+ settings: normalizeSettings(config.settings, configPath, warnings, isLocal),
135
168
  };
136
169
  }
137
170
 
@@ -140,7 +173,8 @@ export function normalizeConfig(
140
173
  */
141
174
  async function loadConfigFile(
142
175
  path: string,
143
- warnings: ConfigWarning[]
176
+ warnings: ConfigWarning[],
177
+ isLocal: boolean
144
178
  ): Promise<PermissionConfig | undefined> {
145
179
  let raw: string;
146
180
  try {
@@ -156,7 +190,7 @@ async function loadConfigFile(
156
190
  }
157
191
 
158
192
  try {
159
- return normalizeConfig(parseJsonc(raw), path, warnings);
193
+ return normalizeConfig(parseJsonc(raw), path, warnings, isLocal);
160
194
  } catch (error) {
161
195
  warnings.push({
162
196
  path,
@@ -186,6 +220,8 @@ function mergeBashSections(
186
220
 
187
221
  /**
188
222
  * Merges configs such that local config overrides global config.
223
+ * If local `settings.ignore_global` is true, global permission
224
+ * sections are ignored.
189
225
  */
190
226
  export function mergeConfigs(
191
227
  globalConfig: PermissionConfig | undefined,
@@ -196,6 +232,20 @@ export function mergeConfigs(
196
232
  }
197
233
 
198
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
+ }
199
249
 
200
250
  return {
201
251
  default: localConfig?.default ?? globalConfig.default,
@@ -205,10 +255,7 @@ export function mergeConfigs(
205
255
  ...globalConfig.external_directory,
206
256
  ...(localConfig?.external_directory ?? {}),
207
257
  },
208
- settings: {
209
- ...globalConfig.settings,
210
- ...(localConfig?.settings ?? {}),
211
- },
258
+ settings: mergedSettings,
212
259
  };
213
260
  }
214
261
 
@@ -220,8 +267,8 @@ export async function loadMergedConfig(
220
267
  localPath: string
221
268
  ): Promise<{ config: PermissionConfig; warnings: ConfigWarning[] }> {
222
269
  const warnings: ConfigWarning[] = [];
223
- const globalConfig = await loadConfigFile(globalPath, warnings);
224
- const localConfig = await loadConfigFile(localPath, warnings);
270
+ const globalConfig = await loadConfigFile(globalPath, warnings, false);
271
+ const localConfig = await loadConfigFile(localPath, warnings, true);
225
272
  return {
226
273
  config: mergeConfigs(globalConfig, localConfig),
227
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,
@@ -22,7 +24,10 @@ import {
22
24
  } from "./prompts.ts";
23
25
  import { buildRequest } from "./request-builder.ts";
24
26
  import { SessionApprovalStore } from "./session-approvals.ts";
25
- import { STATUS_SUMMARIZING_REQUEST } from "./ui-strings.ts";
27
+ import {
28
+ STATUS_CLASSIFYING_REQUEST,
29
+ STATUS_SUMMARIZING_REQUEST,
30
+ } from "./ui-strings.ts";
26
31
  import {
27
32
  CONFIG_WARNING_PREFIX,
28
33
  formatSuppressedConfigWarnings,
@@ -115,14 +120,34 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
115
120
  if (policy.state === ALLOW) { // auto allow
116
121
  return undefined;
117
122
  }
118
- if (!ctx.hasUI) { // no ui, auto deny
119
- return { block: true, reason: formatNoUiReason(request, policy) };
120
- }
121
123
 
122
124
  // check session approval
123
125
  const approval = findSessionApproval(state.approvals.getRules(), request);
124
126
  if (approval) {return undefined;} // approved, proceed
125
127
 
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
+
126
151
  ctx.ui.setStatus(STATUS_KEY, STATUS_SUMMARIZING_REQUEST);
127
152
  let purpose: string | undefined;
128
153
  try {
@@ -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);
57
60
  }
58
- return bashFallbackResolution(config);
61
+ if (isComplex && !appliesWhenComplex(match.state)) {
62
+ return undefined;
63
+ }
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,43 +88,19 @@ function combineBashSegmentResolutions(
76
88
  }
77
89
 
78
90
  /**
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
-
98
- /**
99
- * Resolves bash policy, including simple list splitting.
91
+ * Resolves bash policy, including command splitting.
100
92
  */
101
93
  export function resolveBashPolicy(
102
94
  config: PermissionConfig,
103
95
  request: PermissionRequest
104
96
  ): PolicyResolution {
105
97
  const analysis = bashAnalysisOf(request);
106
- if (analysis.isComplex) {
107
- return (
108
- resolveComplexBashDeny(config, analysis.segments) ??
109
- bashFallbackResolution(config)
110
- );
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);}
111
102
  }
112
- const resolutions = analysis.segments.map((segment) =>
113
- resolveBashSegment(config, segment)
114
- );
115
- return combineBashSegmentResolutions(resolutions);
103
+ return combineBashSegmentResolutions(votes) ?? bashFallbackResolution(config);
116
104
  }
117
105
 
118
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/types.ts CHANGED
@@ -22,8 +22,12 @@ export function toPermissionState(value: unknown): PermissionState | undefined {
22
22
  export type PermissionSection = Record<string, PermissionState>;
23
23
 
24
24
  export interface PermissionSettings {
25
+ /** Tells the local config to ignore global permission sections. */
26
+ ignore_global?: boolean;
25
27
  /** `provider/model-id` override for small helper completions. */
26
28
  ai_model?: string;
29
+ /** Criteria prompt override for what "auto" may allow. */
30
+ auto_allow_criteria?: string;
27
31
  }
28
32
 
29
33
  export interface PermissionConfig {
package/src/ui-strings.ts CHANGED
@@ -7,6 +7,7 @@ import { resolveAllowAlwaysMode } from "./policy-bash.ts";
7
7
  import type { PermissionRequest, PolicyResolution } from "./types.ts";
8
8
 
9
9
  export const STATUS_SUMMARIZING_REQUEST = "Summarizing permission request...";
10
+ export const STATUS_CLASSIFYING_REQUEST = "Classifying permission request...";
10
11
  export const STATUS_GENERATING_WILDCARD = "Generating bash wildcard...";
11
12
 
12
13
  export const PERMISSION_DIALOG_TITLE = "Permission Required";
@@ -11,6 +11,12 @@ export const IGNORED_SETTINGS_WARNING =
11
11
  'Ignoring "settings": expected an object.';
12
12
  export const IGNORED_AI_MODEL_WARNING =
13
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.';
14
20
 
15
21
  export const WILDCARD_LENGTH_REASON =
16
22
  "Pattern must be between 1 and 500 characters.";