@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 +35 -0
- package/README.md +36 -7
- package/package.json +8 -4
- package/src/ai-classifier.ts +53 -0
- package/src/ai-gen.ts +28 -5
- package/src/ai-purpose.ts +3 -1
- package/src/ai-wildcard.ts +6 -2
- package/src/config.ts +95 -6
- package/src/constants.ts +6 -2
- package/src/extension.ts +35 -8
- package/src/permission-dialog.ts +7 -18
- package/src/policy-bash.ts +33 -22
- package/src/prompts.ts +44 -7
- package/src/shell.ts +19 -9
- package/src/types.ts +27 -1
- package/src/ui-strings.ts +56 -14
- package/src/warning-strings.ts +10 -0
- package/src/wildcard-editor.ts +10 -7
- package/src/wildcard-validation.ts +1 -1
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:
|
|
@@ -29,7 +37,7 @@ Reject
|
|
|
29
37
|
|
|
30
38
|
## Configuration
|
|
31
39
|
|
|
32
|
-
The supported top-level keys are: `default`, `tools`, `bash`, and `
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
55
|
+
const model = resolveCompletionModel(ctx, options.aiModel);
|
|
56
|
+
if (!model) {return undefined;}
|
|
34
57
|
|
|
35
58
|
try {
|
|
36
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(
|
|
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
|
-
|
|
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
|
|
package/src/ai-wildcard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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;
|
package/src/permission-dialog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionContext
|
|
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
|
|
54
|
-
const title = `${PERMISSION_DIALOG_TITLE}\n${summary}
|
|
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
|
|
63
|
-
container.addChild(new Text(
|
|
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(
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
};
|
|
58
|
+
if (!match) {
|
|
59
|
+
return isComplex ? undefined : bashFallbackResolution(config);
|
|
60
|
+
}
|
|
61
|
+
if (isComplex && !appliesWhenComplex(match.state)) {
|
|
62
|
+
return undefined;
|
|
57
63
|
}
|
|
58
|
-
return
|
|
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,20 +88,19 @@ function combineBashSegmentResolutions(
|
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
/**
|
|
79
|
-
* Resolves bash policy, including
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const lines = [
|
|
32
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/src/warning-strings.ts
CHANGED
|
@@ -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.";
|
package/src/wildcard-editor.ts
CHANGED
|
@@ -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
|
|
79
|
+
reason: formatWildcardListItemReason(index+1, pattern, result.reason),
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
if (result.broad) {broadPatterns.push(pattern);}
|