@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 +35 -0
- package/README.md +21 -5
- package/package.json +5 -3
- package/src/ai-classifier.ts +53 -0
- package/src/config.ts +58 -11
- package/src/constants.ts +6 -2
- package/src/extension.ts +29 -4
- package/src/policy-bash.ts +33 -45
- package/src/prompts.ts +44 -7
- package/src/types.ts +4 -0
- package/src/ui-strings.ts +1 -0
- package/src/warning-strings.ts +6 -0
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
|
-
"
|
|
15
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
+
"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 {
|
|
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 {
|
package/src/policy-bash.ts
CHANGED
|
@@ -11,10 +11,15 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
const STATE_RANK: Record<PermissionState, number> = {
|
|
13
13
|
allow: 0,
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/src/warning-strings.ts
CHANGED
|
@@ -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.";
|