@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/permission-dialog.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } 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,
|
|
@@ -11,12 +13,21 @@ import type {
|
|
|
11
13
|
PolicyResolution,
|
|
12
14
|
} from "./types.ts";
|
|
13
15
|
import {
|
|
16
|
+
buildPlainSelectorSummary,
|
|
17
|
+
buildStyledSelectorSummary,
|
|
14
18
|
formatAllowAlwaysOption,
|
|
15
19
|
PERMISSION_DIALOG_QUESTION,
|
|
16
20
|
PERMISSION_DIALOG_TITLE,
|
|
17
|
-
PURPOSE_PREFIX,
|
|
18
21
|
} from "./ui-strings.ts";
|
|
19
22
|
|
|
23
|
+
function mapChoice(choice: string | undefined | null): PermissionDialogSelection {
|
|
24
|
+
if (choice === ALLOW_ONCE) {return { kind: "once" };}
|
|
25
|
+
if (choice === ALLOW_ALWAYS_EXACT || choice === ALLOW_ALWAYS_WILDCARD) {
|
|
26
|
+
return { kind: "always" };
|
|
27
|
+
}
|
|
28
|
+
return { kind: "reject" };
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* Shows permission dialog and gets input.
|
|
22
33
|
*/
|
|
@@ -24,26 +35,48 @@ export async function promptForPermission(
|
|
|
24
35
|
ctx: ExtensionContext,
|
|
25
36
|
request: PermissionRequest,
|
|
26
37
|
policy: PolicyResolution,
|
|
27
|
-
summary: string,
|
|
28
38
|
purpose?: string
|
|
29
39
|
): 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
40
|
const options = [ALLOW_ONCE, formatAllowAlwaysOption(request, policy), REJECT];
|
|
36
41
|
|
|
37
|
-
let choice: string | undefined;
|
|
38
42
|
try {
|
|
39
|
-
|
|
43
|
+
// non-tui mode fallback
|
|
44
|
+
if (ctx.mode !== "tui") {
|
|
45
|
+
const summary = buildPlainSelectorSummary(request, purpose);
|
|
46
|
+
const title = `${PERMISSION_DIALOG_TITLE}\n${summary}\n${PERMISSION_DIALOG_QUESTION}`;
|
|
47
|
+
return mapChoice(await ctx.ui.select(title, options));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// build custom tui
|
|
51
|
+
const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
52
|
+
const container = new Container();
|
|
53
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(PERMISSION_DIALOG_TITLE)), 1, 0));
|
|
54
|
+
for (const line of buildStyledSelectorSummary(theme, request, purpose)) {
|
|
55
|
+
container.addChild(new Text(line, 1, 0));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const selector = new ExtensionSelectorComponent(
|
|
59
|
+
PERMISSION_DIALOG_QUESTION,
|
|
60
|
+
options,
|
|
61
|
+
(option) => done(option),
|
|
62
|
+
() => done(null),
|
|
63
|
+
{ tui }
|
|
64
|
+
);
|
|
65
|
+
container.addChild(selector);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
render: (width: number) => container.render(width),
|
|
69
|
+
invalidate: () => container.invalidate(),
|
|
70
|
+
handleInput: (data: string) => {
|
|
71
|
+
selector.handleInput(data);
|
|
72
|
+
tui.requestRender();
|
|
73
|
+
},
|
|
74
|
+
dispose: () => selector.dispose(),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return mapChoice(choice);
|
|
40
79
|
} catch {
|
|
41
80
|
return { kind: "reject" };
|
|
42
81
|
}
|
|
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
82
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
* "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
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolves bash policy, including simple list splitting.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveBashPolicy(
|
|
102
|
+
config: PermissionConfig,
|
|
103
|
+
request: PermissionRequest
|
|
104
|
+
): PolicyResolution {
|
|
105
|
+
const analysis = bashAnalysisOf(request);
|
|
106
|
+
if (analysis.isComplex) {
|
|
107
|
+
return (
|
|
108
|
+
resolveComplexBashDeny(config, analysis.segments) ??
|
|
109
|
+
bashFallbackResolution(config)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const resolutions = analysis.segments.map((segment) =>
|
|
113
|
+
resolveBashSegment(config, segment)
|
|
114
|
+
);
|
|
115
|
+
return combineBashSegmentResolutions(resolutions);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Newest matching bash rule for one command segment.
|
|
120
|
+
*/
|
|
121
|
+
function findBashSegmentSessionApproval(
|
|
122
|
+
rules: readonly AllowAlwaysRule[],
|
|
123
|
+
segment: string
|
|
124
|
+
): AllowAlwaysRule | undefined {
|
|
125
|
+
for (let index = rules.length - 1; index >= 0; index--) {
|
|
126
|
+
const rule = rules[index]!;
|
|
127
|
+
if (rule.tool !== "bash") {continue;}
|
|
128
|
+
if (rule.kind === "exact") {
|
|
129
|
+
if (rule.body === segment) {return rule;}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (matchesWildcard(rule.body, segment)) {return rule;}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Session approval for bash, including per-segment matching for simple lists.
|
|
139
|
+
*/
|
|
140
|
+
export function findBashSessionApproval(
|
|
141
|
+
rules: readonly AllowAlwaysRule[],
|
|
142
|
+
request: PermissionRequest
|
|
143
|
+
): AllowAlwaysRule | undefined {
|
|
144
|
+
// check full exact match first
|
|
145
|
+
for (let index = rules.length - 1; index >= 0; index--) {
|
|
146
|
+
const rule = rules[index]!;
|
|
147
|
+
if (rule.tool === "bash" && rule.kind === "exact" && rule.body === request.body) {
|
|
148
|
+
return rule;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const analysis = bashAnalysisOf(request);
|
|
153
|
+
if (analysis.isComplex) {return undefined;} // complex only has full exact match
|
|
154
|
+
|
|
155
|
+
// check segments with wildcard / exact
|
|
156
|
+
let lastMatch: AllowAlwaysRule | undefined;
|
|
157
|
+
for (const segment of analysis.segments) {
|
|
158
|
+
const match = findBashSegmentSessionApproval(rules, segment);
|
|
159
|
+
if (!match) {return undefined;}
|
|
160
|
+
lastMatch = match;
|
|
161
|
+
}
|
|
162
|
+
return lastMatch;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* How "allow always" should persist this request for the session.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveAllowAlwaysMode(
|
|
169
|
+
request: PermissionRequest,
|
|
170
|
+
policy: PolicyResolution
|
|
171
|
+
): AllowAlwaysMode {
|
|
172
|
+
if (request.isExternalDirectory && policy.source === "external") {return "exact";}
|
|
173
|
+
if (request.toolName !== "bash") {return "exact";}
|
|
174
|
+
|
|
175
|
+
const analysis = bashAnalysisOf(request);
|
|
176
|
+
if (analysis.isComplex) {return "exact";}
|
|
177
|
+
if (analysis.segments.length > 1) {return "wildcard-list";}
|
|
178
|
+
if (analysis.segments.length === 1) {return "wildcard";}
|
|
179
|
+
return "exact";
|
|
180
|
+
}
|
package/src/policy.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
findBashSessionApproval,
|
|
3
|
+
resolveBashPolicy,
|
|
4
|
+
} from "./policy-bash.ts";
|
|
2
5
|
import type {
|
|
3
6
|
PermissionConfig,
|
|
4
7
|
PermissionRequest,
|
|
@@ -7,6 +10,8 @@ import type {
|
|
|
7
10
|
AllowAlwaysRule,
|
|
8
11
|
} from "./types.ts";
|
|
9
12
|
|
|
13
|
+
export { resolveAllowAlwaysMode } from "./policy-bash.ts";
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Returns the permission for that tool if specified in the config.
|
|
12
17
|
*/
|
|
@@ -17,13 +22,6 @@ function configuredToolState(
|
|
|
17
22
|
return Object.hasOwn(config.tools, toolName) ? config.tools[toolName] : undefined;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
/**
|
|
21
|
-
* Returns the permission for that tool or the default if not specified.
|
|
22
|
-
*/
|
|
23
|
-
function fallbackState(config: PermissionConfig, toolName: string): PermissionState {
|
|
24
|
-
return configuredToolState(config, toolName) ?? config.default;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
25
|
/**
|
|
28
26
|
* Returns the policy for the request using the correct precedence rules.
|
|
29
27
|
*/
|
|
@@ -40,36 +38,12 @@ export function resolvePolicy(
|
|
|
40
38
|
state: externalState,
|
|
41
39
|
source: "external",
|
|
42
40
|
matchedPattern: request.toolName,
|
|
43
|
-
isChainedBash: false,
|
|
44
41
|
};
|
|
45
42
|
}
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
if (request.toolName === "bash") {
|
|
49
|
-
|
|
50
|
-
if (chained) { // chained bash - can't match wildcard
|
|
51
|
-
return {
|
|
52
|
-
state: fallbackState(config, "bash"),
|
|
53
|
-
source: configuredToolState(config, "bash") ? "tool" : "default",
|
|
54
|
-
isChainedBash: true,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const match = findLastWildcardMatch(config.bash, request.body);
|
|
59
|
-
if (match) { // matched with wildcard
|
|
60
|
-
return {
|
|
61
|
-
state: match.state,
|
|
62
|
-
source: "bash",
|
|
63
|
-
matchedPattern: match.pattern,
|
|
64
|
-
isChainedBash: false,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return { // fallback to default
|
|
69
|
-
state: fallbackState(config, "bash"),
|
|
70
|
-
source: configuredToolState(config, "bash") ? "tool" : "default",
|
|
71
|
-
isChainedBash: false,
|
|
72
|
-
};
|
|
45
|
+
if (request.toolName === "bash") {
|
|
46
|
+
return resolveBashPolicy(config, request);
|
|
73
47
|
}
|
|
74
48
|
|
|
75
49
|
// other tool
|
|
@@ -77,7 +51,6 @@ export function resolvePolicy(
|
|
|
77
51
|
return {
|
|
78
52
|
state: toolState ?? config.default,
|
|
79
53
|
source: toolState ? "tool" : "default",
|
|
80
|
-
isChainedBash: false,
|
|
81
54
|
};
|
|
82
55
|
}
|
|
83
56
|
|
|
@@ -86,32 +59,19 @@ export function resolvePolicy(
|
|
|
86
59
|
*/
|
|
87
60
|
export function findSessionApproval(
|
|
88
61
|
rules: readonly AllowAlwaysRule[],
|
|
89
|
-
request: PermissionRequest
|
|
90
|
-
isChainedBash: boolean
|
|
62
|
+
request: PermissionRequest
|
|
91
63
|
): AllowAlwaysRule | undefined {
|
|
92
|
-
|
|
64
|
+
if (request.toolName === "bash") {
|
|
65
|
+
return findBashSessionApproval(rules, request);
|
|
66
|
+
}
|
|
93
67
|
|
|
94
|
-
for (let index = rules.length - 1; index >= 0; index
|
|
68
|
+
for (let index = rules.length - 1; index >= 0; index--) {
|
|
95
69
|
const rule = rules[index]!;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (isExternal) {continue;}
|
|
105
|
-
if (rule.tool !== request.toolName) {continue;}
|
|
106
|
-
|
|
107
|
-
if (rule.kind === "exact") {
|
|
108
|
-
if (rule.body === request.body) {return rule;}
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (rule.tool !== "bash" || request.bodyKind !== "command") {continue;}
|
|
113
|
-
if (isChainedBash) {continue;}
|
|
114
|
-
if (matchesWildcard(rule.body, request.body)) {return rule;}
|
|
70
|
+
if (
|
|
71
|
+
rule.kind === "exact"
|
|
72
|
+
&& rule.tool === request.toolName
|
|
73
|
+
&& rule.body === request.body
|
|
74
|
+
) {return rule;}
|
|
115
75
|
}
|
|
116
76
|
|
|
117
77
|
return undefined;
|
|
@@ -129,17 +89,3 @@ export function describePolicy(policy: PolicyResolution): string {
|
|
|
129
89
|
}
|
|
130
90
|
return `${policy.state} (default)`;
|
|
131
91
|
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* True when "Allow Always" will generate a bash wildcard instead of an exact rule.
|
|
135
|
-
* Mirrors the branching in chooseAlwaysRule.
|
|
136
|
-
*/
|
|
137
|
-
export function willGenerateBashWildcard(
|
|
138
|
-
request: PermissionRequest,
|
|
139
|
-
policy: PolicyResolution
|
|
140
|
-
): boolean {
|
|
141
|
-
if (request.isExternalDirectory && policy.source === "external") {return false;}
|
|
142
|
-
if (request.toolName !== "bash") {return false;}
|
|
143
|
-
if (policy.isChainedBash) {return false;}
|
|
144
|
-
return true;
|
|
145
|
-
}
|
package/src/prompts.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function describeBlockedSubject(
|
|
|
20
20
|
return `bash command ${JSON.stringify(request.body)}`;
|
|
21
21
|
}
|
|
22
22
|
if (request.bodyKind === "path") {
|
|
23
|
-
return
|
|
23
|
+
return `${request.toolName} access to ${JSON.stringify(request.body)}`;
|
|
24
24
|
}
|
|
25
25
|
return includeGenericSubject
|
|
26
26
|
? `tool ${JSON.stringify(request.toolName)} with input ${request.body}`
|
|
@@ -30,20 +30,13 @@ export function describeBlockedSubject(
|
|
|
30
30
|
/**
|
|
31
31
|
* Builds context for the ai-generated purpose.
|
|
32
32
|
*/
|
|
33
|
-
export function buildPurposePromptContext(
|
|
34
|
-
request: PermissionRequest,
|
|
35
|
-
policy: PolicyResolution
|
|
36
|
-
): string {
|
|
33
|
+
export function buildPurposePromptContext(request: PermissionRequest): string {
|
|
37
34
|
const lines = [
|
|
38
35
|
`Tool: ${request.toolName}`,
|
|
39
|
-
`Policy: ${describePolicy(policy)}`,
|
|
40
36
|
];
|
|
41
37
|
|
|
42
38
|
if (request.bodyKind === "command") {
|
|
43
39
|
lines.push(`Command:\n${preview(request.body, 600)}`);
|
|
44
|
-
if (policy.isChainedBash) {
|
|
45
|
-
lines.push(CHAINED_BASH_NOTE);
|
|
46
|
-
}
|
|
47
40
|
} else if (request.bodyKind === "path") {
|
|
48
41
|
lines.push(`Path: ${preview(request.body, 400)}`);
|
|
49
42
|
if (request.isExternalDirectory) {
|
|
@@ -63,6 +56,14 @@ export const PURPOSE_SYSTEM_PROMPT = [
|
|
|
63
56
|
|
|
64
57
|
export function buildPurposeInstructions(context: string): string {
|
|
65
58
|
return [
|
|
59
|
+
"Examples:",
|
|
60
|
+
"Tool: bash",
|
|
61
|
+
`Command: git commit -m "Add feature" && git log`,
|
|
62
|
+
"Commits the changes to the repository and lists the commit history.",
|
|
63
|
+
"Tool: bash",
|
|
64
|
+
`Command: grep -rnEI --color=never --exclude-dir={node_modules} --include=\\*.py -B 2 -A 3 "^[[:space:]]*[^/]*\\b(func_1|func_2)\\s*(" .`,
|
|
65
|
+
"Searches the project for instances of func_1 or func_2.",
|
|
66
|
+
"",
|
|
66
67
|
"Explain why the following request is being made in a single sentence.",
|
|
67
68
|
"Do not use markdown or quotes.",
|
|
68
69
|
"",
|
|
@@ -75,31 +76,56 @@ export const WILDCARD_SYSTEM_PROMPT = [
|
|
|
75
76
|
"Return only the pattern itself with no explanation or markdown.",
|
|
76
77
|
].join(" ");
|
|
77
78
|
|
|
79
|
+
export const WILDCARD_LIST_SYSTEM_PROMPT = [
|
|
80
|
+
"You generate shell command permission patterns for the user to approve.",
|
|
81
|
+
"Return only semicolon-separated patterns with no explanation or markdown.",
|
|
82
|
+
].join(" ");
|
|
83
|
+
|
|
84
|
+
const WILDCARD_RULES = [
|
|
85
|
+
"Rules for each pattern:",
|
|
86
|
+
"- Use wildcard syntax, not regular expressions.",
|
|
87
|
+
"- * matches any sequence of characters.",
|
|
88
|
+
"- ? matches exactly one character.",
|
|
89
|
+
"- \\* and \\? match literal characters.",
|
|
90
|
+
"- All other characters are literal.",
|
|
91
|
+
];
|
|
92
|
+
|
|
78
93
|
export function buildWildcardInstructions(command: string): string {
|
|
79
94
|
return [
|
|
80
95
|
"Create a permission wildcard for this command.",
|
|
81
96
|
"Select one that is general enough to be useful but narrow enough to not pose security risks.",
|
|
82
97
|
"",
|
|
83
|
-
|
|
84
|
-
"- Use wildcard syntax, not regular expressions.",
|
|
85
|
-
"- * matches any sequence of characters.",
|
|
86
|
-
"- ? matches exactly one character.",
|
|
87
|
-
"- \\* and \\? match literal characters.",
|
|
88
|
-
"- All other characters are literal.",
|
|
98
|
+
...WILDCARD_RULES,
|
|
89
99
|
"The pattern must match the given command.",
|
|
90
100
|
"",
|
|
91
101
|
`Command: ${JSON.stringify(command)}`,
|
|
92
102
|
].join("\n");
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Builds instructions for generating one wildcard per command in a simple list.
|
|
107
|
+
*/
|
|
108
|
+
export function buildWildcardListInstructions(commands: readonly string[]): string {
|
|
109
|
+
return [
|
|
110
|
+
"Create a permission wildcard for each command below.",
|
|
111
|
+
"Select patterns that are general enough to be useful but narrow enough to not pose security risks.",
|
|
112
|
+
"Return exactly one pattern per command, separated by semicolons, in the same order.",
|
|
113
|
+
"",
|
|
114
|
+
...WILDCARD_RULES,
|
|
115
|
+
"Each pattern must match its corresponding command.",
|
|
116
|
+
"",
|
|
117
|
+
`Commands: ${commands.join("; ")}`,
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
95
121
|
/**
|
|
96
122
|
* Prompt given to the agent on deny.
|
|
97
123
|
*/
|
|
98
124
|
export function formatDenyReason(request: PermissionRequest, policy: PolicyResolution): string {
|
|
99
125
|
const subject = describeBlockedSubject(request, true);
|
|
100
|
-
const chainedNote =
|
|
126
|
+
const chainedNote = request.bashAnalysis?.isComplex ? ` ${CHAINED_BASH_NOTE}` : "";
|
|
101
127
|
|
|
102
|
-
return `Permission denied for ${subject}. Policy: ${describePolicy(policy)}.${chainedNote} Hard stop: do not retry or investigate a bypass
|
|
128
|
+
return `Permission denied for ${subject}. Policy: ${describePolicy(policy)}.${chainedNote} Hard stop: do not retry or investigate a bypass. Report the denial to the user.`;
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
/**
|
|
@@ -114,5 +140,5 @@ export function formatNoUiReason(request: PermissionRequest, policy: PolicyResol
|
|
|
114
140
|
*/
|
|
115
141
|
export function formatRejectReason(request: PermissionRequest): string {
|
|
116
142
|
const subject = describeBlockedSubject(request, false);
|
|
117
|
-
return `User rejected ${subject}.
|
|
143
|
+
return `User rejected ${subject}. Pause the task immediately (do not do further analysis or run more tool requests) and concisely ask the user what to do next.`;
|
|
118
144
|
}
|
package/src/request-builder.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPathTool, resolveToolPath } from "./paths.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { analyzeShellCommand } from "./shell.ts";
|
|
3
3
|
import type { PiToolInput } from "./types-pi.ts";
|
|
4
4
|
import { toRecord, type PermissionRequest } from "./types.ts";
|
|
5
5
|
|
|
@@ -45,7 +45,7 @@ export async function buildRequest(
|
|
|
45
45
|
body: command,
|
|
46
46
|
bodyKind: "command",
|
|
47
47
|
cwd,
|
|
48
|
-
|
|
48
|
+
bashAnalysis: analyzeShellCommand(command),
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
package/src/session-approvals.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import type { AllowAlwaysRule } from "./types.ts";
|
|
2
2
|
|
|
3
|
+
function ruleEq(a: AllowAlwaysRule, b: AllowAlwaysRule): boolean {
|
|
4
|
+
return a.kind === b.kind && a.tool === b.tool && a.body === b.body;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
export class SessionApprovalStore {
|
|
8
|
+
/**
|
|
9
|
+
* Stored as an array rather than a set because JS
|
|
10
|
+
* objects don't compare by value.
|
|
11
|
+
*/
|
|
4
12
|
private readonly rules: AllowAlwaysRule[] = [];
|
|
5
13
|
|
|
6
14
|
add(rule: AllowAlwaysRule): void {
|
|
15
|
+
if (this.rules.some((existing) => ruleEq(existing, rule))) {return;}
|
|
7
16
|
this.rules.push(rule);
|
|
8
17
|
}
|
|
9
18
|
|