@davisliu2006/pi-permission-system 0.1.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 +23 -8
- package/package.json +8 -4
- package/src/ai-gen.ts +28 -5
- package/src/ai-purpose.ts +3 -1
- package/src/ai-wildcard.ts +45 -4
- package/src/config.ts +59 -10
- package/src/constants.ts +2 -1
- package/src/extension.ts +24 -100
- package/src/permission-dialog.ts +48 -15
- package/src/policy-bash.ts +180 -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 +87 -16
- package/src/types-pi.ts +0 -9
- package/src/types.ts +30 -8
- package/src/ui-strings.ts +66 -21
- package/src/warning-strings.ts +58 -0
- package/src/wildcard-editor.ts +139 -0
- package/src/wildcard-validation.ts +68 -3
package/src/shell.ts
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Result of a conservative shell scan.
|
|
3
|
+
*
|
|
4
|
+
* When `isComplex` is false, `segments` are commands separated only by `;`,
|
|
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`.
|
|
10
|
+
*/
|
|
11
|
+
export type ShellCommandAnalysis = {
|
|
12
|
+
isComplex: boolean;
|
|
13
|
+
segments: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const COMPLEX: ShellCommandAnalysis = { isComplex: true, segments: [] };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Conservative shell command scanner.
|
|
20
|
+
* This is not a full parser and is biased toward `isComplex: true`.
|
|
21
|
+
*
|
|
22
|
+
* Splits on:
|
|
23
|
+
* - `;`, `&&`, `||`, and newlines outside quotes
|
|
24
|
+
* - '|' and '|&' outside quotes (also treated as complex)
|
|
25
|
+
*
|
|
26
|
+
* Treats as complex:
|
|
6
27
|
* - Substitutions like `command` and $(command)
|
|
7
|
-
* -
|
|
28
|
+
* - Pipes `|` and background `&`
|
|
8
29
|
* - Parentheses
|
|
9
30
|
*/
|
|
10
|
-
export function
|
|
31
|
+
export function analyzeShellCommand(command: string): ShellCommandAnalysis {
|
|
11
32
|
let quote: "'" | '"' | undefined;
|
|
12
33
|
let index = 0;
|
|
34
|
+
let current = "";
|
|
35
|
+
let isComplex = false;
|
|
36
|
+
const segments: string[] = [];
|
|
37
|
+
|
|
38
|
+
const pushCurrent = (): void => {
|
|
39
|
+
const trimmed = current.trim();
|
|
40
|
+
if (trimmed) {segments.push(trimmed);}
|
|
41
|
+
current = "";
|
|
42
|
+
};
|
|
13
43
|
|
|
14
44
|
while (index < command.length) {
|
|
15
45
|
const character = command[index]!;
|
|
@@ -17,52 +47,93 @@ export function hasShellChaining(command: string): boolean {
|
|
|
17
47
|
// inside quote
|
|
18
48
|
if (quote === "'") {
|
|
19
49
|
if (character === "'") {quote = undefined;}
|
|
50
|
+
current += character;
|
|
20
51
|
index++;
|
|
21
52
|
continue;
|
|
22
53
|
}
|
|
23
54
|
if (quote === '"') {
|
|
24
55
|
if (character === "\\") {
|
|
56
|
+
current += character + (command[index+1] ?? "");
|
|
25
57
|
index += 2;
|
|
26
58
|
continue;
|
|
27
59
|
}
|
|
28
60
|
if (character === '"') {
|
|
29
61
|
quote = undefined;
|
|
62
|
+
current += character;
|
|
30
63
|
index++;
|
|
31
64
|
continue;
|
|
32
65
|
}
|
|
33
|
-
if (character === "`") {return
|
|
34
|
-
if (character === "$" && command[index
|
|
66
|
+
if (character === "`") {return COMPLEX;}
|
|
67
|
+
if (character === "$" && command[index+1] === "(") {return COMPLEX;}
|
|
68
|
+
current += character;
|
|
35
69
|
index++;
|
|
36
70
|
continue;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
// escape character
|
|
40
74
|
if (character === "\\") {
|
|
75
|
+
current += character + (command[index+1] ?? "");
|
|
41
76
|
index += 2;
|
|
42
77
|
continue;
|
|
43
78
|
}
|
|
44
|
-
|
|
45
|
-
//
|
|
79
|
+
|
|
80
|
+
// start quote
|
|
46
81
|
if (character === "'") {
|
|
47
82
|
quote = "'";
|
|
83
|
+
current += character;
|
|
48
84
|
index++;
|
|
49
85
|
continue;
|
|
50
86
|
}
|
|
51
87
|
if (character === '"') {
|
|
52
88
|
quote = '"';
|
|
89
|
+
current += character;
|
|
53
90
|
index++;
|
|
54
91
|
continue;
|
|
55
92
|
}
|
|
56
93
|
|
|
57
|
-
if (character === "`") {return
|
|
58
|
-
if (character === "$" && command[index
|
|
59
|
-
if (character === "
|
|
60
|
-
if (character === "\n" || character === "\r") {return true;}
|
|
61
|
-
if (character === "(" || character === ")") {return true;}
|
|
94
|
+
if (character === "`") {return COMPLEX;}
|
|
95
|
+
if (character === "$" && command[index+1] === "(") {return COMPLEX;}
|
|
96
|
+
if (character === "(" || character === ")") {return COMPLEX;}
|
|
62
97
|
|
|
98
|
+
// simple separators
|
|
99
|
+
if (character === ";") {
|
|
100
|
+
pushCurrent();
|
|
101
|
+
index++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (character === "&") {
|
|
105
|
+
if (command[index+1] !== "&") {return COMPLEX;}
|
|
106
|
+
pushCurrent();
|
|
107
|
+
index += 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (character === "|") {
|
|
111
|
+
pushCurrent();
|
|
112
|
+
if (command[index+1] === "|") {
|
|
113
|
+
index += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
isComplex = true;
|
|
117
|
+
index += command[index+1] === "&" ? 2 : 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (character === "\n" || character === "\r") {
|
|
121
|
+
pushCurrent();
|
|
122
|
+
if (character === "\r" && command[index+1] === "\n") {index += 2;}
|
|
123
|
+
else {index++;}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
current += character;
|
|
63
128
|
index++;
|
|
64
129
|
}
|
|
65
130
|
|
|
66
131
|
// Unterminated quotes are unsafe to reason about.
|
|
67
|
-
|
|
132
|
+
if (quote !== undefined) {return COMPLEX;}
|
|
133
|
+
|
|
134
|
+
pushCurrent();
|
|
135
|
+
return {
|
|
136
|
+
isComplex,
|
|
137
|
+
segments: segments.length > 0 ? segments : [""],
|
|
138
|
+
};
|
|
68
139
|
}
|
package/src/types-pi.ts
CHANGED
|
@@ -23,12 +23,3 @@ export type PiToolInput =
|
|
|
23
23
|
| PathToolInput
|
|
24
24
|
| BashToolInput
|
|
25
25
|
| Record<string, unknown>;
|
|
26
|
-
|
|
27
|
-
export function validatePathToolInput(input: PiToolInput): PathToolInput | undefined {
|
|
28
|
-
if (!("path" in input) || typeof(input.path) !== "string") {return undefined;}
|
|
29
|
-
return input as PathToolInput;
|
|
30
|
-
}
|
|
31
|
-
export function validateBashToolInput(input: PiToolInput): BashToolInput | undefined {
|
|
32
|
-
if (!("command" in input) || typeof(input.command) !== "string") {return undefined;}
|
|
33
|
-
return input as BashToolInput;
|
|
34
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ASK, PERMISSION_STATES } from "./constants.ts";
|
|
2
|
+
import type { ShellCommandAnalysis } from "./shell.ts";
|
|
2
3
|
import type { PiToolInput } from "./types-pi.ts";
|
|
3
4
|
|
|
4
5
|
// utility functions
|
|
@@ -20,22 +21,42 @@ export function toPermissionState(value: unknown): PermissionState | undefined {
|
|
|
20
21
|
|
|
21
22
|
export type PermissionSection = Record<string, PermissionState>;
|
|
22
23
|
|
|
24
|
+
export interface PermissionSettings {
|
|
25
|
+
/** `provider/model-id` override for small helper completions. */
|
|
26
|
+
ai_model?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface PermissionConfig {
|
|
24
30
|
default: PermissionState;
|
|
25
31
|
tools: PermissionSection;
|
|
26
32
|
bash: PermissionSection;
|
|
27
33
|
external_directory: PermissionSection;
|
|
34
|
+
settings: PermissionSettings;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export function createDefaultPermissionConfig(): PermissionConfig {
|
|
31
|
-
return { default: ASK, tools: {}, bash: {}, external_directory: {} };
|
|
38
|
+
return { default: ASK, tools: {}, bash: {}, external_directory: {}, settings: {} };
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
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 };
|
|
55
|
+
}
|
|
35
56
|
|
|
36
57
|
// permission request
|
|
37
58
|
|
|
38
|
-
export type RequestBodyKind = "
|
|
59
|
+
export type RequestBodyKind = "path" | "command" | "generic";
|
|
39
60
|
|
|
40
61
|
/**
|
|
41
62
|
* Request for a tool.
|
|
@@ -44,6 +65,8 @@ export type RequestBodyKind = "command" | "path" | "generic";
|
|
|
44
65
|
* @property body - The body for the permission request.
|
|
45
66
|
* @property bodyKind - The meaning of the body.
|
|
46
67
|
* @property cwd - The current working directory.
|
|
68
|
+
* @property isExternalDirectory - Whether the request uses path to an external directory.
|
|
69
|
+
* @property bashAnalysis - Shell scan for bash requests.
|
|
47
70
|
*/
|
|
48
71
|
export interface PermissionRequest {
|
|
49
72
|
toolName: string;
|
|
@@ -52,12 +75,12 @@ export interface PermissionRequest {
|
|
|
52
75
|
bodyKind: RequestBodyKind;
|
|
53
76
|
cwd: string;
|
|
54
77
|
isExternalDirectory?: boolean;
|
|
55
|
-
|
|
78
|
+
bashAnalysis?: ShellCommandAnalysis;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// resolved policy
|
|
59
82
|
|
|
60
|
-
export type PolicySource = "
|
|
83
|
+
export type PolicySource = "default" | "tool" | "bash" | "external";
|
|
61
84
|
|
|
62
85
|
/**
|
|
63
86
|
* A resolved policy.
|
|
@@ -66,18 +89,17 @@ export interface PolicyResolution {
|
|
|
66
89
|
state: PermissionState;
|
|
67
90
|
source: PolicySource;
|
|
68
91
|
matchedPattern?: string;
|
|
69
|
-
isChainedBash: boolean;
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
export type AllowAlwaysMode = "wildcard" | "wildcard-list" | "exact";
|
|
95
|
+
|
|
72
96
|
// other types
|
|
73
97
|
|
|
74
98
|
export type AllowAlwaysRule = {
|
|
75
|
-
scope: "tool" | "external_directory";
|
|
76
99
|
kind: "exact";
|
|
77
100
|
tool: string;
|
|
78
101
|
body: string;
|
|
79
102
|
} | {
|
|
80
|
-
scope: "tool";
|
|
81
103
|
kind: "wildcard";
|
|
82
104
|
tool: "bash";
|
|
83
105
|
body: string;
|
package/src/ui-strings.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import {
|
|
2
3
|
ALLOW_ALWAYS_EXACT,
|
|
3
4
|
ALLOW_ALWAYS_WILDCARD,
|
|
4
5
|
} from "./constants.ts";
|
|
5
|
-
import {
|
|
6
|
+
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,57 +11,101 @@ 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
|
+
export const WILDCARD_LIST_EDITOR_TITLE = "Review bash wildcards (semicolon-separated)";
|
|
16
17
|
export const BROAD_WILDCARD_TITLE = "Very broad wildcard";
|
|
17
|
-
export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
|
|
18
18
|
|
|
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(
|
|
37
|
-
if (request.
|
|
38
|
-
lines.push(CHAINED_BASH_NOTE);
|
|
40
|
+
lines.push({ label: "Command", value: preview(request.body, 600) });
|
|
41
|
+
if (request.bashAnalysis?.isComplex) {
|
|
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
|
/**
|
|
53
|
-
* Label for the "
|
|
94
|
+
* Label for the "allow always" select option.
|
|
54
95
|
*/
|
|
55
96
|
export function formatAllowAlwaysOption(
|
|
56
97
|
request: PermissionRequest,
|
|
57
98
|
policy: PolicyResolution
|
|
58
99
|
): string {
|
|
59
|
-
return
|
|
60
|
-
?
|
|
61
|
-
:
|
|
100
|
+
return resolveAllowAlwaysMode(request, policy) === "exact"
|
|
101
|
+
? ALLOW_ALWAYS_EXACT
|
|
102
|
+
: ALLOW_ALWAYS_WILDCARD;
|
|
62
103
|
}
|
|
63
104
|
|
|
64
105
|
export function formatBroadWildcardConfirm(pattern: string): string {
|
|
65
106
|
return `This pattern can match many commands:\n\n${pattern}\n\nSave it for this session?`;
|
|
66
107
|
}
|
|
108
|
+
|
|
109
|
+
export function formatBroadWildcardListConfirm(patterns: readonly string[]): string {
|
|
110
|
+
return `These patterns can match many commands:\n\n${patterns.join("\n")}\n\nSave them for this session?`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { EXTENSION_NAME, PERMISSION_STATES_DESCRIPTION } from "./constants.ts";
|
|
2
|
+
|
|
3
|
+
export const CONFIG_WARNING_PREFIX = `${EXTENSION_NAME}: `;
|
|
4
|
+
export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_MUST_BE_OBJECT =
|
|
7
|
+
"Permission config must be a JSON object; using defaults.";
|
|
8
|
+
export const IGNORED_DEFAULT_WARNING =
|
|
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
|
+
|
|
15
|
+
export const WILDCARD_LENGTH_REASON =
|
|
16
|
+
"Pattern must be between 1 and 500 characters.";
|
|
17
|
+
export const WILDCARD_NO_MATCH_REASON =
|
|
18
|
+
"Pattern does not match the current command.";
|
|
19
|
+
|
|
20
|
+
export function formatIgnoredSectionWarning(sectionName: string): string {
|
|
21
|
+
return `Ignoring "${sectionName}": expected an object mapping names to ${PERMISSION_STATES_DESCRIPTION}.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatIgnoredSectionEntryWarning(
|
|
25
|
+
sectionName: string,
|
|
26
|
+
key: string
|
|
27
|
+
): string {
|
|
28
|
+
return `Ignoring "${sectionName}.${key}": expected ${PERMISSION_STATES_DESCRIPTION}.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatConfigReadFailure(error: unknown): string {
|
|
32
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return `Failed to read permission config: ${detail}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatConfigParseFailure(error: unknown): string {
|
|
37
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
38
|
+
return `Failed to parse permission config: ${detail}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatSuppressedConfigWarnings(suppressed: number): string {
|
|
42
|
+
return `${suppressed} additional config warning(s) suppressed.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatWildcardListCountReason(
|
|
46
|
+
expected: number,
|
|
47
|
+
actual: number
|
|
48
|
+
): string {
|
|
49
|
+
return `Expected ${expected} semicolon-separated pattern(s), got ${actual}.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatWildcardListItemReason(
|
|
53
|
+
index: number,
|
|
54
|
+
pattern: string,
|
|
55
|
+
reason: string
|
|
56
|
+
): string {
|
|
57
|
+
return `Pattern ${index} (${JSON.stringify(pattern)}): ${reason}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { generateBashWildcard, generateBashWildcards } from "./ai-wildcard.ts";
|
|
4
|
+
import { STATUS_KEY } from "./constants.ts";
|
|
5
|
+
import { resolveAllowAlwaysMode } from "./policy-bash.ts";
|
|
6
|
+
import { analyzeShellCommand } from "./shell.ts";
|
|
7
|
+
import {
|
|
8
|
+
BROAD_WILDCARD_TITLE,
|
|
9
|
+
formatBroadWildcardConfirm,
|
|
10
|
+
formatBroadWildcardListConfirm,
|
|
11
|
+
STATUS_GENERATING_WILDCARD,
|
|
12
|
+
WILDCARD_EDITOR_TITLE,
|
|
13
|
+
WILDCARD_LIST_EDITOR_TITLE,
|
|
14
|
+
} from "./ui-strings.ts";
|
|
15
|
+
import { INVALID_WILDCARD_PREFIX } from "./warning-strings.ts";
|
|
16
|
+
import {
|
|
17
|
+
validateWildcardSuggestion,
|
|
18
|
+
validateWildcardSuggestionList,
|
|
19
|
+
} from "./wildcard-validation.ts";
|
|
20
|
+
import type {
|
|
21
|
+
AllowAlwaysRule,
|
|
22
|
+
PermissionRequest,
|
|
23
|
+
PolicyResolution,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Single-command wildcard path.
|
|
28
|
+
*/
|
|
29
|
+
async function chooseSingleWildcard(
|
|
30
|
+
ctx: ExtensionContext,
|
|
31
|
+
command: string,
|
|
32
|
+
aiModel?: string
|
|
33
|
+
): Promise<AllowAlwaysRule | undefined> {
|
|
34
|
+
ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
|
|
35
|
+
let suggestion: string | undefined;
|
|
36
|
+
try {
|
|
37
|
+
suggestion = await generateBashWildcard(ctx, command, aiModel);
|
|
38
|
+
} finally {
|
|
39
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let current = suggestion ?? command;
|
|
43
|
+
while (true) {
|
|
44
|
+
const edited = await ctx.ui.editor(WILDCARD_EDITOR_TITLE, current);
|
|
45
|
+
if (edited === undefined) {return undefined;}
|
|
46
|
+
|
|
47
|
+
const pattern = edited.trim();
|
|
48
|
+
const validation = validateWildcardSuggestion(pattern, command);
|
|
49
|
+
if (!validation.valid) {
|
|
50
|
+
ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
|
|
51
|
+
current = pattern || command;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (validation.broad) {
|
|
56
|
+
const confirmed = await ctx.ui.confirm(
|
|
57
|
+
BROAD_WILDCARD_TITLE,
|
|
58
|
+
formatBroadWildcardConfirm(pattern)
|
|
59
|
+
);
|
|
60
|
+
if (!confirmed) {
|
|
61
|
+
current = pattern;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { kind: "wildcard", tool: "bash", body: pattern };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Multi-command wildcard path.
|
|
72
|
+
*/
|
|
73
|
+
async function chooseWildcardListRules(
|
|
74
|
+
ctx: ExtensionContext,
|
|
75
|
+
commands: readonly string[],
|
|
76
|
+
aiModel?: string
|
|
77
|
+
): Promise<AllowAlwaysRule[] | undefined> {
|
|
78
|
+
ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
|
|
79
|
+
let suggestion: string[] | undefined;
|
|
80
|
+
try {
|
|
81
|
+
suggestion = await generateBashWildcards(ctx, commands, aiModel);
|
|
82
|
+
} finally {
|
|
83
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let current = (suggestion ?? commands).join("; ");
|
|
87
|
+
while (true) {
|
|
88
|
+
const edited = await ctx.ui.editor(WILDCARD_LIST_EDITOR_TITLE, current);
|
|
89
|
+
if (edited === undefined) {return undefined;}
|
|
90
|
+
|
|
91
|
+
const validation = validateWildcardSuggestionList(edited, commands);
|
|
92
|
+
if (!validation.valid) {
|
|
93
|
+
ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
|
|
94
|
+
current = edited.trim() || commands.join("; ");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (validation.broadPatterns.length > 0) {
|
|
99
|
+
const confirmed = await ctx.ui.confirm(
|
|
100
|
+
BROAD_WILDCARD_TITLE,
|
|
101
|
+
formatBroadWildcardListConfirm(validation.broadPatterns)
|
|
102
|
+
);
|
|
103
|
+
if (!confirmed) {
|
|
104
|
+
current = validation.patterns.join("; ");
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return validation.patterns.map((pattern) => ({
|
|
110
|
+
kind: "wildcard" as const,
|
|
111
|
+
tool: "bash" as const,
|
|
112
|
+
body: pattern,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Selects the "allow always" matching rules for the request.
|
|
119
|
+
*/
|
|
120
|
+
export async function chooseAlwaysRules(
|
|
121
|
+
ctx: ExtensionContext,
|
|
122
|
+
request: PermissionRequest,
|
|
123
|
+
policy: PolicyResolution,
|
|
124
|
+
aiModel?: string
|
|
125
|
+
): Promise<AllowAlwaysRule[] | undefined> {
|
|
126
|
+
const mode = resolveAllowAlwaysMode(request, policy);
|
|
127
|
+
|
|
128
|
+
if (mode === "wildcard") {
|
|
129
|
+
const rule = await chooseSingleWildcard(ctx, request.body, aiModel);
|
|
130
|
+
return rule ? [rule] : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (mode === "wildcard-list") {
|
|
134
|
+
const analysis = request.bashAnalysis ?? analyzeShellCommand(request.body);
|
|
135
|
+
return chooseWildcardListRules(ctx, analysis.segments, aiModel);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [{ kind: "exact", tool: request.toolName, body: request.body }];
|
|
139
|
+
}
|
|
@@ -3,6 +3,29 @@ import {
|
|
|
3
3
|
isValidWildcardPattern,
|
|
4
4
|
matchesWildcard,
|
|
5
5
|
} from "./matcher.ts";
|
|
6
|
+
import {
|
|
7
|
+
formatWildcardListCountReason,
|
|
8
|
+
formatWildcardListItemReason,
|
|
9
|
+
WILDCARD_LENGTH_REASON,
|
|
10
|
+
WILDCARD_NO_MATCH_REASON,
|
|
11
|
+
} from "./warning-strings.ts";
|
|
12
|
+
|
|
13
|
+
export type WildcardValidationResult = {
|
|
14
|
+
valid: true;
|
|
15
|
+
broad: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
valid: false;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WildcardListValidationResult = {
|
|
22
|
+
valid: true;
|
|
23
|
+
patterns: string[];
|
|
24
|
+
broadPatterns: string[];
|
|
25
|
+
} | {
|
|
26
|
+
valid: false;
|
|
27
|
+
reason: string;
|
|
28
|
+
};
|
|
6
29
|
|
|
7
30
|
/**
|
|
8
31
|
* Validates a wildcard pattern against a command.
|
|
@@ -10,12 +33,54 @@ import {
|
|
|
10
33
|
export function validateWildcardSuggestion(
|
|
11
34
|
pattern: string,
|
|
12
35
|
command: string
|
|
13
|
-
):
|
|
36
|
+
): WildcardValidationResult {
|
|
14
37
|
if (!isValidWildcardPattern(pattern)) {
|
|
15
|
-
return { valid: false, reason:
|
|
38
|
+
return { valid: false, reason: WILDCARD_LENGTH_REASON };
|
|
16
39
|
}
|
|
17
40
|
if (!matchesWildcard(pattern, command)) {
|
|
18
|
-
return { valid: false, reason:
|
|
41
|
+
return { valid: false, reason: WILDCARD_NO_MATCH_REASON };
|
|
19
42
|
}
|
|
20
43
|
return { valid: true, broad: isMatchEverythingPattern(pattern) };
|
|
21
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Splits a semicolon-separated wildcard list from the model or editor.
|
|
48
|
+
*/
|
|
49
|
+
export function splitWildcardList(value: string): string[] {
|
|
50
|
+
return value
|
|
51
|
+
.split(";")
|
|
52
|
+
.map((part) => part.trim())
|
|
53
|
+
.filter((part) => part.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates semicolon-separated patterns against a list of commands.
|
|
58
|
+
*/
|
|
59
|
+
export function validateWildcardSuggestionList(
|
|
60
|
+
patternsText: string,
|
|
61
|
+
commands: readonly string[]
|
|
62
|
+
): WildcardListValidationResult {
|
|
63
|
+
const patterns = splitWildcardList(patternsText);
|
|
64
|
+
if (patterns.length !== commands.length) {
|
|
65
|
+
return {
|
|
66
|
+
valid: false,
|
|
67
|
+
reason: formatWildcardListCountReason(commands.length, patterns.length),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const broadPatterns: string[] = [];
|
|
72
|
+
for (let index = 0; index < patterns.length; index++) {
|
|
73
|
+
const pattern = patterns[index]!;
|
|
74
|
+
const command = commands[index]!;
|
|
75
|
+
const result = validateWildcardSuggestion(pattern, command);
|
|
76
|
+
if (!result.valid) {
|
|
77
|
+
return {
|
|
78
|
+
valid: false,
|
|
79
|
+
reason: formatWildcardListItemReason(index+1, pattern, result.reason),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (result.broad) {broadPatterns.push(pattern);}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { valid: true, patterns, broadPatterns };
|
|
86
|
+
}
|