opencode-thrum-pro 0.1.1 → 0.1.2

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 +68 -9
  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 +34 -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
package/dist/installer.js CHANGED
@@ -15,7 +15,7 @@ function getPluginVersion() {
15
15
  return "0.0.0";
16
16
  }
17
17
  }
18
- function getConfigDir() {
18
+ export function getConfigDir() {
19
19
  const xdg = process.env.XDG_CONFIG_HOME;
20
20
  if (xdg)
21
21
  return path.join(xdg, "opencode");
@@ -84,3 +84,358 @@ export async function installAssets(ctx) {
84
84
  fs.writeFileSync(versionPath, currentVersion);
85
85
  await log(`opencode-thrum v${currentVersion} assets installed`);
86
86
  }
87
+ const ALLOWLIST_ASSET_PATH = path.join(ASSETS_DIR, "thrum_allowlist.json");
88
+ // bashExactPatternFor renders the bare-command form of a canonical
89
+ // command_patterns entry (e.g. "thrum inbox" -> "thrum inbox"), with no
90
+ // trailing glob at all. OpenCode's permission matcher is documented simple-
91
+ // wildcard matching (`*` = zero-or-more of any char, `?` = exactly one,
92
+ // everything else literal — https://opencode.ai/docs/permissions/,
93
+ // verified manually) with NO built-in word-boundary behavior: `*` matches
94
+ // straight through a word boundary, so a bare `${commandPattern}*` key (the
95
+ // round-1 shape) would also allow "thrum inboxx-evil" under the "thrum
96
+ // inbox" grant. This exact-match key is the half of the two-key pair (see
97
+ // bashPatternFor below) that covers invoking the command with NO arguments
98
+ // at all, where a glob suffix would otherwise require a literal trailing
99
+ // character to exist.
100
+ export function bashExactPatternFor(commandPattern) {
101
+ return commandPattern;
102
+ }
103
+ // bashPatternFor renders the with-arguments form of a canonical
104
+ // command_patterns entry (e.g. "thrum inbox" -> "thrum inbox *"). The
105
+ // literal SPACE before the glob is the word-boundary fix (the allowlist effort round
106
+ // 2, finding 7): it requires a real space character to follow the command
107
+ // prefix before the glob can start matching, so "thrum inbox *" matches
108
+ // "thrum inbox --unread" but does NOT match "thrum inboxx-evil" (no space
109
+ // after "inbox" in that string) or "thrum inboxx" (same reason). Round 1's
110
+ // bare `${commandPattern}*` (no space) is what let those adjacent-command
111
+ // false positives through — see the negative-control test added alongside
112
+ // this fix.
113
+ export function bashPatternFor(commandPattern) {
114
+ return `${commandPattern} *`;
115
+ }
116
+ // readPatternFor renders one canonical read_paths entry (e.g. ".thrum")
117
+ // into an OpenCode read/external_directory glob key admitting the whole
118
+ // subtree.
119
+ //
120
+ // LEADING-SLASH STRIP — measured against the real `opencode` binary
121
+ // (the allowlist effort round 4, by-effect fixture): OpenCode's permission matcher
122
+ // normalizes an absolute candidate path with its leading "/" removed before
123
+ // comparing against configured patterns. A literal "/private/tmp/**" key
124
+ // NEVER matched a read of "/private/tmp/x" in the real oracle (fell through
125
+ // to OpenCode's own built-in default every time, regardless of "*" default
126
+ // or key order); the identical pattern with the leading slash stripped,
127
+ // "private/tmp/**", matched correctly both ways — ALLOW under the granted
128
+ // path, DENY on a sibling root. This was previously unverified: no by-effect
129
+ // test exercised an absolute-path read pattern before this round, so this
130
+ // same defect silently affected opencodeGlobalReadPaths's configDir-derived
131
+ // DIRECTORY patterns below (${configDir}/skills, ${configDir}/commands)
132
+ // since they are rendered through this same function. It is a no-op for
133
+ // the already-relative canonical read_paths entries (e.g. ".thrum"), which
134
+ // never had a leading slash to strip.
135
+ //
136
+ // The THIRD opencodeGlobalReadPaths entry, ${configDir}/opencode.json, is a
137
+ // FILE, not a directory — it is rendered through the sibling
138
+ // readFilePatternFor (same strip, no "/**" suffix), not this function; see
139
+ // that function's doc comment for a round-4-review finding where this file
140
+ // entry was initially missed and stayed silently broken one round after
141
+ // this fix landed for the directory entries.
142
+ export function readPatternFor(readPath) {
143
+ const normalized = stripLeadingSlash(readPath);
144
+ return `${normalized}/**`;
145
+ }
146
+ // stripLeadingSlash removes a leading "/" from an absolute path so it
147
+ // matches OpenCode's real permission-matcher normalization (see
148
+ // readPatternFor's doc comment above). Shared by readPatternFor (directory
149
+ // glob entries) and readFilePatternFor (exact-file entries, which must NOT
150
+ // get a "/**" suffix — a file is not a tree) so both render through the
151
+ // SAME normalization instead of two independently-maintained copies of it.
152
+ function stripLeadingSlash(p) {
153
+ return p.startsWith("/") ? p.slice(1) : p;
154
+ }
155
+ // resolveHomePath expands a leading "~" (bare, or "~/...") into an absolute
156
+ // path rooted at homeDir, otherwise returns the path unchanged. Takes homeDir
157
+ // as an explicit parameter (default os.homedir()) rather than calling
158
+ // os.homedir() inline so callers (tests, the by-effect fixture) can resolve
159
+ // against an isolated fake home without mutating process.env.HOME globally —
160
+ // mirrors how installPermissions/mergePermissions already take configDir as
161
+ // an explicit parameter for the identical reason.
162
+ //
163
+ // Every OTHER absolute-path grant this file renders (opencodeGlobalReadPaths,
164
+ // owner_authorized_exceptions' /private/tmp) is either already absolute or
165
+ // derived from getConfigDir(), which itself resolves os.homedir() and never
166
+ // emits a literal "~" (confirmed pattern in this codebase — see getConfigDir
167
+ // above). global_read_paths entries are the first canonical-allowlist value
168
+ // to arrive as a literal "~"-prefixed string, so this is the one call site
169
+ // that needs to expand it before handing the result to readPatternFor —
170
+ // never emit a literal, unexpanded "~" into a rendered permission key.
171
+ export function resolveHomePath(p, homeDir = os.homedir()) {
172
+ if (p === "~")
173
+ return homeDir;
174
+ if (p.startsWith("~/"))
175
+ return path.join(homeDir, p.slice(2));
176
+ return p;
177
+ }
178
+ // readFilePatternFor renders an absolute path to a single FILE (not a
179
+ // directory subtree) into an OpenCode read-permission key: the same
180
+ // leading-slash strip as readPatternFor, but no "/**" suffix — round-4
181
+ // review finding (cq, by-effect-confirmed): opencodeGlobalReadPaths's
182
+ // ${configDir}/opencode.json entry was rendered as a raw, unstripped path
183
+ // and so was STILL silently broken by the same defect readPatternFor's
184
+ // fix addressed everywhere else. Confirmed by-effect (real opencode
185
+ // binary) that only the stripped form renders correctly.
186
+ export function readFilePatternFor(filePath) {
187
+ return stripLeadingSlash(filePath);
188
+ }
189
+ // loadAllowlist reads the canonical allowlist JSON asset bundled with this
190
+ // plugin (opencode-plugin/assets/thrum_allowlist.json — a byte-for-byte
191
+ // copy of internal/permissions/thrum_allowlist.json, kept in sync by
192
+ // scripts/sync-skills.sh's sync_opencode step; see that script's comment
193
+ // for why a copy is needed instead of a live cross-language import).
194
+ export function loadAllowlist(assetPath = ALLOWLIST_ASSET_PATH) {
195
+ const raw = fs.readFileSync(assetPath, "utf8");
196
+ return JSON.parse(raw);
197
+ }
198
+ // OPENCODE_WILDCARD_KEY is the object-map form's documented catch-all key
199
+ // (https://opencode.ai/docs/permissions/, verified manually: "A common
200
+ // pattern is to put the catch-all '*' rule first, and more specific rules
201
+ // after it" — the schema's object-map form has no dedicated default-key
202
+ // field; "*" IS the default-key convention). Rule evaluation is
203
+ // last-match-wins over the map's key order, and a JS object's string keys
204
+ // iterate in insertion order — so this key must always be inserted FIRST,
205
+ // before any thrum-specific pattern, or a later "*" insertion would win and
206
+ // silently shadow every thrum allow entry.
207
+ const OPENCODE_WILDCARD_KEY = "*";
208
+ // preserveBareScalarAsDefault turns a pre-existing BARE-STRING permission
209
+ // action (the schema's PermissionActionConfig form — a blanket default for
210
+ // every command, e.g. permission.bash: "deny") into the seed of an object
211
+ // map that keeps that blanket action as the map's "*" default, so a
212
+ // caller's stricter-than-OpenCode's-own-default posture ("deny"/"ask") is
213
+ // never silently widened by our overlay (the allowlist effort round 2, finding 3).
214
+ // Confirmed against the schema before writing this: the object-map form
215
+ // DOES support a "*" default key (see OPENCODE_WILDCARD_KEY above), so this
216
+ // is the "schema supports a default key" branch, not the leave-untouched
217
+ // fallback — the fallback is unnecessary here.
218
+ function preserveBareScalarAsDefault(existing) {
219
+ if (typeof existing === "string")
220
+ return { [OPENCODE_WILDCARD_KEY]: existing };
221
+ if (existing && typeof existing === "object")
222
+ return { ...existing };
223
+ return {};
224
+ }
225
+ // mergePermissions returns a NEW config object with permission.bash and
226
+ // permission.read populated from the allowlist (plus any already-rendered
227
+ // extraReadPatterns, e.g. OpenCode's own global skills/commands subtree —
228
+ // see opencodeGlobalReadPaths), preserving every other key (including any
229
+ // pre-existing permission.* sub-keys such as permission.edit) untouched.
230
+ // Pure function, no I/O — kept separate from installPermissions so it's
231
+ // directly unit-testable.
232
+ //
233
+ // If an existing permission.bash/read is a bare action string (e.g. "ask"
234
+ // or "deny") rather than an object map, that string is a blanket default
235
+ // the caller set deliberately for EVERY command/path, and is preserved
236
+ // as-is via preserveBareScalarAsDefault — never dropped, never silently
237
+ // widened by treating the overlay as a full replacement.
238
+ export function mergePermissions(config, allowlist, extraReadPatterns = [], homeDir = os.homedir()) {
239
+ const merged = { ...config };
240
+ const existingPermission = merged.permission ?? {};
241
+ const permission = { ...existingPermission };
242
+ const bash = preserveBareScalarAsDefault(existingPermission.bash);
243
+ for (const commandPattern of allowlist.command_patterns) {
244
+ bash[bashExactPatternFor(commandPattern)] = "allow";
245
+ bash[bashPatternFor(commandPattern)] = "allow";
246
+ }
247
+ const read = preserveBareScalarAsDefault(existingPermission.read);
248
+ for (const readPath of allowlist.read_paths) {
249
+ read[readPatternFor(readPath)] = "allow";
250
+ }
251
+ for (const pattern of extraReadPatterns) {
252
+ read[pattern] = "allow";
253
+ }
254
+ // OWNER-AUTHORIZED EXCEPTION — /private/tmp (owner ruling, watcher-
255
+ // recovery P0; scope CONFIRMED all-runtimes by the fleet coordinator the same
256
+ // day — owner's wording was "global configs for all agents", the
257
+ // operational-artifact class is runtime-independent): the canonical
258
+ // allowlist's owner_authorized_exceptions grants EVERY supported runtime
259
+ // broad READ access to /private/tmp/* DESPITE it being a world-writable
260
+ // directory — explicitly ruled acceptable by the owner for
261
+ // coordinator/watcher operational artifacts. This is a DELIBERATE
262
+ // exception, not a template for widening elsewhere: it grants READ/ACCESS
263
+ // ONLY — never execute, never shell-interpolation, never a sibling root
264
+ // like /private or /private/tmpfoo, never write/delete. See
265
+ // internal/permissions/thrum_allowlist.json's own "OWNER-AUTHORIZED
266
+ // EXCEPTION" comment for the full ruling this quotes from. Rendered
267
+ // through the same readPatternFor() glob as canonical read_paths (e.g.
268
+ // "/private/tmp" -> "/private/tmp/**") so it goes through the identical
269
+ // preserveBareScalarAsDefault-aware merge machinery above — never a
270
+ // bypass path.
271
+ const ownerExceptions = allowlist.owner_authorized_exceptions?.opencode ?? [];
272
+ for (const exceptionPath of ownerExceptions) {
273
+ read[readPatternFor(exceptionPath)] = "allow";
274
+ }
275
+ // GLOBAL READ PATHS — ~/.local/bin (owner ruling, consolidated round):
276
+ // the canonical allowlist's global_read_paths grants OpenCode (like Claude
277
+ // and Codex) READ access to the thrum-binary install directory, so
278
+ // PATH-resolved "thrum" can actually execute under the runtime's sandbox.
279
+ // Rendered through the SAME readPatternFor() glob and the SAME
280
+ // preserveBareScalarAsDefault-aware merge machinery as read_paths and
281
+ // owner_authorized_exceptions above — never a bypass path. Each entry is
282
+ // resolved through resolveHomePath() BEFORE readPatternFor() so a literal
283
+ // "~" is expanded to the real absolute path first (every other absolute-
284
+ // path grant in this file is already fully expanded before rendering; see
285
+ // resolveHomePath's doc comment for why this is the one call site that
286
+ // needs the expansion step). Optional chaining: absent on an older/
287
+ // pre-round canonical asset, and a prior round deliberately left OpenCode
288
+ // with no entries here at all (see AllowlistSource's global_read_paths
289
+ // doc comment) — both no-op cleanly to an empty array.
290
+ const globalReadPaths = allowlist.global_read_paths?.opencode ?? [];
291
+ for (const rawPath of globalReadPaths) {
292
+ read[readPatternFor(resolveHomePath(rawPath, homeDir))] = "allow";
293
+ }
294
+ permission.bash = bash;
295
+ permission.read = read;
296
+ merged.permission = permission;
297
+ return merged;
298
+ }
299
+ // readConfig is split out from installPermissions so tests can exercise
300
+ // mergePermissions in isolation without touching disk, and so
301
+ // installPermissions itself stays a thin, obviously-idempotent I/O shell.
302
+ //
303
+ // Mirrors the Go-side hookmerge Load(): an empty config is
304
+ // returned ONLY when the file genuinely does not exist (Node's ENOENT,
305
+ // matching Go's os.IsNotExist). Any other failure — malformed JSON,
306
+ // permission-denied read, or any other I/O error — is THROWN, never
307
+ // swallowed into {}. Swallowing a parse error here previously meant a
308
+ // user's hand-edited-but-broken global opencode.json (e.g. a stray comma)
309
+ // was treated as "absent" and silently overwritten by installPermissions()
310
+ // with a fresh config containing only thrum's permission block, discarding
311
+ // every other setting the user had (the allowlist effort landing-blocker).
312
+ function readConfig(configPath) {
313
+ let raw;
314
+ try {
315
+ raw = fs.readFileSync(configPath, "utf8");
316
+ }
317
+ catch (err) {
318
+ if (err.code === "ENOENT")
319
+ return {};
320
+ throw new Error(`failed to read OpenCode config at ${configPath}: ${err.message}`, { cause: err });
321
+ }
322
+ try {
323
+ return JSON.parse(raw);
324
+ }
325
+ catch (err) {
326
+ throw new Error(`failed to parse OpenCode config at ${configPath} as JSON — it will be left untouched: ${err.message}`, { cause: err });
327
+ }
328
+ }
329
+ // Split out from installPermissions (like readConfig) so tests can exercise
330
+ // the crash-safety contract directly.
331
+ //
332
+ // Pass-3 B3 — ATOMIC by construction. The merge target is the user's REAL
333
+ // global opencode.json, and readConfig deliberately throws on malformed JSON
334
+ // (never treats it as absent), so a half-written file from a crash mid-write
335
+ // would brick every future install AND OpenCode's own config load. The write
336
+ // is therefore:
337
+ // 1. fully serialized in memory FIRST (a non-serializable config throws
338
+ // before anything on disk is touched);
339
+ // 2. written to a temp file in the SAME directory as the target (same
340
+ // filesystem, so the final rename is atomic — never a cross-device
341
+ // EXDEV rename), with the existing file's permission mode preserved on
342
+ // overwrite (a fresh file gets 0o600 — narrower, never wider, than the
343
+ // old writeFileSync default on a fresh create);
344
+ // 3. fsync'd before the rename, so the bytes are durable before the name
345
+ // flips (a crash after the rename can then only leave the old OR the
346
+ // new complete file — never a truncated or mixed one);
347
+ // 4. swapped in by rename (atomic; the original inode is never truncated
348
+ // in place) — and on any failure the temp file is unlinked before the
349
+ // error propagates, so zero temp residue is ever left behind.
350
+ export function writeConfig(configPath, config) {
351
+ const dir = path.dirname(configPath);
352
+ const serialized = `${JSON.stringify(config, null, 2)}\n`;
353
+ fs.mkdirSync(dir, { recursive: true });
354
+ const tmpPath = path.join(dir, `.${path.basename(configPath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
355
+ let mode = 0o600;
356
+ try {
357
+ mode = fs.statSync(configPath).mode & 0o777;
358
+ }
359
+ catch {
360
+ // Target does not exist yet — keep the restrictive fresh-file default.
361
+ }
362
+ let fd;
363
+ try {
364
+ fd = fs.openSync(tmpPath, "w", mode);
365
+ fs.writeSync(fd, serialized);
366
+ fs.fsyncSync(fd);
367
+ fs.closeSync(fd);
368
+ fd = undefined;
369
+ fs.renameSync(tmpPath, configPath);
370
+ }
371
+ catch (err) {
372
+ if (fd !== undefined) {
373
+ try {
374
+ fs.closeSync(fd);
375
+ }
376
+ catch {
377
+ // best-effort close on the error path — the original close error
378
+ // (if any) matters more than this one
379
+ }
380
+ }
381
+ try {
382
+ fs.unlinkSync(tmpPath);
383
+ }
384
+ catch {
385
+ // best-effort residue cleanup; never mask the real failure
386
+ }
387
+ throw err;
388
+ }
389
+ }
390
+ // opencodeGlobalReadPaths returns the already-rendered read-glob patterns
391
+ // for OpenCode's OWN global install subtree under configDir: exactly the
392
+ // ${configDir}/skills and ${configDir}/commands trees installAssets() (see
393
+ // above in this file) writes to, plus the global opencode.json config file
394
+ // itself. This is the allowlist effort round 2 finding 1: the canonical allowlist's
395
+ // read_paths are all PROJECT-relative (.thrum, .claude/skills, etc) and
396
+ // cannot express OpenCode's absolute, outside-the-project global config
397
+ // location — a watcher/orchestrator reading its own installed OpenCode
398
+ // skill/command files needs read access to THAT subtree too, in ADDITION
399
+ // to (never instead of) the canonical project-relative set.
400
+ //
401
+ // Deliberately scoped to configDir and its known children only — no
402
+ // broader home-dir grant. Resolved via getConfigDir() (or the configDir
403
+ // passed to installPermissions), never hardcoded, so it tracks
404
+ // XDG_CONFIG_HOME/HOME like the rest of this file.
405
+ export function opencodeGlobalReadPaths(configDir) {
406
+ return [
407
+ readPatternFor(path.join(configDir, "skills")),
408
+ readPatternFor(path.join(configDir, "commands")),
409
+ readFilePatternFor(path.join(configDir, "opencode.json")),
410
+ ];
411
+ }
412
+ // installPermissions merges the canonical thrum command/read-path allowlist
413
+ // — plus OpenCode's own global skills/commands/config read grant (see
414
+ // opencodeGlobalReadPaths) — into the user's GLOBAL OpenCode config
415
+ // (${configDir}/opencode.json). Idempotent: re-running with unchanged
416
+ // inputs (allowlist + prior config) produces byte-identical output, since
417
+ // mergePermissions is a pure deterministic function of its inputs and
418
+ // readConfig/writeConfig round-trip via JSON.stringify with a fixed
419
+ // 2-space indent.
420
+ //
421
+ // If the existing config is present but malformed (unreadable or invalid
422
+ // JSON), readConfig() throws rather than returning {} — this function does
423
+ // NOT catch that error, so mergePermissions/writeConfig are never reached
424
+ // and the on-disk file is left byte-for-byte untouched. The caller (see
425
+ // index.ts) is responsible for catching and logging that error without
426
+ // crashing plugin bootstrap.
427
+ // homeDir defaults to os.homedir() and is used ONLY to resolve
428
+ // global_read_paths' "~"-prefixed entries (see resolveHomePath); it is
429
+ // independent of configDir (OpenCode's own global config directory, which
430
+ // may live under XDG_CONFIG_HOME and not under homeDir at all). Threaded
431
+ // through as an explicit parameter — never read inline via os.homedir()
432
+ // inside mergePermissions' caller chain — so tests and the by-effect
433
+ // fixture can resolve against an isolated fake home without mutating
434
+ // process.env.HOME globally.
435
+ export async function installPermissions(configDir = getConfigDir(), homeDir = os.homedir()) {
436
+ const configPath = path.join(configDir, "opencode.json");
437
+ const existing = readConfig(configPath);
438
+ const allowlist = loadAllowlist();
439
+ const merged = mergePermissions(existing, allowlist, opencodeGlobalReadPaths(configDir), homeDir);
440
+ writeConfig(configPath, merged);
441
+ }
@@ -0,0 +1 @@
1
+ export {};