surgent 0.7.0-alpha.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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,342 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, type Focusable, type TUI } from "@earendil-works/pi-tui";
3
+ import type {
4
+ Category,
5
+ DisplayRule,
6
+ FileAccess,
7
+ GroupedDisplayRules,
8
+ PermissionRule,
9
+ } from "../types.js";
10
+ import { Frame } from "../../ui/components/frame.js";
11
+ import { Lines } from "../../ui/components/lines.js";
12
+ import { FormField } from "../../ui/components/form-field.js";
13
+ import { CATEGORIES } from "../constants.js";
14
+ import {
15
+ cycleRuleScope,
16
+ cycleRuleValue,
17
+ formatRuleOptionLabel,
18
+ getRulePatternPlaceholder,
19
+ } from "../helpers.js";
20
+
21
+ type RuleOptionEntry = {
22
+ rule: DisplayRule;
23
+ option: FormField;
24
+ deleted: boolean;
25
+ };
26
+
27
+ export default class PermissionRulesList extends Frame implements Focusable {
28
+ private cursor = 0;
29
+ private escapeCount = 0;
30
+ private _focused = false;
31
+ private saved = true;
32
+ private readonly options = new Map<Category, RuleOptionEntry[]>();
33
+
34
+ onDone?: (action: "exit" | "add") => void;
35
+ onSave?: (data: {
36
+ session: PermissionRule;
37
+ project: PermissionRule;
38
+ global: PermissionRule;
39
+ }) => Promise<void> | void;
40
+ onSaveErr?: (error: unknown) => void;
41
+
42
+ constructor(
43
+ tui: TUI,
44
+ keybindings: KeybindingsManager,
45
+ protected theme: Theme,
46
+ groups: GroupedDisplayRules,
47
+ ) {
48
+ super(theme);
49
+
50
+ for (const [group, rules] of Object.entries(groups)) {
51
+ const category = group as Category;
52
+ const options = rules.map((rule) =>
53
+ this.createRuleOption(tui, keybindings, theme, category, rule),
54
+ );
55
+ this.options.set(category, options);
56
+ }
57
+
58
+ this.registerKeybindings([
59
+ {
60
+ key: Key.escape,
61
+ hint: "exit",
62
+ handler: () => {
63
+ if (this.saved) {
64
+ this.onDone?.("exit");
65
+ return;
66
+ }
67
+
68
+ this.escapeCount++;
69
+ const timeout = setTimeout(() => {
70
+ this.escapeCount = 0;
71
+ }, 1000);
72
+
73
+ if (this.escapeCount >= 2) {
74
+ clearTimeout(timeout);
75
+ this.onDone?.("exit");
76
+ }
77
+ },
78
+ },
79
+ {
80
+ key: Key.ctrl("s"),
81
+ hint: "save",
82
+ handler: () => {
83
+ if (!this.saved) this.save().catch(this.onSaveErr);
84
+ },
85
+ },
86
+ {
87
+ key: { navigation: "vertical" },
88
+ hint: "navigate",
89
+ navigate: (keyId) => {
90
+ if (keyId === Key.up) {
91
+ this.cursor = Math.max(0, this.cursor - 1);
92
+ } else {
93
+ this.cursor = Math.min(this.getTotalRules(), this.cursor + 1);
94
+ }
95
+ this.setHint(Key.enter, this.cursor === 0 ? "select" : "edit");
96
+ },
97
+ },
98
+ {
99
+ key: Key.ctrl("d"),
100
+ hint: "delete",
101
+ handler: () => {
102
+ const selectedEntry = this.getSelectedRule();
103
+ if (!selectedEntry) return;
104
+
105
+ selectedEntry.deleted = true;
106
+ this.saved = false;
107
+ this.cursor = Math.min(this.cursor, this.getTotalRules());
108
+ },
109
+ },
110
+ {
111
+ key: Key.enter,
112
+ hint: "select",
113
+ handler: () => {
114
+ if (this.cursor === 0) {
115
+ if (this.saved) {
116
+ this.onDone?.("add");
117
+ } else {
118
+ this.save()
119
+ .then(() => this.onDone?.("add"))
120
+ .catch(this.onSaveErr);
121
+ }
122
+ return;
123
+ }
124
+
125
+ const selectedEntry = this.getSelectedRule();
126
+ if (!selectedEntry) return;
127
+ selectedEntry.option.startEditing();
128
+ this.setEditing(true);
129
+ },
130
+ },
131
+ {
132
+ key: Key.tab,
133
+ hint: "cycle permission",
134
+ handler: () => {
135
+ const selectedEntry = this.getSelectedRule();
136
+ selectedEntry && this.cycleScope(selectedEntry);
137
+ },
138
+ },
139
+ {
140
+ key: Key.shift("tab"),
141
+ hint: "cycle scope",
142
+ handler: () => {
143
+ const selectedEntry = this.getSelectedRule();
144
+ selectedEntry && this.cyclePermission(selectedEntry);
145
+ },
146
+ },
147
+ ]);
148
+ }
149
+
150
+ get focused(): boolean {
151
+ return this._focused;
152
+ }
153
+
154
+ set focused(value: boolean) {
155
+ this._focused = value;
156
+ const selectedEntry = this.getSelectedRule();
157
+ if (selectedEntry) {
158
+ selectedEntry.option.focused = value;
159
+ }
160
+ }
161
+
162
+ private setEditing(value: boolean) {
163
+ this.setHint(Key.enter, value ? "finish editing" : "edit");
164
+ this.setHint(Key.escape, value ? "cancel editing" : "cancel");
165
+
166
+ this.setArrowKeyAccess({ navigation: "vertical" }, !value);
167
+ this.setKeyAccess(Key.enter, { consumable: !value });
168
+ this.setKeyAccess(Key.escape, { consumable: !value });
169
+ this.setKeyAccess(Key.ctrl("d"), !value);
170
+ this.setKeyAccess(Key.ctrl("s"), !value);
171
+ }
172
+
173
+ override invalidate() {
174
+ super.invalidate();
175
+ for (const options of this.options.values()) {
176
+ for (const option of options) {
177
+ option.option.invalidate();
178
+ }
179
+ }
180
+ }
181
+
182
+ protected override children(width: number): string[] {
183
+ const lines = new Lines(width);
184
+ const unsavedWarning = this.theme.fg("warning", !this.saved ? " (unsaved)" : "");
185
+
186
+ lines.add(this.theme.bold("All permission rules") + this.theme.fg("warning", unsavedWarning));
187
+ lines.space();
188
+
189
+ const addSelected = this.cursor === 0;
190
+ const addText = `${addSelected ? "→" : " "} [Add new permission rule]`;
191
+ lines.add(this.theme.fg(addSelected ? "accent" : "text", addText));
192
+
193
+ let orderedIndex = 1;
194
+ for (const category of CATEGORIES) {
195
+ const visibleEntries = this.getOptionsForCategory(category);
196
+ if (visibleEntries.length === 0) continue;
197
+
198
+ lines.space();
199
+ lines.add(this.theme.fg("muted", category), 2);
200
+
201
+ for (const entry of visibleEntries) {
202
+ const isSelected = this.cursor === orderedIndex;
203
+ const prefix = isSelected ? this.theme.fg("accent", "→") : " ";
204
+ entry.option.highlighted = isSelected;
205
+
206
+ const renderedOption = entry.option.render(width - 4);
207
+ if (renderedOption.length > 0) {
208
+ lines.add(`${prefix} ${renderedOption[0]!}`, 2);
209
+ }
210
+ orderedIndex++;
211
+ }
212
+ }
213
+
214
+ if (this.escapeCount === 1) {
215
+ lines.space();
216
+ lines.add(this.theme.fg("warning", "Press Esc/Ctrl+C again to exit without saving."), 1);
217
+ }
218
+
219
+ return lines.get();
220
+ }
221
+
222
+ handleInput(data: string) {
223
+ if (this.handleKb(data)) return;
224
+ const entry = this.getSelectedRule();
225
+ entry?.option.handleInput(data);
226
+ }
227
+
228
+ private async save() {
229
+ const global: PermissionRule = {};
230
+ const session: PermissionRule = {};
231
+ const project: PermissionRule = {};
232
+
233
+ for (const [category, options] of this.options.entries()) {
234
+ for (const optionEntry of options) {
235
+ if (optionEntry.deleted) continue;
236
+ const rule = optionEntry.rule;
237
+
238
+ let target: PermissionRule;
239
+ if (rule.scope === "always") target = global;
240
+ else if (rule.scope === "project") target = project;
241
+ else target = session;
242
+
243
+ if (category === "file") {
244
+ target.file = { ...target.file, [rule.pattern]: rule.value as FileAccess };
245
+ } else if (category === "web") {
246
+ target.web = { ...target.web, [rule.pattern]: rule.value as boolean };
247
+ } else if (category === "mcp") {
248
+ target.mcp = { ...target.mcp, [rule.pattern]: rule.value as boolean };
249
+ } else {
250
+ target.bash = { ...target.bash, [rule.pattern]: rule.value as boolean };
251
+ }
252
+ }
253
+ }
254
+
255
+ const data = { session, project, global };
256
+ await this.onSave?.(data);
257
+ this.saved = true;
258
+ }
259
+
260
+ private getSelectedRule(): RuleOptionEntry | undefined {
261
+ if (this.cursor === 0) return;
262
+ const visibleOptions = this.getVisibleOptions();
263
+ return visibleOptions[this.cursor - 1];
264
+ }
265
+
266
+ private getVisibleOptions(): RuleOptionEntry[] {
267
+ const visibleOptions: RuleOptionEntry[] = [];
268
+ for (const category of CATEGORIES) {
269
+ visibleOptions.push(...this.getOptionsForCategory(category));
270
+ }
271
+ return visibleOptions;
272
+ }
273
+
274
+ private getOptionsForCategory(category: Category): RuleOptionEntry[] {
275
+ const options = this.options.get(category) ?? [];
276
+ return options.filter((optionEntry) => !optionEntry.deleted);
277
+ }
278
+
279
+ private getTotalRules() {
280
+ return this.getVisibleOptions().length;
281
+ }
282
+
283
+ private createRuleOption(
284
+ tui: TUI,
285
+ keybindings: KeybindingsManager,
286
+ theme: Theme,
287
+ category: Category,
288
+ rule: DisplayRule,
289
+ ): RuleOptionEntry {
290
+ const option = new FormField(tui, keybindings, theme, {
291
+ key: "pattern",
292
+ label: formatRuleOptionLabel(rule.scope, rule.value),
293
+ mode: {
294
+ type: "input",
295
+ placeholder: getRulePatternPlaceholder(category),
296
+ text: rule.pattern,
297
+ },
298
+ });
299
+
300
+ const optionEntry: RuleOptionEntry = { rule, option, deleted: false };
301
+
302
+ option.onInputSubmit = (inputValue) => {
303
+ const inputPattern = inputValue.trim();
304
+ const nextPattern = inputPattern || optionEntry.rule.pattern.trim();
305
+ if (!nextPattern) {
306
+ return false;
307
+ }
308
+
309
+ if (nextPattern !== optionEntry.rule.pattern) {
310
+ optionEntry.rule.pattern = nextPattern;
311
+ option.setText(nextPattern);
312
+ this.saved = false;
313
+ }
314
+
315
+ this.setEditing(false);
316
+ return true;
317
+ };
318
+
319
+ option.onInputCancel = () => {
320
+ option.setText(optionEntry.rule.pattern);
321
+ this.setEditing(false);
322
+ };
323
+
324
+ return optionEntry;
325
+ }
326
+
327
+ private cycleScope(optionEntry: RuleOptionEntry) {
328
+ cycleRuleScope(optionEntry.rule);
329
+ optionEntry.option.setLabel(
330
+ formatRuleOptionLabel(optionEntry.rule.scope, optionEntry.rule.value),
331
+ );
332
+ this.saved = false;
333
+ }
334
+
335
+ private cyclePermission(optionEntry: RuleOptionEntry) {
336
+ cycleRuleValue(optionEntry.rule);
337
+ optionEntry.option.setLabel(
338
+ formatRuleOptionLabel(optionEntry.rule.scope, optionEntry.rule.value),
339
+ );
340
+ this.saved = false;
341
+ }
342
+ }
@@ -0,0 +1,48 @@
1
+ export const SCOPES = ["session", "project", "always"] as const;
2
+ export const CATEGORIES = ["file", "web", "bash", "mcp"] as const;
3
+
4
+ export const PERMISSIVE_TOOLS = {
5
+ read: "file",
6
+ write: "file",
7
+ edit: "file",
8
+ grep: "file",
9
+ bash: "bash",
10
+ web_fetch: "web",
11
+ call_mcp_tool: "mcp",
12
+ } as const;
13
+
14
+ export const SUSPICIOUS_BASH_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
15
+ // Command injection syntax
16
+ { pattern: /\$\(/, reason: "Command substitution $(...)" },
17
+ { pattern: /`[^`]+`/, reason: "Backtick command substitution" },
18
+ { pattern: /\$\{[^}]*}/, reason: "Variable/command expansion ${...}" },
19
+
20
+ // Inline shell execution
21
+ { pattern: /\|\s*(?:bash|sh|zsh|fish|ksh|csh)/, reason: "Pipe into shell" },
22
+ { pattern: /(?:bash|sh|zsh)\s+-c/, reason: "Inline shell execution" },
23
+ { pattern: /\beval\b/, reason: "eval" },
24
+ { pattern: /\bexec\b/, reason: "exec" },
25
+
26
+ // Remote fetch
27
+ { pattern: /\bcurl\s/, reason: "Potential remote execution with curl" },
28
+ { pattern: /\bwget\s/, reason: "Potential remote execution with wget" },
29
+ { pattern: /\bfetch\s/, reason: "Potential remote execution with fetch" },
30
+
31
+ // Reverse shell / network tricks
32
+ { pattern: /\/dev\/tcp/, reason: "Bash TCP redirect" },
33
+ { pattern: /\/dev\/udp/, reason: "Bash UDP redirect" },
34
+ { pattern: /\bnc\b/, reason: "netcat" },
35
+ { pattern: /\bncat\b/, reason: "ncat" },
36
+ { pattern: /\bsocat\b/, reason: "socat" },
37
+
38
+ // Obfuscation
39
+ { pattern: /\$'[^']*'/, reason: "ANSI-C quoting (possible obfuscation)" },
40
+ { pattern: /\$\(\(/, reason: "arithmetic expansion" },
41
+ { pattern: /\bbase64\b/, reason: "base64 (possible encoded payload)" },
42
+ { pattern: /\\x[0-9a-f]{2}/i, reason: "Hex escape sequence" },
43
+ { pattern: /\\u[0-9a-f]{4}/i, reason: "Unicode escape sequence" },
44
+
45
+ // Privilege escalation
46
+ { pattern: /\bsudo\b/, reason: "Privilege escalation with sudo" },
47
+ { pattern: /\bsu\b/, reason: "Privilege escalation with su" },
48
+ ];
@@ -0,0 +1,156 @@
1
+ import type { BashCommand, PermissionCheck, PermissiveToolName } from "./types.js";
2
+ import { PERMISSIVE_TOOLS, SUSPICIOUS_BASH_PATTERNS } from "./constants.js";
3
+ import { SCOPES } from "./constants.js";
4
+ import type { Category, DisplayRule, FileAccess, Scope } from "./types.js";
5
+ import type { AgentMode } from "../agent/types.js";
6
+ import { extractBashCommands } from "./bash.js";
7
+ import { unique } from "../utils.js";
8
+
9
+ function getRuleValueLabel(value: FileAccess | boolean): string {
10
+ if (typeof value === "boolean") {
11
+ return value ? "allowed" : "disallowed";
12
+ }
13
+ if (value === "read") {
14
+ return "read only";
15
+ }
16
+ if (value === "write") {
17
+ return "full access";
18
+ }
19
+ return value;
20
+ }
21
+
22
+ export function getScopeLabel(scope: Scope) {
23
+ return scope !== "always" ? `[this ${scope}]` : `[${scope}]`;
24
+ }
25
+
26
+ export function formatRuleOptionLabel(scope: Scope, value: FileAccess | boolean): string {
27
+ return `${getScopeLabel(scope)} ${getRuleValueLabel(value)}`;
28
+ }
29
+
30
+ export function getRulePatternPlaceholder(category: Category): string {
31
+ if (category === "file") {
32
+ return "Path or glob pattern (example: src/**/*.ts)";
33
+ }
34
+ if (category === "web") {
35
+ return "Host or URL pattern (example: api.example.com/**)";
36
+ }
37
+ if (category === "mcp") {
38
+ return "MCP server and tool (example: github:search)";
39
+ }
40
+ return "Command pattern (example: git * or pnpm test)";
41
+ }
42
+
43
+ export function cycleRuleScope(rule: DisplayRule) {
44
+ const scopeIndex = SCOPES.indexOf(rule.scope);
45
+ rule.scope = SCOPES[(scopeIndex + 1) % SCOPES.length]!;
46
+ }
47
+
48
+ export function cycleRuleValue(rule: DisplayRule) {
49
+ if (typeof rule.value === "boolean") {
50
+ rule.value = !rule.value;
51
+ return;
52
+ }
53
+ const fileOps = ["read", "write", "blocked"] as const;
54
+ const valueIndex = fileOps.findIndex((value) => value === rule.value);
55
+ rule.value = fileOps[(valueIndex + 1) % fileOps.length]!;
56
+ }
57
+
58
+ function getBashUncertainty(command: BashCommand): string | undefined {
59
+ if (command.unresolved) return "Dynamic or invalid bash command";
60
+ for (const { pattern, reason } of SUSPICIOUS_BASH_PATTERNS) {
61
+ if (pattern.test(command.text)) return reason;
62
+ }
63
+ }
64
+
65
+ export function getPermissionCheck(
66
+ sessionId: string,
67
+ toolName: string,
68
+ input: Record<string, unknown>,
69
+ ): PermissionCheck | null {
70
+ if (!(toolName in PERMISSIVE_TOOLS)) return null;
71
+ const typedName = toolName as PermissiveToolName;
72
+ const check: PermissionCheck = {
73
+ sessionId,
74
+ toolName: typedName,
75
+ category: PERMISSIVE_TOOLS[typedName],
76
+ raw: "",
77
+ purpose: "",
78
+ extracted: [],
79
+ };
80
+
81
+ switch (typedName) {
82
+ case "read":
83
+ check.raw = input.path as string;
84
+ check.purpose = `Read content from file ${check.raw}`;
85
+ check.extracted = [`read:${check.raw}`];
86
+ break;
87
+ case "write":
88
+ case "edit":
89
+ check.raw = input.path as string;
90
+ check.purpose = `Write content to file ${check.raw}`;
91
+ check.extracted = [`write:${check.raw}`];
92
+ break;
93
+ case "grep":
94
+ check.raw = (input.path as string | undefined) ?? ".";
95
+ check.purpose = `Perform search in path ${check.raw}`;
96
+ check.extracted = [`read:${check.raw}`];
97
+ break;
98
+ case "bash":
99
+ check.raw = input.command as string;
100
+ check.purpose = input.purpose as string;
101
+ break;
102
+ case "web_fetch":
103
+ check.raw = input.url as string;
104
+ check.purpose = `Fetch content from URL ${input.url}`;
105
+ check.extracted = [check.raw];
106
+ break;
107
+ case "call_mcp_tool":
108
+ check.raw = `${(input.server as string).trim()}:${(input.tool as string).trim()}`;
109
+ check.purpose = `Call MCP tool ${check.raw}`;
110
+ check.extracted = [check.raw];
111
+ break;
112
+ }
113
+
114
+ if (typedName === "bash") {
115
+ const commands = extractBashCommands(check.raw);
116
+ const uncertainty = commands.map(getBashUncertainty).filter(Boolean);
117
+ check.extracted = commands.map(({ text }) => text);
118
+ check.uncertainty = unique(uncertainty).join("; ") || undefined;
119
+ }
120
+
121
+ return check;
122
+ }
123
+
124
+ export function cycleMode(mode: AgentMode): AgentMode {
125
+ switch (mode) {
126
+ case "assistant":
127
+ return "yolo";
128
+ case "yolo":
129
+ return "restricted";
130
+ case "restricted":
131
+ return "assistant";
132
+ }
133
+ }
134
+
135
+ export function extractOpAndPath(input: string): ["read" | "write", string] {
136
+ const separator = input.indexOf(":");
137
+ if (separator < 0) return ["read", input];
138
+ return [input.slice(0, separator) === "write" ? "write" : "read", input.slice(separator + 1)];
139
+ }
140
+
141
+ export function mapToRules(
142
+ patterns: string[],
143
+ category: Category,
144
+ allowed: boolean,
145
+ ): Map<string, FileAccess | boolean> {
146
+ const map = new Map<string, FileAccess | boolean>();
147
+ for (const pattern of patterns) {
148
+ if (category === "file") {
149
+ const [op, path] = extractOpAndPath(pattern);
150
+ map.set(path, allowed ? op : "blocked");
151
+ } else {
152
+ map.set(pattern, allowed);
153
+ }
154
+ }
155
+ return map;
156
+ }
@@ -0,0 +1,134 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolCallEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Key, visibleWidth } from "@earendil-works/pi-tui";
7
+ import { handlePermissionsCommand } from "./command.js";
8
+ import { checkAgentRules, resolvePermission } from "./resolution.js";
9
+ import { getPiIgnoreInputs, resolvePiIgnorePathBlock } from "./piignore.js";
10
+ import { readAgentMode, writeAgentMode } from "./storage.js";
11
+ import { loadMainAgent } from "../agent/storage.js";
12
+ import type { PermissionCheck, PromptDecision } from "./types.js";
13
+ import PermissionPrompt from "./components/prompt.js";
14
+ import { getPermissionCheck, cycleMode } from "./helpers.js";
15
+ import type { Agent, AgentMeta, AgentMode } from "../agent/types.js";
16
+
17
+ async function askForPermission(pi: ExtensionAPI, ctx: ExtensionContext, check: PermissionCheck) {
18
+ const decision = await ctx.ui.custom<PromptDecision>((_tui, theme, _keybindings, done) => {
19
+ const component = new PermissionPrompt(theme, check, ctx.cwd);
20
+ component.onDone = done;
21
+ return component;
22
+ });
23
+
24
+ if (!decision.allowed) {
25
+ const appended = decision.amended ? `. User input: ${decision.amended}` : "";
26
+ return {
27
+ block: true,
28
+ reason: `User rejected this tool call. Find a different approach, or skip this step, or abort and report if cannot proceed${appended}`,
29
+ };
30
+ }
31
+ if (decision.amended) {
32
+ pi.sendUserMessage(decision.amended, { deliverAs: "steer" });
33
+ }
34
+ }
35
+
36
+ export async function enforceToolPermission(
37
+ pi: ExtensionAPI,
38
+ event: ToolCallEvent,
39
+ ctx: ExtensionContext,
40
+ meta: AgentMeta,
41
+ sessionId: string,
42
+ mode: AgentMode,
43
+ ) {
44
+ for (const input of getPiIgnoreInputs(event)) {
45
+ const piIgnoreBlock = await resolvePiIgnorePathBlock(ctx.cwd, input);
46
+ if (piIgnoreBlock) {
47
+ return { block: true, reason: piIgnoreBlock };
48
+ }
49
+ }
50
+
51
+ const check = getPermissionCheck(sessionId, event.toolName, event.input);
52
+ if (!check) return;
53
+ if (!checkAgentRules(meta, check)) {
54
+ return { block: true, reason: "Access to this resource is beyond allowed scope" };
55
+ }
56
+
57
+ const permission = await resolvePermission(ctx.cwd, check, mode);
58
+ if (permission === "blocked") {
59
+ return { block: true, reason: "Access to this resource is denied" };
60
+ }
61
+ if (mode === "yolo") return;
62
+ if (permission === "allowed" && !check.uncertainty) return;
63
+ if (!ctx.hasUI) {
64
+ return { block: true, reason: "Permission request requires interactive UI" };
65
+ }
66
+
67
+ return askForPermission(pi, ctx, check);
68
+ }
69
+
70
+ export default function (pi: ExtensionAPI) {
71
+ let agent: Agent;
72
+ let mode: AgentMode;
73
+ let updateStatus: (() => void) | undefined;
74
+
75
+ const updateAgentMode = (ctx: ExtensionContext) => {
76
+ const modeText =
77
+ mode === "yolo"
78
+ ? ctx.ui.theme.fg("warning", "YOLO mode ⚠️")
79
+ : mode === "restricted"
80
+ ? ctx.ui.theme.fg("dim", "restricted mode")
81
+ : ctx.ui.theme.fg("dim", "assistant mode");
82
+ const width = (process.stdout.columns ?? 80) - visibleWidth(modeText) + 1;
83
+ // statuses are sorted alphabetically and joined with " "; use ANSI cursor absolute (CHA)
84
+ // to jump to the right edge — spaces would be collapsed by sanitizeStatusText
85
+ ctx.ui.setStatus("mode", `\x1b[${width}G` + modeText);
86
+ };
87
+
88
+ pi.registerShortcut(Key.alt("m"), {
89
+ description: "Cycle assistant, YOLO, and restricted modes",
90
+ handler: async (ctx) => {
91
+ mode = cycleMode(mode);
92
+ await writeAgentMode(mode);
93
+ updateStatus?.();
94
+
95
+ ctx.ui.notify(
96
+ mode === "yolo"
97
+ ? "YOLO mode ON - agents can now run commands and tools without asking"
98
+ : "YOLO mode OFF",
99
+ "info",
100
+ );
101
+ },
102
+ });
103
+
104
+ pi.registerCommand("permissions", {
105
+ description: "View and manage permissions",
106
+ handler: async (_args, ctx) => await handlePermissionsCommand(ctx),
107
+ });
108
+
109
+ pi.on("session_start", async (_event, ctx) => {
110
+ [agent, mode] = await Promise.all([loadMainAgent(pi, ctx), readAgentMode()]);
111
+ ctx.ui.setStatus("agent", ctx.ui.theme.fg("dim", `agent: ${agent.name}`));
112
+
113
+ if (updateStatus) {
114
+ process.stdout.off("resize", updateStatus);
115
+ }
116
+ updateStatus = () => updateAgentMode(ctx);
117
+ updateStatus();
118
+ process.stdout.on("resize", updateStatus);
119
+ });
120
+
121
+ pi.on("before_agent_start", (event) => ({
122
+ systemPrompt: `${agent.body}\n\n${event.systemPrompt}`,
123
+ }));
124
+
125
+ pi.on("session_shutdown", async (_event, _ctx) => {
126
+ if (updateStatus) {
127
+ process.stdout.off("resize", updateStatus);
128
+ }
129
+ });
130
+
131
+ pi.on("tool_call", async (event, ctx) =>
132
+ enforceToolPermission(pi, event, ctx, agent.meta, ctx.sessionManager.getSessionId(), mode),
133
+ );
134
+ }