opencode-thrum-pro 0.1.1 → 0.1.3

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 (26) hide show
  1. package/assets/commands/thrum-_stamp-protocol.md +65 -0
  2. package/assets/skills/brainstorm-steward/SKILL.md +15 -0
  3. package/assets/skills/choosing-subagent-models/SKILL.md +19 -11
  4. package/assets/skills/conversation-only-context/SKILL.md +186 -0
  5. package/assets/skills/coordinator-dispatching-work/SKILL.md +28 -8
  6. package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +68 -0
  7. package/assets/skills/coordinator-merging-code/SKILL.md +24 -0
  8. package/assets/skills/coordinator-running-brainstorm-cycles/SKILL.md +11 -7
  9. package/assets/skills/efficient-multi-agent-research/SKILL.md +7 -6
  10. package/assets/skills/persistent-watcher-archetype/SKILL.md +80 -15
  11. package/assets/skills/persistent-watcher-archetype/resources/thrum-capture-fallback.sh +123 -0
  12. package/assets/skills/persistent-watcher-archetype/resources/thrum-watch-pane-capture.sh +379 -0
  13. package/assets/skills/prime-agent/SKILL.md +2 -4
  14. package/assets/skills/project-setup/SKILL.md +118 -21
  15. package/assets/skills/update-queue/SKILL.md +3 -1
  16. package/assets/thrum_allowlist.json +39 -0
  17. package/dist/allowlist-sync.test.d.ts +1 -0
  18. package/dist/allowlist-sync.test.js +44 -0
  19. package/dist/index.js +24 -1
  20. package/dist/installer.d.ts +29 -0
  21. package/dist/installer.js +356 -1
  22. package/dist/installer.permissions.test.d.ts +1 -0
  23. package/dist/installer.permissions.test.js +602 -0
  24. package/dist/permission-fixture.integration.test.d.ts +1 -0
  25. package/dist/permission-fixture.integration.test.js +506 -0
  26. package/package.json +3 -2
@@ -0,0 +1,602 @@
1
+ // Tests for the OpenCode permission.bash/permission.read allowlist installer
2
+ // (OpenCode leg). Offline/deterministic: uses a temp dir as the
3
+ // injected config-dir root, never the real $HOME or network.
4
+ //
5
+ // Run: npm test (builds, then `node --test dist`)
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { bashExactPatternFor, bashPatternFor, readPatternFor, mergePermissions, loadAllowlist, installPermissions, getConfigDir, opencodeGlobalReadPaths, readFilePatternFor, resolveHomePath, writeConfig, } from "./installer.js";
12
+ const ALLOWLIST_ASSET_PATH = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "assets", "thrum_allowlist.json");
13
+ function mkTmpDir() {
14
+ return fs.mkdtempSync(path.join(os.tmpdir(), "oc-permtest-"));
15
+ }
16
+ // ─── (a) Fresh config ──────────────────────────────────────────────────────
17
+ test("fresh config: permission.bash/read populated for every canonical entry", () => {
18
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
19
+ const merged = mergePermissions({}, allowlist);
20
+ assert.ok(merged.permission, "permission key must exist");
21
+ const bash = merged.permission.bash;
22
+ const read = merged.permission.read;
23
+ for (const cmd of allowlist.command_patterns) {
24
+ assert.equal(bash[bashExactPatternFor(cmd)], "allow", `missing bare-command allow for ${cmd}`);
25
+ assert.equal(bash[bashPatternFor(cmd)], "allow", `missing with-args allow for ${cmd}`);
26
+ }
27
+ for (const p of allowlist.read_paths) {
28
+ assert.equal(read[readPatternFor(p)], "allow", `missing allow for ${p}`);
29
+ }
30
+ });
31
+ // ─── (b) Idempotency ────────────────────────────────────────────────────────
32
+ test("idempotency: running installPermissions twice yields byte-identical config", async () => {
33
+ const dir = mkTmpDir();
34
+ try {
35
+ await installPermissions(dir);
36
+ const first = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
37
+ await installPermissions(dir);
38
+ const second = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
39
+ assert.equal(second, first, "second run must produce byte-identical output");
40
+ }
41
+ finally {
42
+ fs.rmSync(dir, { recursive: true });
43
+ }
44
+ });
45
+ // ─── (c) Preserves unrelated config ────────────────────────────────────────
46
+ test("preserves unrelated config keys, including a hand-set permission.edit", () => {
47
+ const original = {
48
+ mcp: { thrum: { type: "local", command: ["thrum", "mcp", "serve"], enabled: true } },
49
+ plugin: ["opencode-thrum"],
50
+ instructions: ["AGENTS.md"],
51
+ permission: { edit: "ask" },
52
+ };
53
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
54
+ const merged = mergePermissions(original, allowlist);
55
+ assert.deepEqual(merged.mcp, original.mcp);
56
+ assert.deepEqual(merged.plugin, original.plugin);
57
+ assert.deepEqual(merged.instructions, original.instructions);
58
+ assert.equal(merged.permission.edit, "ask");
59
+ });
60
+ // ─── (d) Positive controls ──────────────────────────────────────────────────
61
+ //
62
+ // NOTE (the allowlist effort consolidated round, Part B): the canonical
63
+ // command_patterns model changed upstream (owner ruling) from an
64
+ // enumerated per-subcommand list to a single family entry — "thrum" now
65
+ // represents the whole command family (any subcommand), with the
66
+ // alt-exec/metachar guard as the primary scoping boundary rather than an
67
+ // enumerated allowlist. This test previously asserted each representative
68
+ // subcommand rendered as its OWN literal bash key (true under the old
69
+ // per-subcommand model); under the family model, a subcommand invocation is
70
+ // covered by the SINGLE family pattern's with-args glob, not by a key of its
71
+ // own. Updated to assert both: (1) the sole canonical entry itself renders
72
+ // bare+with-args allow entries, and (2) representative subcommand
73
+ // invocations are covered by that same family glob (via wouldMatchBashPattern,
74
+ // defined below in this file — function declarations are hoisted, so this
75
+ // forward reference is safe).
76
+ test("positive controls: the canonical family entry renders allow entries, bare and with-args, covering representative subcommands", () => {
77
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
78
+ const merged = mergePermissions({}, allowlist);
79
+ const bash = merged.permission.bash;
80
+ for (const cmd of allowlist.command_patterns) {
81
+ assert.equal(bash[bashExactPatternFor(cmd)], "allow", `expected bare-command allow for ${JSON.stringify(cmd)}`);
82
+ assert.equal(bash[bashPatternFor(cmd)], "allow", `expected allow for ${JSON.stringify(bashPatternFor(cmd))}`);
83
+ }
84
+ const allowPatterns = Object.entries(bash)
85
+ .filter(([, verdict]) => verdict === "allow")
86
+ .map(([pattern]) => pattern);
87
+ const representative = [
88
+ "thrum inbox",
89
+ "thrum send",
90
+ "thrum reply",
91
+ "thrum tmux capture",
92
+ "thrum tmux send",
93
+ "thrum queue",
94
+ "thrum monitor",
95
+ "thrum daemon",
96
+ "thrum status",
97
+ ];
98
+ for (const cmd of representative) {
99
+ assert.ok(allowPatterns.some((p) => wouldMatchBashPattern(p, cmd)), `expected some allow pattern to cover representative subcommand ${JSON.stringify(cmd)}`);
100
+ assert.ok(allowPatterns.some((p) => wouldMatchBashPattern(p, `${cmd} --unread`)), `expected some allow pattern to cover representative subcommand with args ${JSON.stringify(`${cmd} --unread`)}`);
101
+ }
102
+ });
103
+ // ─── (e) Negative controls (REQUIRED acceptance gate) ──────────────────────
104
+ //
105
+ // HONESTY NOTE: this is a STATIC PATTERN-SHAPE assertion, not a live
106
+ // invocation of OpenCode's real bash-permission matcher. OpenCode's docs
107
+ // (https://opencode.ai/docs/permissions/, verified manually) state simple
108
+ // wildcard matching: `*` = zero-or-more of any character, `?` = exactly one,
109
+ // everything else literal — with NO documented word-boundary behavior, so a
110
+ // bare `${prefix}*` key (round 1's shape) matches straight through a word
111
+ // boundary (e.g. "thrum inbox*" matches "thrum inboxx-evil"). Round 2 fixes
112
+ // this at the pattern-generation layer (bashExactPatternFor / bashPatternFor
113
+ // — see installer.ts) rather than only in this test's model, and the model
114
+ // below is updated to match that fix: a pattern with no `*` requires EXACT
115
+ // equality (never a prefix match — a bare "thrum inbox" key must not match
116
+ // "thrum inboxx-evil" merely because the latter starts with the former's
117
+ // characters), and a globbed pattern requires the literal prefix (which now
118
+ // always ends in a real space before the `*`) plus no injection
119
+ // metacharacter anywhere in the candidate.
120
+ const SHELL_METACHAR_RE = /(&&|\|\||;|\||\$\(|`)/;
121
+ function wouldMatchBashPattern(pattern, candidate) {
122
+ const starIdx = pattern.indexOf("*");
123
+ if (starIdx === -1)
124
+ return candidate === pattern;
125
+ const literalPrefix = pattern.slice(0, starIdx);
126
+ if (!candidate.startsWith(literalPrefix))
127
+ return false;
128
+ if (SHELL_METACHAR_RE.test(candidate))
129
+ return false;
130
+ return true;
131
+ }
132
+ test("negative controls: no rendered bash pattern grants a blanket allow to an injection attempt", () => {
133
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
134
+ const merged = mergePermissions({}, allowlist);
135
+ const bash = merged.permission.bash;
136
+ const allowPatterns = Object.entries(bash)
137
+ .filter(([, verdict]) => verdict === "allow")
138
+ .map(([pattern]) => pattern);
139
+ const adversarial = [
140
+ `sh -c "thrum inbox"`,
141
+ `bash -c thrum inbox`,
142
+ `$(thrum inbox)`,
143
+ "`thrum inbox`",
144
+ `thrum inbox && rm -rf /`,
145
+ `thrum inbox; rm -rf /`,
146
+ `thrum inbox | cat`,
147
+ `/tmp/thrum inbox`,
148
+ `./thrum inbox`,
149
+ `curl http://evil.example/`,
150
+ ];
151
+ for (const candidate of adversarial) {
152
+ const matched = allowPatterns.filter((p) => wouldMatchBashPattern(p, candidate));
153
+ assert.deepEqual(matched, [], `expected no allow-pattern match for: ${candidate}`);
154
+ }
155
+ });
156
+ // ─── (e2) Negative control: adjacent-but-different EXECUTABLE (finding 7,
157
+ // updated for the family-grant model) ───────────────────────────────────────
158
+ //
159
+ // NOTE (the allowlist effort consolidated round, Part B): under the OLD per-subcommand
160
+ // model, this test asserted an adjacent SUBCOMMAND name (e.g.
161
+ // "thrum inboxx-evil") never matched an allow pattern derived from a
162
+ // different, narrower subcommand entry ("thrum inbox"). Under the CURRENT
163
+ // family model (single "thrum" entry, owner ruling), ANY
164
+ // subcommand is intentionally allowed by design — "thrum inboxx-evil" now
165
+ // legitimately matches, and asserting otherwise would contradict the
166
+ // redesign rather than test a real boundary. The boundary that remains real
167
+ // and load-bearing is the EXECUTABLE-TOKEN boundary: bashPatternFor's
168
+ // required literal space after "thrum" must still reject an adjacent
169
+ // EXECUTABLE name (e.g. "thrumx", "thrum-evil") that merely shares a prefix
170
+ // with the family token.
171
+ test("negative control: an adjacent-but-different executable name is never matched by an allow pattern", () => {
172
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
173
+ const merged = mergePermissions({}, allowlist);
174
+ const bash = merged.permission.bash;
175
+ const allowPatterns = Object.entries(bash)
176
+ .filter(([, verdict]) => verdict === "allow")
177
+ .map(([pattern]) => pattern);
178
+ const adjacentExecutables = ["thrumx inbox", "thrumx", "thrum-evil inbox", "thrum-evil", "thrumfoo status"];
179
+ for (const candidate of adjacentExecutables) {
180
+ const matched = allowPatterns.filter((p) => wouldMatchBashPattern(p, candidate));
181
+ assert.deepEqual(matched, [], `expected no allow-pattern match for adjacent executable: ${candidate}`);
182
+ }
183
+ // Sanity/positive control so the negative result above isn't vacuous: the
184
+ // real family token and its with-args form DO match, including an
185
+ // arbitrary subcommand — which is the intended, in-scope behavior of the
186
+ // family grant, not a gap.
187
+ assert.ok(allowPatterns.some((p) => wouldMatchBashPattern(p, "thrum")), "expected bare 'thrum' to match some allow pattern");
188
+ assert.ok(allowPatterns.some((p) => wouldMatchBashPattern(p, "thrum inbox --unread")), "expected 'thrum inbox --unread' to match some allow pattern");
189
+ });
190
+ // ─── (f) Host/path-agnostic config-dir resolution ──────────────────────────
191
+ test("getConfigDir: honors XDG_CONFIG_HOME when set", () => {
192
+ const savedXdg = process.env.XDG_CONFIG_HOME;
193
+ const savedHome = process.env.HOME;
194
+ try {
195
+ process.env.XDG_CONFIG_HOME = "/injected/xdg-root";
196
+ delete process.env.HOME;
197
+ assert.equal(getConfigDir(), path.join("/injected/xdg-root", "opencode"));
198
+ }
199
+ finally {
200
+ if (savedXdg === undefined)
201
+ delete process.env.XDG_CONFIG_HOME;
202
+ else
203
+ process.env.XDG_CONFIG_HOME = savedXdg;
204
+ if (savedHome === undefined)
205
+ delete process.env.HOME;
206
+ else
207
+ process.env.HOME = savedHome;
208
+ }
209
+ });
210
+ test("getConfigDir: falls back to ~/.config/opencode derived from HOME when XDG_CONFIG_HOME is unset", () => {
211
+ const savedXdg = process.env.XDG_CONFIG_HOME;
212
+ const savedHome = process.env.HOME;
213
+ try {
214
+ delete process.env.XDG_CONFIG_HOME;
215
+ process.env.HOME = "/injected/home-root";
216
+ assert.equal(getConfigDir(), path.join("/injected/home-root", ".config", "opencode"));
217
+ }
218
+ finally {
219
+ if (savedXdg === undefined)
220
+ delete process.env.XDG_CONFIG_HOME;
221
+ else
222
+ process.env.XDG_CONFIG_HOME = savedXdg;
223
+ if (savedHome === undefined)
224
+ delete process.env.HOME;
225
+ else
226
+ process.env.HOME = savedHome;
227
+ }
228
+ });
229
+ // ─── (g) OpenCode global config/skill/plugin read paths (finding 1) ───────
230
+ test("opencodeGlobalReadPaths: derives skills/commands/config entries strictly under configDir", () => {
231
+ const configDir = "/injected/config-root/opencode";
232
+ const patterns = opencodeGlobalReadPaths(configDir);
233
+ // Glob entries render through readPatternFor, which strips a leading "/"
234
+ // (the allowlist effort round 4 by-effect finding: OpenCode's real matcher does the
235
+ // same before comparing, so a pattern that keeps the leading slash never
236
+ // matches — see readPatternFor's doc comment). configDirRel below mirrors
237
+ // that same stripping for comparison in this test.
238
+ const configDirRel = configDir.replace(/^\//, "");
239
+ assert.ok(patterns.includes(path.join(configDirRel, "skills") + "/**"), "expected a recursive read grant for ${configDir}/skills");
240
+ assert.ok(patterns.includes(path.join(configDirRel, "commands") + "/**"), "expected a recursive read grant for ${configDir}/commands");
241
+ assert.ok(patterns.includes(readFilePatternFor(path.join(configDir, "opencode.json"))), "expected a read grant for ${configDir}/opencode.json itself, leading-slash-stripped");
242
+ // Scope check: nothing in the returned set should escape configDir. All
243
+ // three entries render in their stripped form now (round-4 review fix —
244
+ // the file entry was the one initially missed).
245
+ for (const p of patterns) {
246
+ assert.ok(p.startsWith(configDirRel), `pattern escapes configDir scope: ${p}`);
247
+ }
248
+ });
249
+ // Round-4 review finding (cq, by-effect-confirmed): opencodeGlobalReadPaths's
250
+ // THIRD entry (the config file itself) was rendered via a raw path.join,
251
+ // bypassing the leading-slash strip readPatternFor already applies to the
252
+ // first two entries — so it stayed silently broken one round after the
253
+ // directory entries were fixed. This test pins the file entry specifically
254
+ // so a future regression can't reintroduce an unstripped file pattern.
255
+ test("opencodeGlobalReadPaths: the config-file entry is leading-slash-stripped like the directory entries", () => {
256
+ const configDir = "/injected/config-root/opencode";
257
+ const patterns = opencodeGlobalReadPaths(configDir);
258
+ const fileEntry = path.join(configDir, "opencode.json");
259
+ assert.ok(!patterns.includes(fileEntry), "config-file entry must NOT keep its leading slash");
260
+ assert.ok(patterns.includes(readFilePatternFor(fileEntry)), "config-file entry must be rendered through readFilePatternFor");
261
+ });
262
+ test("installPermissions: resulting permission.read covers OpenCode's own global install subtree", async () => {
263
+ const savedXdg = process.env.XDG_CONFIG_HOME;
264
+ const xdgRoot = mkTmpDir();
265
+ try {
266
+ process.env.XDG_CONFIG_HOME = xdgRoot;
267
+ const configDir = getConfigDir();
268
+ assert.equal(configDir, path.join(xdgRoot, "opencode"), "sanity: getConfigDir resolves under injected XDG root");
269
+ await installPermissions();
270
+ const written = JSON.parse(fs.readFileSync(path.join(configDir, "opencode.json"), "utf8"));
271
+ const read = written.permission.read;
272
+ const configDirRel = configDir.replace(/^\//, "");
273
+ assert.equal(read[path.join(configDirRel, "skills") + "/**"], "allow", "missing global skills read grant");
274
+ assert.equal(read[path.join(configDirRel, "commands") + "/**"], "allow", "missing global commands read grant");
275
+ assert.equal(read[path.join(configDirRel, "opencode.json")], "allow", "missing global config-file read grant (leading-slash-stripped)");
276
+ assert.ok(!(path.join(configDir, "opencode.json") in read), "config-file entry must not also be present in its unstripped form");
277
+ // Canonical project-relative entries must STILL be present alongside
278
+ // the global ones (finding 1: "in addition to", not "instead of").
279
+ assert.equal(read[".thrum/**"], "allow", "canonical project-relative read_paths must survive alongside globals");
280
+ }
281
+ finally {
282
+ fs.rmSync(xdgRoot, { recursive: true });
283
+ if (savedXdg === undefined)
284
+ delete process.env.XDG_CONFIG_HOME;
285
+ else
286
+ process.env.XDG_CONFIG_HOME = savedXdg;
287
+ }
288
+ });
289
+ // ─── (d) Malformed existing config must never be silently discarded ────────
290
+ //
291
+ // Regression for the dual-review landing-blocker: readConfig() previously
292
+ // caught BOTH "file absent" (ENOENT) and "file present but malformed JSON"
293
+ // and returned {} for both, so installPermissions() would silently overwrite
294
+ // a user's hand-edited-but-broken global opencode.json with a fresh config
295
+ // containing only thrum's permission block — discarding every other setting
296
+ // (providers, models, etc). readConfig() must now distinguish the two: {}
297
+ // ONLY on genuine ENOENT, propagate (never swallow) any other read/parse
298
+ // failure — and installPermissions() must perform NO WRITE in that case.
299
+ test("malformed config: installPermissions throws and leaves the file byte-for-byte untouched", async () => {
300
+ const dir = mkTmpDir();
301
+ try {
302
+ const configPath = path.join(dir, "opencode.json");
303
+ // Genuinely invalid JSON: stray trailing comma.
304
+ const malformed = '{\n "mcp": { "thrum": {} },\n "permission": { "edit": "ask", },\n}\n';
305
+ fs.writeFileSync(configPath, malformed);
306
+ const before = fs.readFileSync(configPath, "utf8");
307
+ await assert.rejects(() => installPermissions(dir), /./, "installPermissions must propagate a malformed-config error instead of silently swallowing it");
308
+ const after = fs.readFileSync(configPath, "utf8");
309
+ assert.equal(after, before, "malformed config file must be left byte-for-byte unchanged on disk");
310
+ }
311
+ finally {
312
+ fs.rmSync(dir, { recursive: true });
313
+ }
314
+ });
315
+ test("regression: genuinely absent config still installs a fresh config with no error", async () => {
316
+ const dir = mkTmpDir();
317
+ try {
318
+ const configPath = path.join(dir, "opencode.json");
319
+ assert.equal(fs.existsSync(configPath), false, "sanity: no config file exists yet");
320
+ await installPermissions(dir);
321
+ const written = JSON.parse(fs.readFileSync(configPath, "utf8"));
322
+ const bash = written.permission.bash;
323
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
324
+ for (const cmd of allowlist.command_patterns) {
325
+ assert.equal(bash[bashExactPatternFor(cmd)], "allow", `missing bare-command allow for ${cmd}`);
326
+ }
327
+ }
328
+ finally {
329
+ fs.rmSync(dir, { recursive: true });
330
+ }
331
+ });
332
+ // ─── (h) Bare-scalar permission.bash is never silently clobbered (finding 3) ─
333
+ test("mergePermissions: a pre-existing bare-scalar permission.bash is preserved as the object map's '*' default", () => {
334
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
335
+ const original = { permission: { bash: "deny" } };
336
+ const merged = mergePermissions(original, allowlist);
337
+ const bash = merged.permission.bash;
338
+ assert.equal(typeof bash, "object", "permission.bash must become the object-map form");
339
+ assert.equal(bash["*"], "deny", "the user's original blanket 'deny' must survive as the '*' default");
340
+ // Thrum overlay entries are ADDITIONAL allow exceptions on top of the
341
+ // preserved default, not a replacement of it.
342
+ for (const cmd of allowlist.command_patterns) {
343
+ assert.equal(bash[cmd], "allow", `expected thrum overlay allow for ${cmd}`);
344
+ }
345
+ // Order matters for OpenCode's last-match-wins evaluation: "*" must come
346
+ // before the thrum-specific keys so the overlay can win for those exact
347
+ // patterns while the "*" default still governs everything else.
348
+ const keys = Object.keys(bash);
349
+ const starIdx = keys.indexOf("*");
350
+ assert.ok(starIdx === 0, "'*' must be the first key so more specific rules can override it (last-match-wins)");
351
+ });
352
+ test("mergePermissions: a pre-existing bare-scalar permission.read is likewise preserved as '*'", () => {
353
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
354
+ const original = { permission: { read: "ask" } };
355
+ const merged = mergePermissions(original, allowlist);
356
+ const read = merged.permission.read;
357
+ assert.equal(read["*"], "ask", "the user's original blanket 'ask' must survive as the '*' default");
358
+ for (const p of allowlist.read_paths) {
359
+ assert.equal(read[readPatternFor(p)], "allow", `expected thrum overlay allow for ${p}`);
360
+ }
361
+ });
362
+ // ─── (i) owner_authorized_exceptions.opencode — /private/tmp (the allowlist effort
363
+ // round 4) ───────────────────────────────────────────────────────────────
364
+ //
365
+ // OWNER-AUTHORIZED EXCEPTION — /private/tmp (owner ruling, watcher-
366
+ // recovery P0; scope CONFIRMED all-runtimes by the fleet coordinator the same
367
+ // day — owner's wording was "global configs for all agents", the
368
+ // operational-artifact class is runtime-independent): the canonical
369
+ // allowlist's owner_authorized_exceptions grants EVERY supported runtime
370
+ // broad READ access to /private/tmp/* DESPITE it being a world-writable
371
+ // directory — explicitly ruled acceptable by the owner for
372
+ // coordinator/watcher operational artifacts. This is a DELIBERATE
373
+ // exception, not a template for widening elsewhere: READ/ACCESS ONLY —
374
+ // never execute, never shell-interpolation, never a sibling root like
375
+ // /private or /private/tmpfoo, never write/delete.
376
+ test("owner_authorized_exceptions.opencode: /private/tmp is merged into permission.read as an allow entry", () => {
377
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
378
+ assert.deepEqual(allowlist.owner_authorized_exceptions?.opencode, ["/private/tmp"], "sanity: canonical asset must carry the expected owner-authorized exception entry");
379
+ const merged = mergePermissions({}, allowlist);
380
+ const read = merged.permission.read;
381
+ // "private/tmp/**" (no leading slash) — see readPatternFor's doc comment:
382
+ // OpenCode's real permission matcher strips the leading "/" from an
383
+ // absolute candidate path before comparing, so a pattern that keeps the
384
+ // leading slash never matches (measured against the real binary; see the
385
+ // by-effect fixture in permission-fixture.integration.test.ts).
386
+ assert.equal(read["private/tmp/**"], "allow", "expected an allow entry covering private/tmp/**");
387
+ assert.equal(read["/private/tmp/**"], undefined, "must NOT render a leading-slash form — it never matches OpenCode's real matcher");
388
+ });
389
+ test("owner_authorized_exceptions.opencode: idempotency holds with the new entry", async () => {
390
+ const dir = mkTmpDir();
391
+ try {
392
+ await installPermissions(dir);
393
+ const first = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
394
+ await installPermissions(dir);
395
+ const second = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
396
+ assert.equal(second, first, "second run must produce byte-identical output");
397
+ assert.match(first, /"private\/tmp\/\*\*": "allow"/, "sanity: the owner-exception entry must actually be present");
398
+ }
399
+ finally {
400
+ fs.rmSync(dir, { recursive: true });
401
+ }
402
+ });
403
+ test("owner_authorized_exceptions.opencode: interacts correctly with bare-scalar preservation (finding 3)", () => {
404
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
405
+ const original = { permission: { read: "deny" } };
406
+ const merged = mergePermissions(original, allowlist);
407
+ const read = merged.permission.read;
408
+ assert.equal(read["*"], "deny", "the user's original blanket 'deny' must survive as the '*' default");
409
+ for (const p of allowlist.read_paths) {
410
+ assert.equal(read[readPatternFor(p)], "allow", `expected thrum overlay allow for ${p}`);
411
+ }
412
+ assert.equal(read["private/tmp/**"], "allow", "the owner-authorized /private/tmp overlay must still be present as an allow on top of the preserved deny default");
413
+ });
414
+ test("owner_authorized_exceptions.opencode: negative control — sibling roots are never matched by the rendered pattern", () => {
415
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
416
+ const merged = mergePermissions({}, allowlist);
417
+ const read = merged.permission.read;
418
+ const allowPatterns = Object.entries(read)
419
+ .filter(([, verdict]) => verdict === "allow")
420
+ .map(([pattern]) => pattern);
421
+ const pattern = "private/tmp/**";
422
+ assert.ok(allowPatterns.includes(pattern), "sanity: the pattern under test must actually be rendered");
423
+ // Reuse the same glob-matching model as the bash negative controls above
424
+ // (wouldMatchBashPattern): `*` = zero-or-more of any char, everything else
425
+ // literal, per OpenCode's documented simple-wildcard matcher. Candidates
426
+ // here are given in the SAME leading-slash-stripped form OpenCode's real
427
+ // matcher compares against (see readPatternFor's doc comment) — the
428
+ // by-effect fixture proves this against the real binary using natural
429
+ // absolute-path candidates.
430
+ function wouldMatchReadPattern(p, candidate) {
431
+ const starIdx = p.indexOf("*");
432
+ if (starIdx === -1)
433
+ return candidate === p;
434
+ const literalPrefix = p.slice(0, starIdx);
435
+ return candidate.startsWith(literalPrefix);
436
+ }
437
+ // Positive control: the pattern must match a real file under the granted
438
+ // directory, so the negative result below isn't vacuous.
439
+ assert.ok(wouldMatchReadPattern(pattern, "private/tmp/foo.txt"), "sanity: the pattern must match a file directly under private/tmp");
440
+ const siblings = ["private/tmp", "private", "private/tmpfoo", "private/tmpfoo/x"];
441
+ for (const candidate of siblings) {
442
+ assert.ok(!wouldMatchReadPattern(pattern, candidate), `expected private/tmp/** to NOT match sibling/parent path: ${candidate}`);
443
+ }
444
+ });
445
+ // ─── (j) global_read_paths.opencode — ~/.local/bin (the allowlist effort consolidated
446
+ // round, Part B) ────────────────────────────────────────────────────────────
447
+ //
448
+ // OWNER CORRECTION (owner ruling, consolidated round): global_read_paths
449
+ // grants OpenCode (like Claude and Codex) READ access to the thrum-binary
450
+ // install directory ~/.local/bin, so PATH-resolved "thrum" can actually
451
+ // execute under the runtime's sandbox. A prior round deliberately gave
452
+ // OpenCode NO entries under global_read_paths at all (its own installer
453
+ // derives its global skill/command paths dynamically — see
454
+ // opencodeGlobalReadPaths, a separate, unrelated mechanism); this is the
455
+ // first entry OpenCode gets under this key.
456
+ const FAKE_HOME = "/private/tmp/oc-permtest-fakehome-does-not-need-to-exist";
457
+ const EXPECTED_LOCAL_BIN_KEY = readPatternFor(path.join(FAKE_HOME, ".local", "bin"));
458
+ test("global_read_paths.opencode: ~/.local/bin is merged into permission.read as an allow entry", () => {
459
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
460
+ assert.deepEqual(allowlist.global_read_paths?.opencode, ["~/.local/bin"], "sanity: canonical asset must carry the expected global_read_paths.opencode entry");
461
+ const merged = mergePermissions({}, allowlist, [], FAKE_HOME);
462
+ const read = merged.permission.read;
463
+ assert.equal(read[EXPECTED_LOCAL_BIN_KEY], "allow", `expected an allow entry covering ${EXPECTED_LOCAL_BIN_KEY}`);
464
+ // Must also hold against the real machine's actual os.homedir() default,
465
+ // which is what production code (installPermissions with no homeDir
466
+ // override) actually uses.
467
+ const mergedRealHome = mergePermissions({}, allowlist);
468
+ const readRealHome = mergedRealHome.permission.read;
469
+ assert.equal(readRealHome[readPatternFor(path.join(os.homedir(), ".local", "bin"))], "allow", "expected the real-homedir-resolved key to be present when no homeDir override is given");
470
+ });
471
+ test("global_read_paths.opencode: idempotency holds with the new entry", async () => {
472
+ const dir = mkTmpDir();
473
+ try {
474
+ await installPermissions(dir, FAKE_HOME);
475
+ const first = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
476
+ await installPermissions(dir, FAKE_HOME);
477
+ const second = fs.readFileSync(path.join(dir, "opencode.json"), "utf8");
478
+ assert.equal(second, first, "second run must produce byte-identical output");
479
+ assert.ok(first.includes(`"${EXPECTED_LOCAL_BIN_KEY}": "allow"`), "sanity: the ~/.local/bin entry must actually be present");
480
+ }
481
+ finally {
482
+ fs.rmSync(dir, { recursive: true });
483
+ }
484
+ });
485
+ test("global_read_paths.opencode: preserves unrelated entries — both /private/tmp and ~/.local/bin coexist", () => {
486
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
487
+ const merged = mergePermissions({}, allowlist, [], FAKE_HOME);
488
+ const read = merged.permission.read;
489
+ assert.equal(read["private/tmp/**"], "allow", "expected the pre-existing /private/tmp grant to still be present");
490
+ assert.equal(read[EXPECTED_LOCAL_BIN_KEY], "allow", "expected the new ~/.local/bin grant to also be present");
491
+ for (const p of allowlist.read_paths) {
492
+ assert.equal(read[readPatternFor(p)], "allow", `expected pre-existing canonical read_paths entry for ${p}`);
493
+ }
494
+ });
495
+ test("global_read_paths.opencode: interacts correctly with bare-scalar preservation (finding 3)", () => {
496
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
497
+ const original = { permission: { read: "deny" } };
498
+ const merged = mergePermissions(original, allowlist, [], FAKE_HOME);
499
+ const read = merged.permission.read;
500
+ assert.equal(read["*"], "deny", "the user's original blanket 'deny' must survive as the '*' default");
501
+ assert.equal(read[EXPECTED_LOCAL_BIN_KEY], "allow", "the ~/.local/bin overlay must still be present as an allow on top of the preserved deny default");
502
+ });
503
+ test("global_read_paths.opencode: negative control — sibling/parent paths are never matched by the rendered pattern", () => {
504
+ const allowlist = loadAllowlist(ALLOWLIST_ASSET_PATH);
505
+ const merged = mergePermissions({}, allowlist, [], FAKE_HOME);
506
+ const read = merged.permission.read;
507
+ const allowPatterns = Object.entries(read)
508
+ .filter(([, verdict]) => verdict === "allow")
509
+ .map(([pattern]) => pattern);
510
+ assert.ok(allowPatterns.includes(EXPECTED_LOCAL_BIN_KEY), "sanity: the pattern under test must actually be rendered");
511
+ function wouldMatchReadPattern(p, candidate) {
512
+ const starIdx = p.indexOf("*");
513
+ if (starIdx === -1)
514
+ return candidate === p;
515
+ const literalPrefix = p.slice(0, starIdx);
516
+ return candidate.startsWith(literalPrefix);
517
+ }
518
+ const localBinDir = stripLeadingSlashForTest(path.join(FAKE_HOME, ".local", "bin"));
519
+ assert.ok(wouldMatchReadPattern(EXPECTED_LOCAL_BIN_KEY, `${localBinDir}/thrum`), "sanity: the pattern must match a file directly under ~/.local/bin");
520
+ const siblings = [
521
+ stripLeadingSlashForTest(path.join(FAKE_HOME, ".local")),
522
+ stripLeadingSlashForTest(path.join(FAKE_HOME, ".local", "binx")),
523
+ stripLeadingSlashForTest(path.join(FAKE_HOME, ".local", "binx", "x")),
524
+ ];
525
+ for (const candidate of siblings) {
526
+ assert.ok(!wouldMatchReadPattern(EXPECTED_LOCAL_BIN_KEY, candidate), `expected ${EXPECTED_LOCAL_BIN_KEY} to NOT match sibling/parent path: ${candidate}`);
527
+ }
528
+ });
529
+ function stripLeadingSlashForTest(p) {
530
+ return p.startsWith("/") ? p.slice(1) : p;
531
+ }
532
+ test("resolveHomePath: expands '~' and '~/...' against a given homeDir, leaves other paths untouched", () => {
533
+ assert.equal(resolveHomePath("~/.local/bin", "/fake/home"), path.join("/fake/home", ".local", "bin"));
534
+ assert.equal(resolveHomePath("~", "/fake/home"), "/fake/home");
535
+ assert.equal(resolveHomePath("/private/tmp", "/fake/home"), "/private/tmp");
536
+ assert.equal(resolveHomePath(".thrum", "/fake/home"), ".thrum");
537
+ });
538
+ // ─── (x) writeConfig crash-safety (Pass-3 B3) ──────────────────────────────
539
+ // The merge target is the user's REAL global opencode.json: a half-written
540
+ // file (crash mid-write) bricks every future install, because readConfig
541
+ // deliberately THROWS on malformed JSON instead of treating it as absent.
542
+ // writeConfig must therefore be atomic — write to a temp file in the SAME
543
+ // directory (same filesystem, so the final rename is atomic), flush it to
544
+ // disk, swap by rename, preserve the existing file's permission mode on
545
+ // overwrite, and leave ZERO temp residue on any failure — with the original
546
+ // file byte-identical and untouched whenever the new content did not fully
547
+ // land.
548
+ function noTempResidue(dir) {
549
+ return fs.readdirSync(dir).filter((name) => name.includes(".tmp-"));
550
+ }
551
+ test("writeConfig: successful write lands the content and leaves zero temp residue", () => {
552
+ const dir = mkTmpDir();
553
+ const configPath = path.join(dir, "opencode.json");
554
+ writeConfig(configPath, { permission: { bash: "deny" } });
555
+ const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8"));
556
+ assert.equal(onDisk.permission.bash, "deny", "the written config must round-trip");
557
+ assert.deepEqual(noTempResidue(dir), [], "a successful write must leave no temp residue");
558
+ });
559
+ test("writeConfig: serialization failure leaves the original byte-identical with no residue", () => {
560
+ const dir = mkTmpDir();
561
+ const configPath = path.join(dir, "opencode.json");
562
+ const sentinel = `{"keep":"me","permission":{"bash":"deny"}}\n`;
563
+ fs.writeFileSync(configPath, sentinel);
564
+ const circular = {};
565
+ circular["self"] = circular;
566
+ assert.throws(() => writeConfig(configPath, circular), "a non-serializable config must throw, not silently write a partial file");
567
+ assert.equal(fs.readFileSync(configPath, "utf8"), sentinel, "the original config must be byte-identical after a failed write (never truncated or mixed)");
568
+ assert.deepEqual(noTempResidue(dir), [], "a failed write must clean up its temp file");
569
+ });
570
+ test("writeConfig: rename failure cleans temp residue and rethrows", () => {
571
+ const dir = mkTmpDir();
572
+ // The rename target exists as a DIRECTORY: rename must fail, the error
573
+ // must propagate, and the temp file must not be left behind.
574
+ const configPath = path.join(dir, "opencode.json");
575
+ fs.mkdirSync(configPath);
576
+ assert.throws(() => writeConfig(configPath, { permission: { bash: "deny" } }));
577
+ assert.deepEqual(noTempResidue(dir), [], "a failed rename must not leave temp residue");
578
+ });
579
+ test("writeConfig: overwrite preserves the existing file's permission mode", () => {
580
+ const dir = mkTmpDir();
581
+ const configPath = path.join(dir, "opencode.json");
582
+ fs.writeFileSync(configPath, `{"older":true}\n`);
583
+ fs.chmodSync(configPath, 0o640);
584
+ writeConfig(configPath, { permission: { read: "deny" } });
585
+ const mode = fs.statSync(configPath).mode & 0o777;
586
+ assert.equal(mode, 0o640, "an overwrite must not widen or narrow the existing file's mode");
587
+ const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8"));
588
+ assert.equal(onDisk.permission.read, "deny", "the overwrite itself must land");
589
+ assert.deepEqual(noTempResidue(dir), [], "no residue after a mode-preserving overwrite");
590
+ });
591
+ test("writeConfig: overwrite swaps in a NEW inode via rename, never truncates the original in place", () => {
592
+ const dir = mkTmpDir();
593
+ const configPath = path.join(dir, "opencode.json");
594
+ fs.writeFileSync(configPath, `{"version":"old"}\n`);
595
+ const beforeIno = fs.statSync(configPath).ino;
596
+ writeConfig(configPath, { permission: { bash: "deny" } });
597
+ const afterIno = fs.statSync(configPath).ino;
598
+ assert.notEqual(afterIno, beforeIno, "the overwrite must swap in a freshly-written same-directory file via atomic rename — " +
599
+ "truncating the original inode in place leaves a half-written user config on a crash");
600
+ assert.equal(JSON.parse(fs.readFileSync(configPath, "utf8")).permission.bash, "deny");
601
+ assert.deepEqual(noTempResidue(dir), []);
602
+ });
@@ -0,0 +1 @@
1
+ export {};