opencode-thrum-pro 0.1.15 → 0.1.17

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 (31) hide show
  1. package/assets/commands/thrum-compact-extended.md +14 -12
  2. package/assets/commands/thrum-compact.md +17 -15
  3. package/assets/commands/thrum-prime.md +1 -0
  4. package/assets/skills/configure-roles/SKILL.md +0 -1
  5. package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +15 -6
  6. package/assets/skills/coordinator-maintaining-memory/SKILL.md +2 -0
  7. package/assets/skills/coordinator-merging-code/SKILL.md +25 -10
  8. package/assets/skills/coordinator-merging-code/resources/overbuild-check-prompt.md +60 -0
  9. package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +5 -4
  10. package/assets/skills/implementer-maintaining-memory/SKILL.md +2 -0
  11. package/assets/skills/memory-curate/SKILL.md +2 -0
  12. package/assets/skills/memory-current-rule/SKILL.md +121 -0
  13. package/assets/skills/memory-maintenance/SKILL.md +2 -0
  14. package/assets/skills/memory-write-discipline/SKILL.md +2 -0
  15. package/assets/skills/persistent-watcher-archetype/SKILL.md +142 -60
  16. package/assets/skills/project-hotpath-gate/SKILL.md +8 -0
  17. package/assets/skills/researcher-maintaining-memory/SKILL.md +2 -0
  18. package/assets/skills/thrum/resources/CLI_REFERENCE.md +14 -6
  19. package/assets/skills/thrum/resources/TMUX_SESSIONS.md +16 -8
  20. package/assets/skills/thrum-memory/SKILL.md +2 -0
  21. package/assets/skills/using-the-queue/SKILL.md +6 -7
  22. package/assets/skills/using-thrum-state/SKILL.md +2 -0
  23. package/assets/skills/using-tmux-ssh/resources/tmux-ssh +23 -2
  24. package/assets/skills/watcher-archetype/SKILL.md +13 -5
  25. package/dist/installer-assets.test.js +5 -2
  26. package/dist/installer.d.ts +5 -1
  27. package/dist/installer.js +38 -7
  28. package/dist/installer.test.js +53 -0
  29. package/package.json +4 -1
  30. package/dist/permission-fixture.integration.test.js +0 -506
  31. /package/dist/{permission-fixture.integration.test.d.ts → installer.test.d.ts} +0 -0
@@ -1,506 +0,0 @@
1
- // Hermetic PRODUCTION-PATH fixture for the OpenCode permission.bash allowlist
2
- // (allowlist round 2, finding 6). Unlike installer.permissions.test.ts
3
- // (which only exercises our own mergePermissions()/installPermissions()
4
- // TypeScript logic in-process), THIS file drives the real, installed
5
- // `opencode` CLI's actual permission-matching code against a real
6
- // opencode.json — proving the rendered allow/deny patterns behave correctly
7
- // against the production oracle, not a reimplementation of its matcher.
8
- //
9
- // ─── Isolation notes (read before editing) ─────────────────────────────────
10
- //
11
- // "Hermetic" here means: no network call, no live LLM/model call, no
12
- // external state that could make the test flaky. It does NOT mean zero
13
- // external dependency — this file requires the `opencode` binary to be
14
- // installed locally (see HAS_OPENCODE below) and is skipped gracefully when
15
- // it is absent.
16
- //
17
- // `opencode debug agent <name> --tool bash --params '{...}'` unconditionally
18
- // loads the REAL machine's global config (~/.config/opencode/{config,
19
- // opencode}.{json,jsonc}) in addition to any project-level opencode.json in
20
- // cwd — confirmed by running with `--print-logs --log-level DEBUG` and
21
- // reading the "loading path=..." lines. Neither `OPENCODE_CONFIG_DIR` nor
22
- // `OPENCODE_TEST_HOME` suppresses that global load (empirically verified:
23
- // both still showed the real ~/.config/opencode/opencode.jsonc's custom
24
- // permission.bash rules — e.g. "sudo *": "deny" — merged into the flattened
25
- // rule dump on a DENY, even with both env vars set to scratch paths).
26
- //
27
- // The one override that DOES work is the plain `HOME` env var: os.homedir()
28
- // (which the real global-config path is derived from) reads `process.env
29
- // .HOME` on this platform, so setting HOME to a fresh empty scratch
30
- // directory for the child process makes `opencode` look for
31
- // ~/.config/opencode/* under that fake HOME (nothing there — confirmed via
32
- // the same --print-logs technique) instead of the real one. Every oracle
33
- // invocation below sets HOME to a fresh per-call scratch directory for this
34
- // reason. Project-level config (read from cwd, unaffected by HOME) is our
35
- // actual scratch fixture directory.
36
- //
37
- // Run: npm test (default suite, degrades to a skip if `opencode` is
38
- // missing) or npm run test:integration (this file only).
39
- import { test } from "node:test";
40
- import assert from "node:assert/strict";
41
- import { execFileSync, execSync } from "node:child_process";
42
- import fs from "node:fs";
43
- import os from "node:os";
44
- import path from "node:path";
45
- import { installPermissions } from "./installer.js";
46
- // ─── Binary probe ───────────────────────────────────────────────────────────
47
- function findOpencodeBinary() {
48
- try {
49
- const out = execSync("which opencode", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
50
- return out || null;
51
- }
52
- catch {
53
- return null;
54
- }
55
- }
56
- const OPENCODE_BIN = findOpencodeBinary();
57
- const HAS_OPENCODE = OPENCODE_BIN !== null;
58
- if (!HAS_OPENCODE) {
59
- console.log("[permission-fixture] `opencode` binary not found on PATH — skipping all cases in this file. " +
60
- "Install OpenCode locally to exercise this fixture.");
61
- }
62
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
63
- // Provider-credential-shaped env vars are stripped from every child-process
64
- // invocation below (never merely left absent by chance) so that a passing
65
- // verdict is positive proof no live LLM/API credential was available to be
66
- // used, not just an accident of the ambient shell.
67
- function strippedEnv(home) {
68
- const env = { ...process.env, HOME: home };
69
- for (const key of Object.keys(env)) {
70
- if (/API_KEY|ANTHROPIC|OPENAI|_TOKEN$/i.test(key))
71
- delete env[key];
72
- }
73
- return env;
74
- }
75
- // runOracle invokes the real opencode binary's `debug agent` tool-permission
76
- // route and classifies the result by OUTPUT SHAPE, never by exit code (both
77
- // ALLOW and DENY have been observed to exit 0, and DENY has also been
78
- // observed to exit 1 — see the module-level tests below re-confirming this
79
- // live). Throws on any output shape that matches neither the documented
80
- // ALLOWED nor DENIED form, so an unrecognized oracle response fails loudly
81
- // instead of being silently misclassified.
82
- //
83
- // ALLOWED detection is `"result" in parsed`, not the bash-specific
84
- // `result.metadata.exit` shape it used to check: the "read" tool's ALLOWED
85
- // response carries `result.metadata.{preview,truncated,loaded,display}`
86
- // with no `exit` field at all (measured directly against the real binary),
87
- // so a bash-shaped check would misclassify every ALLOWED read as
88
- // unrecognized-shape.
89
- function runOracle(agent, candidate, cwd, home) {
90
- const tool = typeof candidate === "string" ? "bash" : candidate.tool;
91
- const toolParams = typeof candidate === "string" ? { command: candidate } : candidate.params;
92
- const paramsJson = JSON.stringify(toolParams);
93
- const label = typeof candidate === "string" ? candidate : `${candidate.tool} ${paramsJson}`;
94
- let raw;
95
- try {
96
- raw = execFileSync("opencode", ["debug", "agent", agent, "--tool", tool, "--params", paramsJson], {
97
- cwd,
98
- env: strippedEnv(home),
99
- encoding: "utf8",
100
- stdio: ["ignore", "pipe", "pipe"],
101
- timeout: 30_000,
102
- });
103
- }
104
- catch (err) {
105
- const e = err;
106
- raw = `${e.stdout ?? ""}${e.stderr ?? ""}`;
107
- }
108
- const clean = raw.replace(ANSI_RE, "");
109
- try {
110
- const parsed = JSON.parse(clean);
111
- if (parsed && typeof parsed === "object" && "result" in parsed) {
112
- return { kind: "ALLOWED", raw: clean };
113
- }
114
- }
115
- catch {
116
- // Not JSON — fall through to the DENIED-shape check below.
117
- }
118
- if (clean.includes("Unexpected error") && /specified a rule which prevents/.test(clean)) {
119
- return { kind: "DENIED", raw: clean };
120
- }
121
- throw new Error(`Unrecognized oracle output shape for agent=${agent} candidate=${label}:\n${clean}`);
122
- }
123
- function mkTmp(prefix) {
124
- return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
125
- }
126
- function writeProjectConfig(dir, config) {
127
- fs.writeFileSync(path.join(dir, "opencode.json"), JSON.stringify(config, null, 2));
128
- }
129
- // buildDenyByDefaultFixtureConfig runs the REAL installPermissions() against
130
- // a scratch config-dir to get our actual rendered permission.bash/read
131
- // block (never a hand-written stand-in set), then — as a COPY, never
132
- // mutating the installer's own on-disk output — adds a "*": "deny" default
133
- // key so the fixture has a clean binary ALLOW/DENY signal to test our
134
- // allow entries against. A fresh installPermissions() output has no "*" key
135
- // at all (preserveBareScalarAsDefault returns {} for an absent prior value),
136
- // which the assertion below confirms is still true before we add our own.
137
- async function buildDenyByDefaultFixtureConfig() {
138
- const installDir = mkTmp("oc-fixture-install-");
139
- await installPermissions(installDir);
140
- const rendered = JSON.parse(fs.readFileSync(path.join(installDir, "opencode.json"), "utf8"));
141
- assert.ok(!("*" in rendered.permission.bash), "sanity check failed: a fresh installPermissions() output already sets a bash default key — " +
142
- "buildDenyByDefaultFixtureConfig's premise (installer never sets one) is stale, re-check installer.ts");
143
- const fixture = JSON.parse(JSON.stringify(rendered)); // deep copy; installDir's own file is left untouched
144
- fixture.permission.bash = { "*": "deny", ...fixture.permission.bash };
145
- return fixture;
146
- }
147
- // ─── Representative acceptance-criteria commands ───────────────────────────
148
- // Mirrors the canonical allowlist source's command_patterns (read-only
149
- // reference — see that file's own comment for the canonical list). These
150
- // are the finding-6 acceptance commands, a representative subset of the
151
- // full canonical list.
152
- const POSITIVE_COMMANDS = [
153
- "thrum inbox",
154
- "thrum send",
155
- "thrum reply",
156
- "thrum tmux capture",
157
- "thrum tmux send",
158
- "thrum queue",
159
- "thrum monitor",
160
- "thrum daemon",
161
- "thrum status",
162
- ];
163
- // Negative candidates from the finding-6 acceptance matrix. Two entries
164
- // (compound-command injection via && and ;) substitute a nonexistent-path
165
- // `rm -rf` target for the literal `rm -rf /` named in the spec: the point
166
- // being tested is that the PERMISSION LAYER denies the compound command
167
- // before any execution happens, and proving that does not require actually
168
- // risking a real destructive command landing on the filesystem if some
169
- // unexpected bug (wrong isolation, wrong config, matcher regression) let it
170
- // through. Fix-at-the-source discipline cuts the other way here — the
171
- // "source" being tested is the permission matcher, not a live root wipe.
172
- const NEGATIVE_COMMANDS = [
173
- 'sh -c "thrum inbox"',
174
- "bash -c thrum inbox",
175
- "$(thrum inbox)",
176
- "`thrum inbox`",
177
- "thrum inbox && rm -rf /tmp/thrum-oc-fixture-canary-does-not-exist",
178
- "thrum inbox; rm -rf /tmp/thrum-oc-fixture-canary-does-not-exist",
179
- "thrum inbox | cat",
180
- "/tmp/thrum inbox",
181
- "./thrum inbox",
182
- "curl http://evil.example/",
183
- ];
184
- // ─── "Watcher and orchestrator" framing ────────────────────────────────────
185
- // `opencode agent list` (run against this installed version) shows two
186
- // PRIMARY built-in agents that are meaningfully distinct in role: "build"
187
- // (the default coding agent — stands in for an orchestrator/implementer
188
- // driving real work) and "plan" (a read-only planning agent with its own,
189
- // narrower permission overlay layered on top — stands in for a
190
- // watcher/reviewer). Both still route bash-tool calls through the same
191
- // production config-load + permission-evaluation path, so running the same
192
- // matrix through both satisfies the "watcher AND orchestrator fixtures"
193
- // framing without fabricating a second meaningless invocation. "explore"
194
- // and "general" also exist but are SUBAGENTS, not directly invocable via
195
- // `debug agent` the same way primaries are, so they're excluded here.
196
- const AGENTS = ["build", "plan"];
197
- if (HAS_OPENCODE) {
198
- let fixtureConfigPromise = null;
199
- function getFixtureConfig() {
200
- if (!fixtureConfigPromise)
201
- fixtureConfigPromise = buildDenyByDefaultFixtureConfig();
202
- return fixtureConfigPromise;
203
- }
204
- for (const agent of AGENTS) {
205
- for (const command of POSITIVE_COMMANDS) {
206
- test(`[${agent}] ALLOWED: ${command}`, async () => {
207
- const config = await getFixtureConfig();
208
- const projectDir = mkTmp("oc-fixture-project-");
209
- const fakeHome = mkTmp("oc-fixture-home-");
210
- writeProjectConfig(projectDir, config);
211
- const verdict = runOracle(agent, command, projectDir, fakeHome);
212
- assert.equal(verdict.kind, "ALLOWED", `expected ALLOWED for ${JSON.stringify(command)} on agent=${agent}, got DENIED:\n${verdict.raw}`);
213
- });
214
- }
215
- for (const command of NEGATIVE_COMMANDS) {
216
- test(`[${agent}] DENIED: ${command}`, async () => {
217
- const config = await getFixtureConfig();
218
- const projectDir = mkTmp("oc-fixture-project-");
219
- const fakeHome = mkTmp("oc-fixture-home-");
220
- writeProjectConfig(projectDir, config);
221
- const verdict = runOracle(agent, command, projectDir, fakeHome);
222
- assert.equal(verdict.kind, "DENIED", `expected DENIED for ${JSON.stringify(command)} on agent=${agent}, got ALLOWED:\n${verdict.raw}`);
223
- });
224
- }
225
- }
226
- // ─── Blanket-deny preservation (finding 3), proven behaviorally ──────────
227
- // Builds a "before" config simulating a user's pre-existing stricter
228
- // bare-scalar posture (permission.bash: "deny"), runs it through the REAL
229
- // installPermissions() merge path (preserveBareScalarAsDefault), then
230
- // invokes the REAL oracle against the MERGED result — proving the user's
231
- // original deny-everything posture survives as the "*" default rather
232
- // than falling through to OpenCode's own built-in default (which is
233
- // "ask", not "deny" — see the object-map catch-all discussion in
234
- // installer.ts).
235
- test("blanket-deny preservation survives the real merge + real oracle", async () => {
236
- const installDir = mkTmp("oc-fixture-blanket-install-");
237
- fs.writeFileSync(path.join(installDir, "opencode.json"), JSON.stringify({ permission: { bash: "deny" } }));
238
- await installPermissions(installDir);
239
- const merged = JSON.parse(fs.readFileSync(path.join(installDir, "opencode.json"), "utf8"));
240
- assert.equal(merged.permission.bash["*"], "deny", "preserveBareScalarAsDefault must keep the user's pre-existing bare-scalar 'deny' as the merged '*' default");
241
- const projectDir = mkTmp("oc-fixture-blanket-project-");
242
- const fakeHome = mkTmp("oc-fixture-blanket-home-");
243
- writeProjectConfig(projectDir, merged);
244
- const positive = runOracle("build", "thrum inbox", projectDir, fakeHome);
245
- assert.equal(positive.kind, "ALLOWED", `expected a thrum overlay command to remain ALLOWED after merging onto a bare-scalar deny, got DENIED:\n${positive.raw}`);
246
- const unrelated = runOracle("build", "curl http://evil.example/", projectDir, fakeHome);
247
- assert.equal(unrelated.kind, "DENIED", `expected an unrelated command to stay DENIED (preserving the user's original blanket-deny posture), got ALLOWED:\n${unrelated.raw}`);
248
- });
249
- // ─── owner_authorized_exceptions.opencode — /private/tmp, by-effect
250
- // (allowlist round 4; Pass-3 B2 made the owner root conditional) ────────
251
- //
252
- // This is the strongest available proof on this machine: a real subprocess
253
- // call to the real installed `opencode` binary against a real config
254
- // rendered by our own installPermissions(). Unlike Claude/Codex (which
255
- // have no equivalent invocable production-path oracle for this repo — a
256
- // fact stated here plainly, not as a claim that the Claude/Codex scoping
257
- // is any less correct; this is only a statement about which runtime this
258
- // machine can INVOKE, not a re-litigation of the other runtimes' own
259
- // scoping), OpenCode's `debug agent --tool <id>` route lets us prove
260
- // read-ALLOWED / write-DENIED / sibling-DENIED behaviorally rather than by
261
- // pattern-shape inference.
262
- //
263
- // PASS-3 B2 — the owner root is PROBED, not assumed. The grant target is
264
- // the owner-authorized literal /private/tmp (a macOS path; it does not
265
- // exist on Linux CI and a world-writable root must never be created by a
266
- // test just to satisfy a fixture). The by-effect proof below therefore
267
- // runs ONLY when the owner root actually exists; on a machine without it
268
- // the test emits a named SKIP (never a silent pass) after still asserting
269
- // everything that needs no filesystem: the exact rendered grant key and
270
- // the absence of over-broad forms. Every other scratch root in this file
271
- // (project dirs, fake homes) was already portable os.tmpdir(); the
272
- // grant-target scratch under /private/tmp is deliberately NOT made
273
- // portable, because a scratch dir elsewhere would silently prove nothing
274
- // about the /private/tmp grant — weakening the real positive proof is
275
- // exactly what B2 forbids.
276
- //
277
- // WHY THIS TEST USES THE "read" TOOL, NOT bash "cat" — a real, load-
278
- // bearing finding from running this fixture. `debug agent ... --tool bash
279
- // --params '{"command":"cat <path>"}'` is gated ENTIRELY by
280
- // permission.bash matching the literal command STRING — it never
281
- // consults permission.read at all, since bash execution and file-read are
282
- // two independent OpenCode permission axes. Testing our permission.read
283
- // grant with a bash "cat" command would only prove "cat is not a thrum
284
- // bash pattern" (true regardless of the read grant, i.e. the WRONG axis —
285
- // see the "control that validates the wrong axis" lesson: a passing
286
- // assertion here would be decoration, not proof). The real production
287
- // consumer of permission.read is OpenCode's own dedicated "read" tool
288
- // (the same route an agent's file-reading tool call takes), so that is
289
- // the tool this fixture drives.
290
- //
291
- // ALSO LOAD-BEARING — the LEADING-SLASH FINDING: this fixture is what
292
- // surfaced (allowlist round 4) that OpenCode's real matcher strips the
293
- // leading "/" from an absolute candidate path before comparing against
294
- // configured read patterns. A pattern that keeps the leading slash
295
- // (e.g. "/private/tmp/**") NEVER matched in the real oracle, regardless of
296
- // key order or "*" default — see readPatternFor's doc comment in
297
- // installer.ts for the full measurement. This test exercises the fixed,
298
- // no-leading-slash pattern the installer now renders.
299
- //
300
- // permission.read only governs the "read" tool. Write/delete are governed
301
- // by a SEPARATE OpenCode permission axis this task does not touch — this
302
- // fixture proves that axis stays independently deny-by-default: a bash
303
- // write/delete command into the very directory we just granted read
304
- // access to is still DENIED, but (per the axis-independence finding
305
- // above) that denial comes from permission.bash's own "*": "deny" default
306
- // (buildDenyByDefaultFixtureConfig's flip), not from anything specific to
307
- // the read grant — stated honestly so this isn't read as proof of a
308
- // write-specific denial rule that doesn't exist here.
309
- const OWNER_ROOT = "/private/tmp";
310
- function ownerRootAvailable() {
311
- try {
312
- return fs.statSync(OWNER_ROOT).isDirectory();
313
- }
314
- catch {
315
- return false;
316
- }
317
- }
318
- test("[private-tmp] real oracle: read tool is ALLOWED under the grant, DENIED on sibling roots; bash write/delete stays DENIED", async (t) => {
319
- const config = await getFixtureConfig();
320
- // A separate, read-tool-specific fixture: same real installPermissions()
321
- // output, but with permission.read ALSO flipped to deny-by-default (the
322
- // installer never sets a "*" key for read on its own — mirrors the
323
- // sanity assertion buildDenyByDefaultFixtureConfig already makes for
324
- // bash), so the "read" tool oracle gets the same clean binary signal the
325
- // bash tests already get.
326
- assert.ok(!("*" in config.permission.read), "sanity: fresh installPermissions() sets no read default either");
327
- const readFixture = JSON.parse(JSON.stringify(config));
328
- readFixture.permission.read = { "*": "deny", ...readFixture.permission.read };
329
- // ── Rendered-grant scoping, machine-independent — ALWAYS asserted ────
330
- // Confirm the actual generated key in opencode.json is exactly
331
- // "private/tmp/**" — never a widened form like "private/**" or
332
- // "private/*" (which would also incidentally pass the ALLOW/DENY checks
333
- // below by accident of the specific paths chosen). These assertions need
334
- // no filesystem, so they run (and fail loudly) on every machine.
335
- const renderedRead = readFixture.permission.read;
336
- assert.equal(renderedRead["private/tmp/**"], "allow", "expected the exact rendered key 'private/tmp/**' to be present with action 'allow'");
337
- assert.equal(renderedRead["private/**"], undefined, "must NOT render an over-broad 'private/**' key");
338
- assert.equal(renderedRead["private/*"], undefined, "must NOT render an over-broad 'private/*' key");
339
- if (!ownerRootAvailable()) {
340
- t.skip("owner-authorized root /private/tmp does not exist on this machine — the by-effect proof is skipped (named, not silent); rendered-grant scoping was still asserted above");
341
- return;
342
- }
343
- const projectDir = mkTmp("oc-fixture-privtmp-project-");
344
- const fakeHome = mkTmp("oc-fixture-privtmp-home-");
345
- writeProjectConfig(projectDir, readFixture);
346
- // Real scratch file under a real /private/tmp subdirectory.
347
- const scratchDir = fs.mkdtempSync(path.join(OWNER_ROOT, "thrum-oc-privtmp-fixture-"));
348
- const scratchFile = path.join(scratchDir, "canary.txt");
349
- fs.writeFileSync(scratchFile, "allowlist private-tmp fixture canary\n");
350
- try {
351
- const readVerdict = runOracle("build", { tool: "read", params: { filePath: scratchFile } }, projectDir, fakeHome);
352
- assert.equal(readVerdict.kind, "ALLOWED", `expected ALLOWED reading ${scratchFile} via the read tool, got DENIED:\n${readVerdict.raw}`);
353
- // /private/etc/hosts: a real, existing, world-readable file that is
354
- // genuinely NOT under /private/tmp — the strongest form of this
355
- // negative control, since it rules out any ambiguity between a
356
- // permission denial and a file-not-found error.
357
- const otherRootVerdict = runOracle("build", { tool: "read", params: { filePath: "/private/etc/hosts" } }, projectDir, fakeHome);
358
- assert.equal(otherRootVerdict.kind, "DENIED", `expected DENIED reading an unrelated /private path via the read tool, got ALLOWED:\n${otherRootVerdict.raw}`);
359
- // /private/tmpfoo/x: an adjacent-name sibling root (never created —
360
- // confirmed above that OpenCode's permission check runs BEFORE any
361
- // file-existence check, so a nonexistent path under a denied pattern
362
- // still comes back as a genuine permission DENIED rather than a
363
- // "file not found" error; this is the exact acceptance-bar candidate
364
- // named in the task spec).
365
- const tmpfooSiblingVerdict = runOracle("build", { tool: "read", params: { filePath: "/private/tmpfoo/x" } }, projectDir, fakeHome);
366
- assert.equal(tmpfooSiblingVerdict.kind, "DENIED", `expected DENIED reading a sibling-root path outside /private/tmp via the read tool, got ALLOWED:\n${tmpfooSiblingVerdict.raw}`);
367
- // Write/delete: driven through the bash tool (the axis those
368
- // operations actually go through in this repo's Bash-tool-shaped
369
- // agent surface), against the ORIGINAL bash-deny-by-default fixture —
370
- // see the comment above the test for why this denial is attributable
371
- // to permission.bash's own default, not the read grant.
372
- const writeVerdict = runOracle("build", `echo x > ${scratchFile}`, projectDir, fakeHome);
373
- assert.equal(writeVerdict.kind, "DENIED", `expected DENIED writing to ${scratchFile} via bash, got ALLOWED:\n${writeVerdict.raw}`);
374
- const deleteVerdict = runOracle("build", `rm ${scratchFile}`, projectDir, fakeHome);
375
- assert.equal(deleteVerdict.kind, "DENIED", `expected DENIED deleting ${scratchFile} via bash, got ALLOWED:\n${deleteVerdict.raw}`);
376
- // ── Coordinator-prescribed bash-oracle negative controls ────────────
377
- // A reviewer asked for a `cat <path>` probe issued through the bash
378
- // tool specifically, for the parent (/private) and a sibling root
379
- // (/private/tmpfoo), alongside the write/delete checks above. Adding
380
- // them as requested: they DO come back DENIED, but — stated plainly,
381
- // per the measurement above — that denial is coming from
382
- // permission.bash's own "*": "deny" default (no bash pattern admits
383
- // "cat" at all), NOT from anything path-specific to /private vs
384
- // /private/tmp. A bash "cat" of the GRANTED path itself would be
385
- // denied for the identical, unrelated reason — proven directly above
386
- // (bash:"*":"allow" + read:"*":"deny" still lets `cat` succeed, so
387
- // permission.read has no bearing on bash execution either way). These
388
- // two assertions are therefore correct but NOT informative about the
389
- // read grant; they are included for completeness against the exact
390
- // form requested, with the caveat on record so a future reader does
391
- // not mistake "DENIED via bash cat" for evidence the read grant is
392
- // scoped correctly — the read-tool assertions above are what actually
393
- // prove that.
394
- const bashCatParentVerdict = runOracle("build", "cat /private", projectDir, fakeHome);
395
- assert.equal(bashCatParentVerdict.kind, "DENIED", `expected DENIED for bash cat of the parent /private, got ALLOWED:\n${bashCatParentVerdict.raw}`);
396
- const bashCatSiblingVerdict = runOracle("build", "cat /private/tmpfoo/x", projectDir, fakeHome);
397
- assert.equal(bashCatSiblingVerdict.kind, "DENIED", `expected DENIED for bash cat of a sibling root /private/tmpfoo, got ALLOWED:\n${bashCatSiblingVerdict.raw}`);
398
- }
399
- finally {
400
- fs.rmSync(scratchDir, { recursive: true, force: false });
401
- }
402
- });
403
- // Round-4 review finding (cq, by-effect-confirmed): opencodeGlobalReadPaths's
404
- // THIRD entry — the global config file itself, ${configDir}/opencode.json —
405
- // was still rendered with its leading slash kept (readFilePatternFor was
406
- // added for the private-tmp fix above but the file entry wasn't routed
407
- // through it), so a real read of the granted global config file came back
408
- // DENIED even though the sibling skills/** grant worked. This is a
409
- // by-effect regression test analogous to the private-tmp fixture above,
410
- // proving the fix: the REAL global opencode.json file installPermissions()
411
- // itself wrote is readable via the real "read" tool oracle.
412
- test("[global-config-file] real oracle: read tool is ALLOWED for the granted ${configDir}/opencode.json", async () => {
413
- // installPermissions(installDir) both renders the grant AND writes the
414
- // real file the grant is supposed to cover — installDir plays the role
415
- // of configDir here, so the file at path.join(installDir, "opencode.json")
416
- // is the exact real artifact this grant targets, not a synthetic stand-in.
417
- const installDir = mkTmp("oc-fixture-globalconfig-install-");
418
- await installPermissions(installDir);
419
- const rendered = JSON.parse(fs.readFileSync(path.join(installDir, "opencode.json"), "utf8"));
420
- assert.ok(!("*" in rendered.permission.read), "sanity: fresh installPermissions() sets no read default either");
421
- const readFixture = JSON.parse(JSON.stringify(rendered));
422
- readFixture.permission.read = { "*": "deny", ...readFixture.permission.read };
423
- const projectDir = mkTmp("oc-fixture-globalconfig-project-");
424
- const fakeHome = mkTmp("oc-fixture-globalconfig-home-");
425
- writeProjectConfig(projectDir, readFixture);
426
- const globalConfigFile = path.join(installDir, "opencode.json");
427
- const verdict = runOracle("build", { tool: "read", params: { filePath: globalConfigFile } }, projectDir, fakeHome);
428
- assert.equal(verdict.kind, "ALLOWED", `expected ALLOWED reading the granted global config file ${globalConfigFile} via the read tool, got DENIED:\n${verdict.raw}`);
429
- // Confirm the rendered key is the stripped, no-leading-slash form —
430
- // the exact thing that was missing before this fix.
431
- const strippedKey = globalConfigFile.replace(/^\//, "");
432
- assert.equal(readFixture.permission.read[strippedKey], "allow", `expected the exact stripped key ${JSON.stringify(strippedKey)} to be present with action 'allow'`);
433
- assert.equal(readFixture.permission.read[globalConfigFile], undefined, "must NOT also render the unstripped (leading-slash) form");
434
- });
435
- // ─── global_read_paths.opencode — ~/.local/bin, by-effect (allowlist
436
- // consolidated round, Part B) ─────────────────────────────────────────────
437
- //
438
- // Mirrors the [private-tmp] fixture above, with one structural difference:
439
- // the grant target here is HOME-RELATIVE ("~/.local/bin"), not a fixed
440
- // absolute path, so the fixture must isolate HOME the same way runOracle's
441
- // child-process invocations already do — installPermissions() is called
442
- // with an explicit homeDir override pointing at a fresh scratch directory
443
- // (NOT the real machine's actual ~/.local/bin), and the real scratch file
444
- // is written under that SAME fake home's .local/bin subdirectory so the
445
- // rendered grant and the real file agree on what "~/.local/bin" resolved
446
- // to. This never touches the real machine's ~/.local/bin.
447
- test("[global-read-paths-local-bin] real oracle: read tool is ALLOWED under ~/.local/bin, DENIED on siblings", async () => {
448
- const fakeHome = mkTmp("oc-fixture-localbin-home-");
449
- const installDir = mkTmp("oc-fixture-localbin-install-");
450
- await installPermissions(installDir, fakeHome);
451
- const rendered = JSON.parse(fs.readFileSync(path.join(installDir, "opencode.json"), "utf8"));
452
- assert.ok(!("*" in rendered.permission.read), "sanity: fresh installPermissions() sets no read default either");
453
- const readFixture = JSON.parse(JSON.stringify(rendered));
454
- readFixture.permission.read = { "*": "deny", ...readFixture.permission.read };
455
- const projectDir = mkTmp("oc-fixture-localbin-project-");
456
- writeProjectConfig(projectDir, readFixture);
457
- // Real scratch file under the FAKE home's .local/bin — never the real
458
- // machine's ~/.local/bin.
459
- const localBinDir = path.join(fakeHome, ".local", "bin");
460
- fs.mkdirSync(localBinDir, { recursive: true });
461
- const scratchFile = path.join(localBinDir, "thrum");
462
- fs.writeFileSync(scratchFile, "#!/bin/sh\n# allowlist local-bin fixture canary\n");
463
- const strippedKey = localBinDir.replace(/^\//, "") + "/**";
464
- assert.equal(readFixture.permission.read[strippedKey], "allow", `expected the exact rendered key ${JSON.stringify(strippedKey)} to be present with action 'allow'`);
465
- const readVerdict = runOracle("build", { tool: "read", params: { filePath: scratchFile } }, projectDir, fakeHome);
466
- assert.equal(readVerdict.kind, "ALLOWED", `expected ALLOWED reading ${scratchFile} via the read tool, got DENIED:\n${readVerdict.raw}`);
467
- // Sibling: ~/.local/binx/x — an adjacent-name sibling root, never created.
468
- const siblingPath = path.join(fakeHome, ".local", "binx", "x");
469
- const siblingVerdict = runOracle("build", { tool: "read", params: { filePath: siblingPath } }, projectDir, fakeHome);
470
- assert.equal(siblingVerdict.kind, "DENIED", `expected DENIED reading a sibling-root path ${siblingPath} via the read tool, got ALLOWED:\n${siblingVerdict.raw}`);
471
- // Parent: ~/.local — genuinely a real, existing directory under fakeHome
472
- // (created above via mkdirSync of localBinDir), so this rules out any
473
- // ambiguity with a file-not-found error.
474
- const parentPath = path.join(fakeHome, ".local");
475
- const parentVerdict = runOracle("build", { tool: "read", params: { filePath: parentPath } }, projectDir, fakeHome);
476
- assert.equal(parentVerdict.kind, "DENIED", `expected DENIED reading the parent path ${parentPath} via the read tool, got ALLOWED:\n${parentVerdict.raw}`);
477
- });
478
- // ─── No-network-dependency confirmation ──────────────────────────────────
479
- // Every runOracle() call above already strips every provider-credential-
480
- // shaped env var (ANTHROPIC_*, OPENAI_*, *_API_KEY, *_TOKEN) before
481
- // invoking the child process — see strippedEnv(). This test makes that
482
- // fact an explicit, named assertion: a plain tool-permission check
483
- // succeeds with a clean ALLOW/DENY verdict with no such credential
484
- // present anywhere in the child's environment, confirming `debug agent
485
- // --tool` does not need a model/API credential for this route.
486
- test("tool-permission oracle needs no LLM/API credential", async () => {
487
- const config = await getFixtureConfig();
488
- const projectDir = mkTmp("oc-fixture-nocred-project-");
489
- const fakeHome = mkTmp("oc-fixture-nocred-home-");
490
- writeProjectConfig(projectDir, config);
491
- const env = strippedEnv(fakeHome);
492
- const hasProviderKey = Object.keys(env).some((k) => /API_KEY|ANTHROPIC|OPENAI|_TOKEN$/i.test(k));
493
- assert.equal(hasProviderKey, false, "strippedEnv must remove every provider-credential-shaped var");
494
- const verdict = runOracle("build", "thrum inbox", projectDir, fakeHome);
495
- assert.equal(verdict.kind, "ALLOWED", `expected ALLOWED with no provider credential present:\n${verdict.raw}`);
496
- });
497
- }
498
- else {
499
- test("permission-fixture (skipped: opencode binary not on PATH)", () => {
500
- // Intentionally a no-op assertion so this file always contributes at
501
- // least one passing/visible test result even when the binary this
502
- // whole file depends on is absent — see the console.log above for the
503
- // human-readable explanation.
504
- assert.ok(true);
505
- });
506
- }