@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.
@@ -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
- choice = await ctx.ui.select(title, options);
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 { findLastWildcardMatch, matchesWildcard } from "./matcher.ts";
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") { // bash
49
- const chained = request.isChainedBash === true;
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
- const isExternal = request.isExternalDirectory === true;
64
+ if (request.toolName === "bash") {
65
+ return findBashSessionApproval(rules, request);
66
+ }
93
67
 
94
- for (let index = rules.length - 1; index >= 0; index -= 1) {
68
+ for (let index = rules.length - 1; index >= 0; index--) {
95
69
  const rule = rules[index]!;
96
-
97
- if (rule.scope === "external_directory") {
98
- if (!isExternal) {continue;}
99
- if (rule.tool !== request.toolName) {continue;}
100
- if (rule.body === request.body) {return rule;}
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 `access to ${JSON.stringify(request.body)}`;
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
- "Rules for the pattern:",
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 = policy.isChainedBash ? ` ${CHAINED_BASH_NOTE}` : "";
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; report the denial to the user.`;
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}. Do not retry the rejected request. Ask the user what to do next.`;
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
  }
@@ -1,5 +1,5 @@
1
1
  import { isPathTool, resolveToolPath } from "./paths.ts";
2
- import { hasShellChaining } from "./shell.ts";
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
- isChainedBash: hasShellChaining(command),
48
+ bashAnalysis: analyzeShellCommand(command),
49
49
  };
50
50
  }
51
51
 
@@ -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