@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,311 @@
1
+ /**
2
+ * Sample worlds for the preview and the grid test: the states a design gets wrong
3
+ * (docs/building/testing.md) — nothing at all, a first week, a busy project, a paused one, one
4
+ * where something broke, and one grouped into families with one of them widened by hand.
5
+ *
6
+ * Each is built by running the real engine over real requests, so the preview shows what the
7
+ * engine actually produces rather than what a hand-written state hoped it would.
8
+ */
9
+
10
+ import { createEngine } from "./engine.js";
11
+ import { DAY } from "./ledger.js";
12
+ import { rulesFrom } from "./rules.js";
13
+ export const SAMPLE_ROOT = "/work/app";
14
+ export const SAMPLE_NOW = 1_790_300_000_000;
15
+ export const SAMPLE_SETTINGS = {
16
+ threshold: 3,
17
+ dangerExtra: 5,
18
+ expireDays: 30,
19
+ keep: 20
20
+ };
21
+ const RULES = rulesFrom({
22
+ permission: {
23
+ bash: {
24
+ "*": "ask",
25
+ "npm publish *": "ask"
26
+ },
27
+ edit: "ask",
28
+ webfetch: "ask"
29
+ }
30
+ });
31
+ function build(script) {
32
+ const engine = createEngine(SAMPLE_SETTINGS);
33
+ let n = 0;
34
+ let clock = SAMPLE_NOW - 3 * DAY;
35
+ const ask = (line, permission = "bash", patterns = [line], agent = "build") => {
36
+ n++;
37
+ const request = {
38
+ id: `per_${n}`,
39
+ sessionID: "ses_sample",
40
+ permission,
41
+ patterns,
42
+ always: permission === "bash" ? [`${line.split(" ").slice(0, 2).join(" ")} *`] : ["*"],
43
+ call: `call_${n}`
44
+ };
45
+ const {
46
+ judgement,
47
+ event
48
+ } = engine.ask({
49
+ request,
50
+ context: {
51
+ line,
52
+ root: SAMPLE_ROOT
53
+ },
54
+ agent,
55
+ rules: RULES,
56
+ at: clock
57
+ });
58
+ engine.load([event]);
59
+ return {
60
+ id: request.id,
61
+ judgement
62
+ };
63
+ };
64
+ const steps = {
65
+ at: ms => {
66
+ clock = ms;
67
+ },
68
+ approve(line, times, how = "once", permission = "bash", agent = "build") {
69
+ for (let i = 0; i < times; i++) {
70
+ const {
71
+ id
72
+ } = ask(line, permission, [line], agent);
73
+ clock += 2_000;
74
+ engine.load(engine.replied({
75
+ requestID: id,
76
+ reply: how,
77
+ at: clock
78
+ }).events);
79
+ clock += 60_000;
80
+ }
81
+ },
82
+ auto(line, times, permission = "bash", agent = "build") {
83
+ for (let i = 0; i < times; i++) {
84
+ const {
85
+ id,
86
+ judgement
87
+ } = ask(line, permission, [line], agent);
88
+ if (!judgement.answer) throw new Error(`sample: ${line} is not trusted (${judgement.why})`);
89
+ clock += 25;
90
+ const event = engine.answered(id, clock);
91
+ if (event) engine.load([event]);
92
+ engine.replied({
93
+ requestID: id,
94
+ reply: "once",
95
+ at: clock + 5
96
+ });
97
+ clock += 90_000;
98
+ }
99
+ },
100
+ pending(line) {
101
+ ask(line);
102
+ },
103
+ widen(family, agent, permission = "bash") {
104
+ engine.load([{
105
+ v: 1,
106
+ at: clock,
107
+ type: "widened",
108
+ permission,
109
+ agent,
110
+ family
111
+ }]);
112
+ clock += 1_000;
113
+ }
114
+ };
115
+ script(steps);
116
+ return engine;
117
+ }
118
+ /** The `crowded` afternoon, as steps: the oldest first, so the newest are what the ledger leads with. */
119
+ function crowd(s) {
120
+ const general = (line, times, how = "once") => s.approve(line, times, how, "bash", "general");
121
+ /** An agent reading a project: each of these asked once, and never again. */
122
+ for (let i = 1; i <= 101; i++) s.approve(i % 3 === 0 ? `wc -l src/part${i}.ts` : `sed -n ${i},${i + 40}p src/part${i}.ts`, 1);
123
+ s.approve("git push origin feat/trust", 5);
124
+ s.approve("rm -rf dist", 4);
125
+ s.approve("docker compose -p prod down -v", 2);
126
+ for (const line of ["uniq -c", "wc -l", 'sed "s|^\\./||"', "sort", "sort -rn"]) general(line, 3);
127
+ general('find packages -type f -not -path "*/dist/*"', 3);
128
+ general('find site -type f -not -path "site/node_modules/*" -not -path "site/dist/*"', 2);
129
+ general("find . -path ./.git -prune -o -type f -print", 2);
130
+ general("head -30", 3);
131
+ for (const line of ["head -40", "head -80", "head -50", "head -20"]) general(line, 2);
132
+ general("find . -name '*.md'", 1, "always");
133
+ general("ls src", 1, "always");
134
+ s.at(SAMPLE_NOW - 3_600_000);
135
+ general("cat package.json", 2);
136
+ s.widen("cat", "general");
137
+ s.auto("cat package.json", 2, "bash", "general");
138
+ s.auto("cat src/app.ts", 1, "bash", "general");
139
+ s.approve("bun --version", 2);
140
+ s.approve("exit 0", 2);
141
+ general("exit 1", 2);
142
+ general("sleep 5", 2);
143
+ general("echo ---", 3);
144
+ general("echo boom", 2);
145
+ s.approve("echo trust-test", 3);
146
+ s.approve("git status --short", 3);
147
+ general("git status --short", 2);
148
+ s.approve("ls -la", 3);
149
+ general("ls -la", 3);
150
+ s.at(SAMPLE_NOW - 120_000);
151
+ s.auto("git status --short", 3);
152
+ s.auto("echo trust-test", 1);
153
+ s.auto("ls -la", 1);
154
+ s.at(SAMPLE_NOW - 2_000);
155
+ s.pending("git push origin feat/trust");
156
+ }
157
+
158
+ /** A week of a project, as steps: rules earned early on, answered on most days since, the newest today. */
159
+ function week(s) {
160
+ const general = (line, times, how = "once") => s.approve(line, times, how, "bash", "general");
161
+ const hour = 3_600_000;
162
+ s.at(SAMPLE_NOW - 8 * DAY);
163
+ s.approve("git status --short", 3);
164
+ s.approve("echo trust-test", 3);
165
+ s.approve("bun test", 3);
166
+ s.approve("src/app.ts", 3, "once", "edit");
167
+ general("ls -la", 3);
168
+ general("head -30", 3);
169
+ general("cat package.json", 2);
170
+ s.widen("cat", "general");
171
+ s.at(SAMPLE_NOW - 6 * DAY - 5 * hour);
172
+ s.auto("git status --short", 1);
173
+ s.at(SAMPLE_NOW - 4 * DAY - 3 * hour);
174
+ s.auto("git status --short", 2);
175
+ s.auto("ls -la", 1, "bash", "general");
176
+ s.at(SAMPLE_NOW - 3 * DAY - 2 * hour);
177
+ s.auto("bun test", 1);
178
+ s.at(SAMPLE_NOW - 2 * DAY - 6 * hour);
179
+ s.auto("git status --short", 3);
180
+ s.auto("src/app.ts", 2, "edit");
181
+ s.at(SAMPLE_NOW - DAY - 4 * hour);
182
+ s.auto("echo trust-test", 2);
183
+ s.at(SAMPLE_NOW - DAY + 2 * hour);
184
+ general("head -40", 2);
185
+ general("head -60", 2);
186
+ general("head -80 README.md", 1);
187
+ general("git status --short -uno", 2);
188
+ s.approve("bun --version", 2);
189
+ general("sleep 5", 1);
190
+ s.approve("git push origin feat/trust", 5);
191
+ general("find . -name '*.md'", 1, "always");
192
+ general("sort -rn", 1, "always");
193
+ for (const once of ["wc -l src/app.ts", "sed -n 1,40p src/app.ts", "pwd"]) s.approve(once, 1);
194
+ s.at(SAMPLE_NOW - 50 * 60_000);
195
+ s.auto("cat src/app.ts", 1, "bash", "general");
196
+ s.at(SAMPLE_NOW - 20 * 60_000);
197
+ s.auto("git status --short && echo trust-test", 1);
198
+ s.at(SAMPLE_NOW - 12 * 60_000);
199
+ s.auto("bun test", 1);
200
+ s.at(SAMPLE_NOW - 3 * 60_000);
201
+ s.auto("ls -la", 1, "bash", "general");
202
+ s.auto("git status --short", 1);
203
+ s.at(SAMPLE_NOW - 2_000);
204
+ s.pending("git push origin feat/trust");
205
+ }
206
+ export const SAMPLES = {
207
+ /** A new install: nothing approved, nothing to say. */
208
+ empty: () => ({
209
+ engine: build(() => {})
210
+ }),
211
+ /** A first session: one command trusted and answered, one being counted, on screen now. */
212
+ first: () => ({
213
+ engine: build(s => {
214
+ s.approve("git status", 3);
215
+ s.at(SAMPLE_NOW - 600_000);
216
+ s.auto("git status", 1);
217
+ s.approve("docker compose -p cockpit up -d", 2);
218
+ s.at(SAMPLE_NOW - 1_000);
219
+ s.pending("docker compose -p cockpit up -d");
220
+ })
221
+ }),
222
+ /**
223
+ * A busy week, as the activity screen was designed from: rules earned over the week and answered
224
+ * most days, a family widened by hand, commands one approval away, a dangerous one half way, and an
225
+ * "always" given to OpenCode itself.
226
+ */
227
+ busy: () => ({
228
+ engine: build(week)
229
+ }),
230
+ /** Dangerous commands on their way: each needs eight in a row, and none of their families widens. */
231
+ dangerous: () => ({
232
+ engine: build(s => {
233
+ s.at(SAMPLE_NOW - 2 * DAY);
234
+ s.approve("git status", 3);
235
+ s.approve("git push origin feat/trust", 5);
236
+ s.approve("rm -rf dist", 7);
237
+ s.approve("docker compose -p prod down -v", 2);
238
+ s.approve("kubectl delete pod web-0", 3, "once", "bash", "general");
239
+ s.at(SAMPLE_NOW - 3_600_000);
240
+ s.auto("git status", 2);
241
+ s.at(SAMPLE_NOW - 2_000);
242
+ s.pending("rm -rf dist");
243
+ })
244
+ }),
245
+ /**
246
+ * Families: three `ls` rules and one answered only because you widened `ls` for general; `echo ---`
247
+ * (the line a font drew as `echo ──`); one `git status --short` earned by two agents; a dangerous
248
+ * family that can never be widened; and a tail of commands approved once, folded.
249
+ */
250
+ families: () => ({
251
+ engine: build(s => {
252
+ s.approve("ls -la", 3, "once", "bash", "general");
253
+ s.approve("ls -la src", 3, "once", "bash", "general");
254
+ s.approve("ls -R docs", 2, "once", "bash", "general");
255
+ s.approve("echo ---", 3, "once", "bash", "general");
256
+ s.approve("git status --short", 3);
257
+ s.approve("git status --short", 2, "once", "bash", "general");
258
+ s.approve("git -C packages/web status", 3);
259
+ s.approve("git push origin feat/trust", 5);
260
+ s.approve("docker compose -p cockpit up -d", 3);
261
+ s.approve("docker compose -p prod down -v", 2);
262
+ s.approve("src/app.ts", 3, "once", "edit");
263
+ s.approve("src/view.ts", 2, "once", "edit");
264
+ for (const once of ["head -60 README.md", "wc -l src/app.ts", "cat package.json", "pwd"]) s.approve(once, 1);
265
+ s.at(SAMPLE_NOW - 900_000);
266
+ s.widen("ls", "general");
267
+ s.at(SAMPLE_NOW - 600_000);
268
+ s.auto("ls -la", 3, "bash", "general");
269
+ s.auto("echo ---", 1, "bash", "general");
270
+ s.auto("git status --short", 2);
271
+ s.auto("ls -x", 1, "bash", "general");
272
+ })
273
+ }),
274
+ /**
275
+ * A real afternoon, as a user's screenshots of the ledger showed it: two agents, a hundred commands
276
+ * approved once, families half trusted and half counting, a family widened by hand, dangerous rules
277
+ * on their way, a long `find`, the `---` a font merges, and OpenCode's own "always" twice.
278
+ */
279
+ crowded: () => ({
280
+ engine: build(crowd)
281
+ }),
282
+ /** The same afternoon, paused: the dialog has to say it before anything else. */
283
+ "crowded-paused": () => {
284
+ const engine = build(crowd);
285
+ engine.load([{
286
+ v: 1,
287
+ at: SAMPLE_NOW - 60_000,
288
+ type: "paused"
289
+ }]);
290
+ return {
291
+ engine
292
+ };
293
+ },
294
+ /** The busy week, paused: still learning, answering nothing — and every surface says so. */
295
+ paused: () => {
296
+ const engine = build(week);
297
+ engine.load([{
298
+ v: 1,
299
+ at: SAMPLE_NOW - 60_000,
300
+ type: "paused"
301
+ }]);
302
+ return {
303
+ engine
304
+ };
305
+ },
306
+ /** The ledger could not be written: the block speaks even with nothing else to say. */
307
+ trouble: () => ({
308
+ engine: build(() => {}),
309
+ trouble: "ledger not saved: EACCES"
310
+ })
311
+ };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * A command line, read the way Trust needs it: split into the commands it runs, each as words.
3
+ *
4
+ * Not a shell. It has one job and one direction to fail in: anything it cannot read *for certain* —
5
+ * an expansion, a substitution, a subshell, a heredoc, a program that runs a string it was handed —
6
+ * comes back `opaque`, and an opaque line is always asked. A reader that guesses would let the one
7
+ * line it misread be approved by the approvals of another.
8
+ *
9
+ * Why not OpenCode's tree-sitter: one runtime dependency per package (docs/building/a-new-bay.md),
10
+ * and a grammar still has to be told what "cannot be known statically" means — which is this file.
11
+ */
12
+
13
+ import { posix } from "node:path";
14
+ class Opaque extends Error {}
15
+
16
+ /** Programs that run a string, a file or stdin as code: what they do is not on the line. */
17
+ const RUNS_CODE = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "source", ".", "exec"]);
18
+ /** Directory moves that cannot be followed from the text alone. */
19
+ const MOVES = new Set(["pushd", "popd"]);
20
+ /**
21
+ * Shell grammar, not programs: `while true; do echo tick; sleep 2; done` is one loop, and read word by
22
+ * word it became four "commands" — `while true`, `do echo tick`, `done` — each counting on its own.
23
+ * What a loop or a condition runs depends on what it tests, which the text does not settle.
24
+ */
25
+ const GRAMMAR = new Set(["if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "select", "function", "coproc", "{", "}", "[[", "!"]);
26
+ function tokenize(line) {
27
+ const tokens = [];
28
+ let word = "";
29
+ /** A word can be empty and still exist: `echo ""` has an argument. */
30
+ let started = false;
31
+ const flush = () => {
32
+ if (started) tokens.push({
33
+ type: "word",
34
+ text: word
35
+ });
36
+ word = "";
37
+ started = false;
38
+ };
39
+ const op = text => {
40
+ flush();
41
+ tokens.push({
42
+ type: "op",
43
+ text
44
+ });
45
+ };
46
+ const expansion = next => next !== undefined && /[({A-Za-z_0-9!?#@*$-]/.test(next);
47
+ for (let i = 0; i < line.length; i++) {
48
+ const c = line[i];
49
+ const next = line[i + 1];
50
+ if (c === " " || c === "\t") {
51
+ flush();
52
+ continue;
53
+ }
54
+ if (c === "\n") {
55
+ op(";");
56
+ continue;
57
+ }
58
+ if (c === "#" && !started) {
59
+ while (i < line.length && line[i] !== "\n") i++;
60
+ i--;
61
+ continue;
62
+ }
63
+ if (c === "\\") {
64
+ if (next === "\n") {
65
+ i++;
66
+ continue;
67
+ }
68
+ if (next === undefined) throw new Opaque("a trailing backslash");
69
+ word += next;
70
+ started = true;
71
+ i++;
72
+ continue;
73
+ }
74
+ if (c === "'") {
75
+ const end = line.indexOf("'", i + 1);
76
+ if (end < 0) throw new Opaque("an unclosed quote");
77
+ word += line.slice(i + 1, end);
78
+ started = true;
79
+ i = end;
80
+ continue;
81
+ }
82
+ if (c === '"') {
83
+ started = true;
84
+ let j = i + 1;
85
+ for (; j < line.length && line[j] !== '"'; j++) {
86
+ const d = line[j];
87
+ if (d === "`") throw new Opaque("a command substitution");
88
+ if (d === "$" && expansion(line[j + 1])) throw new Opaque("an expansion");
89
+ if (d === "\\" && /[$`"\\\n]/.test(line[j + 1] ?? "")) {
90
+ word += line[j + 1];
91
+ j++;
92
+ continue;
93
+ }
94
+ word += d;
95
+ }
96
+ if (j >= line.length) throw new Opaque("an unclosed quote");
97
+ i = j;
98
+ continue;
99
+ }
100
+ if (c === "`") throw new Opaque("a command substitution");
101
+ if (c === "$" && expansion(next)) throw new Opaque("an expansion");
102
+ if (c === "(" || c === ")") throw new Opaque("a subshell");
103
+ if (c === "<" || c === ">") {
104
+ if (next === "(") throw new Opaque("a process substitution");
105
+ if (c === "<" && next === "<") throw new Opaque("a heredoc");
106
+ /** `2>` and `2>&1`: a file descriptor is part of the redirection, not a word of its own. */
107
+ const fd = /^\d+$/.test(word) ? word : "";
108
+ if (!fd) flush();
109
+ word = "";
110
+ started = false;
111
+ let redirect = fd + c;
112
+ if (next === ">" || next === "&") {
113
+ redirect += next;
114
+ i++;
115
+ if (next === "&" && /\d|-/.test(line[i + 1] ?? "")) {
116
+ redirect += line[i + 1];
117
+ i++;
118
+ }
119
+ }
120
+ tokens.push({
121
+ type: "word",
122
+ text: redirect,
123
+ redirect: true
124
+ });
125
+ continue;
126
+ }
127
+ if (c === "&") {
128
+ if (next === "&") {
129
+ op("&&");
130
+ i++;
131
+ } else if (next === ">") {
132
+ flush();
133
+ const redirect = line[i + 2] === ">" ? "&>>" : "&>";
134
+ tokens.push({
135
+ type: "word",
136
+ text: redirect,
137
+ redirect: true
138
+ });
139
+ i += redirect.length - 1;
140
+ } else op("&");
141
+ continue;
142
+ }
143
+ if (c === "|") {
144
+ if (next === "|" || next === "&") {
145
+ op(`|${next}`);
146
+ i++;
147
+ } else op("|");
148
+ continue;
149
+ }
150
+ if (c === ";") {
151
+ op(";");
152
+ if (next === ";") i++;
153
+ continue;
154
+ }
155
+ word += c;
156
+ started = true;
157
+ }
158
+ flush();
159
+ return tokens;
160
+ }
161
+
162
+ /**
163
+ * Reads a command line into the commands it runs.
164
+ *
165
+ * `cd` is not returned as a command — it asks nothing by itself, as in OpenCode — but it moves every
166
+ * command after it, and that is kept: `cd build && rm -rf *` and `cd /tmp && rm -rf *` are different
167
+ * lines and must never share an approval.
168
+ */
169
+ export function parse(line) {
170
+ let tokens;
171
+ try {
172
+ tokens = tokenize(line);
173
+ } catch (error) {
174
+ if (error instanceof Opaque) return {
175
+ kind: "opaque",
176
+ reason: error.message
177
+ };
178
+ throw error;
179
+ }
180
+ const commands = [];
181
+ let cwd;
182
+ let words = [];
183
+ let piped = false;
184
+ const end = after => {
185
+ const current = words;
186
+ words = [];
187
+ if (current.length === 0) return undefined;
188
+ const split = current.findIndex(w => w.redirect || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
189
+ const env = (split < 0 ? current : current.slice(0, split)).map(w => w.text);
190
+ const rest = split < 0 ? [] : current.slice(split);
191
+ const argv = rest.map(w => w.text);
192
+ const redirects = rest.flatMap((w, i) => w.redirect ? [i] : []);
193
+ const program = argv[0];
194
+ if (program !== undefined && RUNS_CODE.has(posix.basename(program))) {
195
+ return {
196
+ kind: "opaque",
197
+ reason: `\`${program}\` runs code that is not on the line`
198
+ };
199
+ }
200
+ if (program !== undefined && GRAMMAR.has(program)) {
201
+ return {
202
+ kind: "opaque",
203
+ reason: `\`${program}\` is a loop or a condition, not a command`
204
+ };
205
+ }
206
+ if (program !== undefined && MOVES.has(program)) {
207
+ return {
208
+ kind: "opaque",
209
+ reason: `\`${program}\` moves where later commands run`
210
+ };
211
+ }
212
+ if (program === "cd" && env.length === 0) {
213
+ const target = argv[1];
214
+ if (argv.length > 2 || target === "-" || piped || after === "|" || after === "|&") {
215
+ return {
216
+ kind: "opaque",
217
+ reason: "a `cd` that cannot be followed"
218
+ };
219
+ }
220
+ cwd = target === undefined ? "~" : cwd && !posix.isAbsolute(target) && !target.startsWith("~") ? posix.join(cwd, target) : target;
221
+ return undefined;
222
+ }
223
+ commands.push({
224
+ env,
225
+ argv,
226
+ ...(cwd !== undefined ? {
227
+ cwd
228
+ } : {}),
229
+ ...(redirects.length > 0 ? {
230
+ redirects
231
+ } : {})
232
+ });
233
+ return undefined;
234
+ };
235
+ for (const token of tokens) {
236
+ if (token.type === "word") {
237
+ words.push({
238
+ text: token.text,
239
+ redirect: token.redirect === true
240
+ });
241
+ continue;
242
+ }
243
+ const stop = end(token.text);
244
+ if (stop) return stop;
245
+ piped = token.text === "|" || token.text === "|&";
246
+ }
247
+ const stop = end(undefined);
248
+ if (stop) return stop;
249
+ return {
250
+ kind: "commands",
251
+ commands
252
+ };
253
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What an approval is an approval *of*.
3
+ *
4
+ * A signature is the command exactly as it will run — environment, program, every argument in order,
5
+ * and where it runs — with quoting normalised so that `echo 'a b'` and `echo "a b"` are one thing.
6
+ * Nothing is generalised away. OpenCode's own "always" generalises, and measured on its own arity
7
+ * table, `docker compose -p cockpit up -d` and `docker compose -p prod down -v` both became
8
+ * `docker compose -p *` (docs/opencode/permissions.md). A signature can only ever match the command
9
+ * that earned it; making one cover more is something a person does, in the ledger, on purpose.
10
+ */
11
+
12
+ import { posix } from "node:path";
13
+ /** Words that need no quotes to read back as themselves. */
14
+ const PLAIN = /^[A-Za-z0-9_@%+=:,./~^-]+$/;
15
+ export function quote(word) {
16
+ if (PLAIN.test(word)) return word;
17
+ return `'${word.replace(/'/g, `'\\''`)}'`;
18
+ }
19
+
20
+ /**
21
+ * Where a command runs, said relative to the project: `cd /path/to/project && git status` is
22
+ * `git status`. A directory outside the project stays absolute, so it cannot pass for one inside.
23
+ */
24
+ export function place(cwd, root) {
25
+ if (cwd === undefined) return undefined;
26
+ if (root === undefined || !posix.isAbsolute(cwd)) return posix.normalize(cwd);
27
+ const relative = posix.relative(root, posix.normalize(cwd));
28
+ if (relative === "") return undefined;
29
+ if (relative.startsWith("..") || posix.isAbsolute(relative)) return posix.normalize(cwd);
30
+ return relative;
31
+ }
32
+ export function signature(command, root) {
33
+ const where = place(command.cwd, root);
34
+ /** A redirection is written bare and an argument quoted, so `echo > x` and `echo '>' x` differ. */
35
+ const ops = new Set(command.redirects ?? []);
36
+ const words = [...command.env.map(quote), ...command.argv.map((word, i) => ops.has(i) ? word : quote(word))].join(" ");
37
+ return where === undefined ? words : `(in ${quote(where)}) ${words}`;
38
+ }