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