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.
- package/assets/commands/thrum-compact-extended.md +14 -12
- package/assets/commands/thrum-compact.md +17 -15
- package/assets/commands/thrum-prime.md +1 -0
- package/assets/skills/configure-roles/SKILL.md +0 -1
- package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +15 -6
- package/assets/skills/coordinator-maintaining-memory/SKILL.md +2 -0
- package/assets/skills/coordinator-merging-code/SKILL.md +25 -10
- package/assets/skills/coordinator-merging-code/resources/overbuild-check-prompt.md +60 -0
- package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +5 -4
- package/assets/skills/implementer-maintaining-memory/SKILL.md +2 -0
- package/assets/skills/memory-curate/SKILL.md +2 -0
- package/assets/skills/memory-current-rule/SKILL.md +121 -0
- package/assets/skills/memory-maintenance/SKILL.md +2 -0
- package/assets/skills/memory-write-discipline/SKILL.md +2 -0
- package/assets/skills/persistent-watcher-archetype/SKILL.md +142 -60
- package/assets/skills/project-hotpath-gate/SKILL.md +8 -0
- package/assets/skills/researcher-maintaining-memory/SKILL.md +2 -0
- package/assets/skills/thrum/resources/CLI_REFERENCE.md +14 -6
- package/assets/skills/thrum/resources/TMUX_SESSIONS.md +16 -8
- package/assets/skills/thrum-memory/SKILL.md +2 -0
- package/assets/skills/using-the-queue/SKILL.md +6 -7
- package/assets/skills/using-thrum-state/SKILL.md +2 -0
- package/assets/skills/using-tmux-ssh/resources/tmux-ssh +23 -2
- package/assets/skills/watcher-archetype/SKILL.md +13 -5
- package/dist/installer-assets.test.js +5 -2
- package/dist/installer.d.ts +5 -1
- package/dist/installer.js +38 -7
- package/dist/installer.test.js +53 -0
- package/package.json +4 -1
- package/dist/permission-fixture.integration.test.js +0 -506
- /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
|
-
}
|
|
File without changes
|