@davisliu2006/pi-permission-system 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +129 -3
- package/index.ts +6 -0
- package/package.json +52 -6
- package/src/ai-gen.ts +79 -0
- package/src/ai-purpose.ts +68 -0
- package/src/ai-wildcard.ts +88 -0
- package/src/config.ts +187 -0
- package/src/constants.ts +18 -0
- package/src/extension.ts +163 -0
- package/src/jsonc.ts +78 -0
- package/src/matcher.ts +116 -0
- package/src/paths.ts +89 -0
- package/src/permission-dialog.ts +93 -0
- package/src/policy-bash.ts +157 -0
- package/src/policy.ts +91 -0
- package/src/prompts.ts +144 -0
- package/src/request-builder.ts +73 -0
- package/src/session-approvals.ts +26 -0
- package/src/shell.ts +129 -0
- package/src/types-pi.ts +25 -0
- package/src/types.ts +93 -0
- package/src/ui-strings.ts +70 -0
- package/src/warning-strings.ts +54 -0
- package/src/wildcard-editor.ts +136 -0
- package/src/wildcard-validation.ts +86 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { clearPurposeCache, generateRequestPurpose } from "./ai-purpose.ts";
|
|
5
|
+
import { clearWildcardCache } from "./matcher.ts";
|
|
6
|
+
import {
|
|
7
|
+
ALLOW,
|
|
8
|
+
CONFIG_FILENAME,
|
|
9
|
+
DENY,
|
|
10
|
+
LOCAL_CONFIG_DIR_SEGMENTS,
|
|
11
|
+
MAX_CONFIG_WARNINGS,
|
|
12
|
+
STATUS_KEY,
|
|
13
|
+
} from "./constants.ts";
|
|
14
|
+
import { loadMergedConfig } from "./config.ts";
|
|
15
|
+
import { promptForPermission } from "./permission-dialog.ts";
|
|
16
|
+
import { findSessionApproval, resolvePolicy } from "./policy.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildPurposePromptContext,
|
|
19
|
+
formatDenyReason,
|
|
20
|
+
formatNoUiReason,
|
|
21
|
+
formatRejectReason,
|
|
22
|
+
} from "./prompts.ts";
|
|
23
|
+
import { buildRequest } from "./request-builder.ts";
|
|
24
|
+
import { SessionApprovalStore } from "./session-approvals.ts";
|
|
25
|
+
import {
|
|
26
|
+
buildSelectorSummary,
|
|
27
|
+
STATUS_SUMMARIZING_REQUEST,
|
|
28
|
+
} from "./ui-strings.ts";
|
|
29
|
+
import {
|
|
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";
|
|
35
|
+
|
|
36
|
+
interface ExtensionState {
|
|
37
|
+
config?: PermissionConfig;
|
|
38
|
+
configCwd?: string;
|
|
39
|
+
readonly approvals: SessionApprovalStore;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create an empty extension state.
|
|
44
|
+
*/
|
|
45
|
+
function createNewState(): ExtensionState {
|
|
46
|
+
return { approvals: new SessionApprovalStore() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Loads config for the cwd.
|
|
51
|
+
* Displays any warnings that are generated.
|
|
52
|
+
*/
|
|
53
|
+
async function loadConfig(ctx: ExtensionContext): Promise<PermissionConfig> {
|
|
54
|
+
const { config, warnings } = await loadMergedConfig(
|
|
55
|
+
join(getAgentDir(), CONFIG_FILENAME),
|
|
56
|
+
join(ctx.cwd, ...LOCAL_CONFIG_DIR_SEGMENTS, CONFIG_FILENAME)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (ctx.hasUI) {
|
|
60
|
+
for (const warning of warnings.slice(0, MAX_CONFIG_WARNINGS)) {
|
|
61
|
+
ctx.ui.notify(`${CONFIG_WARNING_PREFIX}${warning.message}`, "warning");
|
|
62
|
+
}
|
|
63
|
+
if (warnings.length > MAX_CONFIG_WARNINGS) {
|
|
64
|
+
const suppressed = warnings.length - MAX_CONFIG_WARNINGS;
|
|
65
|
+
ctx.ui.notify(
|
|
66
|
+
`${CONFIG_WARNING_PREFIX}${formatSuppressedConfigWarnings(suppressed)}`,
|
|
67
|
+
"warning"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Registers the extension.
|
|
77
|
+
*/
|
|
78
|
+
export function registerPermissionSystem(pi: ExtensionAPI): void {
|
|
79
|
+
const state = createNewState();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ensures the config matches with the cwd.
|
|
83
|
+
*/
|
|
84
|
+
const ensureConfig = async (ctx: ExtensionContext): Promise<PermissionConfig> => {
|
|
85
|
+
if (state.config && state.configCwd === ctx.cwd) {return state.config;}
|
|
86
|
+
const config = await loadConfig(ctx);
|
|
87
|
+
state.config = config;
|
|
88
|
+
state.configCwd = ctx.cwd;
|
|
89
|
+
return config;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// session start
|
|
93
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
94
|
+
state.approvals.clear();
|
|
95
|
+
clearPurposeCache();
|
|
96
|
+
clearWildcardCache();
|
|
97
|
+
state.config = undefined;
|
|
98
|
+
state.configCwd = undefined;
|
|
99
|
+
state.config = await ensureConfig(ctx);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// session end
|
|
103
|
+
pi.on("session_shutdown", () => {
|
|
104
|
+
state.approvals.clear();
|
|
105
|
+
clearPurposeCache();
|
|
106
|
+
clearWildcardCache();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// tool called
|
|
110
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
111
|
+
const request = await buildRequest(event.toolName, event.input, ctx.cwd);
|
|
112
|
+
const config = await ensureConfig(ctx);
|
|
113
|
+
const policy = resolvePolicy(config, request);
|
|
114
|
+
|
|
115
|
+
if (policy.state === DENY) { // auto deny
|
|
116
|
+
return { block: true, reason: formatDenyReason(request, policy) };
|
|
117
|
+
}
|
|
118
|
+
if (policy.state === ALLOW) { // auto allow
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!ctx.hasUI) { // no ui, auto deny
|
|
122
|
+
return { block: true, reason: formatNoUiReason(request, policy) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// check session approval
|
|
126
|
+
const approval = findSessionApproval(state.approvals.getRules(), request);
|
|
127
|
+
if (approval) {return undefined;} // approved, proceed
|
|
128
|
+
|
|
129
|
+
const summary = buildSelectorSummary(request);
|
|
130
|
+
ctx.ui.setStatus(STATUS_KEY, STATUS_SUMMARIZING_REQUEST);
|
|
131
|
+
let purpose: string | undefined;
|
|
132
|
+
try {
|
|
133
|
+
purpose = await generateRequestPurpose(
|
|
134
|
+
ctx,
|
|
135
|
+
request,
|
|
136
|
+
buildPurposePromptContext(request)
|
|
137
|
+
);
|
|
138
|
+
} finally {
|
|
139
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
while (true) {
|
|
143
|
+
const choice = await promptForPermission(ctx, request, policy, summary, purpose);
|
|
144
|
+
if (choice.kind === "once") { // approved, proceed
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (choice.kind === "reject") { // rejected
|
|
148
|
+
return { block: true, reason: formatRejectReason(request) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// "allow always"
|
|
152
|
+
const rules = await chooseAlwaysRules(ctx, request, policy);
|
|
153
|
+
if (!rules) {
|
|
154
|
+
// the user cancelled the wildcard editor, return to the permission dialog
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const rule of rules) {
|
|
158
|
+
state.approvals.add(rule);
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
package/src/jsonc.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips comments from JSON.
|
|
3
|
+
*/
|
|
4
|
+
function stripComments(input: string): string {
|
|
5
|
+
let output = "";
|
|
6
|
+
let inString = false; // comments cannot exist in string
|
|
7
|
+
let escaped = false;
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < input.length; i++) {
|
|
10
|
+
const char = input[i]!;
|
|
11
|
+
if (inString) {
|
|
12
|
+
output += char;
|
|
13
|
+
if (escaped) {escaped = false;}
|
|
14
|
+
else if (char === "\\") {escaped = true;}
|
|
15
|
+
else if (char === '"') {inString = false;}
|
|
16
|
+
} else if (char === '"') {
|
|
17
|
+
inString = true;
|
|
18
|
+
output += char;
|
|
19
|
+
} else if (char === "/" && input[i+1] === "/") { // single-line comment
|
|
20
|
+
i += 2;
|
|
21
|
+
while (i < input.length && input[i] !== "\n") {i++;}
|
|
22
|
+
if (i < input.length) {output += "\n";}
|
|
23
|
+
} else if (char === "/" && input[i+1] === "*") { // multi-line comment
|
|
24
|
+
i += 2;
|
|
25
|
+
while (i < input.length && !(input[i] === "*" && input[i+1] === "/")) {
|
|
26
|
+
if (input[i] === "\n") {output += "\n";}
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
i++;
|
|
30
|
+
} else {
|
|
31
|
+
output += char;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strips extraneous trailing commas from JSON.
|
|
40
|
+
*/
|
|
41
|
+
function stripTrailingCommas(input: string): string {
|
|
42
|
+
let output = "";
|
|
43
|
+
let inString = false; // trailing comma cannot exist in string
|
|
44
|
+
let escaped = false;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < input.length; i++) {
|
|
47
|
+
const char = input[i]!;
|
|
48
|
+
if (inString) {
|
|
49
|
+
output += char;
|
|
50
|
+
if (escaped) {escaped = false;}
|
|
51
|
+
else if (char === "\\") {escaped = true;}
|
|
52
|
+
else if (char === '"') {inString = false;}
|
|
53
|
+
} else if (char === '"') {
|
|
54
|
+
inString = true;
|
|
55
|
+
output += char;
|
|
56
|
+
} else if (char === ",") {
|
|
57
|
+
let lookahead = i+1;
|
|
58
|
+
while (lookahead < input.length && /\s/.test(input[lookahead]!)) {lookahead++;}
|
|
59
|
+
if (input[lookahead] === "}" || input[lookahead] === "]") {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
output += char;
|
|
63
|
+
} else {
|
|
64
|
+
output += char;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return output;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert JSONC to standard JSON.
|
|
73
|
+
*/
|
|
74
|
+
export function parseJsonc(input: string): unknown {
|
|
75
|
+
const withoutComments = stripComments(input);
|
|
76
|
+
const withoutTrailingCommas = stripTrailingCommas(withoutComments);
|
|
77
|
+
return JSON.parse(withoutTrailingCommas);
|
|
78
|
+
}
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { PermissionState } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const MAX_PATTERN_LENGTH = 500; // guard cache size
|
|
4
|
+
const NEVER_MATCH = /$^/;
|
|
5
|
+
|
|
6
|
+
export interface WildcardMatch {
|
|
7
|
+
pattern: string;
|
|
8
|
+
state: PermissionState;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Cache for the compileWildcard function.
|
|
13
|
+
*/
|
|
14
|
+
const cache = new Map<string, RegExp>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clears the compiled wildcard cache.
|
|
18
|
+
*/
|
|
19
|
+
export function clearWildcardCache(): void {
|
|
20
|
+
cache.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* If the character is a special regex character, prepend '\'.
|
|
25
|
+
*/
|
|
26
|
+
function escapeRegexChar(char: string): string {
|
|
27
|
+
return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Converts wildcard to regex.
|
|
32
|
+
*/
|
|
33
|
+
function compileWildcard(pattern: string): RegExp {
|
|
34
|
+
const cached = cache.get(pattern);
|
|
35
|
+
if (cached) {return cached;}
|
|
36
|
+
|
|
37
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
38
|
+
cache.set(pattern, NEVER_MATCH);
|
|
39
|
+
return NEVER_MATCH;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let regexSource = "";
|
|
43
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
44
|
+
const char = pattern[i];
|
|
45
|
+
if (char === "\\") { // escape
|
|
46
|
+
const next = pattern[i+1];
|
|
47
|
+
if (next === "*" || next === "?") {
|
|
48
|
+
regexSource += `\\${next}`;
|
|
49
|
+
i++;
|
|
50
|
+
} else {
|
|
51
|
+
regexSource += "\\\\";
|
|
52
|
+
}
|
|
53
|
+
} else if (char === "*") {
|
|
54
|
+
regexSource += ".*";
|
|
55
|
+
} else if (char === "?") {
|
|
56
|
+
regexSource += ".";
|
|
57
|
+
} else {
|
|
58
|
+
regexSource += escapeRegexChar(char);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const compiled = new RegExp(`^${regexSource}$`, "s");
|
|
63
|
+
cache.set(pattern, compiled);
|
|
64
|
+
return compiled;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True if the string matches wildcard.
|
|
69
|
+
*/
|
|
70
|
+
export function matchesWildcard(pattern: string, str: string): boolean {
|
|
71
|
+
return compileWildcard(pattern).test(str);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Finds the last wildcard match in a list of rules.
|
|
76
|
+
*/
|
|
77
|
+
export function findLastWildcardMatch(
|
|
78
|
+
rules: Record<string, PermissionState>,
|
|
79
|
+
str: string
|
|
80
|
+
): WildcardMatch | undefined {
|
|
81
|
+
const entries = Object.entries(rules);
|
|
82
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
83
|
+
const [pattern, state] = entries[i]!;
|
|
84
|
+
if (matchesWildcard(pattern, str)) {
|
|
85
|
+
return { pattern, state };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True if the pattern is equivalent to "*".
|
|
93
|
+
*/
|
|
94
|
+
export function isMatchEverythingPattern(pattern: string): boolean {
|
|
95
|
+
if (!pattern) {return false;}
|
|
96
|
+
let sawStar = false;
|
|
97
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
98
|
+
const char = pattern[i];
|
|
99
|
+
if (char === "\\") {
|
|
100
|
+
const next = pattern[i+1];
|
|
101
|
+
if (next === "*" || next === "?") {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
i++;
|
|
105
|
+
} else if (char === "*") {
|
|
106
|
+
sawStar = true;
|
|
107
|
+
} else if (char !== "?") {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return sawStar;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isValidWildcardPattern(pattern: string): boolean {
|
|
115
|
+
return pattern.length > 0 && pattern.length <= MAX_PATTERN_LENGTH;
|
|
116
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
|
|
6
|
+
|
|
7
|
+
export interface ResolvedToolPath {
|
|
8
|
+
absolute: string;
|
|
9
|
+
permissionPath: string;
|
|
10
|
+
outsideCwd: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True if the string is one of the builtin path tools:
|
|
15
|
+
* "read", "write", "edit", "grep", "find", or "ls".
|
|
16
|
+
*/
|
|
17
|
+
export function isPathTool(toolName: string): boolean {
|
|
18
|
+
return PATH_TOOLS.has(toolName);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Expands "~" to the home directory.
|
|
23
|
+
*/
|
|
24
|
+
export function expandHome(filePath: string): string {
|
|
25
|
+
if (filePath === "~") {return homedir();}
|
|
26
|
+
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
27
|
+
return join(homedir(), filePath.slice(2));
|
|
28
|
+
}
|
|
29
|
+
return filePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes the path to use '/' as a separator.
|
|
34
|
+
*/
|
|
35
|
+
export function toPermissionPath(filePath: string): string {
|
|
36
|
+
return filePath.split(sep).join("/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True if target is outside cwd.
|
|
41
|
+
*/
|
|
42
|
+
export function isPathOutside(cwd: string, target: string): boolean {
|
|
43
|
+
const relativePath = relative(cwd, target);
|
|
44
|
+
return (
|
|
45
|
+
relativePath === ".." ||
|
|
46
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
47
|
+
isAbsolute(relativePath)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gets the actual path of a path with a symlink.
|
|
53
|
+
*/
|
|
54
|
+
async function resolvePathWithSymlink(absolutePath: string): Promise<string> {
|
|
55
|
+
let current = absolutePath;
|
|
56
|
+
const missingSegments: string[] = [];
|
|
57
|
+
|
|
58
|
+
while (true) {
|
|
59
|
+
try {
|
|
60
|
+
const resolved = await realpath(current);
|
|
61
|
+
return missingSegments.length > 0 ? join(resolved, ...missingSegments.reverse()) : resolved;
|
|
62
|
+
} catch {
|
|
63
|
+
const parent = dirname(current);
|
|
64
|
+
if (parent === current) {
|
|
65
|
+
return absolutePath;
|
|
66
|
+
}
|
|
67
|
+
missingSegments.push(current.slice(parent.length).replace(/^[\\/]+/, ""));
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns a ResolvedToolPath from cwd and raw requested path.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveToolPath(
|
|
77
|
+
cwd: string,
|
|
78
|
+
rawPath: string
|
|
79
|
+
): Promise<ResolvedToolPath> {
|
|
80
|
+
const absolute = resolve(cwd, expandHome(rawPath));
|
|
81
|
+
const resolvedAbsolute = await resolvePathWithSymlink(absolute);
|
|
82
|
+
const resolvedCwd = await resolvePathWithSymlink(cwd);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
absolute: resolvedAbsolute,
|
|
86
|
+
permissionPath: toPermissionPath(resolvedAbsolute),
|
|
87
|
+
outsideCwd: isPathOutside(resolvedCwd, resolvedAbsolute),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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";
|
|
4
|
+
import {
|
|
5
|
+
ALLOW_ALWAYS_EXACT,
|
|
6
|
+
ALLOW_ALWAYS_WILDCARD,
|
|
7
|
+
ALLOW_ONCE,
|
|
8
|
+
REJECT,
|
|
9
|
+
} from "./constants.ts";
|
|
10
|
+
import type {
|
|
11
|
+
PermissionDialogSelection,
|
|
12
|
+
PermissionRequest,
|
|
13
|
+
PolicyResolution,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
import {
|
|
16
|
+
formatAllowAlwaysOption,
|
|
17
|
+
PERMISSION_DIALOG_QUESTION,
|
|
18
|
+
PERMISSION_DIALOG_TITLE,
|
|
19
|
+
PURPOSE_PREFIX,
|
|
20
|
+
} from "./ui-strings.ts";
|
|
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
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Shows permission dialog and gets input.
|
|
40
|
+
*/
|
|
41
|
+
export async function promptForPermission(
|
|
42
|
+
ctx: ExtensionContext,
|
|
43
|
+
request: PermissionRequest,
|
|
44
|
+
policy: PolicyResolution,
|
|
45
|
+
summary: string,
|
|
46
|
+
purpose?: string
|
|
47
|
+
): Promise<PermissionDialogSelection> {
|
|
48
|
+
const options = [ALLOW_ONCE, formatAllowAlwaysOption(request, policy), REJECT];
|
|
49
|
+
|
|
50
|
+
try {
|
|
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);
|
|
90
|
+
} catch {
|
|
91
|
+
return { kind: "reject" };
|
|
92
|
+
}
|
|
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
|
+
}
|