@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
|
|
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
|
|
3
|
+
Reviews agent `bash` and `script_runner` requests before they run.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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.
|
|
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": {
|