pi-roundtable 0.22.4 → 0.23.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/CHANGELOG.md +61 -1
  2. package/README.md +36 -10
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +7 -3
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/http/websocket.ts +2 -1
  75. package/src/core/i18n/agent-panel.ts +14 -21
  76. package/src/core/interactions/prompts.ts +12 -0
  77. package/src/core/modes/autonomy.ts +23 -0
  78. package/src/core/modes/conversation-modes.ts +213 -0
  79. package/src/core/modes/modes-plugin.ts +24 -0
  80. package/src/core/modules/delegation/delegator.ts +4 -4
  81. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  82. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  83. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  84. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  85. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  86. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  87. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  88. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  89. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  90. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  91. package/src/core/modules/schedules/schedules.ts +9 -3
  92. package/src/core/plugin.ts +8 -1
  93. package/src/core/presence/direct-channels.ts +30 -0
  94. package/src/core/presentation/reply-body.ts +25 -0
  95. package/src/core/registry/contributions.ts +31 -7
  96. package/src/core/registry/providers.ts +1 -1
  97. package/src/core/routing/conversation-turns.ts +2 -15
  98. package/src/core/routing/surface-port.ts +5 -1
  99. package/src/core/runtime/extensions/approval-card.ts +1 -5
  100. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  101. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  102. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  103. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  104. package/src/core/runtime/extensions/held-calls.ts +16 -1
  105. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  106. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  107. package/src/core/runtime/extensions/yolo.ts +105 -0
  108. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  109. package/src/core/runtime/runtime-plugin.ts +42 -1
  110. package/src/core/runtime/runtime-types.ts +11 -0
  111. package/src/core/runtime/turn-budget.ts +32 -5
  112. package/src/core/runtime/turn-limits.ts +14 -3
  113. package/src/core/services.ts +61 -9
  114. package/src/core/shared/attachment-reader.ts +6 -0
  115. package/src/core/shared/unix-server.ts +10 -1
  116. package/src/core/testing/modules.ts +12 -8
  117. package/src/core/testing/surface-threads.ts +58 -0
  118. package/src/core/testing/thread-host.ts +17 -5
  119. package/src/core/threads/dispatch-threads.ts +299 -0
  120. package/src/core/threads/host-threads.ts +19 -0
  121. package/src/core/tool-set.snapshot.json +5 -0
  122. package/src/index.ts +27 -1
  123. package/src/kit/characters.ts +5 -0
  124. package/src/kit/index.ts +2 -0
  125. package/src/kit/shell.ts +1 -0
  126. package/src/kit/support.ts +1 -1
  127. package/src/kit/threads.ts +1 -1
  128. package/src/testing.ts +4 -0
  129. package/templates/adapters/web/package.json.tmpl +1 -1
  130. package/templates/agents.ts +1 -1
  131. package/templates/package.json.tmpl +1 -1
  132. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -1,17 +1,37 @@
1
1
  import { basename } from "node:path";
2
2
  import type { HoldContext, HoldRule } from "../../holds.ts";
3
- import { assistantName } from "../../i18n/index.ts";
4
- import { ownPush, type PushPolicy } from "./git-push.ts";
3
+ import type { PushPolicy } from "./git-push.ts";
4
+ import { COMMAND_PREFIX_WORDS, EXPANDED, lexShell } from "./shell-lexer.ts";
5
5
  import {
6
+ bindingCounts,
7
+ deviceTarget,
8
+ expand,
9
+ expandName,
6
10
  follow,
7
- insideRoots,
11
+ forgetRebound,
8
12
  type LineState,
9
13
  lineState,
10
- resolvePath,
11
14
  rmHeld,
15
+ writable,
12
16
  } from "./shell-paths.ts";
17
+ import { type Held, type ShellClass, verbHeld } from "./shell-programs.ts";
18
+ import {
19
+ readsUnseenScript,
20
+ recordWrites,
21
+ SHELLS,
22
+ shellScript,
23
+ teeOperands,
24
+ } from "./shell-scripts.ts";
25
+ import {
26
+ ASSIGNMENT_WORD,
27
+ program,
28
+ replaceString,
29
+ WRAPPERS,
30
+ } from "./shell-wrappers.ts";
13
31
 
14
32
  export type { PushPolicy } from "./git-push.ts";
33
+ export { simpleCommands } from "./shell-lexer.ts";
34
+ export { YOLO_CARDED_SHELL_CLASSES } from "./shell-programs.ts";
15
35
 
16
36
  const MAX_SHOWN_COMMAND = 600;
17
37
 
@@ -23,376 +43,311 @@ export const SHELL_TOOLS = Object.freeze([
23
43
  "write",
24
44
  ] as const);
25
45
 
26
- /** Programs that are held whatever their arguments. */
27
- const ALWAYS_HELD = new Set([
28
- "sudo",
29
- "su",
30
- "doas",
31
- "dd",
32
- "shutdown",
33
- "reboot",
34
- "poweroff",
35
- "halt",
36
- "kill",
37
- "pkill",
38
- "killall",
39
- ]);
40
- const SYSTEMD_VERBS = new Set([
41
- "start",
42
- "stop",
43
- "restart",
44
- "reload",
45
- "try-restart",
46
- "reload-or-restart",
47
- "enable",
48
- "disable",
49
- "mask",
50
- "unmask",
51
- "kill",
52
- "isolate",
53
- ]);
54
- const DOCKER_VERBS = new Set(["stop", "kill", "restart", "rm", "rmi", "prune"]);
55
- const DOCKER_OBJECTS = new Set([
56
- "container",
57
- "image",
58
- "volume",
59
- "network",
60
- "system",
61
- "builder",
62
- ]);
63
- const COMPOSE_VERBS = new Set(["down", "stop", "kill", "restart", "rm"]);
64
- const PACKAGE_VERBS = new Set([
65
- "install",
66
- "reinstall",
67
- "remove",
68
- "purge",
69
- "autoremove",
70
- "upgrade",
71
- "full-upgrade",
72
- "dist-upgrade",
73
- ]);
74
- /** Words that run the next word as the command. */
75
- const WRAPPERS = new Set([
76
- "env",
77
- "nohup",
78
- "time",
79
- "nice",
80
- "ionice",
81
- "timeout",
82
- "stdbuf",
83
- "exec",
84
- "command",
85
- "xargs",
86
- ]);
46
+ /** The words the lexer gives a redirection operator. */
47
+ const REDIRECTS: ReadonlySet<string> = new Set(["<", ">", ">>", "dup"]);
48
+
49
+ /** Why a command whose name is not known until it runs is held. */
50
+ const DYNAMIC_NAME: Held = {
51
+ reason: "a command whose name is built at run time",
52
+ class: "shell.unreadable",
53
+ };
87
54
 
88
55
  /**
89
- * Whether a write to the word stays inside a scratch root. A word the line cannot resolve is read
90
- * as written from the workspace, as before variables were followed.
56
+ * The program a command word names, or undefined when the word expands to more than the line says:
57
+ * a variable not known, a substitution, a brace expansion or a pattern (`$cmd`, `{sudo,ls}`,
58
+ * `/usr/bin/sud*`).
91
59
  */
92
- function writable(word: string, state: LineState): boolean {
93
- if (word === "/dev/null") return true;
94
- const path =
95
- resolvePath(word, state) ??
96
- resolvePath(word, { ...state, vars: new Map(), cwd: state.workspace }) ??
97
- word;
98
- return insideRoots(path, state);
60
+ function commandName(word: string, state: LineState): string | undefined {
61
+ // `[` and `[[` are the test commands, not patterns.
62
+ if (word !== "[" && word !== "[[" && /[*?[]/.test(word)) return undefined;
63
+ if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(word)) return undefined;
64
+ return word.includes("$") || word.includes("`")
65
+ ? expandName(word, state)
66
+ : word;
99
67
  }
100
68
 
101
- /** Splits a command line into simple commands of words; quotes are removed, operators separate. */
102
- export function simpleCommands(command: string): string[][] {
103
- const commands: string[][] = [];
104
- let words: string[] = [];
105
- let word = "";
106
- let inWord = false;
107
- let quote: "'" | '"' | undefined;
108
- const endWord = () => {
109
- if (inWord) words.push(word);
110
- word = "";
111
- inWord = false;
112
- };
113
- const endCommand = () => {
114
- endWord();
115
- if (words.length > 0) commands.push(words);
116
- words = [];
117
- };
118
- for (let i = 0; i < command.length; i++) {
119
- const ch = command[i] as string;
120
- if (quote) {
121
- if (ch === quote) quote = undefined;
122
- else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
123
- word += command[++i];
124
- } else word += ch;
125
- continue;
126
- }
127
- if (ch === "'" || ch === '"') {
128
- quote = ch;
129
- inWord = true;
130
- } else if (ch === "\\" && i + 1 < command.length) {
131
- word += command[++i];
132
- inWord = true;
133
- } else if (/\s/.test(ch)) {
134
- if (ch === "\n") endCommand();
135
- else endWord();
136
- } else if (";&|()`{}".includes(ch)) {
137
- // A word cut by a command substitution keeps its mark, so its value counts as unknown.
138
- if (ch === "`" && inWord) word += ch;
139
- endCommand();
140
- } else if (ch === "$" && command[i + 1] === "(") {
141
- if (inWord) word += "$(";
142
- endCommand();
143
- i++;
144
- } else if (ch === ">" || ch === "<") {
145
- // Redirections become their own words, so their targets can be checked.
146
- endWord();
147
- let op = ch;
148
- if (command[i + 1] === ">") op += command[++i];
149
- if (command[i + 1] === "&") {
150
- // `>&2` duplicates a descriptor rather than naming a file.
151
- i++;
152
- op = "dup";
153
- }
154
- words.push(op);
155
- } else {
156
- word += ch;
157
- inWord = true;
158
- }
159
- }
160
- endCommand();
161
- return commands;
162
- }
69
+ /** Variables whose value bash runs as a command or expands with substitutions on its own. */
70
+ const SELF_RUNNING_VARIABLE =
71
+ /^(?:PROMPT_COMMAND|PS[0124]|BASH_ENV|ENV|SHELLOPTS|BASH_ALIASES\[.*\])\+?=/;
72
+
73
+ /** Builtins whose arguments can be assignments. */
74
+ const ASSIGNING = new Set([
75
+ "export",
76
+ "declare",
77
+ "typeset",
78
+ "local",
79
+ "readonly",
80
+ "env",
81
+ ]);
163
82
 
164
83
  /**
165
- * Drops leading variable assignments, wrappers, and their options, leaving the real program;
166
- * `prefix` collects what was dropped.
84
+ * Whether the command sets a variable that makes a shell run something later: before the command
85
+ * (`PS4='$(x)' bash -x`) or as an argument of `export`, `declare` and the like.
167
86
  */
168
- function program(words: string[], prefix: string[] = []): string[] {
169
- let rest = words;
170
- for (;;) {
171
- const [first] = rest;
172
- if (first === undefined) return rest;
173
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
174
- prefix.push(first);
175
- rest = rest.slice(1);
176
- continue;
177
- }
178
- if (WRAPPERS.has(basename(first))) {
179
- prefix.push(basename(first));
180
- rest = rest.slice(1);
181
- // Wrapper options and the timeout's duration.
182
- while (
183
- rest[0] !== undefined &&
184
- (rest[0].startsWith("-") ||
185
- /^\d+[smhd]?$/.test(rest[0]) ||
186
- /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0]))
187
- )
188
- rest = rest.slice(1);
189
- continue;
190
- }
191
- return rest;
87
+ function setsSelfRunningVariable(words: readonly string[]): boolean {
88
+ for (const word of words) {
89
+ if (SELF_RUNNING_VARIABLE.test(word)) return true;
90
+ const name = basename(word);
91
+ if (
92
+ !/^[A-Za-z_][A-Za-z0-9_]*(?:\[.*\])?\+?=/.test(word) &&
93
+ !word.startsWith("-") &&
94
+ !COMMAND_PREFIX_WORDS.has(word) &&
95
+ !WRAPPERS.has(name) &&
96
+ !ASSIGNING.has(name)
97
+ )
98
+ return false;
192
99
  }
100
+ return false;
193
101
  }
194
102
 
195
- /** Why one simple command is held, or undefined; reads the line's state as it is before it. */
196
- function heldReason(
103
+ /** Builtins whose arguments can name a variable with a subscript, such as `a[$(x)]`, which bash evaluates. */
104
+ const SUBSCRIPT_EVALUATORS = new Set([
105
+ "declare",
106
+ "typeset",
107
+ "local",
108
+ "readonly",
109
+ "export",
110
+ "printf",
111
+ "read",
112
+ "unset",
113
+ "mapfile",
114
+ "readarray",
115
+ "getopts",
116
+ "wait",
117
+ "test",
118
+ "[",
119
+ "[[",
120
+ "let",
121
+ ]);
122
+
123
+ /** The script a `trap` command installs, or undefined when it only lists or clears traps. */
124
+ function trapScript(args: readonly string[]): string | undefined {
125
+ const rest = [...args];
126
+ while (rest[0]?.startsWith("-")) if (rest.shift() === "--") break;
127
+ const [script] = rest;
128
+ return rest.length > 1 && script !== "" && script !== "-"
129
+ ? script
130
+ : undefined;
131
+ }
132
+
133
+ /** The command `find` runs for each match (`-exec`, `-execdir`, `-ok`, `-okdir`), up to its `;` or `+`. */
134
+ function findExec(args: readonly string[]): string[] | undefined {
135
+ const at = args.findIndex((arg) => /^-(exec|execdir|ok|okdir)$/.test(arg));
136
+ if (at === -1) return undefined;
137
+ const rest = args.slice(at + 1);
138
+ const stop = rest.findIndex((arg) => arg === ";" || arg === "+");
139
+ return stop === -1 ? rest : rest.slice(0, stop);
140
+ }
141
+
142
+ /** The class of each program that is held whatever its arguments. */
143
+ const ALWAYS_HELD_CLASS: ReadonlyMap<string, ShellClass> = new Map([
144
+ ["sudo", "shell.privilege"],
145
+ ["su", "shell.privilege"],
146
+ ["doas", "shell.privilege"],
147
+ ["dd", "shell.disk"],
148
+ ["shutdown", "shell.power"],
149
+ ["reboot", "shell.power"],
150
+ ["poweroff", "shell.power"],
151
+ ["halt", "shell.power"],
152
+ ["kill", "shell.kill"],
153
+ ["pkill", "shell.kill"],
154
+ ["killall", "shell.kill"],
155
+ ]);
156
+
157
+ /** Every reason one simple command is held, the redirections first; reads the line's state as it is before it. */
158
+ function heldReasons(
197
159
  words: string[],
198
160
  state: LineState,
199
161
  policy: PushPolicy,
200
- ): string | undefined {
162
+ ): Held[] {
163
+ const found: Held[] = [];
201
164
  for (let i = 0; i < words.length; i++) {
202
165
  const op = words[i];
203
166
  if (op === ">" || op === ">>") {
204
167
  const target = words[i + 1];
205
- if (target && !writable(target, state)) return `writes to ${target}`;
168
+ if (target && !writable(target, state))
169
+ found.push({
170
+ reason: `writes to ${target}`,
171
+ class: deviceTarget(target, state) ? "shell.disk" : "shell.write",
172
+ });
206
173
  }
207
174
  }
175
+ return [...found, ...programHeld(words, state, policy)];
176
+ }
177
+
178
+ /** Why the program a simple command runs is held, apart from its redirections. */
179
+ function programHeld(
180
+ words: string[],
181
+ state: LineState,
182
+ policy: PushPolicy,
183
+ ): Held[] {
208
184
  const prefix: string[] = [];
209
- const argv = program(
210
- words.filter(
211
- (w, i) =>
212
- !["<", ">", ">>", "dup"].includes(w) &&
213
- !["<", ">", ">>"].includes(words[i - 1] ?? ""),
214
- ),
215
- prefix,
185
+ // A redirection is no part of the command: not its operator nor its target.
186
+ const own = words.filter(
187
+ (w, i) => !REDIRECTS.has(w) && !REDIRECTS.has(words[i - 1] ?? ""),
216
188
  );
217
- const [head, ...args] = argv;
218
- if (!head) return undefined;
189
+ const argv = program(own, prefix);
190
+ // A prompt or a startup file that runs a command needs no command word of its own
191
+ // (`PS4='$(x)'; set -x`), and `env -S` splits its argument into a command line.
192
+ if (setsSelfRunningVariable(own) || prefix.includes("env -S"))
193
+ return [DYNAMIC_SCRIPT];
194
+ const [word, ...args] = argv;
195
+ // `xargs env` runs what its input says.
196
+ if (!word)
197
+ return prefix.includes("xargs") && prefix.at(-1) !== "xargs"
198
+ ? [DYNAMIC_SCRIPT]
199
+ : [];
200
+ const head = commandName(word, state);
201
+ if (head === undefined) {
202
+ // A variable the line binds may be empty when bash gets there, so the name is unknown; what
203
+ // the line gives it is still judged, so the card says what it would run.
204
+ const guess = word.includes("$") ? expand(word, state) : undefined;
205
+ return [
206
+ ...(guess === undefined
207
+ ? []
208
+ : programHeld([guess, ...args], state, policy)),
209
+ DYNAMIC_NAME,
210
+ ];
211
+ }
219
212
  const name = basename(head);
220
- if (ALWAYS_HELD.has(name) || /^mkfs(\..+)?$/.test(name)) return name;
221
- if (name === "rm")
213
+ // A command or a script `xargs` fills in from its input, or one the line does not show.
214
+ const replace = replaceString(prefix);
215
+ if (
216
+ (replace !== undefined &&
217
+ (word.includes(replace) ||
218
+ (SHELLS.has(name) && args.some((arg) => arg.includes(replace))))) ||
219
+ readsUnseenScript(word, name, args, prefix, state)
220
+ )
221
+ return [DYNAMIC_SCRIPT];
222
+ // `mapfile -C` runs a callback for every chunk it reads.
223
+ if (
224
+ (name === "mapfile" || name === "readarray") &&
225
+ args.some((arg) => /^-[A-Za-z]*C/.test(arg))
226
+ )
227
+ return [DYNAMIC_SCRIPT];
228
+ // A builtin that takes a name evaluates a subscript in it, and that runs a substitution.
229
+ if (
230
+ SUBSCRIPT_EVALUATORS.has(name) &&
231
+ args.some((arg) => /\[[^\]]*(?:\$\(|`)/.test(arg))
232
+ )
233
+ return [DYNAMIC_SCRIPT];
234
+ // An alias names a command at run time; `expand_aliases` is what lets a script use one.
235
+ if (name === "alias" && args.some((arg) => arg.includes("=")))
236
+ return [DYNAMIC_NAME];
237
+ if (name === "trap") {
238
+ const script = trapScript(args);
239
+ if (script !== undefined) return scriptHeld(script, state, policy);
240
+ }
241
+ const always = ALWAYS_HELD_CLASS.get(name);
242
+ if (always) return [{ reason: name, class: always }];
243
+ if (/^mkfs(\..+)?$/.test(name))
244
+ return [{ reason: name, class: "shell.disk" }];
245
+ if (name === "rm") {
222
246
  // xargs adds operands nobody can see here.
223
- return prefix.includes("xargs") ? "rm" : rmHeld(args, state);
224
- if ((name === "bash" || name === "sh" || name === "zsh") && args[0] === "-c")
225
- return commandHeld(args[1] ?? "", state, policy);
226
- const verbs = args.filter((a) => !a.startsWith("-"));
227
- switch (name) {
228
- case "systemctl":
229
- return verbs.some((v) => SYSTEMD_VERBS.has(v))
230
- ? "changes a systemd unit"
231
- : undefined;
232
- case "service":
233
- return verbs.some((v) =>
234
- ["start", "stop", "restart", "reload"].includes(v),
235
- )
236
- ? "changes a service"
237
- : undefined;
238
- case "docker": {
239
- const [first, second] = verbs;
240
- if (first === "compose")
241
- return second && COMPOSE_VERBS.has(second)
242
- ? `docker compose ${second}`
243
- : undefined;
244
- if (first && DOCKER_VERBS.has(first)) return `docker ${first}`;
245
- if (
246
- first &&
247
- DOCKER_OBJECTS.has(first) &&
248
- second &&
249
- (DOCKER_VERBS.has(second) || second === "remove")
250
- )
251
- return `docker ${first} ${second}`;
252
- return undefined;
253
- }
254
- case "docker-compose":
255
- return verbs[0] && COMPOSE_VERBS.has(verbs[0])
256
- ? `docker-compose ${verbs[0]}`
257
- : undefined;
258
- case "ufw":
259
- return verbs[0] === "status" || verbs.length === 0
260
- ? undefined
261
- : "changes the firewall";
262
- case "iptables":
263
- case "ip6tables":
264
- case "nft":
265
- return args.some((a) => ["-L", "--list", "-S", "list"].includes(a))
266
- ? undefined
267
- : "changes the firewall";
268
- case "apt":
269
- case "apt-get":
270
- case "snap":
271
- return verbs.some((v) => PACKAGE_VERBS.has(v))
272
- ? "installs or removes packages"
273
- : undefined;
274
- case "dpkg":
275
- return args.some((a) =>
276
- ["-i", "-r", "-P", "--install", "--remove", "--purge"].includes(a),
277
- )
278
- ? "installs or removes packages"
279
- : undefined;
280
- case "git": {
281
- const [globals, [verb, ...rest]] = gitCommand(args);
282
- if (verb === "push") {
283
- const plain = !prefix.some((w) => w.startsWith("GIT_"));
284
- if (plain && ownPush(globals, rest, state, policy)) return undefined;
285
- return rest.some((a) => a === "-f" || a.startsWith("--force"))
286
- ? "force-pushes"
287
- : `pushes to GitHub, which deploys ${assistantName()} when the branch is main`;
288
- }
289
- if (verb === "reset" && rest.includes("--hard"))
290
- return "git reset --hard";
291
- return undefined;
292
- }
293
- case "gh":
294
- return ghHeld(args);
295
- case "crontab":
296
- return args.includes("-r") ? "removes a crontab" : undefined;
297
- case "tee": {
298
- const target = args.find(
299
- (a) => !a.startsWith("-") && !writable(a, state),
300
- );
301
- return target ? `writes to ${target}` : undefined;
302
- }
303
- default:
304
- return undefined;
247
+ const reason = prefix.includes("xargs") ? "rm" : rmHeld(args, state);
248
+ return reason ? [{ reason, class: "shell.rm" }] : [];
249
+ }
250
+ if (name === "eval") {
251
+ // What eval runs is its arguments after expansion: all of them must be known.
252
+ const known = args.map((arg) => expandName(arg, state));
253
+ return known.includes(undefined)
254
+ ? [DYNAMIC_SCRIPT]
255
+ : commandHeld(known.join(" "), state, policy);
305
256
  }
257
+ if (SHELLS.has(name)) {
258
+ const script = shellScript(args);
259
+ if (script !== undefined) return scriptHeld(script, state, policy);
260
+ }
261
+ if (name === "find") {
262
+ const exec = findExec(args);
263
+ if (exec) return programHeld(exec, state, policy);
264
+ }
265
+ const held = verbHeld(name, args, prefix, state, policy);
266
+ return held ? [held] : [];
306
267
  }
307
268
 
308
- /** Git's global options that take a value, as separate words. */
309
- const GIT_VALUE_OPTIONS = new Set([
310
- "-C",
311
- "-c",
312
- "--git-dir",
313
- "--work-tree",
314
- "--namespace",
315
- ]);
316
-
317
- /** Git's global options, such as `-C <dir>`, and the subcommand with its arguments. */
318
- function gitCommand(args: string[]): [string[], string[]] {
319
- let i = 0;
320
- while (i < args.length && (args[i] as string).startsWith("-"))
321
- i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
322
- return [args.slice(0, i), args.slice(i)];
269
+ /** Whether a simple command evaluates arithmetic without `(( ))`: `let`, `declare -i`, `[[ a -eq b ]]`. */
270
+ function evaluatesArithmetic(words: readonly string[]): boolean {
271
+ const [first] = words;
272
+ if (first === "let") return true;
273
+ if (first === "declare" || first === "typeset" || first === "local")
274
+ return words.some((word) => /^-[A-Za-z]*i/.test(word));
275
+ return (
276
+ first === "[[" &&
277
+ words.some((word) => /^-(?:eq|ne|lt|gt|le|ge)$/.test(word))
278
+ );
323
279
  }
324
280
 
325
- const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
326
- const GH_READ_VERBS = new Set(["list", "view", "download"]);
327
- const GH_REPO_CHANGES = new Set([
328
- "create",
329
- "delete",
330
- "edit",
331
- "rename",
332
- "archive",
333
- "unarchive",
334
- ]);
281
+ /** Why a line the lexer could not read is held. */
282
+ const UNREADABLE: Held = {
283
+ reason: "the command line is too long or too nested to check",
284
+ class: "shell.unreadable",
285
+ };
335
286
 
336
- /** Why a `gh` command writes to GitHub in a way the owner approves, or undefined. */
337
- function ghHeld(args: string[]): string | undefined {
338
- const [group, verb] = args.filter((a) => !a.startsWith("-"));
339
- switch (group) {
340
- case "pr":
341
- return verb === "merge" ? "merges a pull request" : undefined;
342
- case "api": {
343
- const body = args.some((a) =>
344
- GH_BODY_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)),
345
- );
346
- if (verb === "graphql")
347
- return args.some((a) => /\bmutation\b/.test(a))
348
- ? "changes GitHub through the API"
349
- : undefined;
350
- const index = args.findIndex((a) => a === "-X" || a === "--method");
351
- const method = (
352
- index === -1
353
- ? args.find((a) => a.startsWith("--method="))?.slice(9)
354
- : args[index + 1]
355
- )?.toUpperCase();
356
- return body || (method !== undefined && method !== "GET")
357
- ? "changes GitHub through the API"
358
- : undefined;
359
- }
360
- case "release":
361
- case "secret":
362
- case "variable":
363
- return verb && !GH_READ_VERBS.has(verb)
364
- ? `changes GitHub ${group}s`
365
- : undefined;
366
- case "repo":
367
- return verb && GH_REPO_CHANGES.has(verb)
368
- ? `changes a GitHub repository (${verb})`
369
- : undefined;
370
- default:
371
- return undefined;
287
+ /**
288
+ * Why a script another shell runs (`bash -c`) is held. A script with a substitution the outer shell
289
+ * expands, or one that is a variable, is built from output not known here, so it is held whole. A
290
+ * substitution the outer shell leaves alone (single quotes) is the inner shell's, and judged as one.
291
+ */
292
+ function scriptHeld(
293
+ script: string,
294
+ state: LineState,
295
+ policy: PushPolicy,
296
+ ): Held[] {
297
+ if (script.includes(EXPANDED)) return [DYNAMIC_SCRIPT];
298
+ if (script.trimStart().startsWith("$")) {
299
+ const known = expandName(script, state);
300
+ return known === undefined
301
+ ? [DYNAMIC_SCRIPT]
302
+ : commandHeld(known, state, policy);
372
303
  }
304
+ return commandHeld(script, state, policy);
373
305
  }
374
306
 
375
- /** Why a command line is held; a `bash -c` inside one starts from the outer line's state. */
307
+ const DYNAMIC_SCRIPT: Held = {
308
+ reason: "a script built at run time",
309
+ class: "shell.unreadable",
310
+ };
311
+
312
+ /** Every reason a command line is held, command by command; a `bash -c` inside one starts from the outer line's state. */
376
313
  function commandHeld(
377
314
  command: string,
378
315
  outer: LineState,
379
316
  policy: PushPolicy,
380
- ): string | undefined {
317
+ ): Held[] {
381
318
  const state: LineState = {
382
319
  ...lineState(command, outer.workspace, undefined),
320
+ written: new Set(outer.written),
321
+ bound: new Set([
322
+ ...outer.bound,
323
+ ...lineState(command, outer.workspace, undefined).bound,
324
+ ]),
383
325
  roots: outer.roots,
384
326
  vars: new Map(outer.vars),
385
327
  cwd: outer.cwd,
386
328
  };
387
- for (const words of simpleCommands(command)) {
388
- const reason = heldReason(words, state, policy);
389
- if (reason) return reason;
329
+ const lexed = lexShell(command);
330
+ // A line the lexer cannot read could run anything.
331
+ if (lexed.unreadable) return [UNREADABLE];
332
+ const found: Held[] = [];
333
+ // Arithmetic evaluates a `$(` in the value of a variable, text this rule cannot follow.
334
+ if (
335
+ lexed.literalSubstitution &&
336
+ (lexed.arithmetic || lexed.commands.some(evaluatesArithmetic))
337
+ )
338
+ found.push(DYNAMIC_SCRIPT);
339
+ let counts: Map<string, number> | undefined;
340
+ for (const words of lexed.commands) {
341
+ found.push(...heldReasons(words, state, policy));
342
+ const [first, ...rest] = program(words);
343
+ recordWrites(words, first ? teeOperands(first, rest) : [], state);
390
344
  follow(program(words), state);
391
345
  // `export` and `cd` are kept by program(); bare assignments are dropped by it.
392
- if (words.every((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)))
393
- follow(words, state);
346
+ if (words.every((w) => ASSIGNMENT_WORD.test(w))) follow(words, state);
347
+ counts ??= bindingCounts(command);
348
+ forgetRebound(words, state, counts);
394
349
  }
395
- return undefined;
350
+ return found;
396
351
  }
397
352
 
398
353
  /**
@@ -406,6 +361,44 @@ export function shellActionNeedingConfirmation(
406
361
  context: HoldContext & { workspace: string },
407
362
  policy: PushPolicy = {},
408
363
  ): string | undefined {
364
+ return shellHold(toolName, input, context, policy)?.action;
365
+ }
366
+
367
+ /**
368
+ * What a shell call is held for, in words, and the class of every command it is held for. A
369
+ * classifier that fails holds the call: no error lets a command run unjudged.
370
+ */
371
+ function shellHold(
372
+ toolName: string,
373
+ input: Record<string, unknown>,
374
+ context: HoldContext & { workspace: string },
375
+ policy: PushPolicy,
376
+ ): { action: string; classes: ShellClass[] } | undefined {
377
+ try {
378
+ return judge(toolName, input, context, policy);
379
+ } catch {
380
+ if (!SHELL_TOOLS.includes(toolName as (typeof SHELL_TOOLS)[number]))
381
+ return undefined;
382
+ let detail = `the ${toolName} call`;
383
+ try {
384
+ if (toolName === "bash" && typeof input.command === "string")
385
+ detail = `the shell command \`${input.command.slice(0, MAX_SHOWN_COMMAND)}\``;
386
+ } catch {
387
+ // The input itself cannot be read; the call is held without showing it.
388
+ }
389
+ return {
390
+ action: `run ${detail} on the host (it could not be checked)`,
391
+ classes: ["shell.unreadable"],
392
+ };
393
+ }
394
+ }
395
+
396
+ function judge(
397
+ toolName: string,
398
+ input: Record<string, unknown>,
399
+ context: HoldContext & { workspace: string },
400
+ policy: PushPolicy,
401
+ ): { action: string; classes: ShellClass[] } | undefined {
409
402
  const state = lineState(
410
403
  typeof input.command === "string" ? input.command : "",
411
404
  context.workspace,
@@ -413,16 +406,23 @@ export function shellActionNeedingConfirmation(
413
406
  );
414
407
  if (toolName === "bash") {
415
408
  const command = typeof input.command === "string" ? input.command : "";
416
- const reason = commandHeld(command, state, policy);
417
- return reason
418
- ? `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${reason})`
409
+ const found = commandHeld(command, state, policy);
410
+ const [first] = found;
411
+ return first
412
+ ? {
413
+ action: `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${first.reason})`,
414
+ classes: [...new Set(found.map((held) => held.class))],
415
+ }
419
416
  : undefined;
420
417
  }
421
418
  if (toolName === "write" || toolName === "edit") {
422
419
  const path = typeof input.path === "string" ? input.path : "";
423
420
  return writable(path.replace(/^@/, ""), state)
424
421
  ? undefined
425
- : `${toolName} the file ${path} on the host`;
422
+ : {
423
+ action: `${toolName} the file ${path} on the host`,
424
+ classes: ["shell.write"],
425
+ };
426
426
  }
427
427
  return undefined;
428
428
  }
@@ -447,6 +447,15 @@ export function shellHoldRuleFor(policy: PushPolicy = {}): Readonly<HoldRule> {
447
447
  { ...context, workspace: context.workspace },
448
448
  frozen,
449
449
  ),
450
+ holdClasses: (tool, input, context) =>
451
+ context.workspace === undefined
452
+ ? undefined
453
+ : shellHold(
454
+ tool,
455
+ input,
456
+ { ...context, workspace: context.workspace },
457
+ frozen,
458
+ )?.classes,
450
459
  });
451
460
  }
452
461