@shanepadgett/tau-agent 0.36.0 → 0.37.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.
@@ -128,7 +128,7 @@ Adds `/tau`, `/tau init [--global|--project]`, and `/tau doctor` for Tau setup a
128
128
 
129
129
  ## tool-approval
130
130
 
131
- Reviews agent `bash` and `script_runner` requests with a quick-effort model before execution. Set `extensions.toolApproval.autoApprove` to run every reviewer-approved request without another confirmation. The reviewer approves routine local development work. Concrete destructive, system, production, privileged, or security-sensitive effects require human approval with one explanatory paragraph. Reviewer failures fall back to human approval and send an attention notification.
131
+ Reviews agent `bash` and `script_runner` requests before they run. Common read-only bash commands skip review. Set `extensions.toolApproval.autoApprove` to run every reviewer-approved request without another confirmation. Those auto-approvals show a user-only marker. The reviewer approves routine local development work. Concrete destructive, system, production, privileged, or security-sensitive effects require human approval with one explanatory paragraph. Reviewer failures fall back to human approval and send an attention notification.
132
132
 
133
133
  ## tool-loader
134
134
 
@@ -1,8 +1,10 @@
1
1
  # Tool Approval
2
2
 
3
- Reviews agent `bash` and `script_runner` requests with a quick-effort model before execution. The reviewer returns a validated decision and one concise paragraph that explains the request.
3
+ Reviews agent `bash` and `script_runner` requests before they run.
4
4
 
5
- Trivially recognized read-only bash commands can run without a human prompt after a valid approval. With `autoApprove` enabled, every reviewer-approved request runs without another confirmation. Routine local development work should be approved, including requests that modify project files or run scripts. The reviewer asks for human approval only when it finds a concrete destructive, system, production, privileged, or security-sensitive effect.
5
+ Common read-only bash commands skip review and run immediately. Other bash and every `script_runner` request go to a quick-effort model. The reviewer returns a validated decision and one concise paragraph that explains the request.
6
+
7
+ With `autoApprove` enabled, reviewer-approved requests run without another confirmation. Tau shows a user-only marker after those auto-approvals. Common read-only bash that skips review does not get a marker. Routine local development work should be approved, including requests that modify project files or run scripts. The reviewer asks for human approval only when it finds a concrete destructive, system, production, privileged, or security-sensitive effect.
6
8
 
7
9
  When approval is required, Tau shows one paragraph that explains the effect and risk without repeating the request. If the reviewer fails or returns a malformed decision, Tau asks for direct human approval instead of running it automatically. Tau also sends an attention notification when the approval window opens.
8
10
 
@@ -0,0 +1,501 @@
1
+ import { parse } from "unbash";
2
+ import type { ArithmeticExpression, Command, Node, ParsedScript, Redirect, Word, WordPart } from "unbash";
3
+
4
+ const ALWAYS_SKIP = new Set([
5
+ "cat",
6
+ "head",
7
+ "tail",
8
+ "wc",
9
+ "ls",
10
+ "tree",
11
+ "pwd",
12
+ "echo",
13
+ "printf",
14
+ "true",
15
+ "false",
16
+ "test",
17
+ "[",
18
+ "grep",
19
+ "egrep",
20
+ "fgrep",
21
+ "cut",
22
+ "tr",
23
+ "uniq",
24
+ "comm",
25
+ "cmp",
26
+ "paste",
27
+ "join",
28
+ "column",
29
+ "fmt",
30
+ "fold",
31
+ "nl",
32
+ "rev",
33
+ "tac",
34
+ "expand",
35
+ "unexpand",
36
+ "tsort",
37
+ "pr",
38
+ "seq",
39
+ "expr",
40
+ "getconf",
41
+ "id",
42
+ "whoami",
43
+ "uname",
44
+ "printenv",
45
+ "type",
46
+ "which",
47
+ "dirname",
48
+ "basename",
49
+ "readlink",
50
+ "realpath",
51
+ "file",
52
+ "stat",
53
+ "du",
54
+ "df",
55
+ "diff",
56
+ "md5sum",
57
+ "sha1sum",
58
+ "sha256sum",
59
+ "shasum",
60
+ "ps",
61
+ "uptime",
62
+ "sleep",
63
+ "cd",
64
+ "bat",
65
+ "jq",
66
+ ]);
67
+
68
+ const FIND_VETO = new Set(["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fls", "-fprint", "-fprint0", "-fprintf"]);
69
+
70
+ const GIT_READ_VERBS = new Set([
71
+ "status",
72
+ "log",
73
+ "diff",
74
+ "show",
75
+ "blame",
76
+ "rev-parse",
77
+ "describe",
78
+ "ls-files",
79
+ "ls-tree",
80
+ "cat-file",
81
+ "rev-list",
82
+ "name-rev",
83
+ "merge-base",
84
+ "reflog",
85
+ "grep",
86
+ "for-each-ref",
87
+ "shortlog",
88
+ "version",
89
+ "help",
90
+ "count-objects",
91
+ "whatchanged",
92
+ "cherry",
93
+ "range-diff",
94
+ "var",
95
+ ]);
96
+
97
+ const WRITE_REDIRECTS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
98
+ const SED_PRINT = /^(?:\d+|\d+,\d+)p$/;
99
+
100
+ interface WalkState {
101
+ sawCd: boolean;
102
+ sawGit: boolean;
103
+ }
104
+
105
+ export function isAllowlistedBash(command: string): boolean {
106
+ if (!command.trim()) return false;
107
+ let script: ParsedScript;
108
+ try {
109
+ script = parse(command);
110
+ } catch {
111
+ return false;
112
+ }
113
+ const state: WalkState = { sawCd: false, sawGit: false };
114
+ return walkScript(script, state) && !(state.sawCd && state.sawGit);
115
+ }
116
+
117
+ function walkScript(script: ParsedScript, state: WalkState): boolean {
118
+ if (script.errors && script.errors.length > 0) return false;
119
+ if (script.shebang) return false;
120
+ if (script.commands.length === 0) return false;
121
+ return script.commands.every((statement) => walkNode(statement, state));
122
+ }
123
+
124
+ function walkNode(node: Node, state: WalkState): boolean {
125
+ switch (node.type) {
126
+ case "Statement":
127
+ if (node.background) return false;
128
+ return walkRedirects(node.redirects, state) && walkNode(node.command, state);
129
+ case "Command":
130
+ return walkCommand(node, state);
131
+ case "Pipeline":
132
+ if (node.time || node.negated) return false;
133
+ if (node.operators.includes("|&")) return false;
134
+ return node.commands.every((child) => walkNode(child, state));
135
+ case "AndOr":
136
+ return node.commands.every((child) => walkNode(child, state));
137
+ case "CompoundList":
138
+ return node.commands.every((child) => walkNode(child, state));
139
+ default:
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function walkCommand(command: Command, state: WalkState): boolean {
145
+ if (command.prefix.length > 0) return false;
146
+ if (!command.name) return false;
147
+ if (!walkRedirects(command.redirects, state)) return false;
148
+ if (!walkWord(command.name, state)) return false;
149
+ if (!command.suffix.every((word) => walkWord(word, state))) return false;
150
+
151
+ const name = staticBareName(command.name);
152
+ if (!name) return false;
153
+ if (name === "cd") state.sawCd = true;
154
+ if (name === "git") state.sawGit = true;
155
+ if (state.sawCd && state.sawGit) return false;
156
+ if (ALWAYS_SKIP.has(name)) return true;
157
+
158
+ const args: string[] = [];
159
+ for (const word of command.suffix) {
160
+ const value = staticWordValue(word);
161
+ if (value === undefined) return false;
162
+ args.push(value);
163
+ }
164
+
165
+ switch (name) {
166
+ case "find":
167
+ return !args.some((arg) => FIND_VETO.has(arg));
168
+ case "fd":
169
+ return !hasFdVeto(args);
170
+ case "rg":
171
+ return !hasRgVeto(args);
172
+ case "sort":
173
+ case "base64":
174
+ case "iconv":
175
+ return !hasOutputFlag(args);
176
+ case "xxd":
177
+ return !hasShortOption(args, "r") && !hasLongOption(args, "revert");
178
+ case "yq":
179
+ return !hasYqInplace(args);
180
+ case "sed":
181
+ return isSafeSed(args);
182
+ case "hostname":
183
+ return !hasPositional(args);
184
+ case "date":
185
+ return !hasShortOption(args, "s") && !hasLongOption(args, "set");
186
+ case "git":
187
+ return isSafeGit(args);
188
+ default:
189
+ return false;
190
+ }
191
+ }
192
+
193
+ function walkRedirects(redirects: Redirect[], state: WalkState): boolean {
194
+ for (const redirect of redirects) {
195
+ if (redirect.target && !walkWord(redirect.target, state)) return false;
196
+ if (redirect.body && !walkWord(redirect.body, state)) return false;
197
+ if (WRITE_REDIRECTS.has(redirect.operator)) {
198
+ if (!redirect.target || staticWordValue(redirect.target) !== "/dev/null") return false;
199
+ continue;
200
+ }
201
+ if (redirect.operator === ">&" || redirect.operator === "<&") {
202
+ const target = redirect.target ? staticWordValue(redirect.target) : undefined;
203
+ if (target === "1" || target === "2" || target === "-") continue;
204
+ if (redirect.operator === "<&" && target === "0") continue;
205
+ return false;
206
+ }
207
+ if (
208
+ redirect.operator !== "<" &&
209
+ redirect.operator !== "<<" &&
210
+ redirect.operator !== "<<-" &&
211
+ redirect.operator !== "<<<"
212
+ ) {
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+
219
+ function walkWord(word: Word, state: WalkState): boolean {
220
+ const parts = word.parts;
221
+ if (!parts) return true;
222
+ return parts.every((part) => walkPart(part, state));
223
+ }
224
+
225
+ function walkPart(part: WordPart, state: WalkState): boolean {
226
+ switch (part.type) {
227
+ case "Literal":
228
+ case "SingleQuoted":
229
+ case "AnsiCQuoted":
230
+ case "SimpleExpansion":
231
+ return true;
232
+ case "DoubleQuoted":
233
+ case "LocaleString":
234
+ return part.parts.every((child) => walkPart(child, state));
235
+ case "ParameterExpansion":
236
+ if (part.indexParts && !part.indexParts.every((child) => walkPart(child, state))) return false;
237
+ if (part.operand && !walkWord(part.operand, state)) return false;
238
+ if (part.slice) {
239
+ if (!walkWord(part.slice.offset, state)) return false;
240
+ if (part.slice.length && !walkWord(part.slice.length, state)) return false;
241
+ }
242
+ if (part.replace) {
243
+ if (!walkWord(part.replace.pattern, state)) return false;
244
+ if (!walkWord(part.replace.replacement, state)) return false;
245
+ }
246
+ return true;
247
+ case "CommandExpansion":
248
+ return walkNestedScript(part.script, state);
249
+ case "ProcessSubstitution":
250
+ return false;
251
+ case "ArithmeticExpansion":
252
+ return part.expression ? walkArithmetic(part.expression, state) : false;
253
+ case "ExtendedGlob":
254
+ case "BraceExpansion":
255
+ return !part.parts || part.parts.every((child) => walkPart(child, state));
256
+ }
257
+ }
258
+
259
+ function walkArithmetic(expression: ArithmeticExpression, state: WalkState): boolean {
260
+ switch (expression.type) {
261
+ case "ArithmeticBinary":
262
+ return walkArithmetic(expression.left, state) && walkArithmetic(expression.right, state);
263
+ case "ArithmeticUnary":
264
+ return walkArithmetic(expression.operand, state);
265
+ case "ArithmeticTernary":
266
+ return (
267
+ walkArithmetic(expression.test, state) &&
268
+ walkArithmetic(expression.consequent, state) &&
269
+ walkArithmetic(expression.alternate, state)
270
+ );
271
+ case "ArithmeticGroup":
272
+ return walkArithmetic(expression.expression, state);
273
+ case "ArithmeticWord": {
274
+ const parts = expression.parts;
275
+ return !parts || parts.every((part) => walkPart(part, state));
276
+ }
277
+ case "ArithmeticCommandExpansion":
278
+ return walkNestedScript(expression.script, state);
279
+ }
280
+ }
281
+
282
+ function walkNestedScript(script: ParsedScript | undefined, state: WalkState): boolean {
283
+ if (!script) return false;
284
+ return walkScript(script, state);
285
+ }
286
+
287
+ function staticBareName(word: Word): string | undefined {
288
+ const value = staticWordValue(word);
289
+ if (!value || value.includes("/")) return undefined;
290
+ return value;
291
+ }
292
+
293
+ function staticWordValue(word: Word): string | undefined {
294
+ const parts = word.parts;
295
+ if (!parts) return word.value;
296
+ if (!parts.every(isStaticPart)) return undefined;
297
+ return word.value;
298
+ }
299
+
300
+ function isStaticPart(part: WordPart): boolean {
301
+ switch (part.type) {
302
+ case "Literal":
303
+ case "SingleQuoted":
304
+ case "AnsiCQuoted":
305
+ return true;
306
+ case "DoubleQuoted":
307
+ case "LocaleString":
308
+ return part.parts.every((child) => child.type === "Literal");
309
+ default:
310
+ return false;
311
+ }
312
+ }
313
+
314
+ function hasFdVeto(args: readonly string[]): boolean {
315
+ return (
316
+ hasShortOption(args, "x") ||
317
+ hasShortOption(args, "X") ||
318
+ hasLongOption(args, "exec") ||
319
+ hasLongOption(args, "exec-batch")
320
+ );
321
+ }
322
+
323
+ function hasRgVeto(args: readonly string[]): boolean {
324
+ return (
325
+ hasShortOption(args, "z") ||
326
+ hasLongOption(args, "pre") ||
327
+ hasLongOption(args, "hostname-bin") ||
328
+ hasLongOption(args, "search-zip")
329
+ );
330
+ }
331
+
332
+ function hasOutputFlag(args: readonly string[]): boolean {
333
+ return hasShortOption(args, "o") || hasLongOption(args, "output");
334
+ }
335
+
336
+ function hasYqInplace(args: readonly string[]): boolean {
337
+ return hasShortOption(args, "i") || hasLongOption(args, "inplace") || hasLongOption(args, "in-place");
338
+ }
339
+
340
+ function isSafeSed(args: readonly string[]): boolean {
341
+ if (args.length < 2 || args[0] !== "-n") return false;
342
+ const script = args[1];
343
+ if (script === undefined || !SED_PRINT.test(script)) return false;
344
+ for (const arg of args.slice(2)) {
345
+ if (arg === "--") continue;
346
+ if (arg.startsWith("-")) return false;
347
+ }
348
+ return true;
349
+ }
350
+
351
+ function hasPositional(args: readonly string[]): boolean {
352
+ let endFlags = false;
353
+ for (const arg of args) {
354
+ if (!endFlags) {
355
+ if (arg === "--") {
356
+ endFlags = true;
357
+ continue;
358
+ }
359
+ if (arg.startsWith("-") && arg !== "-") continue;
360
+ }
361
+ return true;
362
+ }
363
+ return false;
364
+ }
365
+
366
+ function isSafeGit(args: readonly string[]): boolean {
367
+ if (hasGitWriteExecFlag(args)) return false;
368
+ const verbIndex = gitVerbIndex(args);
369
+ if (verbIndex === undefined) return false;
370
+ const verb = args[verbIndex];
371
+ if (verb === undefined) return false;
372
+ const rest = args.slice(verbIndex + 1);
373
+ if (GIT_READ_VERBS.has(verb)) return true;
374
+ if (verb === "branch") return isGitBranch(rest);
375
+ if (verb === "remote") return isGitRemote(rest);
376
+ if (verb === "tag") return isGitTag(rest);
377
+ return false;
378
+ }
379
+
380
+ function gitVerbIndex(args: readonly string[]): number | undefined {
381
+ for (let index = 0; index < args.length; index += 1) {
382
+ const arg = args[index];
383
+ if (arg === undefined || arg === "--") return undefined;
384
+ if (arg === "--no-pager" || arg === "-P" || arg === "--no-color") continue;
385
+ if (
386
+ arg === "-C" ||
387
+ arg === "-c" ||
388
+ arg === "-p" ||
389
+ arg === "--paginate" ||
390
+ isLongOption(arg, "config-env") ||
391
+ isLongOption(arg, "exec-path") ||
392
+ isLongOption(arg, "git-dir") ||
393
+ isLongOption(arg, "work-tree") ||
394
+ isLongOption(arg, "namespace") ||
395
+ isLongOption(arg, "super-prefix")
396
+ ) {
397
+ return undefined;
398
+ }
399
+ if (arg.startsWith("-")) return undefined;
400
+ return index;
401
+ }
402
+ return undefined;
403
+ }
404
+
405
+ function hasGitWriteExecFlag(args: readonly string[]): boolean {
406
+ for (const arg of args) {
407
+ if (arg === "--") break;
408
+ if (
409
+ arg === "--paginate" ||
410
+ isLongOption(arg, "output") ||
411
+ isLongOption(arg, "ext-diff") ||
412
+ isLongOption(arg, "textconv") ||
413
+ isLongOption(arg, "exec")
414
+ ) {
415
+ return true;
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+
421
+ function isGitBranch(args: readonly string[]): boolean {
422
+ for (let index = 0; index < args.length; index += 1) {
423
+ const arg = args[index];
424
+ if (arg === undefined || arg === "--") return false;
425
+ if (
426
+ arg === "--list" ||
427
+ arg === "--all" ||
428
+ arg === "--remotes" ||
429
+ arg === "--verbose" ||
430
+ arg === "--show-current"
431
+ ) {
432
+ continue;
433
+ }
434
+ if (isLongOption(arg, "format")) {
435
+ if (arg === "--format") {
436
+ if (args[index + 1] === undefined) return false;
437
+ index += 1;
438
+ }
439
+ continue;
440
+ }
441
+ if (arg.startsWith("--")) return false;
442
+ if (!arg.startsWith("-") || arg.length < 2) return false;
443
+ for (const flag of arg.slice(1)) {
444
+ if (flag !== "a" && flag !== "l" && flag !== "r" && flag !== "v") return false;
445
+ }
446
+ }
447
+ return true;
448
+ }
449
+
450
+ function isGitRemote(args: readonly string[]): boolean {
451
+ let index = 0;
452
+ while (index < args.length) {
453
+ const arg = args[index];
454
+ if (arg !== "-v" && arg !== "--verbose") break;
455
+ index += 1;
456
+ }
457
+ if (index >= args.length) return true;
458
+ const verb = args[index];
459
+ return verb === "show" || verb === "get-url";
460
+ }
461
+
462
+ function isGitTag(args: readonly string[]): boolean {
463
+ let listing = false;
464
+ for (const arg of args) {
465
+ if (arg === "--") return false;
466
+ if (arg === "-l" || arg === "--list") {
467
+ listing = true;
468
+ continue;
469
+ }
470
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1) {
471
+ for (const flag of arg.slice(1)) {
472
+ if (flag !== "l") return false;
473
+ }
474
+ listing = true;
475
+ continue;
476
+ }
477
+ if (arg.startsWith("-") || !listing) return false;
478
+ }
479
+ return true;
480
+ }
481
+
482
+ function hasShortOption(args: readonly string[], letter: string): boolean {
483
+ for (const arg of args) {
484
+ if (arg === "--") break;
485
+ if (arg.length < 2 || !arg.startsWith("-") || arg.startsWith("--")) continue;
486
+ if (arg.slice(1).includes(letter)) return true;
487
+ }
488
+ return false;
489
+ }
490
+
491
+ function hasLongOption(args: readonly string[], name: string): boolean {
492
+ for (const arg of args) {
493
+ if (arg === "--") break;
494
+ if (isLongOption(arg, name)) return true;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ function isLongOption(arg: string, name: string): boolean {
500
+ return arg === `--${name}` || arg.startsWith(`--${name}=`);
501
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type ExtensionContext,
6
6
  type ToolCallEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { Marker } from "@shanepadgett/tau-tui";
8
9
  import { Type, type Static } from "typebox";
9
10
  import { Value } from "typebox/value";
10
11
  import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
@@ -12,9 +13,11 @@ import { resolveEffortCandidates } from "../../shared/model-effort.ts";
12
13
  import { generateToolValidated } from "../../shared/model-fallback/index.ts";
13
14
  import { errorText, truncAt } from "../../shared/text.ts";
14
15
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
16
+ import { isAllowlistedBash } from "./allowlist.ts";
15
17
  import toolApprovalSettings from "./settings.ts";
16
18
 
17
19
  const STATUS_KEY = "tool-approval";
20
+ const AUTO_APPROVED_TYPE = "tau.tool-approval.auto-approved";
18
21
  const MAX_REVIEW_CHARS = 12_000;
19
22
 
20
23
  const SUMMARY_SCHEMA = Type.String({
@@ -61,29 +64,6 @@ const REVIEW_SYSTEM_PROMPT = [
61
64
  "An approved review has no reason field. A review that requires user approval must give one concise reason naming the concrete risk without repeating the summary.",
62
65
  ].join("\n");
63
66
 
64
- const PLAIN_COMMAND_PATTERN = /^[A-Za-z0-9_./:@%+,=-]+(?: +[A-Za-z0-9_./:@%+,=-]+)*$/;
65
- const TRIVIAL_READ_ONLY_COMMANDS = new Set(["git diff", "git log", "git show", "git status", "pwd"]);
66
- const TRIVIAL_READ_ONLY_PROGRAMS = new Set([
67
- "basename",
68
- "cat",
69
- "comm",
70
- "cut",
71
- "dirname",
72
- "du",
73
- "echo",
74
- "grep",
75
- "head",
76
- "ls",
77
- "printf",
78
- "realpath",
79
- "rg",
80
- "tail",
81
- "test",
82
- "uniq",
83
- "wc",
84
- "which",
85
- ]);
86
-
87
67
  const REVIEW_TOOL = {
88
68
  name: "submit_tool_review",
89
69
  description: "Submit the complete safety review for the agent tool request.",
@@ -98,9 +78,24 @@ interface ToolApprovalRequest {
98
78
  input: Record<string, unknown>;
99
79
  }
100
80
 
81
+ interface AutoApprovedMarker {
82
+ toolName: ApprovalToolName;
83
+ }
84
+
101
85
  export default function toolApprovalExtension(pi: ExtensionAPI): void {
102
86
  let settings = toolApprovalSettings.defaults;
103
87
 
88
+ pi.registerEntryRenderer<AutoApprovedMarker>(AUTO_APPROVED_TYPE, (entry, _options, theme) => {
89
+ const marker = autoApprovedMarker(entry.data);
90
+ if (!marker) return undefined;
91
+ return new Marker({
92
+ theme,
93
+ state: "complete",
94
+ label: "Auto-approved",
95
+ parts: [toolLabel(marker.toolName)],
96
+ });
97
+ });
98
+
104
99
  async function refreshSettings(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Promise<void> {
105
100
  settings = await loadTauExtensionSettings(ctx, toolApprovalSettings);
106
101
  }
@@ -114,7 +109,8 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
114
109
  if (!settings.enabled) return undefined;
115
110
  return {
116
111
  systemPrompt: `${event.systemPrompt}\n\n${[
117
- "Agent bash and script_runner requests are reviewed by a separate quick-effort safety classifier before execution.",
112
+ "Known-safe read-only bash commands skip review.",
113
+ "Other bash and every script_runner request are reviewed by a separate quick-effort safety classifier before execution.",
118
114
  "Treat classifier approval as a gate, not as permission to hide command intent from the user.",
119
115
  "Routine local development requests can be approved automatically.",
120
116
  "Requests with destructive, system, production, privileged, or security-sensitive effects require human confirmation.",
@@ -143,9 +139,9 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
143
139
  return block("bash command is empty");
144
140
  }
145
141
  command = value;
142
+ if (isAllowlistedBash(command)) return undefined;
146
143
  }
147
144
 
148
- const readOnlyCommand = request.toolName === "bash" && isTriviallyReadOnly(command ?? "");
149
145
  ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
150
146
  try {
151
147
  const review = await reviewToolRequest(ctx, request);
@@ -158,7 +154,10 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
158
154
  formatApproval(review.summary, review.reason),
159
155
  );
160
156
  }
161
- if (settings.autoApprove || readOnlyCommand) return undefined;
157
+ if (settings.autoApprove) {
158
+ pi.appendEntry<AutoApprovedMarker>(AUTO_APPROVED_TYPE, { toolName: request.toolName });
159
+ return undefined;
160
+ }
162
161
  return requestToolApproval(
163
162
  pi,
164
163
  ctx,
@@ -194,17 +193,17 @@ function approvalRequest(event: ToolCallEvent): ToolApprovalRequest | undefined
194
193
  return undefined;
195
194
  }
196
195
 
197
- function isTriviallyReadOnly(command: string): boolean {
198
- const plainCommand = PLAIN_COMMAND_PATTERN.test(command);
199
- const separator = command.indexOf(" ");
200
- const program = separator === -1 ? command : command.slice(0, separator);
201
- return plainCommand && (TRIVIAL_READ_ONLY_COMMANDS.has(command) || TRIVIAL_READ_ONLY_PROGRAMS.has(program));
202
- }
203
-
204
196
  function toolLabel(toolName: ApprovalToolName): string {
205
197
  return toolName === "bash" ? "bash command" : "script_runner request";
206
198
  }
207
199
 
200
+ function autoApprovedMarker(value: unknown): AutoApprovedMarker | undefined {
201
+ if (!value || typeof value !== "object") return undefined;
202
+ const toolName = (value as AutoApprovedMarker).toolName;
203
+ if (toolName !== "bash" && toolName !== "script_runner") return undefined;
204
+ return { toolName };
205
+ }
206
+
208
207
  async function reviewToolRequest(ctx: ExtensionContext, request: ToolApprovalRequest): Promise<ToolReview> {
209
208
  const requestJson = JSON.stringify(request);
210
209
  if (!requestJson || requestJson.length > MAX_REVIEW_CHARS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,10 +35,11 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@ast-grep/wasm": "0.45.0",
38
- "@shanepadgett/tau-tui": "0.36.0",
38
+ "@shanepadgett/tau-tui": "0.37.0",
39
39
  "@vscode/tree-sitter-wasm": "0.3.1",
40
40
  "image-size": "2.0.2",
41
41
  "smol-toml": "1.7.1",
42
+ "unbash": "4.0.10",
42
43
  "web-tree-sitter": "0.26.11"
43
44
  },
44
45
  "peerDependencies": {