talentos 1.1.5 → 1.2.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.
@@ -0,0 +1,653 @@
1
+ import { ROOM_TOOLS } from "../codex-policy.js";
2
+ import { canonical, isInside, protectedPaths, protectedReason, reachesProtected, } from "./paths.js";
3
+ import { parseShell } from "./shell.js";
4
+ const NON_ALNUM = /[^A-Za-z\d]+/u;
5
+ const GREP_R_DIFF = /^-\w*r|^--recursive/u;
6
+ const GREP_R = /^-\w*[rR]|^--recursive|^--dereference-recursive/u;
7
+ const LS_R = /^-\w*R|^--recursive/u;
8
+ const PATTERN_FLAG = /^-\w*e|^--regexp|^-\w*f|^--file/u;
9
+ const GLOB_CHARS = /[*?[{]/u;
10
+ const ASK = "This needs the owner's approval, which isn't available yet. Tell the owner in your reply what you wanted to do and why. Do not retry or work around it.";
11
+ const BLOCKED = "Blocked: this touches credentials or the settings that protect this session. Do not retry or work around it.";
12
+ const allow = (mutates = false) => ({ allow: true, mutates });
13
+ const deny = (tier, reason) => ({
14
+ allow: false,
15
+ reason: reason ?? (tier === 0 ? BLOCKED : ASK),
16
+ tier,
17
+ });
18
+ const PLAIN_TOOLS = new Set([
19
+ "Agent",
20
+ "AskUserQuestion",
21
+ "BashOutput",
22
+ "ExitPlanMode",
23
+ "Glob",
24
+ "Grep",
25
+ "KillShell",
26
+ "LS",
27
+ "NotebookRead",
28
+ "Read",
29
+ "Skill",
30
+ "TaskCreate",
31
+ "TaskGet",
32
+ "TaskList",
33
+ "TaskStop",
34
+ "TaskUpdate",
35
+ "TodoWrite",
36
+ "ToolSearch",
37
+ "WebSearch",
38
+ ]);
39
+ const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "NotebookEdit", "Write"]);
40
+ const READ_VERBS = new Set([
41
+ "count",
42
+ "describe",
43
+ "find",
44
+ "get",
45
+ "list",
46
+ "lookup",
47
+ "read",
48
+ "retrieve",
49
+ "search",
50
+ "show",
51
+ "view",
52
+ ]);
53
+ // Any of these anywhere in an MCP tool name makes it a write, whatever it starts
54
+ // with. Outward verbs also match their inflections (sends, sending, deleted, ...).
55
+ const OUTWARD_VERBS = [
56
+ "add",
57
+ "approve",
58
+ "archive",
59
+ "assign",
60
+ "cancel",
61
+ "close",
62
+ "comment",
63
+ "create",
64
+ "delete",
65
+ "deploy",
66
+ "destroy",
67
+ "edit",
68
+ "execute",
69
+ "forward",
70
+ "insert",
71
+ "invite",
72
+ "merge",
73
+ "move",
74
+ "post",
75
+ "publish",
76
+ "push",
77
+ "remove",
78
+ "rename",
79
+ "reply",
80
+ "revoke",
81
+ "run",
82
+ "send",
83
+ "set",
84
+ "share",
85
+ "submit",
86
+ "trash",
87
+ "trigger",
88
+ "update",
89
+ "upload",
90
+ "write",
91
+ ];
92
+ const OTHER_WRITE_WORDS = [
93
+ "accept",
94
+ "append",
95
+ "apply",
96
+ "clear",
97
+ "commit",
98
+ "confirm",
99
+ "disable",
100
+ "download",
101
+ "draft",
102
+ "enable",
103
+ "export",
104
+ "fire",
105
+ "import",
106
+ "invoke",
107
+ "kill",
108
+ "label",
109
+ "mark",
110
+ "notify",
111
+ "pin",
112
+ "ping",
113
+ "reject",
114
+ "reset",
115
+ "restore",
116
+ "schedule",
117
+ "sms",
118
+ "star",
119
+ "start",
120
+ "stop",
121
+ "sync",
122
+ "text",
123
+ "toggle",
124
+ "unpin",
125
+ "unsubscribe",
126
+ ];
127
+ const CONSONANT_VOWEL_CONSONANT = /[^aeiou][aeiou][^aeiouwy]$/u;
128
+ function inflections(verb) {
129
+ const stem = verb.endsWith("e") ? verb.slice(0, -1) : verb;
130
+ const doubled = CONSONANT_VOWEL_CONSONANT.test(verb)
131
+ ? `${verb}${verb.slice(-1)}`
132
+ : verb;
133
+ return [
134
+ verb,
135
+ `${verb}s`,
136
+ `${verb}es`,
137
+ `${verb}d`,
138
+ `${verb}ed`,
139
+ `${stem}ing`,
140
+ `${doubled}ing`,
141
+ `${doubled}ed`,
142
+ ];
143
+ }
144
+ const WRITE_WORDS = new Set([
145
+ ...OUTWARD_VERBS.flatMap(inflections),
146
+ ...OTHER_WRITE_WORDS,
147
+ ]);
148
+ /** Splits `search_emails`, `search-emails` and `searchEmails` into lower-case words. */
149
+ function nameWords(name) {
150
+ return name
151
+ .replace(/([a-z\d])([A-Z])/gu, "$1 $2")
152
+ .split(NON_ALNUM)
153
+ .filter(Boolean)
154
+ .map((word) => word.toLowerCase());
155
+ }
156
+ export function classifyMcpTool(server, tool) {
157
+ if (server === "talentos_room") {
158
+ return ROOM_TOOLS.includes(tool) ? allow() : deny(0);
159
+ }
160
+ // The owner's personal-key TalentOS server carries owner authority; a room
161
+ // job never gets it (it is also removed from the tool list at launch).
162
+ if (server === "talentos" || server.startsWith("talentos_")) {
163
+ return deny(0);
164
+ }
165
+ const words = nameWords(tool);
166
+ const [first] = words;
167
+ if (first !== undefined &&
168
+ READ_VERBS.has(first) &&
169
+ !words.some((word) => WRITE_WORDS.has(word))) {
170
+ return allow();
171
+ }
172
+ // Outward or unknown MCP tool: the owner may allow it once, or always for this
173
+ // exact tool. The name is the only identity the hook has.
174
+ return askDeny(ASK, {
175
+ detail: tool,
176
+ kind: `tool:mcp__${server}__${tool}`.slice(0, 80),
177
+ summary: `${server}: ${tool}`.slice(0, 200),
178
+ tier: 2,
179
+ });
180
+ }
181
+ const SECRET_WORDS = /(bearer\s+)\S+|((?:token|key|secret|password|passwd|auth)[=:]\s*)\S+/giu;
182
+ const LONG_BLOB = /\b[A-Za-z\d+/_-]{32,}\b/gu;
183
+ /** Owner-only summaries still never carry what looks like a credential. */
184
+ export function redactSummary(value) {
185
+ return value
186
+ .replace(SECRET_WORDS, (_m, bearer, named) => `${bearer ?? named}…`)
187
+ .replace(LONG_BLOB, "…")
188
+ .replace(/\s+/gu, " ")
189
+ .trim()
190
+ .slice(0, 200);
191
+ }
192
+ const askDeny = (reason, ask) => ({
193
+ allow: false,
194
+ ask,
195
+ reason,
196
+ tier: ask.tier,
197
+ });
198
+ // ---- shell ----------------------------------------------------------------
199
+ const SECRET_PROGRAMS = new Set([
200
+ "env",
201
+ "export",
202
+ "gpg",
203
+ "history",
204
+ "op",
205
+ "pass",
206
+ "printenv",
207
+ "security",
208
+ "set",
209
+ "ssh-add",
210
+ "ssh-keygen",
211
+ ]);
212
+ const READ_ONLY = {
213
+ basename: { paths: "none" },
214
+ cat: { paths: "all" },
215
+ cut: { paths: "all" },
216
+ date: { badFlags: /^-s|^--set/u, paths: "none" },
217
+ df: { paths: "all" },
218
+ diff: {
219
+ paths: "all",
220
+ recursive: (a) => a.some((x) => GREP_R_DIFF.test(x)),
221
+ },
222
+ dirname: { paths: "none" },
223
+ du: { paths: "all", recursive: () => true },
224
+ echo: { paths: "none" },
225
+ false: { paths: "none" },
226
+ file: { badFlags: /^-C|^-m|^--magic/u, paths: "all" },
227
+ find: {
228
+ badFlags: /^-(exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/u,
229
+ paths: "find",
230
+ recursive: () => true,
231
+ },
232
+ grep: {
233
+ paths: "after-pattern",
234
+ recursive: (a) => a.some((x) => GREP_R.test(x)),
235
+ },
236
+ head: { paths: "all" },
237
+ ls: {
238
+ paths: "all",
239
+ recursive: (a) => a.some((x) => LS_R.test(x)),
240
+ },
241
+ printf: { paths: "none" },
242
+ pwd: { paths: "none" },
243
+ readlink: { paths: "all" },
244
+ realpath: { paths: "all" },
245
+ rg: {
246
+ badFlags: /^--pre|^-z$|^--search-zip|^--hostname-bin/u,
247
+ paths: "after-pattern",
248
+ recursive: () => true,
249
+ },
250
+ sort: { badFlags: /^-o|^--output|^--compress-program/u, paths: "all" },
251
+ stat: { paths: "all" },
252
+ tail: { badFlags: /^-\w*f|^--follow/u, paths: "all" },
253
+ tr: { paths: "none" },
254
+ tree: { badFlags: /^-o$/u, paths: "all", recursive: () => true },
255
+ true: { paths: "none" },
256
+ uniq: { paths: "all" },
257
+ wc: { paths: "all" },
258
+ which: { paths: "none" },
259
+ };
260
+ const RUNNER_SCRIPTS = new Set(["build", "check", "lint", "test", "typecheck"]);
261
+ const SAFE_ARG = /^--?[A-Za-z][\w-]*(=[\w./:@-]+)?$|^--$/u;
262
+ function isFlag(word) {
263
+ return word.text.startsWith("-") && word.text !== "-";
264
+ }
265
+ function pathCheck(word, ctx, recursive) {
266
+ if (word.glob) {
267
+ // A glob can only be judged by its literal directory; keep it inside cwd.
268
+ if (word.text.startsWith("/") ||
269
+ word.text.startsWith("~") ||
270
+ word.text.split("/").includes("..")) {
271
+ return deny(1, "This command uses a pattern the hook cannot check. Ask the owner.");
272
+ }
273
+ return;
274
+ }
275
+ const real = canonical(word.text, ctx.cwd, ctx.home);
276
+ if (protectedReason(real, "read", ctx.home, ctx.guard)) {
277
+ return deny(0);
278
+ }
279
+ if (recursive && reachesProtected(real, ctx.home, ctx.guard)) {
280
+ return deny(0);
281
+ }
282
+ return;
283
+ }
284
+ function classifyReadOnly(spec, args, ctx) {
285
+ const flags = args.filter(isFlag).map((word) => word.text);
286
+ if (spec.badFlags && flags.some((flag) => spec.badFlags?.test(flag))) {
287
+ return deny(2);
288
+ }
289
+ const recursive = spec.recursive?.(flags) ?? false;
290
+ let targets = [];
291
+ if (spec.paths === "all") {
292
+ targets = args.filter((word) => !isFlag(word));
293
+ }
294
+ else if (spec.paths === "after-pattern") {
295
+ const positional = args.filter((word) => !isFlag(word));
296
+ const patternGiven = flags.some((flag) => PATTERN_FLAG.test(flag));
297
+ targets = patternGiven ? positional : positional.slice(1);
298
+ }
299
+ else if (spec.paths === "find") {
300
+ const leading = [];
301
+ for (const word of args) {
302
+ if (isFlag(word) || word.text === "(" || word.text === "!") {
303
+ break;
304
+ }
305
+ leading.push(word);
306
+ }
307
+ targets = leading;
308
+ }
309
+ if (recursive && targets.length === 0) {
310
+ targets = [{ glob: false, text: "." }];
311
+ }
312
+ for (const target of targets) {
313
+ const refused = pathCheck(target, ctx, recursive);
314
+ if (refused) {
315
+ return refused;
316
+ }
317
+ }
318
+ // `echo $x > file` style writes are handled as redirects by the caller.
319
+ return allow();
320
+ }
321
+ function classifyRunner(args, ctx) {
322
+ const [first, second, third] = args.map((word) => word.text);
323
+ let rest;
324
+ if (first !== undefined &&
325
+ ["npm", "pnpm", "yarn"].includes(first) &&
326
+ ((second !== undefined && RUNNER_SCRIPTS.has(second)) ||
327
+ (second === "run" && third !== undefined && RUNNER_SCRIPTS.has(third)))) {
328
+ rest = args.slice(second === "run" ? 3 : 2);
329
+ }
330
+ else if (first === "pytest") {
331
+ rest = args.slice(1);
332
+ }
333
+ else if (first !== undefined &&
334
+ ["go", "cargo"].includes(first) &&
335
+ second === "test") {
336
+ rest = args.slice(2);
337
+ }
338
+ else {
339
+ return;
340
+ }
341
+ // Runners execute repository code. That is the owner's own repo until the
342
+ // model has changed it; after an edit, a run asks (R5).
343
+ if (ctx.dirty) {
344
+ return deny(2, "You changed files in this job, so running tests or builds needs the owner's approval, which isn't available yet. Tell the owner what you changed and how to run it.");
345
+ }
346
+ for (const word of rest) {
347
+ if (isFlag(word) || word.text === "--") {
348
+ if (!SAFE_ARG.test(word.text)) {
349
+ return deny(2);
350
+ }
351
+ continue;
352
+ }
353
+ const real = canonical(word.text, ctx.cwd, ctx.home);
354
+ if (word.glob ||
355
+ protectedReason(real, "read", ctx.home, ctx.guard) ||
356
+ !ctx.writeRoots.some((root) => isInside(real, root))) {
357
+ return deny(2);
358
+ }
359
+ }
360
+ return allow();
361
+ }
362
+ // Read-only git. Flags that run programs, write files or read outside the
363
+ // repository are refused; anything that changes a ref or the network is not here.
364
+ const GIT_READ = new Set([
365
+ "blame",
366
+ "branch",
367
+ "describe",
368
+ "diff",
369
+ "log",
370
+ "ls-files",
371
+ "rev-parse",
372
+ "shortlog",
373
+ "show",
374
+ "status",
375
+ ]);
376
+ const GIT_BAD_FLAG = /^(--output|-c$|-C$|--exec-path|--upload-pack|--git-dir|--work-tree|--no-index|--ext-diff|--textconv|--open-files-in-pager|-O)/u;
377
+ const GIT_LIST_FLAG = /^(-a|-r|-v|-vv|--all|--remotes|--list|--show-current)$/u;
378
+ function classifyGit(args, ctx) {
379
+ const [sub, ...rest] = args;
380
+ if (!(sub && GIT_READ.has(sub.text))) {
381
+ return deny(2);
382
+ }
383
+ const flags = rest.filter(isFlag);
384
+ const positional = rest.filter((word) => !isFlag(word));
385
+ if (flags.some((flag) => GIT_BAD_FLAG.test(flag.text))) {
386
+ return deny(2);
387
+ }
388
+ // `git branch NAME` creates a branch; only the listing forms pass.
389
+ if (sub.text === "branch" &&
390
+ (positional.length > 0 ||
391
+ flags.some((flag) => !GIT_LIST_FLAG.test(flag.text)))) {
392
+ return deny(2);
393
+ }
394
+ for (const word of positional) {
395
+ const refused = pathCheck(word, ctx, false);
396
+ if (refused) {
397
+ return refused;
398
+ }
399
+ }
400
+ return allow();
401
+ }
402
+ // What an always-allow rule can name: local repository work the owner expects
403
+ // from a coding agent. Network, interpreters, installs and anything else ask
404
+ // every time (tier 1).
405
+ const GIT_TIER2 = new Set([
406
+ "add",
407
+ "checkout",
408
+ "clone",
409
+ "commit",
410
+ "fetch",
411
+ "pull",
412
+ "push",
413
+ "restore",
414
+ "stash",
415
+ "switch",
416
+ ]);
417
+ const PROTECTED_BRANCH = /^(?:main|master|trunk|develop|dev|release|production|prod|stable)$|^release[/-]/u;
418
+ const PUSH_RISKY_FLAG = /^(-f$|--force|--delete$|-d$|--mirror|--all$|--prune)/u;
419
+ const HEADS_PREFIX = /^refs\/heads\//u;
420
+ const FILE_UTILS = new Set(["cp", "mkdir", "mv", "touch"]);
421
+ function gitPushTier(args) {
422
+ const rest = args.slice(1);
423
+ const flags = rest.filter(isFlag).map((word) => word.text);
424
+ if (flags.some((flag) => PUSH_RISKY_FLAG.test(flag))) {
425
+ return 1;
426
+ }
427
+ const positional = rest
428
+ .filter((word) => !isFlag(word))
429
+ .map((word) => word.text);
430
+ const refspec = positional[1];
431
+ if (!refspec || refspec.startsWith("+") || refspec.startsWith(":")) {
432
+ return 1;
433
+ }
434
+ const target = refspec.includes(":")
435
+ ? (refspec.split(":")[1] ?? "")
436
+ : refspec;
437
+ const branch = target.replace(HEADS_PREFIX, "");
438
+ return branch.startsWith("talentos/") && !PROTECTED_BRANCH.test(branch)
439
+ ? 2
440
+ : 1;
441
+ }
442
+ function shellAsk(command, ctx) {
443
+ const [programWord, ...args] = command.argv;
444
+ const program = programWord?.text ?? "?";
445
+ if (command.redirects.some((redirect) => redirect.mode === "write" &&
446
+ redirect.target.text !== "/dev/null" &&
447
+ !ctx.writeRoots.some((root) => isInside(canonical(redirect.target.text, ctx.cwd, ctx.home), root)))) {
448
+ return { kind: "shell:redirect-outside", tier: 1 };
449
+ }
450
+ if (program.includes("/")) {
451
+ return { kind: "shell:script", tier: 1 };
452
+ }
453
+ if (program === "git") {
454
+ const sub = args[0]?.text ?? "";
455
+ const badFlag = args.some((word) => isFlag(word) && GIT_BAD_FLAG.test(word.text));
456
+ if (!GIT_TIER2.has(sub) || badFlag) {
457
+ return { kind: `git:${sub || "?"}`, tier: 1 };
458
+ }
459
+ return { kind: `git:${sub}`, tier: sub === "push" ? gitPushTier(args) : 2 };
460
+ }
461
+ if (FILE_UTILS.has(program)) {
462
+ const inside = args
463
+ .filter((word) => !isFlag(word))
464
+ .every((word) => !word.glob &&
465
+ ctx.writeRoots.some((root) => isInside(canonical(word.text, ctx.cwd, ctx.home), root)) &&
466
+ !protectedReason(canonical(word.text, ctx.cwd, ctx.home), "write", ctx.home, ctx.guard));
467
+ return { kind: `shell:${program}`, tier: inside ? 2 : 1 };
468
+ }
469
+ const [first, second, third] = command.argv.map((word) => word.text);
470
+ if (ctx.dirty &&
471
+ ["npm", "pnpm", "yarn"].includes(first ?? "") &&
472
+ (RUNNER_SCRIPTS.has(second ?? "") ||
473
+ (second === "run" && RUNNER_SCRIPTS.has(third ?? "")))) {
474
+ return {
475
+ kind: `run:${first} ${second === "run" ? third : second}`,
476
+ tier: 1,
477
+ };
478
+ }
479
+ if (ctx.dirty &&
480
+ (first === "pytest" ||
481
+ (["go", "cargo"].includes(first ?? "") && second === "test"))) {
482
+ return {
483
+ kind: `run:${first}${first === "pytest" ? "" : " test"}`,
484
+ tier: 1,
485
+ };
486
+ }
487
+ return { kind: `shell:${program}`, tier: 1 };
488
+ }
489
+ function classifySimple(command, ctx) {
490
+ const [programWord, ...args] = command.argv;
491
+ if (!programWord) {
492
+ return deny(1);
493
+ }
494
+ const program = programWord.text;
495
+ if (program.includes("/") || programWord.glob) {
496
+ return deny(2);
497
+ }
498
+ let verdict;
499
+ if (SECRET_PROGRAMS.has(program)) {
500
+ verdict = deny(0);
501
+ }
502
+ else {
503
+ const spec = READ_ONLY[program];
504
+ if (program === "git") {
505
+ verdict = classifyGit(args, ctx);
506
+ }
507
+ else {
508
+ verdict = spec
509
+ ? classifyReadOnly(spec, args, ctx)
510
+ : (classifyRunner(command.argv, ctx) ?? deny(2));
511
+ }
512
+ }
513
+ if (!verdict.allow) {
514
+ return verdict;
515
+ }
516
+ let mutates = false;
517
+ for (const redirect of command.redirects) {
518
+ if (redirect.mode === "read") {
519
+ const refused = pathCheck(redirect.target, ctx, false);
520
+ if (refused) {
521
+ return refused;
522
+ }
523
+ continue;
524
+ }
525
+ if (redirect.target.text === "/dev/null") {
526
+ continue;
527
+ }
528
+ const real = canonical(redirect.target.text, ctx.cwd, ctx.home);
529
+ if (protectedReason(real, "write", ctx.home, ctx.guard)) {
530
+ return deny(0);
531
+ }
532
+ if (redirect.target.glob ||
533
+ !ctx.writeRoots.some((root) => isInside(real, root))) {
534
+ return deny(2);
535
+ }
536
+ mutates = true;
537
+ }
538
+ return allow(mutates);
539
+ }
540
+ export function classifyShell(source, ctx) {
541
+ const parsed = parseShell(source);
542
+ const summary = redactSummary(source);
543
+ if (!parsed.ok) {
544
+ return askDeny("The hook could not read this command safely. Use a simpler command, or ask the owner.", { detail: source, kind: "shell:unparseable", summary, tier: 1 });
545
+ }
546
+ let mutates = false;
547
+ let worst;
548
+ const asked = [];
549
+ for (const command of parsed.commands) {
550
+ const verdict = classifySimple(command, ctx);
551
+ if (verdict.allow) {
552
+ mutates ||= verdict.mutates;
553
+ continue;
554
+ }
555
+ if (verdict.tier === 0) {
556
+ return verdict;
557
+ }
558
+ asked.push(shellAsk(command, ctx));
559
+ if (!worst || (!worst.allow && verdict.tier < worst.tier)) {
560
+ worst = verdict;
561
+ }
562
+ }
563
+ if (!worst || worst.allow) {
564
+ return allow(mutates);
565
+ }
566
+ // One ask covers the whole command. Its tier is the strictest part's, and its
567
+ // kind names every part, so a rule for one program never covers a chain.
568
+ const kinds = [...new Set(asked.map((entry) => entry.kind))].sort();
569
+ const joined = kinds.join(" + ");
570
+ const kind = joined.length <= 80 ? joined : `${kinds[0]} +${kinds.length - 1}`;
571
+ const tier = Math.min(...asked.map((entry) => entry.tier));
572
+ return { ...worst, ask: { detail: source, kind, summary, tier }, tier };
573
+ }
574
+ // ---- tools ----------------------------------------------------------------
575
+ function text(value) {
576
+ return typeof value === "string" && value.length > 0 ? value : undefined;
577
+ }
578
+ export function classifyCall(call, context) {
579
+ const guard = context.guard ?? protectedPaths(context.home);
580
+ const ctx = { ...context, guard, cwd: call.cwd };
581
+ const { tool, toolInput: input } = call;
582
+ if (tool.startsWith("mcp__")) {
583
+ const [, server, ...rest] = tool.split("__");
584
+ return server && rest.length > 0
585
+ ? classifyMcpTool(server, rest.join("__"))
586
+ : deny(2);
587
+ }
588
+ if (tool === "Bash") {
589
+ const command = text(input.command);
590
+ return command ? classifyShell(command, ctx) : deny(1);
591
+ }
592
+ if (EDIT_TOOLS.has(tool)) {
593
+ const target = text(input.file_path) ?? text(input.notebook_path);
594
+ if (!target) {
595
+ return deny(1);
596
+ }
597
+ const real = canonical(target, call.cwd, context.home);
598
+ if (protectedReason(real, "write", context.home, guard)) {
599
+ return deny(0);
600
+ }
601
+ return context.writeRoots.some((root) => isInside(real, root))
602
+ ? allow(true)
603
+ : askDeny("That file is outside this job's workspace. Needs the owner's approval.", {
604
+ detail: `${tool} ${real}`,
605
+ kind: "write-outside-workspace",
606
+ summary: redactSummary(`${tool} ${real}`),
607
+ tier: 1,
608
+ });
609
+ }
610
+ if (PLAIN_TOOLS.has(tool)) {
611
+ const raw = text(input.file_path) ?? text(input.notebook_path) ?? text(input.path);
612
+ const recursive = tool === "Grep" || tool === "Glob" || tool === "LS";
613
+ const patternPath = tool === "Glob" ? text(input.pattern) : undefined;
614
+ for (const candidate of [
615
+ raw ?? (recursive ? "." : undefined),
616
+ patternPath,
617
+ ]) {
618
+ if (candidate === undefined) {
619
+ continue;
620
+ }
621
+ // A glob pattern is judged by its literal directory prefix.
622
+ const literal = candidate.split(GLOB_CHARS)[0] ?? candidate;
623
+ const refused = pathCheck({ glob: false, text: literal === "" ? "." : literal }, ctx, recursive);
624
+ if (refused) {
625
+ return refused;
626
+ }
627
+ }
628
+ return allow();
629
+ }
630
+ // WebFetch (a GET carries data; rules are per domain), scheduling, messaging,
631
+ // worktree switches and every tool this list does not know: ask.
632
+ if (tool === "WebFetch") {
633
+ let host;
634
+ try {
635
+ host = new URL(text(input.url) ?? "").hostname.toLowerCase();
636
+ }
637
+ catch {
638
+ host = undefined;
639
+ }
640
+ return askDeny(ASK, {
641
+ detail: text(input.url) ?? "",
642
+ kind: host ? `web:${host}`.slice(0, 80) : "web:?",
643
+ summary: redactSummary(text(input.url) ?? ""),
644
+ tier: host ? 2 : 1,
645
+ });
646
+ }
647
+ return askDeny(ASK, {
648
+ detail: JSON.stringify(input).slice(0, 4000),
649
+ kind: `tool:${tool}`.slice(0, 80),
650
+ summary: redactSummary(`${tool} ${JSON.stringify(input)}`),
651
+ tier: 1,
652
+ });
653
+ }