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