@opencode-cockpit/trust 0.0.0 → 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 (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli/preview.js +251 -0
  3. package/dist/core/adapt/seen.js +24 -0
  4. package/dist/core/adapt/v1.js +109 -0
  5. package/dist/core/adapt/v2.js +109 -0
  6. package/dist/core/config.js +93 -0
  7. package/dist/core/danger.js +366 -0
  8. package/dist/core/engine.js +245 -0
  9. package/dist/core/family.js +512 -0
  10. package/dist/core/history.js +169 -0
  11. package/dist/core/index.js +16 -0
  12. package/dist/core/keys.js +149 -0
  13. package/dist/core/ledger.js +202 -0
  14. package/dist/core/paths.js +38 -0
  15. package/dist/core/policy.js +128 -0
  16. package/dist/core/rules.js +152 -0
  17. package/dist/core/sample.js +311 -0
  18. package/dist/core/shell.js +253 -0
  19. package/dist/core/signature.js +38 -0
  20. package/dist/core/view/actions.js +321 -0
  21. package/dist/core/view/activity.js +557 -0
  22. package/dist/core/view/explorer.js +970 -0
  23. package/dist/core/view/model.js +193 -0
  24. package/dist/core/view/parts.js +290 -0
  25. package/dist/core/view/rows.js +165 -0
  26. package/dist/core/view/sidebar.js +170 -0
  27. package/dist/tui/index.js +920 -0
  28. package/dist/tui/journal.js +80 -0
  29. package/dist/tui/render.js +85 -0
  30. package/dist/tui/source.js +112 -0
  31. package/dist/tui/view/dialog.js +43 -0
  32. package/dist/tui/view/rows.js +69 -0
  33. package/package.json +49 -3
  34. package/tui.js +6 -0
  35. package/types/cli/preview.d.ts +22 -0
  36. package/types/core/adapt/seen.d.ts +46 -0
  37. package/types/core/adapt/v1.d.ts +18 -0
  38. package/types/core/adapt/v2.d.ts +20 -0
  39. package/types/core/config.d.ts +57 -0
  40. package/types/core/danger.d.ts +50 -0
  41. package/types/core/engine.d.ts +117 -0
  42. package/types/core/family.d.ts +97 -0
  43. package/types/core/history.d.ts +81 -0
  44. package/types/core/index.d.ts +16 -0
  45. package/types/core/keys.d.ts +68 -0
  46. package/types/core/ledger.d.ts +158 -0
  47. package/types/core/paths.d.ts +24 -0
  48. package/types/core/policy.d.ts +52 -0
  49. package/types/core/rules.d.ts +55 -0
  50. package/types/core/sample.d.ts +22 -0
  51. package/types/core/shell.d.ts +40 -0
  52. package/types/core/signature.d.ts +18 -0
  53. package/types/core/view/actions.d.ts +76 -0
  54. package/types/core/view/activity.d.ts +101 -0
  55. package/types/core/view/explorer.d.ts +154 -0
  56. package/types/core/view/model.d.ts +106 -0
  57. package/types/core/view/parts.d.ts +76 -0
  58. package/types/core/view/rows.d.ts +60 -0
  59. package/types/core/view/sidebar.d.ts +62 -0
  60. package/types/tui/index.d.ts +15 -0
  61. package/types/tui/journal.d.ts +19 -0
  62. package/types/tui/render.d.ts +14 -0
  63. package/types/tui/source.d.ts +35 -0
  64. package/types/tui/view/dialog.d.ts +24 -0
  65. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * What `x`, `w` and `c` do, on whatever is selected — a command, a family, an answer in the feed, or
3
+ * OpenCode's own "always". Pure: each returns the events to append and the sentence to say. Nothing
4
+ * in the ledger is ever changed in place.
5
+ */
6
+
7
+ import { anyOf, readSubject, showSubject, widenable } from "../family.js";
8
+ import { leadOf } from "./model.js";
9
+ import { agentsText, plural } from "./parts.js";
10
+ /** What a widening never covers, in words — the three `family.outside` holds back. */
11
+ export const EXCEPT = "except dangerous ones, ones that write a file and ones that run another program";
12
+ export const NOT_COVERED = "dangerous ones, and any that write a file or run another program";
13
+ const revoked = (permission, agent, subject) => ({
14
+ v: 1,
15
+ at: 0,
16
+ type: "revoked",
17
+ permission,
18
+ agent,
19
+ subject
20
+ });
21
+ const stamp = (events, at) => events.map(event => ({
22
+ ...event,
23
+ at
24
+ }));
25
+
26
+ /* ─── x ──────────────────────────────────────────────────────────────────────────────────────── */
27
+
28
+ /** What `x` is called on a target: revoke what answers, forget what is only counting. */
29
+ export function revokeLabel(target) {
30
+ if (target.kind === "always") return {
31
+ label: "Revoke",
32
+ off: true
33
+ };
34
+ if (target.kind === "answer") return {
35
+ label: "Revoke",
36
+ off: false
37
+ };
38
+ if (target.kind === "family") {
39
+ const answering = target.family.widened.length > 0 || target.family.commands.some(c => c.phase === "answering");
40
+ return {
41
+ label: answering ? "Revoke all" : "Forget all",
42
+ off: false
43
+ };
44
+ }
45
+ return {
46
+ label: target.command.phase === "answering" ? "Revoke" : "Forget",
47
+ off: false
48
+ };
49
+ }
50
+
51
+ /**
52
+ * `x`. A command loses its standing for every agent. A family loses every command in it and its
53
+ * widenings. An answer in the feed stops what gave it: the rule's own count, or the widening.
54
+ * OpenCode's own "always" is OpenCode's: Trust cannot take it back, and says so.
55
+ */
56
+ export function revoke(target, reading, at) {
57
+ const {
58
+ threshold
59
+ } = reading.settings;
60
+ if (target.kind === "always") return {
61
+ events: [],
62
+ notice: {
63
+ text: 'OpenCode keeps its own "always" until it restarts — Trust cannot take it back.',
64
+ tone: "warning"
65
+ }
66
+ };
67
+ if (target.kind === "answer") {
68
+ const {
69
+ answer
70
+ } = target;
71
+ const events = [];
72
+ const widened = new Set();
73
+ for (const item of answer.items) {
74
+ if (item.via !== undefined) {
75
+ if (widened.has(item.via)) continue;
76
+ widened.add(item.via);
77
+ events.push({
78
+ v: 1,
79
+ at,
80
+ type: "unwidened",
81
+ permission: answer.permission,
82
+ agent: answer.agent,
83
+ family: item.via
84
+ });
85
+ } else events.push(revoked(answer.permission, answer.agent, item.subject));
86
+ }
87
+ const name = answer.items.map(item => showSubject(answer.permission, item.subject)).join(" && ");
88
+ return {
89
+ events: stamp(events, at),
90
+ notice: {
91
+ text: widened.size > 0 ? `Stopped: ${[...widened].map(family => anyOf(answer.permission, family)).join(", ")} no longer answered for ${answer.agent}.` : `Revoked: ${name} is asked again until you approve it ${threshold}× more, as ${answer.agent}.`,
92
+ tone: "muted"
93
+ }
94
+ };
95
+ }
96
+ if (target.kind === "command") {
97
+ const {
98
+ command
99
+ } = target;
100
+ const name = showSubject(command.permission, command.subject);
101
+ const agents = command.standings.map(each => each.entry.agent);
102
+ const via = command.standings.find(each => each.stand.kind === "widened");
103
+ const events = command.standings.map(each => revoked(each.entry.permission, each.entry.agent, each.entry.subject));
104
+ const others = agents.length > 1 ? ` — for ${agentsText(agents)}` : "";
105
+ return {
106
+ events: stamp(events, at),
107
+ notice: {
108
+ text: via?.stand.kind === "widened" ? `Forgot ${name}'s own count${others}; ${anyOf(command.permission, via.stand.family)} still answers it — [w] undoes that.` : command.phase === "answering" ? `Revoked: ${name} is asked again until you approve it ${threshold}× more${others}.` : `Forgot the count for ${name}${others}: it starts again from 0.`,
109
+ tone: "muted"
110
+ }
111
+ };
112
+ }
113
+ const {
114
+ family
115
+ } = target;
116
+ const events = [...family.commands.flatMap(command => command.standings.map(each => revoked(each.entry.permission, each.entry.agent, each.entry.subject))), ...family.widened.map(each => ({
117
+ v: 1,
118
+ at,
119
+ type: "unwidened",
120
+ permission: each.permission,
121
+ agent: each.agent,
122
+ family: each.family
123
+ }))];
124
+ const name = showSubject(family.permission, family.family);
125
+ const answering = family.commands.filter(command => command.phase === "answering").length;
126
+ return {
127
+ events: stamp(events, at),
128
+ notice: {
129
+ text: answering > 0 || family.widened.length > 0 ? `Revoked ${plural(family.commands.length, "command")} in ${name}${family.widened.length > 0 ? ", and its widening" : ""} — each is asked again until approved ${threshold}× in a row.` : `Forgot ${plural(family.commands.length, "count")} in ${name}: each starts again from 0.`,
130
+ tone: "muted"
131
+ }
132
+ };
133
+ }
134
+
135
+ /* ─── w ──────────────────────────────────────────────────────────────────────────────────────── */
136
+
137
+ /** The family `w` acts on, and for which agent. */
138
+
139
+ export function widenScope(target, families) {
140
+ if (target.kind === "always") return undefined;
141
+ const find = (permission, family) => families.find(each => each.permission === permission && each.family === family);
142
+ if (target.kind === "family") {
143
+ const {
144
+ family
145
+ } = target;
146
+ return {
147
+ permission: family.permission,
148
+ family: family.family,
149
+ ...(family.commands[0] ? {
150
+ agent: leadOf(family.commands[0]).entry.agent
151
+ } : {}),
152
+ undo: family.widened.map(each => each.agent)
153
+ };
154
+ }
155
+ const [permission, familyName, agent] = target.kind === "answer" ? [target.answer.permission, target.answer.items.find(item => item.via !== undefined)?.via ?? familyOfAnswer(target.answer, families), target.answer.agent] : [target.command.permission, target.command.family, leadOf(target.command).entry.agent];
156
+ if (familyName === undefined) return undefined;
157
+ const widened = find(permission, familyName)?.widened ?? [];
158
+ return {
159
+ permission,
160
+ family: familyName,
161
+ agent,
162
+ undo: widened.filter(each => each.agent === agent).map(each => each.agent)
163
+ };
164
+ }
165
+
166
+ /** The family of an answer's first command, as the model grouped it. */
167
+ function familyOfAnswer(answer, families) {
168
+ const first = answer.items[0]?.subject;
169
+ if (first === undefined) return undefined;
170
+ return families.find(family => family.commands.some(command => command.permission === answer.permission && command.subject === first))?.family;
171
+ }
172
+
173
+ /** The family's name in a button: commands stay lowercase, where it runs is left to the card. */
174
+ export function familyLabel(permission, family) {
175
+ const text = permission === "bash" ? showSubject("bash", family).replace(/^\(in .*?\) /, "") : family;
176
+ return text.length > 20 ? `${text.slice(0, 19)}…` : text;
177
+ }
178
+
179
+ /** What `w` is called: `Trust any head`, `Undo any head`, or dimmed when the family never widens. */
180
+ export function widenLabel(scope, named = true) {
181
+ if (!scope) return {
182
+ label: "Trust any",
183
+ off: true
184
+ };
185
+ const name = named ? ` ${familyLabel(scope.permission, scope.family)}` : "";
186
+ if (scope.undo.length > 0) return {
187
+ label: `Undo any${name}`,
188
+ off: false
189
+ };
190
+ return {
191
+ label: `Trust any${name}`,
192
+ off: !widenable(scope.permission, scope.family).ok
193
+ };
194
+ }
195
+
196
+ /**
197
+ * `w`: trust a whole family for one agent, or take that back. Never automatic — this is the only place
198
+ * a `widened` event is made, and only a person reaches it. A dangerous family is refused, with why.
199
+ */
200
+ export function widen(scope, families, at) {
201
+ if (!scope) return {
202
+ events: [],
203
+ notice: {
204
+ text: "OpenCode's own approvals have no family to widen.",
205
+ tone: "warning"
206
+ }
207
+ };
208
+ const any = anyOf(scope.permission, scope.family);
209
+ if (scope.undo.length > 0) return {
210
+ events: scope.undo.map(agent => ({
211
+ v: 1,
212
+ at,
213
+ type: "unwidened",
214
+ permission: scope.permission,
215
+ agent,
216
+ family: scope.family
217
+ })),
218
+ notice: {
219
+ text: `Back to exact rules: ${any} is no longer answered for ${agentsText(scope.undo)}.`,
220
+ tone: "muted"
221
+ }
222
+ };
223
+ const can = widenable(scope.permission, scope.family);
224
+ if (!can.ok) return {
225
+ events: [],
226
+ notice: {
227
+ text: `Not widened: ${can.why}.`,
228
+ tone: "warning"
229
+ }
230
+ };
231
+ if (scope.agent === undefined) return {
232
+ events: [],
233
+ notice: {
234
+ text: "No agent to widen it for.",
235
+ tone: "warning"
236
+ }
237
+ };
238
+ const family = families.find(each => each.permission === scope.permission && each.family === scope.family);
239
+ const others = [...new Set((family?.commands ?? []).flatMap(command => command.standings.map(each => each.entry.agent).filter(agent => agent !== scope.agent)))];
240
+ return {
241
+ events: [{
242
+ v: 1,
243
+ at,
244
+ type: "widened",
245
+ permission: scope.permission,
246
+ agent: scope.agent,
247
+ family: scope.family
248
+ }],
249
+ notice: {
250
+ text: `Trusted ${any} for ${scope.agent}${scope.permission === "bash" ? ` — ${EXCEPT} still ask` : ""}.${others.length > 0 ? ` ${agentsText(others)} keep${others.length === 1 ? "s" : ""} counting.` : ""} [w] again undoes it.`,
251
+ tone: "success"
252
+ }
253
+ };
254
+ }
255
+
256
+ /* ─── c ──────────────────────────────────────────────────────────────────────────────────────── */
257
+
258
+ /**
259
+ * The target as `opencode.json` would say it, for you to paste — Trust never writes OpenCode's
260
+ * config. A family is a wildcard (`"ls *": "allow"`), and config says less than a widening: not which
261
+ * agent, and not the commands a widening still asks about.
262
+ */
263
+ export function configSnippet(target) {
264
+ if (target.kind === "always") {
265
+ const permission = {};
266
+ for (const group of target.groups) for (const pattern of group.patterns) {
267
+ const rules = permission[group.permission] ?? {};
268
+ rules[pattern] = "allow";
269
+ permission[group.permission] = rules;
270
+ }
271
+ return {
272
+ text: JSON.stringify({
273
+ permission
274
+ })
275
+ };
276
+ }
277
+ if (target.kind === "family") return familySnippet(target.family);
278
+ const [permission, subject] = target.kind === "answer" ? [target.answer.permission, target.answer.items[0]?.subject ?? ""] : [target.command.permission, target.command.subject];
279
+ const placed = subject.match(/^\(in [^)]*\) (.*)$/s);
280
+ const pattern = permission === "webfetch" ? `https://${subject}/*` : placed ? placed[1] : subject;
281
+ const text = JSON.stringify({
282
+ permission: {
283
+ [permission]: {
284
+ [pattern]: "allow"
285
+ }
286
+ }
287
+ });
288
+ return placed ? {
289
+ text,
290
+ note: "a config rule cannot say where a command runs: this one allows it anywhere"
291
+ } : {
292
+ text
293
+ };
294
+ }
295
+ function familySnippet(family) {
296
+ const notes = [];
297
+ let pattern = family.family;
298
+ if (family.permission === "bash") {
299
+ const read = readSubject(family.family);
300
+ if (read) {
301
+ const env = read.command.env.map(word => word.replace(/=…$/, "=*"));
302
+ pattern = `${[...env, ...read.command.argv].join(" ")} *`;
303
+ if (read.place !== undefined) notes.push("anywhere, not only where it ran");
304
+ }
305
+ } else if (family.permission === "edit") {
306
+ pattern = `${family.family === "./" ? "" : family.family}*`;
307
+ notes.push("its * reaches into subfolders too");
308
+ } else if (family.permission === "webfetch") pattern = `https://${family.family}/*`;
309
+ const any = anyOf(family.permission, family.family);
310
+ notes.unshift(family.widened.length > 0 ? `config cannot say which agent, so it allows ${any} for every one — even the ones Trust still asks about` : `wider than anything Trust answers here: ${any}, for every agent`);
311
+ return {
312
+ text: JSON.stringify({
313
+ permission: {
314
+ [family.permission]: {
315
+ [pattern]: "allow"
316
+ }
317
+ }
318
+ }),
319
+ note: notes.join("; ")
320
+ };
321
+ }