@davisliu2006/pi-permission-system 0.1.0 → 0.2.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 +8 -6
- package/package.json +4 -2
- package/src/ai-wildcard.ts +40 -3
- package/src/config.ts +16 -9
- package/src/constants.ts +2 -1
- package/src/extension.ts +16 -94
- package/src/permission-dialog.ts +58 -14
- package/src/policy-bash.ts +157 -0
- package/src/policy.ts +18 -72
- package/src/prompts.ts +44 -18
- package/src/request-builder.ts +2 -2
- package/src/session-approvals.ts +9 -0
- package/src/shell.ts +77 -16
- package/src/types-pi.ts +0 -9
- package/src/types.ts +8 -8
- package/src/ui-strings.ts +12 -8
- package/src/warning-strings.ts +54 -0
- package/src/wildcard-editor.ts +136 -0
- package/src/wildcard-validation.ts +68 -3
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ More specific rules override less specific rules:
|
|
|
49
49
|
|
|
50
50
|
### Global Configuration
|
|
51
51
|
|
|
52
|
-
Located in
|
|
52
|
+
Located in `~/.pi/agent/pi-permissions.jsonc`.
|
|
53
53
|
|
|
54
54
|
Example:
|
|
55
55
|
|
|
@@ -97,9 +97,9 @@ Example:
|
|
|
97
97
|
|
|
98
98
|
### Local Configuration
|
|
99
99
|
|
|
100
|
-
Located in
|
|
100
|
+
Located in the local Pi config folder:
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
`<project-root>/.pi/pi-permissions.jsonc`
|
|
103
103
|
|
|
104
104
|
Local configuration overrides global configuration, including `deny` rules.
|
|
105
105
|
|
|
@@ -120,8 +120,10 @@ Body matching rules:
|
|
|
120
120
|
|
|
121
121
|
## Bash Matching Safeguards
|
|
122
122
|
|
|
123
|
-
Bash matching safeguards against
|
|
123
|
+
Bash matching safeguards against unsafe shell syntax.
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
Simple sequences separated only by `;`, `&&`, `||`, or newlines are split and each segment is matched against `bash` rules. The strictest result wins (`deny` > `ask` > `allow`).
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
If a command contains other chaining or control syntax (`|`, `&`, command substitution, process substitution, or parentheses), `bash` pattern rules are skipped and the `tools.bash` policy is used instead, falling back to `default`.
|
|
128
|
+
|
|
129
|
+
"Allow always" for complex or multi-command 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.2.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -39,11 +39,13 @@
|
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@earendil-works/pi-ai": ">=0.78.0",
|
|
42
|
-
"@earendil-works/pi-coding-agent": ">=0.78.0"
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=0.78.0",
|
|
43
|
+
"@earendil-works/pi-tui": ">=0.78.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@earendil-works/pi-ai": "^0.78.1",
|
|
46
47
|
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
48
|
+
"@earendil-works/pi-tui": "^0.78.1",
|
|
47
49
|
"@types/node": "^22.10.0",
|
|
48
50
|
"typescript": "^5.7.3"
|
|
49
51
|
}
|
package/src/ai-wildcard.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { completeText } from "./ai-gen.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
buildWildcardInstructions,
|
|
5
|
+
buildWildcardListInstructions,
|
|
6
|
+
WILDCARD_LIST_SYSTEM_PROMPT,
|
|
7
|
+
WILDCARD_SYSTEM_PROMPT,
|
|
8
|
+
} from "./prompts.ts";
|
|
9
|
+
import {
|
|
10
|
+
validateWildcardSuggestion,
|
|
11
|
+
validateWildcardSuggestionList,
|
|
12
|
+
} from "./wildcard-validation.ts";
|
|
5
13
|
|
|
6
14
|
const WILDCARD_TIMEOUT_MS = 8000;
|
|
7
15
|
const MAX_WILDCARD_TOKENS = 120;
|
|
16
|
+
const MAX_WILDCARD_LIST_TOKENS = 240;
|
|
8
17
|
|
|
9
18
|
/**
|
|
10
19
|
* Sanitizes against:
|
|
@@ -26,7 +35,7 @@ function sanitizeWildcard(value: string): string | undefined {
|
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
* Generates
|
|
38
|
+
* Generates one bash wildcard recommendation.
|
|
30
39
|
*/
|
|
31
40
|
export async function generateBashWildcard(
|
|
32
41
|
ctx: ExtensionContext,
|
|
@@ -49,3 +58,31 @@ export async function generateBashWildcard(
|
|
|
49
58
|
|
|
50
59
|
return pattern;
|
|
51
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generates a list of bash wildcards.
|
|
64
|
+
* Expects semicolon-separated patterns back.
|
|
65
|
+
*/
|
|
66
|
+
export async function generateBashWildcards(
|
|
67
|
+
ctx: ExtensionContext,
|
|
68
|
+
commands: readonly string[]
|
|
69
|
+
): Promise<string[] | undefined> {
|
|
70
|
+
if (commands.length === 0) {return undefined;}
|
|
71
|
+
|
|
72
|
+
const text = await completeText(ctx, {
|
|
73
|
+
systemPrompt: WILDCARD_LIST_SYSTEM_PROMPT,
|
|
74
|
+
userText: buildWildcardListInstructions(commands),
|
|
75
|
+
maxTokens: MAX_WILDCARD_LIST_TOKENS,
|
|
76
|
+
timeoutMs: WILDCARD_TIMEOUT_MS,
|
|
77
|
+
joinWith: "\n",
|
|
78
|
+
});
|
|
79
|
+
if (!text) {return undefined;}
|
|
80
|
+
|
|
81
|
+
const sanitized = sanitizeWildcard(text);
|
|
82
|
+
if (!sanitized) {return undefined;}
|
|
83
|
+
|
|
84
|
+
const validation = validateWildcardSuggestionList(sanitized, commands);
|
|
85
|
+
if (!validation.valid) {return undefined;}
|
|
86
|
+
|
|
87
|
+
return validation.patterns;
|
|
88
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { ASK
|
|
2
|
+
import { ASK } from "./constants.ts";
|
|
3
3
|
import { parseJsonc } from "./jsonc.ts";
|
|
4
4
|
import {
|
|
5
|
-
DEFAULT_PERMISSION_CONFIG,
|
|
6
5
|
createDefaultPermissionConfig,
|
|
7
6
|
toPermissionState,
|
|
8
7
|
toRecord,
|
|
@@ -11,6 +10,14 @@ import {
|
|
|
11
10
|
type PermissionSection,
|
|
12
11
|
type PermissionState,
|
|
13
12
|
} from "./types.ts";
|
|
13
|
+
import {
|
|
14
|
+
CONFIG_MUST_BE_OBJECT,
|
|
15
|
+
formatConfigParseFailure,
|
|
16
|
+
formatConfigReadFailure,
|
|
17
|
+
formatIgnoredSectionEntryWarning,
|
|
18
|
+
formatIgnoredSectionWarning,
|
|
19
|
+
IGNORED_DEFAULT_WARNING,
|
|
20
|
+
} from "./warning-strings.ts";
|
|
14
21
|
|
|
15
22
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
16
23
|
|
|
@@ -29,7 +36,7 @@ function normalizeSection(
|
|
|
29
36
|
if (!record) {
|
|
30
37
|
warnings.push({
|
|
31
38
|
path: configPath,
|
|
32
|
-
message:
|
|
39
|
+
message: formatIgnoredSectionWarning(sectionName),
|
|
33
40
|
});
|
|
34
41
|
return {};
|
|
35
42
|
}
|
|
@@ -43,7 +50,7 @@ function normalizeSection(
|
|
|
43
50
|
} else {
|
|
44
51
|
warnings.push({
|
|
45
52
|
path: configPath,
|
|
46
|
-
message:
|
|
53
|
+
message: formatIgnoredSectionEntryWarning(sectionName, key),
|
|
47
54
|
});
|
|
48
55
|
}
|
|
49
56
|
}
|
|
@@ -63,7 +70,7 @@ function normalizeConfig(
|
|
|
63
70
|
if (!config) {
|
|
64
71
|
warnings.push({
|
|
65
72
|
path: configPath,
|
|
66
|
-
message:
|
|
73
|
+
message: CONFIG_MUST_BE_OBJECT,
|
|
67
74
|
});
|
|
68
75
|
return createDefaultPermissionConfig();
|
|
69
76
|
}
|
|
@@ -72,7 +79,7 @@ function normalizeConfig(
|
|
|
72
79
|
if (config.default !== undefined && parsedDefault === undefined) {
|
|
73
80
|
warnings.push({
|
|
74
81
|
path: configPath,
|
|
75
|
-
message:
|
|
82
|
+
message: IGNORED_DEFAULT_WARNING,
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
85
|
const defaultState = parsedDefault ?? ASK;
|
|
@@ -105,7 +112,7 @@ async function loadConfigFile(
|
|
|
105
112
|
if (code === "ENOENT") {return undefined;} // file does not exist
|
|
106
113
|
warnings.push({
|
|
107
114
|
path,
|
|
108
|
-
message:
|
|
115
|
+
message: formatConfigReadFailure(error),
|
|
109
116
|
});
|
|
110
117
|
return undefined;
|
|
111
118
|
}
|
|
@@ -115,7 +122,7 @@ async function loadConfigFile(
|
|
|
115
122
|
} catch (error) {
|
|
116
123
|
warnings.push({
|
|
117
124
|
path,
|
|
118
|
-
message:
|
|
125
|
+
message: formatConfigParseFailure(error),
|
|
119
126
|
});
|
|
120
127
|
return undefined;
|
|
121
128
|
}
|
|
@@ -150,7 +157,7 @@ export function mergeConfigs(
|
|
|
150
157
|
return createDefaultPermissionConfig();
|
|
151
158
|
}
|
|
152
159
|
|
|
153
|
-
globalConfig = globalConfig ??
|
|
160
|
+
globalConfig = globalConfig ?? createDefaultPermissionConfig();
|
|
154
161
|
|
|
155
162
|
return {
|
|
156
163
|
default: localConfig?.default ?? globalConfig.default,
|
package/src/constants.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const EXTENSION_NAME = "pi-permission-system";
|
|
2
2
|
export const STATUS_KEY = EXTENSION_NAME;
|
|
3
|
-
export const CONFIG_WARNING_PREFIX = `${EXTENSION_NAME}: `;
|
|
4
3
|
export const MAX_CONFIG_WARNINGS = 5;
|
|
5
4
|
|
|
6
5
|
export const CONFIG_FILENAME = "pi-permissions.jsonc";
|
|
6
|
+
/** Local config directory under the cwd. */
|
|
7
|
+
export const LOCAL_CONFIG_DIR_SEGMENTS = [".pi"] as const;
|
|
7
8
|
|
|
8
9
|
export const ALLOW = "allow";
|
|
9
10
|
export const ASK = "ask";
|
package/src/extension.ts
CHANGED
|
@@ -2,19 +2,18 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { clearPurposeCache, generateRequestPurpose } from "./ai-purpose.ts";
|
|
5
|
-
import { generateBashWildcard } from "./ai-wildcard.ts";
|
|
6
5
|
import { clearWildcardCache } from "./matcher.ts";
|
|
7
6
|
import {
|
|
8
7
|
ALLOW,
|
|
9
8
|
CONFIG_FILENAME,
|
|
10
|
-
CONFIG_WARNING_PREFIX,
|
|
11
9
|
DENY,
|
|
10
|
+
LOCAL_CONFIG_DIR_SEGMENTS,
|
|
12
11
|
MAX_CONFIG_WARNINGS,
|
|
13
12
|
STATUS_KEY,
|
|
14
13
|
} from "./constants.ts";
|
|
15
14
|
import { loadMergedConfig } from "./config.ts";
|
|
16
15
|
import { promptForPermission } from "./permission-dialog.ts";
|
|
17
|
-
import { findSessionApproval, resolvePolicy
|
|
16
|
+
import { findSessionApproval, resolvePolicy } from "./policy.ts";
|
|
18
17
|
import {
|
|
19
18
|
buildPurposePromptContext,
|
|
20
19
|
formatDenyReason,
|
|
@@ -24,21 +23,15 @@ import {
|
|
|
24
23
|
import { buildRequest } from "./request-builder.ts";
|
|
25
24
|
import { SessionApprovalStore } from "./session-approvals.ts";
|
|
26
25
|
import {
|
|
27
|
-
BROAD_WILDCARD_TITLE,
|
|
28
26
|
buildSelectorSummary,
|
|
29
|
-
formatBroadWildcardConfirm,
|
|
30
|
-
INVALID_WILDCARD_PREFIX,
|
|
31
|
-
STATUS_GENERATING_WILDCARD,
|
|
32
27
|
STATUS_SUMMARIZING_REQUEST,
|
|
33
|
-
WILDCARD_EDITOR_TITLE,
|
|
34
28
|
} from "./ui-strings.ts";
|
|
35
|
-
import { validateWildcardSuggestion } from "./wildcard-validation.ts";
|
|
36
29
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} from "./types.ts";
|
|
30
|
+
CONFIG_WARNING_PREFIX,
|
|
31
|
+
formatSuppressedConfigWarnings,
|
|
32
|
+
} from "./warning-strings.ts";
|
|
33
|
+
import { chooseAlwaysRules } from "./wildcard-editor.ts";
|
|
34
|
+
import type { PermissionConfig } from "./types.ts";
|
|
42
35
|
|
|
43
36
|
interface ExtensionState {
|
|
44
37
|
config?: PermissionConfig;
|
|
@@ -60,7 +53,7 @@ function createNewState(): ExtensionState {
|
|
|
60
53
|
async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
|
|
61
54
|
const { config, warnings } = await loadMergedConfig(
|
|
62
55
|
join(getAgentDir(), CONFIG_FILENAME),
|
|
63
|
-
join(ctx.cwd, CONFIG_FILENAME)
|
|
56
|
+
join(ctx.cwd, ...LOCAL_CONFIG_DIR_SEGMENTS, CONFIG_FILENAME)
|
|
64
57
|
);
|
|
65
58
|
|
|
66
59
|
if (ctx.hasUI) {
|
|
@@ -70,7 +63,7 @@ async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
|
|
|
70
63
|
if (warnings.length > MAX_CONFIG_WARNINGS) {
|
|
71
64
|
const suppressed = warnings.length - MAX_CONFIG_WARNINGS;
|
|
72
65
|
ctx.ui.notify(
|
|
73
|
-
`${CONFIG_WARNING_PREFIX}${suppressed}
|
|
66
|
+
`${CONFIG_WARNING_PREFIX}${formatSuppressedConfigWarnings(suppressed)}`,
|
|
74
67
|
"warning"
|
|
75
68
|
);
|
|
76
69
|
}
|
|
@@ -79,71 +72,6 @@ async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
|
|
|
79
72
|
return config;
|
|
80
73
|
}
|
|
81
74
|
|
|
82
|
-
/**
|
|
83
|
-
* Selects the "allow always" matching rule for the request.
|
|
84
|
-
*/
|
|
85
|
-
async function chooseAlwaysRule(
|
|
86
|
-
ctx: ExtensionContext,
|
|
87
|
-
request: PermissionRequest,
|
|
88
|
-
policy: PolicyResolution
|
|
89
|
-
): Promise<AllowAlwaysRule | undefined> {
|
|
90
|
-
if (request.isExternalDirectory && policy.source === "external") {
|
|
91
|
-
return {
|
|
92
|
-
scope: "external_directory",
|
|
93
|
-
tool: request.toolName,
|
|
94
|
-
kind: "exact",
|
|
95
|
-
body: request.body,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// no wildcard
|
|
100
|
-
if (!willGenerateBashWildcard(request, policy)) {
|
|
101
|
-
return { scope: "tool", tool: request.toolName, kind: "exact", body: request.body };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// generate wildcard
|
|
105
|
-
ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
|
|
106
|
-
let suggestion: string | undefined;
|
|
107
|
-
try {
|
|
108
|
-
suggestion = await generateBashWildcard(ctx, request.body);
|
|
109
|
-
} finally {
|
|
110
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// show wildcard editor
|
|
114
|
-
let initial = suggestion ?? request.body;
|
|
115
|
-
while (true) {
|
|
116
|
-
const edited = await ctx.ui.editor(WILDCARD_EDITOR_TITLE, initial);
|
|
117
|
-
if (edited === undefined) {return undefined;}
|
|
118
|
-
|
|
119
|
-
const pattern = edited.trim();
|
|
120
|
-
const validation = validateWildcardSuggestion(pattern, request.body);
|
|
121
|
-
if (!validation.valid) {
|
|
122
|
-
ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
|
|
123
|
-
initial = pattern || request.body;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (validation.broad) {
|
|
128
|
-
const confirmed = await ctx.ui.confirm(
|
|
129
|
-
BROAD_WILDCARD_TITLE,
|
|
130
|
-
formatBroadWildcardConfirm(pattern)
|
|
131
|
-
);
|
|
132
|
-
if (!confirmed) {
|
|
133
|
-
initial = pattern;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
scope: "tool",
|
|
140
|
-
tool: "bash",
|
|
141
|
-
kind: "wildcard",
|
|
142
|
-
body: pattern,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
75
|
/**
|
|
148
76
|
* Registers the extension.
|
|
149
77
|
*/
|
|
@@ -195,15 +123,7 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
|
|
|
195
123
|
}
|
|
196
124
|
|
|
197
125
|
// check session approval
|
|
198
|
-
const
|
|
199
|
-
policy.source === "external"
|
|
200
|
-
? request
|
|
201
|
-
: { ...request, isExternalDirectory: false };
|
|
202
|
-
const approval = findSessionApproval(
|
|
203
|
-
state.approvals.getRules(),
|
|
204
|
-
sessionRequest,
|
|
205
|
-
policy.isChainedBash
|
|
206
|
-
);
|
|
126
|
+
const approval = findSessionApproval(state.approvals.getRules(), request);
|
|
207
127
|
if (approval) {return undefined;} // approved, proceed
|
|
208
128
|
|
|
209
129
|
const summary = buildSelectorSummary(request);
|
|
@@ -213,7 +133,7 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
|
|
|
213
133
|
purpose = await generateRequestPurpose(
|
|
214
134
|
ctx,
|
|
215
135
|
request,
|
|
216
|
-
buildPurposePromptContext(request
|
|
136
|
+
buildPurposePromptContext(request)
|
|
217
137
|
);
|
|
218
138
|
} finally {
|
|
219
139
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -229,12 +149,14 @@ export function registerPermissionSystem(pi: ExtensionAPI): void {
|
|
|
229
149
|
}
|
|
230
150
|
|
|
231
151
|
// "allow always"
|
|
232
|
-
const
|
|
233
|
-
if (!
|
|
152
|
+
const rules = await chooseAlwaysRules(ctx, request, policy);
|
|
153
|
+
if (!rules) {
|
|
234
154
|
// the user cancelled the wildcard editor, return to the permission dialog
|
|
235
155
|
continue;
|
|
236
156
|
}
|
|
237
|
-
|
|
157
|
+
for (const rule of rules) {
|
|
158
|
+
state.approvals.add(rule);
|
|
159
|
+
}
|
|
238
160
|
return undefined;
|
|
239
161
|
}
|
|
240
162
|
});
|
package/src/permission-dialog.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ExtensionSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
2
4
|
import {
|
|
3
5
|
ALLOW_ALWAYS_EXACT,
|
|
4
6
|
ALLOW_ALWAYS_WILDCARD,
|
|
@@ -17,6 +19,22 @@ import {
|
|
|
17
19
|
PURPOSE_PREFIX,
|
|
18
20
|
} from "./ui-strings.ts";
|
|
19
21
|
|
|
22
|
+
function mapChoice(choice: string | undefined | null): PermissionDialogSelection {
|
|
23
|
+
if (choice === ALLOW_ONCE) {return { kind: "once" };}
|
|
24
|
+
if (choice === ALLOW_ALWAYS_EXACT || choice === ALLOW_ALWAYS_WILDCARD) {
|
|
25
|
+
return { kind: "always" };
|
|
26
|
+
}
|
|
27
|
+
return { kind: "reject" };
|
|
28
|
+
}
|
|
29
|
+
|
|
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
|
+
|
|
20
38
|
/**
|
|
21
39
|
* Shows permission dialog and gets input.
|
|
22
40
|
*/
|
|
@@ -27,23 +45,49 @@ export async function promptForPermission(
|
|
|
27
45
|
summary: string,
|
|
28
46
|
purpose?: string
|
|
29
47
|
): Promise<PermissionDialogSelection> {
|
|
30
|
-
// if there's no ui, just reject
|
|
31
|
-
if (!ctx.hasUI) {return { kind: "reject" };}
|
|
32
|
-
|
|
33
|
-
const purposeLine = purpose ? `\n${PURPOSE_PREFIX}${purpose}` : "";
|
|
34
|
-
const title = `${PERMISSION_DIALOG_TITLE}\n${summary}${purposeLine}\n${PERMISSION_DIALOG_QUESTION}`;
|
|
35
48
|
const options = [ALLOW_ONCE, formatAllowAlwaysOption(request, policy), REJECT];
|
|
36
49
|
|
|
37
|
-
let choice: string | undefined;
|
|
38
50
|
try {
|
|
39
|
-
|
|
51
|
+
// non-tui mode fallback
|
|
52
|
+
if (ctx.mode !== "tui") {
|
|
53
|
+
const purposeLine = purpose ? `\n${PURPOSE_PREFIX}${purpose}` : "";
|
|
54
|
+
const title = `${PERMISSION_DIALOG_TITLE}\n${summary}${purposeLine}\n${PERMISSION_DIALOG_QUESTION}`;
|
|
55
|
+
return mapChoice(await ctx.ui.select(title, options));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// build custom tui
|
|
59
|
+
const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
60
|
+
const container = new Container();
|
|
61
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(PERMISSION_DIALOG_TITLE)), 1, 0));
|
|
62
|
+
for (const line of summary.split("\n")) {
|
|
63
|
+
container.addChild(new Text(styleSummaryLine(theme, line), 1, 0));
|
|
64
|
+
}
|
|
65
|
+
if (purpose) {
|
|
66
|
+
container.addChild(new Text(styleSummaryLine(theme, `${PURPOSE_PREFIX}${purpose}`), 1, 0));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const selector = new ExtensionSelectorComponent(
|
|
70
|
+
PERMISSION_DIALOG_QUESTION,
|
|
71
|
+
options,
|
|
72
|
+
(option) => done(option),
|
|
73
|
+
() => done(null),
|
|
74
|
+
{ tui }
|
|
75
|
+
);
|
|
76
|
+
container.addChild(selector);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
render: (width: number) => container.render(width),
|
|
80
|
+
invalidate: () => container.invalidate(),
|
|
81
|
+
handleInput: (data: string) => {
|
|
82
|
+
selector.handleInput(data);
|
|
83
|
+
tui.requestRender();
|
|
84
|
+
},
|
|
85
|
+
dispose: () => selector.dispose(),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return mapChoice(choice);
|
|
40
90
|
} catch {
|
|
41
91
|
return { kind: "reject" };
|
|
42
92
|
}
|
|
43
|
-
|
|
44
|
-
if (choice === ALLOW_ONCE) {return { kind: "once" };}
|
|
45
|
-
if (choice === ALLOW_ALWAYS_EXACT || choice === ALLOW_ALWAYS_WILDCARD) {
|
|
46
|
-
return { kind: "always" };
|
|
47
|
-
}
|
|
48
|
-
return { kind: "reject" };
|
|
49
93
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { findLastWildcardMatch, matchesWildcard } from "./matcher.ts";
|
|
2
|
+
import { analyzeShellCommand, type ShellCommandAnalysis } from "./shell.ts";
|
|
3
|
+
import type {
|
|
4
|
+
AllowAlwaysMode,
|
|
5
|
+
AllowAlwaysRule,
|
|
6
|
+
PermissionConfig,
|
|
7
|
+
PermissionRequest,
|
|
8
|
+
PermissionState,
|
|
9
|
+
PolicyResolution,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const STATE_RANK: Record<PermissionState, number> = {
|
|
13
|
+
allow: 0,
|
|
14
|
+
ask: 1,
|
|
15
|
+
deny: 2,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Prefers the analysis stored on the request.
|
|
20
|
+
* Falls back for hand-built fixtures.
|
|
21
|
+
*/
|
|
22
|
+
function bashAnalysisOf(request: PermissionRequest): ShellCommandAnalysis {
|
|
23
|
+
return request.bashAnalysis ?? analyzeShellCommand(request.body);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The general permission state for bash.
|
|
28
|
+
*/
|
|
29
|
+
function generalBashToolState(
|
|
30
|
+
config: PermissionConfig
|
|
31
|
+
): PermissionState | undefined {
|
|
32
|
+
return Object.hasOwn(config.tools, "bash") ? config.tools.bash : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bashFallbackResolution(config: PermissionConfig): PolicyResolution {
|
|
36
|
+
const toolState = generalBashToolState(config);
|
|
37
|
+
return {
|
|
38
|
+
state: toolState ?? config.default,
|
|
39
|
+
source: toolState ? "tool" : "default",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves one simple bash segment against bash rules, then tools.bash / default.
|
|
45
|
+
*/
|
|
46
|
+
function resolveBashSegment(
|
|
47
|
+
config: PermissionConfig,
|
|
48
|
+
segment: string
|
|
49
|
+
): PolicyResolution {
|
|
50
|
+
const match = findLastWildcardMatch(config.bash, segment);
|
|
51
|
+
if (match) {
|
|
52
|
+
return {
|
|
53
|
+
state: match.state,
|
|
54
|
+
source: "bash",
|
|
55
|
+
matchedPattern: match.pattern,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return bashFallbackResolution(config);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Worst state wins across simple list segments.
|
|
63
|
+
* When ranks tie, keep the later segment's resolution.
|
|
64
|
+
*/
|
|
65
|
+
function combineBashSegmentResolutions(
|
|
66
|
+
resolutions: PolicyResolution[]
|
|
67
|
+
): PolicyResolution {
|
|
68
|
+
let worst = resolutions[0]!;
|
|
69
|
+
for (let index = 1; index < resolutions.length; index++) {
|
|
70
|
+
const next = resolutions[index]!;
|
|
71
|
+
if (STATE_RANK[next.state] >= STATE_RANK[worst.state]) {
|
|
72
|
+
worst = next;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return worst;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves bash policy, including simple list splitting.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveBashPolicy(
|
|
82
|
+
config: PermissionConfig,
|
|
83
|
+
request: PermissionRequest
|
|
84
|
+
): PolicyResolution {
|
|
85
|
+
const analysis = bashAnalysisOf(request);
|
|
86
|
+
if (analysis.isComplex) {
|
|
87
|
+
return bashFallbackResolution(config);
|
|
88
|
+
}
|
|
89
|
+
const resolutions = analysis.segments.map((segment) =>
|
|
90
|
+
resolveBashSegment(config, segment)
|
|
91
|
+
);
|
|
92
|
+
return combineBashSegmentResolutions(resolutions);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Newest matching bash rule for one command segment.
|
|
97
|
+
*/
|
|
98
|
+
function findBashSegmentSessionApproval(
|
|
99
|
+
rules: readonly AllowAlwaysRule[],
|
|
100
|
+
segment: string
|
|
101
|
+
): AllowAlwaysRule | undefined {
|
|
102
|
+
for (let index = rules.length - 1; index >= 0; index--) {
|
|
103
|
+
const rule = rules[index]!;
|
|
104
|
+
if (rule.tool !== "bash") {continue;}
|
|
105
|
+
if (rule.kind === "exact") {
|
|
106
|
+
if (rule.body === segment) {return rule;}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (matchesWildcard(rule.body, segment)) {return rule;}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Session approval for bash, including per-segment matching for simple lists.
|
|
116
|
+
*/
|
|
117
|
+
export function findBashSessionApproval(
|
|
118
|
+
rules: readonly AllowAlwaysRule[],
|
|
119
|
+
request: PermissionRequest
|
|
120
|
+
): AllowAlwaysRule | undefined {
|
|
121
|
+
// check full exact match first
|
|
122
|
+
for (let index = rules.length - 1; index >= 0; index--) {
|
|
123
|
+
const rule = rules[index]!;
|
|
124
|
+
if (rule.tool === "bash" && rule.kind === "exact" && rule.body === request.body) {
|
|
125
|
+
return rule;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const analysis = bashAnalysisOf(request);
|
|
130
|
+
if (analysis.isComplex) {return undefined;} // complex only has full exact match
|
|
131
|
+
|
|
132
|
+
// check segments with wildcard / exact
|
|
133
|
+
let lastMatch: AllowAlwaysRule | undefined;
|
|
134
|
+
for (const segment of analysis.segments) {
|
|
135
|
+
const match = findBashSegmentSessionApproval(rules, segment);
|
|
136
|
+
if (!match) {return undefined;}
|
|
137
|
+
lastMatch = match;
|
|
138
|
+
}
|
|
139
|
+
return lastMatch;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How "allow always" should persist this request for the session.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveAllowAlwaysMode(
|
|
146
|
+
request: PermissionRequest,
|
|
147
|
+
policy: PolicyResolution
|
|
148
|
+
): AllowAlwaysMode {
|
|
149
|
+
if (request.isExternalDirectory && policy.source === "external") {return "exact";}
|
|
150
|
+
if (request.toolName !== "bash") {return "exact";}
|
|
151
|
+
|
|
152
|
+
const analysis = bashAnalysisOf(request);
|
|
153
|
+
if (analysis.isComplex) {return "exact";}
|
|
154
|
+
if (analysis.segments.length > 1) {return "wildcard-list";}
|
|
155
|
+
if (analysis.segments.length === 1) {return "wildcard";}
|
|
156
|
+
return "exact";
|
|
157
|
+
}
|