@opencode-cockpit/trust 0.0.0-stage → 0.8.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/dist/cli/preview.js +251 -0
  4. package/dist/core/adapt/seen.js +24 -0
  5. package/dist/core/adapt/v1.js +109 -0
  6. package/dist/core/adapt/v2.js +109 -0
  7. package/dist/core/config.js +93 -0
  8. package/dist/core/danger.js +366 -0
  9. package/dist/core/engine.js +245 -0
  10. package/dist/core/family.js +512 -0
  11. package/dist/core/history.js +169 -0
  12. package/dist/core/index.js +16 -0
  13. package/dist/core/keys.js +149 -0
  14. package/dist/core/ledger.js +202 -0
  15. package/dist/core/paths.js +38 -0
  16. package/dist/core/policy.js +128 -0
  17. package/dist/core/rules.js +152 -0
  18. package/dist/core/sample.js +311 -0
  19. package/dist/core/shell.js +253 -0
  20. package/dist/core/signature.js +38 -0
  21. package/dist/core/view/actions.js +321 -0
  22. package/dist/core/view/activity.js +557 -0
  23. package/dist/core/view/explorer.js +970 -0
  24. package/dist/core/view/model.js +193 -0
  25. package/dist/core/view/parts.js +290 -0
  26. package/dist/core/view/rows.js +165 -0
  27. package/dist/core/view/sidebar.js +170 -0
  28. package/dist/tui/index.js +920 -0
  29. package/dist/tui/journal.js +80 -0
  30. package/dist/tui/render.js +85 -0
  31. package/dist/tui/source.js +112 -0
  32. package/dist/tui/view/dialog.js +43 -0
  33. package/dist/tui/view/rows.js +69 -0
  34. package/package.json +61 -4
  35. package/tui.js +6 -0
  36. package/types/cli/preview.d.ts +22 -0
  37. package/types/core/adapt/seen.d.ts +46 -0
  38. package/types/core/adapt/v1.d.ts +18 -0
  39. package/types/core/adapt/v2.d.ts +20 -0
  40. package/types/core/config.d.ts +57 -0
  41. package/types/core/danger.d.ts +50 -0
  42. package/types/core/engine.d.ts +117 -0
  43. package/types/core/family.d.ts +97 -0
  44. package/types/core/history.d.ts +81 -0
  45. package/types/core/index.d.ts +16 -0
  46. package/types/core/keys.d.ts +68 -0
  47. package/types/core/ledger.d.ts +158 -0
  48. package/types/core/paths.d.ts +24 -0
  49. package/types/core/policy.d.ts +52 -0
  50. package/types/core/rules.d.ts +55 -0
  51. package/types/core/sample.d.ts +22 -0
  52. package/types/core/shell.d.ts +40 -0
  53. package/types/core/signature.d.ts +18 -0
  54. package/types/core/view/actions.d.ts +76 -0
  55. package/types/core/view/activity.d.ts +101 -0
  56. package/types/core/view/explorer.d.ts +154 -0
  57. package/types/core/view/model.d.ts +106 -0
  58. package/types/core/view/parts.d.ts +76 -0
  59. package/types/core/view/rows.d.ts +60 -0
  60. package/types/core/view/sidebar.d.ts +62 -0
  61. package/types/tui/index.d.ts +15 -0
  62. package/types/tui/journal.d.ts +19 -0
  63. package/types/tui/render.d.ts +14 -0
  64. package/types/tui/source.d.ts +35 -0
  65. package/types/tui/view/dialog.d.ts +24 -0
  66. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * OpenCode 2's events, as what Trust takes from them.
3
+ *
4
+ * `ctx.data.listen` hands over `{ name, details: { data } }`. Shapes measured on 2.0.18
5
+ * (docs/opencode/permissions.md) and read from its own schema (`Permission.Request`):
6
+ *
7
+ * permission.asked { id, sessionID, action: "shell", resources, save, metadata, message,
8
+ * source: { type: "tool", messageID, id } }
9
+ * permission.replied { sessionID, requestID, reply }
10
+ * session.tool.called { sessionID, assistantMessageID, id, input } — a shell call's `command`, `cwd`
11
+ * session.created { sessionID, agent }
12
+ * session.agent.selected { sessionID, agent }
13
+ * config.updated {}
14
+ *
15
+ * The request carries no agent (v2's schema has none), so it comes from the session's events.
16
+ */
17
+
18
+ import { canonical } from "../rules.js";
19
+ import { isReply, obj, str, strings } from "./seen.js";
20
+ export function fromV2Event(raw) {
21
+ const event = obj(raw);
22
+ const name = str(event.name) ?? str(event.type);
23
+ const details = obj(event.details);
24
+ /** `details.data`, as measured; the bare payload too, for a request read from a list. */
25
+ const data = obj(details.data ?? event.data ?? event.properties);
26
+ switch (name) {
27
+ case "permission.asked":
28
+ {
29
+ const id = str(data.id);
30
+ const sessionID = str(data.sessionID);
31
+ const action = str(data.action) ?? str(data.permission);
32
+ if (!id || !sessionID || !action) return [];
33
+ const source = obj(data.source);
34
+ const call = source.type === "tool" ? str(source.id) : undefined;
35
+ return [{
36
+ type: "asked",
37
+ request: {
38
+ id,
39
+ sessionID,
40
+ permission: canonical(action),
41
+ patterns: strings(data.resources ?? data.patterns),
42
+ always: strings(data.save ?? data.always),
43
+ ...(call ? {
44
+ call
45
+ } : {}),
46
+ ...(str(source.messageID) ? {
47
+ messageID: str(source.messageID)
48
+ } : {})
49
+ }
50
+ }];
51
+ }
52
+ case "permission.replied":
53
+ {
54
+ const sessionID = str(data.sessionID);
55
+ const requestID = str(data.requestID);
56
+ const reply = data.reply ?? data.decision;
57
+ return sessionID && requestID && isReply(reply) ? [{
58
+ type: "replied",
59
+ sessionID,
60
+ requestID,
61
+ reply
62
+ }] : [];
63
+ }
64
+ case "session.tool.called":
65
+ {
66
+ const sessionID = str(data.sessionID);
67
+ const call = str(data.id);
68
+ const input = obj(data.input);
69
+ if (!sessionID || !call || Object.keys(input).length === 0) return [];
70
+ const messageID = str(data.assistantMessageID) ?? str(data.messageID);
71
+ return [{
72
+ type: "call",
73
+ sessionID,
74
+ call,
75
+ ...(messageID ? {
76
+ messageID
77
+ } : {}),
78
+ input
79
+ }];
80
+ }
81
+ case "session.created":
82
+ case "session.agent.selected":
83
+ {
84
+ const sessionID = str(data.sessionID);
85
+ const agent = str(data.agent);
86
+ return sessionID && agent ? [{
87
+ type: "agent",
88
+ sessionID,
89
+ agent
90
+ }] : [];
91
+ }
92
+ case "config.updated":
93
+ return [{
94
+ type: "config"
95
+ }];
96
+ default:
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /** A pending request from a list, in the same shape as the event's data. */
102
+ export function fromV2Pending(list) {
103
+ return (Array.isArray(list) ? list : []).flatMap(request => fromV2Event({
104
+ name: "permission.asked",
105
+ details: {
106
+ data: request
107
+ }
108
+ }));
109
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Trust's settings, through the same merge every bay uses (docs/building/a-new-bay.md):
3
+ *
4
+ * ~/.config/opencode-cockpit/config.json → <project>/.cockpit.json → plugin-entry options
5
+ *
6
+ * Only the `trust` section of a file is read. A file without one says nothing about Trust — reading
7
+ * the whole file as Trust's settings is the trap that page warns about. An unreadable or invalid file
8
+ * is ignored rather than fatal: a typo in a config should never cost you the interface.
9
+ *
10
+ * This is Cockpit's config, not OpenCode's. What OpenCode allows, denies and asks about is read from
11
+ * OpenCode itself (`rules.ts`), and never written by Trust.
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ export const DEFAULTS = {
18
+ enabled: true,
19
+ threshold: 3,
20
+ dangerExtra: 5,
21
+ expireDays: 30,
22
+ sidebar: false,
23
+ sidebarRows: 3
24
+ };
25
+ export const CONFIG_FILE = "config.json";
26
+ export const PROJECT_FILE = ".cockpit.json";
27
+ const KEYS = ["enabled", "threshold", "dangerExtra", "expireDays", "sidebar", "sidebarRows", "sidebarOrder", "keybinds"];
28
+ export function globalConfigPath(env = process.env) {
29
+ const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config");
30
+ return join(base, "opencode-cockpit", CONFIG_FILE);
31
+ }
32
+
33
+ /** The `trust` section of a config file. */
34
+ export function trustSection(raw) {
35
+ if (!raw || typeof raw !== "object") return {};
36
+ const section = raw.trust;
37
+ return section && typeof section === "object" ? pick(section) : {};
38
+ }
39
+
40
+ /**
41
+ * Plugin-entry options: a whole cockpit config (`{ trust: {...} }`) or Trust's own keys, because the
42
+ * bundle hands each bay its own section and a standalone entry carries them directly.
43
+ */
44
+ export function asTrustConfig(input) {
45
+ if (!input || typeof input !== "object") return {};
46
+ const raw = input;
47
+ if (raw.trust && typeof raw.trust === "object") return pick(raw.trust);
48
+ return pick(raw);
49
+ }
50
+ function pick(raw) {
51
+ const own = {};
52
+ for (const key of KEYS) if (raw[key] !== undefined) Object.assign(own, {
53
+ [key]: raw[key]
54
+ });
55
+ return own;
56
+ }
57
+ export function mergeTrust(base, over) {
58
+ const merged = {
59
+ ...base,
60
+ ...over
61
+ };
62
+ if (base.keybinds || over.keybinds) merged.keybinds = {
63
+ ...base.keybinds,
64
+ ...over.keybinds
65
+ };
66
+ return merged;
67
+ }
68
+ async function readSection(path) {
69
+ try {
70
+ return trustSection(JSON.parse(await readFile(path, "utf8")));
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ /** Reads and merges every source, without blocking the interface thread. */
77
+ export async function loadTrustConfig(directory, options, env = process.env) {
78
+ const [global, project] = await Promise.all([readSection(globalConfigPath(env)), readSection(join(directory, PROJECT_FILE))]);
79
+ return mergeTrust(mergeTrust(global, project), asTrustConfig(options));
80
+ }
81
+ const whole = (value, fallback, least) => typeof value === "number" && Number.isFinite(value) ? Math.max(least, Math.floor(value)) : fallback;
82
+
83
+ /** Settings with every gap filled and every number made sensible: a threshold of 0 would trust anything. */
84
+ export function resolveSettings(config) {
85
+ return {
86
+ enabled: config.enabled !== false,
87
+ threshold: whole(config.threshold, DEFAULTS.threshold, 1),
88
+ dangerExtra: whole(config.dangerExtra, DEFAULTS.dangerExtra, 0),
89
+ expireDays: whole(config.expireDays, DEFAULTS.expireDays, 0),
90
+ sidebar: config.sidebar === true,
91
+ sidebarRows: whole(config.sidebarRows, DEFAULTS.sidebarRows, 0)
92
+ };
93
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Which commands cost more trust.
3
+ *
4
+ * Nothing here blocks anything. A dangerous command still earns trust like any other; it needs the
5
+ * normal threshold *plus* `dangerExtra` approvals in a row (docs/roadmap/trust.md, "Decided"). So a
6
+ * miss in this list is never a hole — the command still had to be approved `threshold` times — it is
7
+ * only a command that became automatic sooner than it should have. A false alarm costs a few more
8
+ * approvals and nothing else, which is why every doubt below is settled towards "dangerous".
9
+ *
10
+ * It sees one command at a time: `git add -A && git push --force` arrives as two, and only the second
11
+ * is dangerous. `cwd` is already part of the signature; this is about what the words do.
12
+ *
13
+ * The answer is a reason rather than a yes, because the ledger shows it: "git push" says why a rule
14
+ * needs eight approvals where `true` would leave the person guessing.
15
+ */
16
+
17
+ import { posix } from "node:path";
18
+ /* ─── the tables ─────────────────────────────────────────────────────────────────────────────── */
19
+
20
+ /**
21
+ * Programs that destroy, stop or rewrite whatever they are pointed at, whatever their arguments.
22
+ * `truncate` empties files; `shred` and `srm` exist to make data unrecoverable.
23
+ */
24
+ const ALWAYS = new Set(["rm", "rmdir", "unlink", "shred", "srm", "dd", "fdisk", "sfdisk", "parted", "wipefs", "kill", "killall", "pkill", "truncate", "shutdown", "reboot", "halt", "poweroff"]);
25
+
26
+ /** `mkfs`, `mkfs.ext4`, `mkfs.vfat`…: every variant formats a disk. */
27
+ const ALWAYS_PREFIX = ["mkfs"];
28
+
29
+ /** Permission and ownership changes are only dangerous when they reach a whole tree. */
30
+ const RECURSIVE_ONLY = new Set(["chmod", "chown", "chgrp"]);
31
+
32
+ /**
33
+ * Flags that are dangerous on any program. Only long forms: `-f` means *file* to `tail -f`,
34
+ * `docker compose -f` and `grep -f`, so a short `-f` is judged per program below, never globally.
35
+ */
36
+ const FLAGS = new Set(["--force", "--force-with-lease", "--force-if-includes", "--no-preserve-root"]);
37
+
38
+ /**
39
+ * Wrappers run the command after them, so the wrapped command is the one judged. Each lists the
40
+ * flags that take a separate value, so `timeout -s KILL 5 rm x` finds `rm` rather than `KILL`.
41
+ * `sudo` and `doas` are dangerous in themselves: whatever runs, runs as root.
42
+ */
43
+
44
+ export const WRAPPERS = {
45
+ sudo: {
46
+ values: ["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T"],
47
+ dangerous: "sudo"
48
+ },
49
+ doas: {
50
+ values: ["-u", "-C"],
51
+ dangerous: "doas"
52
+ },
53
+ env: {
54
+ values: ["-u", "-C", "-S", "--unset", "--chdir", "--split-string"]
55
+ },
56
+ time: {
57
+ values: ["-o", "-f", "--output", "--format"]
58
+ },
59
+ nohup: {},
60
+ timeout: {
61
+ values: ["-s", "-k", "--signal", "--kill-after"],
62
+ positionals: 1
63
+ },
64
+ nice: {
65
+ values: ["-n", "--adjustment"]
66
+ },
67
+ ionice: {
68
+ values: ["-c", "-n", "-p", "--class", "--classdata"]
69
+ },
70
+ stdbuf: {
71
+ values: ["-i", "-o", "-e"]
72
+ },
73
+ command: {},
74
+ builtin: {},
75
+ xargs: {
76
+ values: ["-I", "-i", "-n", "-P", "-L", "-l", "-d", "-E", "-e", "-s", "-a", "--max-args", "--max-procs"]
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Programs with subcommands. `globals` are the flags before the subcommand that take a separate
82
+ * value — without them `docker compose -p prod down -v` would read `prod` as the subcommand.
83
+ * `judge` sees the subcommand words and everything after them.
84
+ */
85
+
86
+ const has = (args, ...flags) => args.some(arg => flags.includes(arg) || flags.some(flag => flag.startsWith("--") && arg.startsWith(`${flag}=`)));
87
+
88
+ /** A short flag inside a cluster: `-fd` has `-f`. Only for programs where the letter is known. */
89
+ const short = (args, letter) => args.some(arg => /^-[A-Za-z]+$/.test(arg) && arg.includes(letter));
90
+ const recursive = args => has(args, "--recursive") || short(args, "R");
91
+ const GIT_GLOBALS = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"];
92
+ const DOCKER_GLOBALS = ["-H", "--host", "-c", "--context", "--config", "-l", "--log-level"];
93
+ export const COMPOSE_GLOBALS = ["-f", "--file", "-p", "--project-name", "--profile", "--env-file", "--project-directory", "--ansi", "--progress", "--parallel"];
94
+ const KUBE_GLOBALS = ["-n", "--namespace", "--context", "--cluster", "--kubeconfig", "-s", "--server", "--user", "--token"];
95
+
96
+ /** Container engines share one vocabulary; `podman` and `nerdctl` copied Docker's. */
97
+ function container(words) {
98
+ const [sub, next] = words;
99
+ if (sub === "compose") {
100
+ const [action, ...after] = subcommand(words.slice(1), COMPOSE_GLOBALS);
101
+ if (action === "down" && (has(after, "--volumes", "--rmi") || short(after, "v"))) return "compose down -v";
102
+ if (action === "rm") return "compose rm";
103
+ return undefined;
104
+ }
105
+ if (sub === "rm" || sub === "rmi" || sub === "kill") return sub;
106
+ // `docker container rm`, `docker volume prune`, `docker image rm`…: the object, then what to do.
107
+ if (next === "prune" || next === "rm" || next === "kill") return `${sub} ${next}`;
108
+ if (sub === "system" && next === "reset") return "system reset";
109
+ return undefined;
110
+ }
111
+
112
+ /** SQL that throws data away, wherever it appears in a client's arguments. */
113
+ const DESTRUCTIVE_SQL = /\b(drop|truncate)\b|\bdelete\s+from\b|\balter\s+table\b[^;]*\bdrop\b/i;
114
+ function sql(_words, all) {
115
+ const found = all.join(" ").match(DESTRUCTIVE_SQL);
116
+ return found ? found[0].toLowerCase().split(/\s+/)[0] : undefined;
117
+ }
118
+ const publish = words => {
119
+ const [sub] = words;
120
+ return sub === "publish" || sub === "unpublish" || sub === "deprecate" ? sub : undefined;
121
+ };
122
+ const TOOLS = {
123
+ git: {
124
+ globals: GIT_GLOBALS,
125
+ judge([sub, ...args]) {
126
+ switch (sub) {
127
+ case "push":
128
+ return "git push";
129
+ case "clean":
130
+ return "git clean";
131
+ case "reset":
132
+ return has(args, "--hard", "--merge", "--keep") ? "git reset --hard" : undefined;
133
+ case "checkout":
134
+ // `git checkout -- a.ts` and `git checkout .` throw away work in the tree; `-f` too.
135
+ return args.includes("--") || args.includes(".") || short(args, "f") ? "git checkout over files" : undefined;
136
+ case "switch":
137
+ return has(args, "--discard-changes") || short(args, "f") ? "git switch -f" : undefined;
138
+ case "restore":
139
+ {
140
+ // Unstaging touches nothing on disk; anything that writes the working tree does.
141
+ const staged = has(args, "--staged") || short(args, "S");
142
+ const tree = has(args, "--worktree") || short(args, "W");
143
+ return staged && !tree ? undefined : "git restore";
144
+ }
145
+ case "branch":
146
+ return short(args, "D") || has(args, "--delete", "-d") && has(args, "--force") ? "git branch -D" : undefined;
147
+ case "stash":
148
+ return args[0] === "drop" || args[0] === "clear" ? `git stash ${args[0]}` : undefined;
149
+ case "rebase":
150
+ case "filter-branch":
151
+ case "filter-repo":
152
+ return `git ${sub}`;
153
+ case "update-ref":
154
+ return short(args, "d") ? "git update-ref -d" : undefined;
155
+ case "reflog":
156
+ return args[0] === "expire" || args[0] === "delete" ? `git reflog ${args[0]}` : undefined;
157
+ case "tag":
158
+ return short(args, "d") || has(args, "--delete") ? "git tag -d" : undefined;
159
+ default:
160
+ return undefined;
161
+ }
162
+ }
163
+ },
164
+ docker: {
165
+ globals: DOCKER_GLOBALS,
166
+ judge: container
167
+ },
168
+ podman: {
169
+ globals: DOCKER_GLOBALS,
170
+ judge: container
171
+ },
172
+ nerdctl: {
173
+ globals: DOCKER_GLOBALS,
174
+ judge: container
175
+ },
176
+ "docker-compose": {
177
+ globals: COMPOSE_GLOBALS,
178
+ judge: ([sub, ...args]) => sub === "down" && (has(args, "--volumes", "--rmi") || short(args, "v")) ? "compose down -v" : sub === "rm" ? "compose rm" : undefined
179
+ },
180
+ kubectl: {
181
+ globals: KUBE_GLOBALS,
182
+ judge: ([sub, ...args]) => sub === "delete" || sub === "drain" ? `kubectl ${sub}` : sub === "replace" && has(args, "--force") ? "kubectl replace --force" : undefined
183
+ },
184
+ helm: {
185
+ globals: ["-n", "--namespace", "--kube-context", "--kubeconfig"],
186
+ judge: ([sub]) => sub === "uninstall" || sub === "delete" || sub === "rollback" ? `helm ${sub}` : undefined
187
+ },
188
+ terraform: {
189
+ globals: [],
190
+ judge: ([sub, next]) => sub === "destroy" || sub === "apply" || sub === "import" ? `terraform ${sub}` : sub === "state" && (next === "rm" || next === "mv" || next === "push") ? `terraform state ${next}` : undefined
191
+ },
192
+ psql: {
193
+ judge: sql
194
+ },
195
+ mysql: {
196
+ judge: sql
197
+ },
198
+ mariadb: {
199
+ judge: sql
200
+ },
201
+ sqlite3: {
202
+ judge: sql
203
+ },
204
+ "clickhouse-client": {
205
+ judge: sql
206
+ },
207
+ "redis-cli": {
208
+ judge: (_words, all) => {
209
+ const found = all.find(word => /^(flushall|flushdb)$/i.test(word));
210
+ return found ? found.toLowerCase() : undefined;
211
+ }
212
+ },
213
+ npm: {
214
+ judge: publish
215
+ },
216
+ pnpm: {
217
+ judge: publish
218
+ },
219
+ bun: {
220
+ judge: publish
221
+ },
222
+ yarn: {
223
+ judge: words => publish(words[0] === "npm" ? words.slice(1) : words)
224
+ },
225
+ cargo: {
226
+ judge: ([sub]) => sub === "publish" ? "cargo publish" : undefined
227
+ },
228
+ gem: {
229
+ judge: ([sub]) => sub === "push" || sub === "yank" ? `gem ${sub}` : undefined
230
+ },
231
+ twine: {
232
+ judge: ([sub]) => sub === "upload" ? "twine upload" : undefined
233
+ },
234
+ gh: {
235
+ globals: ["-R", "--repo"],
236
+ // `gh repo delete`, `gh release delete`, `gh secret delete`…: whatever it is, it goes.
237
+ judge: words => words.includes("delete") ? "gh … delete" : undefined
238
+ },
239
+ aws: {
240
+ globals: ["--profile", "--region", "--endpoint-url", "--output"],
241
+ judge: ([service, action]) => service === "s3" && (action === "rm" || action === "rb") ? `aws s3 ${action}` : action && /^(delete|terminate|remove|purge)-/.test(action) ? `aws ${action}` : undefined
242
+ },
243
+ gcloud: {
244
+ judge: words => words.includes("delete") ? "gcloud … delete" : undefined
245
+ },
246
+ systemctl: {
247
+ judge: ([sub]) => sub === "stop" || sub === "disable" || sub === "mask" || sub === "kill" ? `systemctl ${sub}` : undefined
248
+ },
249
+ launchctl: {
250
+ judge: ([sub]) => sub === "unload" || sub === "remove" || sub === "bootout" ? `launchctl ${sub}` : undefined
251
+ },
252
+ crontab: {
253
+ judge: (_words, all) => short(all, "r") ? "crontab -r" : undefined
254
+ },
255
+ rsync: {
256
+ judge: (_words, all) => all.some(arg => arg.startsWith("--delete")) ? "rsync --delete" : undefined
257
+ }
258
+ };
259
+
260
+ /**
261
+ * The flags before a tool's subcommand that take a separate value, by program: what family.ts needs
262
+ * to find `status` in `git -C /x status`. Read from the table above so the two cannot disagree.
263
+ */
264
+ export const TOOL_GLOBALS = Object.fromEntries(Object.entries(TOOLS).map(([name, tool]) => [name, tool.globals ?? []]));
265
+
266
+ /* ─── reading a command ──────────────────────────────────────────────────────────────────────── */
267
+
268
+ /** The words after the global flags: `[-C, /tmp, push, origin]` → `[push, origin]`. */
269
+ export function subcommand(args, globals = []) {
270
+ let i = 0;
271
+ while (i < args.length) {
272
+ const arg = args[i];
273
+ if (arg === "--") return args.slice(i + 1);
274
+ if (!arg.startsWith("-") || arg === "-") break;
275
+ i += globals.includes(arg) ? 2 : 1;
276
+ }
277
+ return args.slice(i);
278
+ }
279
+
280
+ /**
281
+ * One wrapper taken off the front: its name, and the words it runs. Undefined when `argv` does not
282
+ * start with a wrapper. Families (family.ts) keep the wrapper's name — `sudo ls` is not `ls` — and
283
+ * drop its flags, so this is shared rather than copied.
284
+ */
285
+ export function unwrapOnce(argv) {
286
+ const program = argv[0];
287
+ if (program === undefined) return undefined;
288
+ const name = posix.basename(program);
289
+ const wrapper = WRAPPERS[name];
290
+ if (!wrapper) return undefined;
291
+ let i = 1;
292
+ while (i < argv.length) {
293
+ const word = argv[i];
294
+ if (word === "--") {
295
+ i++;
296
+ break;
297
+ }
298
+ // `env NAME=value cmd`: assignments are the wrapper's, not the command.
299
+ if (name === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
300
+ i++;
301
+ continue;
302
+ }
303
+ if (!word.startsWith("-")) break;
304
+ i += wrapper.values?.includes(word) ? 2 : 1;
305
+ }
306
+ return {
307
+ wrapper: name,
308
+ rest: argv.slice(i + (wrapper.positionals ?? 0))
309
+ };
310
+ }
311
+
312
+ /** The command a wrapper runs, and whether the wrapper itself already made it dangerous. */
313
+ function unwrap(argv) {
314
+ let words = argv;
315
+ let reason;
316
+ for (let depth = 0; depth < 8; depth++) {
317
+ const once = unwrapOnce(words);
318
+ if (!once) break;
319
+ reason ??= WRAPPERS[once.wrapper]?.dangerous;
320
+ words = once.rest;
321
+ }
322
+ return reason ? {
323
+ argv: words,
324
+ reason
325
+ } : {
326
+ argv: words
327
+ };
328
+ }
329
+
330
+ /** `find … -delete`, or `-exec rm {} ;` judged by the program it runs. */
331
+ function find(args) {
332
+ if (args.includes("-delete")) return "find -delete";
333
+ for (let i = 0; i < args.length; i++) {
334
+ if (!["-exec", "-execdir", "-ok", "-okdir"].includes(args[i])) continue;
335
+ const end = args.findIndex((word, at) => at > i && (word === ";" || word === "+"));
336
+ const inner = args.slice(i + 1, end < 0 ? undefined : end);
337
+ const reason = judge(inner);
338
+ if (reason) return `find -exec ${reason}`;
339
+ }
340
+ return undefined;
341
+ }
342
+ function judge(argv) {
343
+ const unwrapped = unwrap(argv);
344
+ const [program, ...args] = unwrapped.argv;
345
+ if (program === undefined) return unwrapped.reason;
346
+ const name = posix.basename(program);
347
+ const reason = (() => {
348
+ if (ALWAYS.has(name) || ALWAYS_PREFIX.some(prefix => name === prefix || name.startsWith(`${prefix}.`))) return name;
349
+ if (RECURSIVE_ONLY.has(name)) return recursive(args) ? `${name} -R` : undefined;
350
+ if (name === "find") return find(args);
351
+ const tool = TOOLS[name];
352
+ return tool ? tool.judge(subcommand(args, tool.globals), args) : undefined;
353
+ })();
354
+ if (reason) return unwrapped.reason ? `${unwrapped.reason} ${reason}` : reason;
355
+ const flag = args.find(arg => FLAGS.has(arg.split("=")[0]));
356
+ if (flag) return unwrapped.reason ? `${unwrapped.reason} ${flag}` : flag.split("=")[0];
357
+ return unwrapped.reason;
358
+ }
359
+
360
+ /** Why a command costs more trust, in a few words — or nothing when it costs the usual. */
361
+ export function dangerOf(command) {
362
+ return judge(command.argv);
363
+ }
364
+ export function dangerous(command) {
365
+ return dangerOf(command) !== undefined;
366
+ }