pi-roundtable 0.22.5 → 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.
- package/CHANGELOG.md +55 -1
- package/README.md +35 -9
- package/README.zh-TW.md +51 -13
- package/docs/migrating-0.8.md +2 -2
- package/docs/plugins.md +350 -28
- package/examples/discord-adapter.ts +9 -1
- package/examples/seeds.test.ts +2 -2
- package/examples/seeds.ts +1 -2
- package/package.json +6 -2
- package/src/characters/catalog.ts +1055 -0
- package/src/characters/characters.css +68 -0
- package/src/characters/colors.ts +108 -0
- package/src/characters/compose.ts +281 -0
- package/src/characters/css.ts +63 -0
- package/src/characters/index.ts +33 -0
- package/src/characters/parse.ts +196 -0
- package/src/characters/parts-face.ts +354 -0
- package/src/characters/parts-hair.ts +56 -0
- package/src/characters/parts-hats.ts +57 -0
- package/src/characters/parts-outfits.ts +81 -0
- package/src/characters/parts-props.ts +137 -0
- package/src/characters/spec.ts +224 -0
- package/src/characters/suggest.ts +101 -0
- package/src/cli/checks/characters.ts +23 -0
- package/src/cli/checks/images.ts +5 -3
- package/src/cli/doctor.ts +2 -0
- package/src/core/agents/agent-claim.ts +30 -11
- package/src/core/agents/agent-dashboard.ts +1 -1
- package/src/core/agents/agent-messages.ts +43 -15
- package/src/core/agents/agent-prompt.ts +2 -4
- package/src/core/agents/agent-rules.ts +42 -3
- package/src/core/agents/agent-schema.ts +37 -15
- package/src/core/agents/agent-store.ts +57 -13
- package/src/core/agents/agent-team-fixture.ts +90 -14
- package/src/core/agents/agent-team.ts +83 -50
- package/src/core/agents/agent-tools.ts +94 -41
- package/src/core/agents/avatar-studio.ts +40 -4
- package/src/core/agents/character-png.ts +40 -0
- package/src/core/agents/group-messages.ts +7 -4
- package/src/core/agents/group-turns.ts +27 -14
- package/src/core/agents/team-editing.ts +99 -58
- package/src/core/agents/team-keys.ts +177 -11
- package/src/core/agents/team-lifecycle.ts +151 -84
- package/src/core/agents/team-options.ts +13 -6
- package/src/core/agents/team-status.ts +31 -13
- package/src/core/agents/team-text.ts +13 -3
- package/src/core/agents/team-turn-types.ts +25 -7
- package/src/core/agents/team-turns.ts +143 -28
- package/src/core/attachments/attachment-fetcher.ts +162 -64
- package/src/core/attachments/prompt-block.ts +7 -1
- package/src/core/attachments/tool-attachment.ts +20 -1
- package/src/core/attachments/turn-attachments.ts +18 -2
- package/src/core/builtin/agent-server.ts +103 -40
- package/src/core/builtin/discord.ts +4 -17
- package/src/core/builtin/modules.ts +6 -5
- package/src/core/builtin/threads.ts +37 -0
- package/src/core/config/agent-team.ts +43 -0
- package/src/core/config/config.ts +50 -59
- package/src/core/config/limits.ts +93 -0
- package/src/core/config/ops.ts +34 -0
- package/src/core/config/schema.ts +26 -0
- package/src/core/contract/channels.ts +20 -0
- package/src/core/contract/surface.ts +77 -7
- package/src/core/define-roundtable.ts +70 -42
- package/src/core/discord/agent-commands.ts +108 -86
- package/src/core/discord/agent-panel.ts +11 -2
- package/src/core/discord/connection.ts +1 -1
- package/src/core/discord/discord-surface.ts +10 -1
- package/src/core/discord/dispatch-thread-host.ts +1 -1
- package/src/core/domain/conversation.ts +24 -0
- package/src/core/holds.ts +221 -15
- package/src/core/host-registration.ts +2 -1
- package/src/core/http/listeners.ts +63 -3
- package/src/core/i18n/agent-panel.ts +14 -21
- package/src/core/interactions/prompts.ts +12 -0
- package/src/core/modes/autonomy.ts +23 -0
- package/src/core/modes/conversation-modes.ts +213 -0
- package/src/core/modes/modes-plugin.ts +24 -0
- package/src/core/modules/delegation/delegator.ts +4 -4
- package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
- package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
- package/src/core/modules/host-shell/shell-lexer.ts +493 -0
- package/src/core/modules/host-shell/shell-paths.ts +85 -4
- package/src/core/modules/host-shell/shell-policy.ts +341 -332
- package/src/core/modules/host-shell/shell-programs.ts +298 -0
- package/src/core/modules/host-shell/shell-quoting.ts +237 -0
- package/src/core/modules/host-shell/shell-scripts.ts +114 -0
- package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
- package/src/core/modules/schedules/precheck-tools.ts +17 -0
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/presence/direct-channels.ts +30 -0
- package/src/core/presentation/reply-body.ts +25 -0
- package/src/core/registry/contributions.ts +31 -7
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/conversation-turns.ts +2 -15
- package/src/core/routing/surface-port.ts +5 -1
- package/src/core/runtime/extensions/approval-card.ts +1 -5
- package/src/core/runtime/extensions/approval-fields.ts +3 -7
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
- package/src/core/runtime/extensions/gate-refusals.ts +23 -0
- package/src/core/runtime/extensions/held-calls.ts +16 -1
- package/src/core/runtime/extensions/hold-describer.ts +58 -8
- package/src/core/runtime/extensions/hold-lookups.ts +19 -19
- package/src/core/runtime/extensions/yolo.ts +105 -0
- package/src/core/runtime/pi-agent-runtime.ts +3 -5
- package/src/core/runtime/runtime-plugin.ts +42 -1
- package/src/core/runtime/runtime-types.ts +11 -0
- package/src/core/runtime/turn-budget.ts +32 -5
- package/src/core/runtime/turn-limits.ts +14 -3
- package/src/core/services.ts +61 -9
- package/src/core/shared/attachment-reader.ts +6 -0
- package/src/core/shared/unix-server.ts +10 -1
- package/src/core/testing/modules.ts +12 -8
- package/src/core/testing/surface-threads.ts +58 -0
- package/src/core/testing/thread-host.ts +17 -5
- package/src/core/threads/dispatch-threads.ts +299 -0
- package/src/core/threads/host-threads.ts +19 -0
- package/src/core/tool-set.snapshot.json +5 -0
- package/src/index.ts +27 -1
- package/src/kit/characters.ts +5 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/shell.ts +1 -0
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/testing.ts +4 -0
- package/templates/agents.ts +1 -1
- 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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
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
|
-
*
|
|
90
|
-
*
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
word
|
|
98
|
-
|
|
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
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
166
|
-
* `
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
/**
|
|
196
|
-
|
|
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
|
-
):
|
|
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))
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
"
|
|
312
|
-
"
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
"
|
|
329
|
-
|
|
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
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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) =>
|
|
393
|
-
|
|
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
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
:
|
|
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
|
|