@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,149 @@
1
+ /**
2
+ * What a permission request is a request *for*, as the things an approval can be counted against.
3
+ *
4
+ * Each permission type has its own notion of "the same thing again":
5
+ *
6
+ * | permission | one subject per | e.g. |
7
+ * | --- | --- | --- |
8
+ * | `bash` (v2 `shell`) | command, by its exact signature | `git status`, `(in web) bun test` |
9
+ * | `edit` | file path | `src/app.ts` |
10
+ * | `webfetch` | host | `docs.example.com` |
11
+ * | `task` (v2 `subagent`) | agent type | `explore` |
12
+ * | `external_directory`, `doom_loop` | — never answered | |
13
+ * | anything else | the request's patterns, together | `opencode_read_mcp_resource` `*` |
14
+ *
15
+ * The key a count is kept under is the subject *and* the permission *and* the agent: trust earned
16
+ * by `build` running `git push` is not trust for `general` to run it.
17
+ */
18
+
19
+ import { dangerOf } from "./danger.js";
20
+ import { canonical } from "./rules.js";
21
+ import { parse } from "./shell.js";
22
+ import { signature } from "./signature.js";
23
+
24
+ /** A permission request, whichever OpenCode asked it (see `adapt/`). */
25
+
26
+ /** Everything about the call that the request itself does not carry. */
27
+
28
+ /** Permissions Trust never answers: they exist to make a person look. */
29
+ const NEVER = {
30
+ external_directory: "outside the project — always yours to answer",
31
+ doom_loop: "the agent is repeating itself — always yours to answer"
32
+ };
33
+
34
+ /** A command's words as OpenCode's patterns spell them: for matching config, not for counting. */
35
+ const written = command => [...command.env, ...command.argv].join(" ");
36
+ function bash(request, context) {
37
+ if (context.line === undefined) return {
38
+ kind: "opaque",
39
+ why: "the command line was not available"
40
+ };
41
+ const parsed = parse(context.line);
42
+ if (parsed.kind === "opaque") return {
43
+ kind: "opaque",
44
+ why: parsed.reason
45
+ };
46
+ const subjects = parsed.commands.map(command => {
47
+ const placed = withWorkdir(command, context.workdir);
48
+ const danger = dangerOf(placed);
49
+ return {
50
+ subject: signature(placed, context.root),
51
+ texts: [written(command)],
52
+ ...(danger ? {
53
+ danger
54
+ } : {})
55
+ };
56
+ });
57
+ if (subjects.length === 0) return {
58
+ kind: "opaque",
59
+ why: "nothing on the line runs"
60
+ };
61
+ return {
62
+ kind: "subjects",
63
+ subjects,
64
+ patterns: request.patterns
65
+ };
66
+ }
67
+
68
+ /** The tool's working directory, under any `cd` the line made. */
69
+ function withWorkdir(command, workdir) {
70
+ if (!workdir) return command;
71
+ if (!command.cwd) return {
72
+ ...command,
73
+ cwd: workdir
74
+ };
75
+ if (command.cwd.startsWith("/") || command.cwd.startsWith("~")) return command;
76
+ return {
77
+ ...command,
78
+ cwd: `${workdir.replace(/\/+$/, "")}/${command.cwd}`
79
+ };
80
+ }
81
+ function host(url) {
82
+ try {
83
+ const parsed = new URL(url);
84
+ return parsed.host || undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ export function subjectsOf(request, context) {
90
+ const permission = canonical(request.permission);
91
+ const never = NEVER[permission];
92
+ if (never) return {
93
+ kind: "never",
94
+ why: never
95
+ };
96
+ if (permission === "bash") return bash(request, context);
97
+ if (request.patterns.length === 0) return {
98
+ kind: "opaque",
99
+ why: "the request names nothing"
100
+ };
101
+ if (permission === "webfetch") {
102
+ const subjects = [];
103
+ for (const url of request.patterns) {
104
+ const name = host(url);
105
+ if (!name) return {
106
+ kind: "opaque",
107
+ why: `not a URL: ${url}`
108
+ };
109
+ subjects.push({
110
+ subject: name,
111
+ texts: [url]
112
+ });
113
+ }
114
+ return {
115
+ kind: "subjects",
116
+ subjects: dedupe(subjects),
117
+ patterns: request.patterns
118
+ };
119
+ }
120
+ if (permission === "edit" || permission === "task") return {
121
+ kind: "subjects",
122
+ subjects: dedupe(request.patterns.map(p => ({
123
+ subject: p,
124
+ texts: [p]
125
+ }))),
126
+ patterns: request.patterns
127
+ };
128
+ return {
129
+ kind: "subjects",
130
+ subjects: [{
131
+ subject: request.patterns.join(" "),
132
+ texts: [...request.patterns]
133
+ }],
134
+ patterns: request.patterns
135
+ };
136
+ }
137
+
138
+ /** Two patterns naming one host or one file are one subject; config is checked against both. */
139
+ function dedupe(subjects) {
140
+ const by = new Map();
141
+ for (const subject of subjects) {
142
+ const known = by.get(subject.subject);
143
+ if (known) known.texts.push(...subject.texts);else by.set(subject.subject, {
144
+ ...subject,
145
+ texts: [...subject.texts]
146
+ });
147
+ }
148
+ return [...by.values()];
149
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The ledger: what happened, one event per line, and what it adds up to.
3
+ *
4
+ * Append-only on purpose. Several OpenCode windows on one project write to the same file, and a
5
+ * whole line appended in one write is the only update that needs no lock: nothing is ever rewritten,
6
+ * so no window can undo another's. The state is never stored — it is a fold over the events, so it
7
+ * cannot drift from them, and a rule's history ("approved 3×, rejected once, approved again") is
8
+ * there to read rather than summarised away.
9
+ *
10
+ * Reading is forgiving in exactly one way: a line cut short by a crash is skipped, and a line written
11
+ * after it (which then starts mid-line) is recovered. Anything else that does not parse is ignored —
12
+ * a ledger you edited by hand should cost you a rule, not the bay.
13
+ */
14
+
15
+ import { canonical } from "./rules.js";
16
+
17
+ /** What every event about one request carries. */
18
+
19
+ /** One subject's standing, under one permission and one agent. */
20
+
21
+ /** An "always" a person gave OpenCode itself: broader than it looks, and gone when OpenCode stops. */
22
+
23
+ /** A family you trusted as a whole, for one agent under one permission. */
24
+
25
+ export const DAY = 86_400_000;
26
+ export const emptyState = () => ({
27
+ entries: new Map(),
28
+ widened: new Map(),
29
+ always: [],
30
+ paused: false,
31
+ settled: new Set()
32
+ });
33
+ export const keyOf = (permission, agent, subject) => JSON.stringify([canonical(permission), agent, subject]);
34
+ function entry(state, permission, agent, item, at) {
35
+ const key = keyOf(permission, agent, item.subject);
36
+ let found = state.entries.get(key);
37
+ if (!found) {
38
+ found = {
39
+ key,
40
+ permission: canonical(permission),
41
+ agent,
42
+ subject: item.subject,
43
+ streak: 0,
44
+ approvals: 0,
45
+ autos: 0,
46
+ firstAt: at,
47
+ lastAt: at
48
+ };
49
+ state.entries.set(key, found);
50
+ }
51
+ if (item.danger) found.danger = item.danger;else delete found.danger;
52
+ return found;
53
+ }
54
+ const expired = (found, at, options) => options.expireMs > 0 && found.lastAt > 0 && at - found.lastAt > options.expireMs;
55
+ export function apply(state, event, options) {
56
+ switch (event.type) {
57
+ case "asked":
58
+ return state;
59
+ case "approved":
60
+ case "rejected":
61
+ case "auto":
62
+ {
63
+ if (state.settled.has(event.request)) return state;
64
+ state.settled.add(event.request);
65
+ for (const item of event.items) {
66
+ const found = entry(state, event.permission, event.agent, item, event.at);
67
+ if (event.type === "rejected") {
68
+ found.streak = 0;
69
+ found.rejectedAt = event.at;
70
+ continue;
71
+ }
72
+ if (expired(found, event.at, options)) found.streak = 0;
73
+ if (event.type === "approved") {
74
+ found.streak++;
75
+ found.approvals++;
76
+ } else found.autos++;
77
+ found.lastAt = Math.max(found.lastAt, event.at);
78
+ }
79
+ if (event.type === "approved" && event.always?.length) state.always.push({
80
+ at: event.at,
81
+ session: event.session,
82
+ permission: canonical(event.permission),
83
+ agent: event.agent,
84
+ patterns: event.always
85
+ });
86
+ return state;
87
+ }
88
+ case "revoked":
89
+ {
90
+ const found = state.entries.get(keyOf(event.permission, event.agent, event.subject));
91
+ if (found) {
92
+ found.streak = 0;
93
+ found.revokedAt = event.at;
94
+ }
95
+ return state;
96
+ }
97
+ case "widened":
98
+ {
99
+ const key = keyOf(event.permission, event.agent, event.family);
100
+ state.widened.set(key, {
101
+ key,
102
+ permission: canonical(event.permission),
103
+ agent: event.agent,
104
+ family: event.family,
105
+ at: event.at
106
+ });
107
+ return state;
108
+ }
109
+ case "unwidened":
110
+ state.widened.delete(keyOf(event.permission, event.agent, event.family));
111
+ return state;
112
+ case "paused":
113
+ state.paused = true;
114
+ return state;
115
+ case "resumed":
116
+ state.paused = false;
117
+ return state;
118
+ }
119
+ }
120
+ export function applyAll(state, events, options) {
121
+ for (const event of events) apply(state, event, options);
122
+ return state;
123
+ }
124
+
125
+ /* ─── standing ───────────────────────────────────────────────────────────────────────────────── */
126
+
127
+ export function needFor(danger, settings) {
128
+ return settings.threshold + (danger ? settings.dangerExtra : 0);
129
+ }
130
+
131
+ /** Where a subject stands now. `danger` is today's reading, which wins over the one stored. */
132
+ export function standing(found, danger, settings, now) {
133
+ const need = needFor(danger, settings);
134
+ if (!found) return {
135
+ have: 0,
136
+ need,
137
+ trusted: false,
138
+ expired: false
139
+ };
140
+ const gone = settings.expireDays > 0 && now - found.lastAt > settings.expireDays * DAY;
141
+ const have = gone ? 0 : found.streak;
142
+ return {
143
+ have,
144
+ need,
145
+ trusted: have >= need,
146
+ expired: gone
147
+ };
148
+ }
149
+
150
+ /* ─── the file ───────────────────────────────────────────────────────────────────────────────── */
151
+
152
+ const TYPES = new Set(["asked", "approved", "rejected", "auto", "revoked", "widened", "unwidened", "paused", "resumed"]);
153
+ function asEvent(value) {
154
+ if (!value || typeof value !== "object") return undefined;
155
+ const raw = value;
156
+ if (raw.v !== 1 || typeof raw.at !== "number" || typeof raw.type !== "string" || !TYPES.has(raw.type)) return undefined;
157
+ if (raw.type === "revoked") return typeof raw.permission === "string" && typeof raw.agent === "string" && typeof raw.subject === "string" ? raw : undefined;
158
+ if (raw.type === "widened" || raw.type === "unwidened") return typeof raw.permission === "string" && typeof raw.agent === "string" && typeof raw.family === "string" ? raw : undefined;
159
+ if (raw.type === "paused" || raw.type === "resumed") return raw;
160
+ if (typeof raw.request !== "string" || typeof raw.permission !== "string" || typeof raw.agent !== "string" || !Array.isArray(raw.items) || !raw.items.every(item => item && typeof item.subject === "string")) return undefined;
161
+ return raw;
162
+ }
163
+ function parseLine(line) {
164
+ try {
165
+ return asEvent(JSON.parse(line));
166
+ } catch {
167
+ /**
168
+ * A writer that died mid-line leaves no newline, so the next event is appended onto the remains:
169
+ * `{"v":1,"at":17…{"v":1,"at":18…}`. The last event start in the line is the whole one.
170
+ */
171
+ const at = line.lastIndexOf('{"v":1');
172
+ if (at <= 0) return undefined;
173
+ try {
174
+ return asEvent(JSON.parse(line.slice(at)));
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Events in `text`, and what is left after the last newline. The rest is not an error: it is a line
183
+ * another window is still writing, read again next time.
184
+ */
185
+ export function parseLines(text) {
186
+ const end = text.lastIndexOf("\n");
187
+ const whole = end < 0 ? "" : text.slice(0, end);
188
+ const rest = end < 0 ? text : text.slice(end + 1);
189
+ const events = [];
190
+ for (const line of whole.split("\n")) {
191
+ if (line.trim() === "") continue;
192
+ const event = parseLine(line);
193
+ if (event) events.push(event);
194
+ }
195
+ return {
196
+ events,
197
+ rest
198
+ };
199
+ }
200
+
201
+ /** One line of the file. Never contains a newline, whatever a subject holds. */
202
+ export const serialize = event => `${JSON.stringify(event)}\n`;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Where a project's ledger lives on disk.
3
+ *
4
+ * Pure: creates nothing, reads nothing, and takes its environment as an argument — as Review's
5
+ * `core/store/paths.ts` does, which this mirrors.
6
+ *
7
+ * **Outside the project.** What you have approved is about you, not the work; a ledger in the repo
8
+ * would turn up in `git status` and, worse, travel to everyone who clones it — trust you earned would
9
+ * answer for them.
10
+ *
11
+ * **Keyed by the project's full path.** The readable part is the last two segments, which tells two
12
+ * checkouts apart at a glance but not for certain (`~/a/web` and `~/b/web`); a short hash of the whole
13
+ * path makes it certain, so one checkout's trust can never answer in another.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ /** Anything that is not plainly a filename becomes a dash. */
20
+ export function slug(text) {
21
+ const cleaned = text.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
22
+ return cleaned.length > 0 ? cleaned : "unnamed";
23
+ }
24
+ export function projectSlug(directory) {
25
+ const parts = directory.split("/").filter(Boolean);
26
+ return slug(parts.slice(-2).join("-"));
27
+ }
28
+ export function shortHash(text) {
29
+ return createHash("sha256").update(text).digest("hex").slice(0, 8);
30
+ }
31
+ export function trustPaths(directory, env = process.env) {
32
+ const base = env.COCKPIT_HOME ?? join(env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share"), "opencode-cockpit");
33
+ const dir = join(base, "trust", `${projectSlug(directory)}-${shortHash(directory)}`);
34
+ return {
35
+ dir,
36
+ events: join(dir, "events.ndjson")
37
+ };
38
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Whether Trust answers a request, and why — a pure function of the request, what config says, and
3
+ * what the ledger adds up to.
4
+ *
5
+ * The order of the questions is the design:
6
+ *
7
+ * 1. **Can it be read?** A command line with `$(…)` in it, a permission that exists to make a person
8
+ * look (`external_directory`, `doom_loop`): asked, and nothing is counted.
9
+ * 2. **Did you ask to be asked?** A specific `ask` rule in config matching any part of the request
10
+ * holds all of it. So does a `deny`, though OpenCode never lets one reach us.
11
+ * 3. **Is every part trusted, or allowed by config?** One untrusted command in a line is enough to
12
+ * ask: `git status && rm -rf build` is not half-approved. A part is trusted by its own count, or
13
+ * by a family you widened for this agent — unless it is one a widening never covers (dangerous,
14
+ * writing a file, running another program: `family.outside`). Config's `ask` was settled in 2, so
15
+ * it still wins over a widening.
16
+ *
17
+ * The answer always carries what an approval of the request would count towards, so a person's
18
+ * approval of a request Trust declined is counted against exactly what Trust looked at.
19
+ */
20
+
21
+ import { familyOf, outside } from "./family.js";
22
+ import { subjectsOf } from "./keys.js";
23
+ import { keyOf, standing } from "./ledger.js";
24
+ import { describeRule, gate } from "./rules.js";
25
+ const left = why => ({
26
+ answer: false,
27
+ why,
28
+ items: [],
29
+ progress: []
30
+ });
31
+ export function decide(input) {
32
+ const {
33
+ request,
34
+ context,
35
+ agent,
36
+ rules,
37
+ state,
38
+ settings,
39
+ now
40
+ } = input;
41
+ const keyed = subjectsOf(request, context);
42
+ if (keyed.kind !== "subjects") return left(keyed.why);
43
+ for (const text of [...keyed.subjects.flatMap(subject => subject.texts), ...keyed.patterns]) {
44
+ const said = gate(rules, request.permission, text);
45
+ if (said.kind === "held") return left(said.rule.action === "deny" ? `config denies it: ${describeRule(said.rule)}` : `you asked to be asked: ${describeRule(said.rule)}`);
46
+ }
47
+ const progress = [];
48
+ for (const subject of keyed.subjects) {
49
+ /** Allowed by config, all of it: not Trust's to count, and no reason to ask. */
50
+ if (subject.texts.every(text => gate(rules, request.permission, text).kind === "allowed")) continue;
51
+ const found = state.entries.get(keyOf(request.permission, agent, subject.subject));
52
+ const where = standing(found, subject.danger, settings, now);
53
+ const via = where.trusted ? undefined : widenedFor(state, request.permission, agent, subject.subject);
54
+ progress.push({
55
+ subject: subject.subject,
56
+ ...(subject.danger ? {
57
+ danger: subject.danger
58
+ } : {}),
59
+ have: where.have,
60
+ need: where.need,
61
+ trusted: where.trusted || via !== undefined,
62
+ ...(via !== undefined ? {
63
+ via
64
+ } : {})
65
+ });
66
+ }
67
+ const items = progress.map(({
68
+ subject,
69
+ danger
70
+ }) => danger ? {
71
+ subject,
72
+ danger
73
+ } : {
74
+ subject
75
+ });
76
+
77
+ /**
78
+ * OpenCode asked, yet by our reading config allows every part: the readings disagree, and when
79
+ * they do the person decides.
80
+ */
81
+ if (progress.length === 0) return left("config allows all of it by our reading — yours to answer");
82
+ const short = progress.filter(each => !each.trusted);
83
+ if (short.length > 0) {
84
+ const worst = short.reduce((a, b) => b.need - b.have > a.need - a.have ? b : a);
85
+ return {
86
+ answer: false,
87
+ why: progress.length === 1 ? `${worst.have}/${worst.need} approvals in a row` : `${short.length} of ${progress.length} not yet trusted (${worst.subject} ${worst.have}/${worst.need})`,
88
+ items,
89
+ progress
90
+ };
91
+ }
92
+ if (state.paused) return {
93
+ answer: false,
94
+ why: "Trust is paused in this project",
95
+ items,
96
+ progress
97
+ };
98
+ const only = progress[0];
99
+ /** An answer records which widening gave it, so the ledger can say "any ls" answered `ls -R`. */
100
+ const answered = progress.map(({
101
+ subject,
102
+ danger,
103
+ via
104
+ }) => ({
105
+ subject,
106
+ ...(danger ? {
107
+ danger
108
+ } : {}),
109
+ ...(via !== undefined ? {
110
+ via
111
+ } : {})
112
+ }));
113
+ const widened = progress.filter(each => each.via !== undefined);
114
+ return {
115
+ answer: true,
116
+ why: progress.length === 1 ? only.via !== undefined ? `in a family you widened: ${only.via}` : `approved by you ${only.have}× in a row${only.danger ? ` (dangerous: ${only.danger})` : ""}` : widened.length > 0 ? `all ${progress.length} commands trusted (${widened.length} by a family you widened)` : `all ${progress.length} commands approved enough times in a row`,
117
+ items: answered,
118
+ progress
119
+ };
120
+ }
121
+
122
+ /** The family this subject is answered through, when you widened it for this agent and it covers it. */
123
+ function widenedFor(state, permission, agent, subject) {
124
+ if (state.widened.size === 0) return undefined;
125
+ const family = familyOf(permission, subject);
126
+ if (!state.widened.has(keyOf(permission, agent, family))) return undefined;
127
+ return outside(permission, subject) === undefined ? family : undefined;
128
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * What `opencode.json` says about a permission, read the way OpenCode reads it.
3
+ *
4
+ * Trust only ever sees the gap config left open (docs/opencode/permissions.md): `deny` never emits an
5
+ * event and `allow` never needs one. Inside that gap there are two kinds of "ask", and telling them
6
+ * apart is this file's job:
7
+ *
8
+ * - **a catch-all** — `"bash": "ask"`, `{ "bash": { "*": "ask" } }`, or no rule at all — is a default:
9
+ * "I have not decided about these". Trust may fill it.
10
+ * - **a specific pattern set to ask** — `"git push *": "ask"` — is a decision: "always ask me about
11
+ * this one". Trust never answers it.
12
+ *
13
+ * Matching is OpenCode's own: its wildcard (`*` any run, `?` one character, and a trailing ` *` that
14
+ * also matches nothing, so `ls *` covers `ls`), evaluated over the ordered rules, last match wins.
15
+ * The merge across files is OpenCode's too — v1 hands over the merged config; v2 hands over its
16
+ * documents in priority order, and concatenating their rules in that order keeps "last match wins"
17
+ * meaning what it meant.
18
+ */
19
+
20
+ /**
21
+ * One name per permission, whichever OpenCode asked. v2 renamed several (measured in 2.0.18's own
22
+ * table: `bash → shell`, `task → subagent`, `write`/`patch → edit`); a rule written for one must
23
+ * still match a request from the other.
24
+ */
25
+ const CANONICAL = {
26
+ shell: "bash",
27
+ subagent: "task",
28
+ write: "edit",
29
+ patch: "edit",
30
+ apply_patch: "edit",
31
+ multiedit: "edit"
32
+ };
33
+ export const canonical = permission => CANONICAL[permission] ?? permission;
34
+
35
+ /** OpenCode's `Wildcard.match`, as in `util/wildcard.ts`. */
36
+ export function match(text, pattern) {
37
+ const subject = text.replaceAll("\\", "/");
38
+ let escaped = pattern.replaceAll("\\", "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
39
+ // "ls *" covers both "ls" and "ls -la".
40
+ if (escaped.endsWith(" .*")) escaped = `${escaped.slice(0, -3)}( .*)?`;
41
+ return new RegExp(`^${escaped}$`, "s").test(subject);
42
+ }
43
+ const ACTIONS = new Set(["allow", "deny", "ask"]);
44
+ const isAction = value => typeof value === "string" && ACTIONS.has(value);
45
+ const obj = value => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
46
+
47
+ /**
48
+ * OpenCode 1's `permission` block: `"ask"` for everything, `{ bash: "ask" }` for one permission, or
49
+ * `{ bash: { "git push *": "ask" } }` for patterns. Key order is rule order.
50
+ */
51
+ function fromV1(permission) {
52
+ if (isAction(permission)) return [{
53
+ permission: "*",
54
+ pattern: "*",
55
+ action: permission
56
+ }];
57
+ const block = obj(permission);
58
+ if (!block) return [];
59
+ const rules = [];
60
+ for (const [name, value] of Object.entries(block)) {
61
+ if (isAction(value)) rules.push({
62
+ permission: canonical(name),
63
+ pattern: "*",
64
+ action: value
65
+ });else for (const [pattern, action] of Object.entries(obj(value) ?? {})) if (isAction(action)) rules.push({
66
+ permission: canonical(name),
67
+ pattern,
68
+ action
69
+ });
70
+ }
71
+ return rules;
72
+ }
73
+
74
+ /** OpenCode 2's `permissions`: an ordered list of `{ action, resource, effect }`. */
75
+ function fromV2(permissions) {
76
+ if (!Array.isArray(permissions)) return [];
77
+ return permissions.flatMap(entry => {
78
+ const rule = obj(entry);
79
+ if (!rule || typeof rule.action !== "string" || typeof rule.resource !== "string" || !isAction(rule.effect)) return [];
80
+ return [{
81
+ permission: canonical(rule.action),
82
+ pattern: rule.resource,
83
+ action: rule.effect
84
+ }];
85
+ });
86
+ }
87
+
88
+ /** Every rule one config document holds at its top level, in order. */
89
+ function own(config) {
90
+ return [...fromV1(config.permission), ...fromV2(config.permissions)];
91
+ }
92
+
93
+ /** The rules an agent adds on top: v1 `agent.<name>.permission` (and the older `mode`), v2 `agents`. */
94
+ function agentRules(config, agent) {
95
+ if (!agent) return [];
96
+ const rules = [];
97
+ for (const key of ["mode", "agent", "agents"]) {
98
+ const entry = obj(obj(config[key])?.[agent]);
99
+ if (entry) rules.push(...own(entry));
100
+ }
101
+ return rules;
102
+ }
103
+
104
+ /**
105
+ * The rules in force for an agent, from whatever `config.get` returned: v1's merged config, or v2's
106
+ * list of documents (`{ type: "document", info }`, lowest priority first). The agent's rules come last
107
+ * because OpenCode lays them over the global ones.
108
+ */
109
+ export function rulesFrom(config, agent) {
110
+ const documents = Array.isArray(config) ? config.flatMap(entry => {
111
+ const info = obj(obj(entry)?.info);
112
+ return info ? [info] : [];
113
+ }) : obj(config) ? [obj(config)] : [];
114
+ return [...documents.flatMap(own), ...documents.flatMap(document => agentRules(document, agent))];
115
+ }
116
+
117
+ /** The rule that decides `text` for `permission`, as OpenCode picks it: the last that matches. */
118
+ export function evaluate(rules, permission, text) {
119
+ const name = canonical(permission);
120
+ return rules.findLast(rule => match(name, rule.permission) && match(text, rule.pattern));
121
+ }
122
+
123
+ /**
124
+ * What config means for one pattern of a request.
125
+ *
126
+ * `open` — nothing decided it, or a catch-all said ask: Trust's to fill.
127
+ * `allowed` — config allows it; the request asked because of something else in it.
128
+ * `held` — you asked to be asked (a specific `ask`), or config denies it: Trust stays out.
129
+ */
130
+
131
+ export function gate(rules, permission, text) {
132
+ const rule = evaluate(rules, permission, text);
133
+ if (!rule) return {
134
+ kind: "open"
135
+ };
136
+ if (rule.action === "allow") return {
137
+ kind: "allowed",
138
+ rule
139
+ };
140
+ if (rule.action === "ask" && rule.pattern === "*") return {
141
+ kind: "open"
142
+ };
143
+ return {
144
+ kind: "held",
145
+ rule
146
+ };
147
+ }
148
+
149
+ /** A rule as you would have written it, for a reason shown to you. */
150
+ export function describeRule(rule) {
151
+ return rule.pattern === "*" ? `"${rule.permission}": "${rule.action}"` : `"${rule.pattern}": "${rule.action}"`;
152
+ }