agent-dealer 1.1.1 → 1.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.
@@ -0,0 +1,488 @@
1
+ // NOT-180: per-attempt native Muse Code configuration.
2
+ //
3
+ // Materializes everything one `muse exec` attempt may see — settings.json, XDG dirs, auth link,
4
+ // argv, env — outside the worktree, and refuses (before anything is written or spawned) when a
5
+ // required restriction is not one NOT-177 proved Muse can enforce. Only controls recorded in
6
+ // docs/evaluations/muse-code/headless-contract.md are emitted; nothing here is assumed.
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ export const NOT_177_EVIDENCE = Object.freeze({
11
+ mcp_tool_allowlist_enforcement: false,
12
+ cron_tool_disable: false,
13
+ });
14
+ export const MUSE_MODEL = "muse-spark-1.3-contributor";
15
+ export const AGENT_DECK_SERVER = "agent-deck";
16
+ /** Deck reads a worker needs. `call_service_tool` (outbound mutation) is never in this list. */
17
+ export const AGENT_DECK_READ_TOOLS = [
18
+ "get_bound_deck",
19
+ "get_playbook",
20
+ "list_service_tools",
21
+ "bind_workspace",
22
+ ];
23
+ export const AGENT_DECK_DENIED_TOOLS = ["call_service_tool"];
24
+ const CRON_TOOLS = ["cron_create", "cron_list", "cron_delete"];
25
+ const REMINDERS = [
26
+ "skill-reminder",
27
+ "verify-reminder",
28
+ "memory-reminder",
29
+ "todo-reminder",
30
+ "goal-reminder",
31
+ "scope-reminder",
32
+ ];
33
+ /** Flags that widen trust, sandbox, approvals or orchestration. Never emitted, refused if seen. */
34
+ const FORBIDDEN_FLAGS = [
35
+ "--yolo",
36
+ "--disable-sandbox",
37
+ "--disable-approval",
38
+ "--trust-workspace",
39
+ "--worktree",
40
+ "-w",
41
+ "--subagent-worktree-isolation",
42
+ "--preset",
43
+ "--agents",
44
+ "--permission-profile",
45
+ "--no-session-log",
46
+ ];
47
+ /** Which ambient env vars reach Muse. Everything else (META_API_KEY, MUSE_*, CODEX_HOME, ...) is dropped. */
48
+ const ENV_ALLOWLIST = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM", "TMPDIR"];
49
+ const ATTEMPT_PREFIX = "muse-attempt-";
50
+ const SENTINEL = ".dealer-muse-attempt";
51
+ /** Messages name fields and paths, never values that could be credentials. */
52
+ export class MuseIsolationError extends Error {
53
+ code;
54
+ capabilities;
55
+ constructor(code, message, capabilities = []) {
56
+ super(message);
57
+ this.code = code;
58
+ this.capabilities = capabilities;
59
+ this.name = "MuseIsolationError";
60
+ }
61
+ }
62
+ /** Capabilities a role needs that the evidence does not prove. Both roles need both today. */
63
+ export function unenforceableRestrictions(_role, evidence = NOT_177_EVIDENCE) {
64
+ const missing = [];
65
+ // Outbound mutation (`call_service_tool`) must stay denied for every role, and Muse only
66
+ // stores enabled_tools/disabled_tools unless this is proven.
67
+ if (!evidence.mcp_tool_allowlist_enforcement)
68
+ missing.push("mcp_tool_allowlist_enforcement");
69
+ // cron jobs are background orchestration that fired a second agent run inside the process.
70
+ if (!evidence.cron_tool_disable)
71
+ missing.push("cron_tool_disable");
72
+ return missing;
73
+ }
74
+ export function buildMuseSettings(agentDeck, evidence = NOT_177_EVIDENCE) {
75
+ const runtime_capabilities = {};
76
+ for (const name of REMINDERS) {
77
+ runtime_capabilities[`plugin:tbh-reminders:reminder:${name}`] = { enabled: false };
78
+ }
79
+ if (evidence.cron_tool_disable) {
80
+ for (const tool of CRON_TOOLS)
81
+ runtime_capabilities[`tool:${tool}`] = { enabled: false };
82
+ }
83
+ const server = {
84
+ type: "streamable-http",
85
+ url: agentDeck.url,
86
+ headers: {
87
+ "x-agent-deck-deck-id": agentDeck.deckId,
88
+ "x-agent-deck-workspace": agentDeck.workspace,
89
+ },
90
+ // `mode`, not `required`: writing both drops the whole MCP block. Never "optional".
91
+ mode: "required",
92
+ };
93
+ if (evidence.mcp_tool_allowlist_enforcement) {
94
+ server.enabled_tools = [...AGENT_DECK_READ_TOOLS];
95
+ server.disabled_tools = [...AGENT_DECK_DENIED_TOOLS];
96
+ }
97
+ return {
98
+ schema_version: 1,
99
+ run: { workflow_trigger_mode: "off", subagent_delegation_mode: "off" },
100
+ runtime_capabilities,
101
+ mcpServers: { [AGENT_DECK_SERVER]: server },
102
+ };
103
+ }
104
+ /** Whitelist check on a settings object (generated or re-read from disk). */
105
+ export function assertMuseSettings(settings, agentDeck, evidence = NOT_177_EVIDENCE) {
106
+ const expected = buildMuseSettings(agentDeck, evidence);
107
+ if (JSON.stringify(sortKeys(settings)) !== JSON.stringify(sortKeys(expected))) {
108
+ throw new MuseIsolationError("invalid_settings", "Muse settings differ from the approved role settings (extra, missing, or altered keys)");
109
+ }
110
+ const servers = Object.keys(settings.mcpServers ?? {});
111
+ if (servers.length !== 1 || servers[0] !== AGENT_DECK_SERVER) {
112
+ throw new MuseIsolationError("invalid_settings", "Muse settings must configure exactly the agent-deck MCP server");
113
+ }
114
+ }
115
+ function sortKeys(value) {
116
+ if (Array.isArray(value))
117
+ return value.map(sortKeys);
118
+ if (value && typeof value === "object") {
119
+ return Object.fromEntries(Object.entries(value)
120
+ .sort(([a], [b]) => a.localeCompare(b))
121
+ .map(([k, v]) => [k, sortKeys(v)]));
122
+ }
123
+ return value;
124
+ }
125
+ export function buildMuseArgv(input) {
126
+ const argv = [
127
+ "exec",
128
+ ...(input.apiKeyStdin ? ["--api-key-stdin"] : []),
129
+ "--json",
130
+ "--no-foreign-personal-context",
131
+ "--model",
132
+ MUSE_MODEL,
133
+ "--approval-mode",
134
+ "never",
135
+ "--approval-judge",
136
+ "off",
137
+ "--sandbox-network",
138
+ "restricted",
139
+ "--disable-web-tools",
140
+ "--session-id",
141
+ input.sessionId,
142
+ "--max-model-steps",
143
+ String(input.maxModelSteps),
144
+ ];
145
+ if (input.role === "reviewer")
146
+ argv.push("--disable-write", "--disable-shell");
147
+ argv.push(input.prompt);
148
+ assertMuseArgv(argv, input.role);
149
+ return argv;
150
+ }
151
+ /** Refuses argv that widens trust/sandbox/orchestration or drops a required restriction. */
152
+ export function assertMuseArgv(argv, role) {
153
+ // The prompt is the last positional; flags inside it would be parsed by Muse.
154
+ const flags = argv.slice(0, -1);
155
+ for (const arg of flags) {
156
+ const name = arg.split("=")[0];
157
+ if (FORBIDDEN_FLAGS.includes(name)) {
158
+ throw new MuseIsolationError("invalid_argv", `Muse argv contains forbidden flag ${name}`);
159
+ }
160
+ }
161
+ if (argv[0] !== "exec")
162
+ throw new MuseIsolationError("invalid_argv", "Muse argv must start with exec");
163
+ const requireValue = (flag, value) => {
164
+ const i = flags.indexOf(flag);
165
+ if (i < 0 || flags[i + 1] !== value) {
166
+ throw new MuseIsolationError("invalid_argv", `Muse argv must set ${flag} ${value}`);
167
+ }
168
+ };
169
+ requireValue("--approval-mode", "never");
170
+ requireValue("--sandbox-network", "restricted");
171
+ requireValue("--model", MUSE_MODEL);
172
+ if (!flags.includes("--json"))
173
+ throw new MuseIsolationError("invalid_argv", "Muse argv must set --json");
174
+ if (!flags.includes("--no-foreign-personal-context")) {
175
+ throw new MuseIsolationError("invalid_argv", "Muse argv must set --no-foreign-personal-context");
176
+ }
177
+ const readOnly = flags.includes("--disable-write") && flags.includes("--disable-shell");
178
+ if (role === "reviewer" && !readOnly) {
179
+ throw new MuseIsolationError("invalid_argv", "Reviewer argv must set --disable-write and --disable-shell");
180
+ }
181
+ if (role === "developer" && (flags.includes("--disable-write") || flags.includes("--disable-shell"))) {
182
+ throw new MuseIsolationError("invalid_argv", "Developer argv must not disable write or shell");
183
+ }
184
+ }
185
+ /** Env for the child: filtered ambient vars plus the per-attempt XDG dirs. No credentials. */
186
+ export function buildMuseEnv(root, ambient = process.env) {
187
+ const env = {};
188
+ for (const key of ENV_ALLOWLIST) {
189
+ const value = ambient[key];
190
+ if (value !== undefined)
191
+ env[key] = value;
192
+ }
193
+ for (const [key, value] of Object.entries(ambient)) {
194
+ if (key.startsWith("LC_") && value !== undefined)
195
+ env[key] = value;
196
+ }
197
+ env.MUSE_NO_AUTO_UPDATE = "1";
198
+ env.XDG_CONFIG_HOME = path.join(root, "config");
199
+ env.XDG_DATA_HOME = path.join(root, "data");
200
+ return env;
201
+ }
202
+ /** `$XDG_CONFIG_HOME/muse/auth.json` (or `~/.config/muse/auth.json`) of the operator. Never read. */
203
+ export function defaultOperatorAuthPath(env = process.env) {
204
+ const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(env.HOME ?? os.homedir(), ".config");
205
+ return path.join(configHome, "muse", "auth.json");
206
+ }
207
+ function isInside(child, parent) {
208
+ const rel = path.relative(parent, child);
209
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
210
+ }
211
+ function realDir(p, label) {
212
+ if (!path.isAbsolute(p))
213
+ throw new MuseIsolationError("invalid_input", `${label} must be an absolute path`);
214
+ try {
215
+ if (!fs.statSync(p).isDirectory())
216
+ throw new Error("not a directory");
217
+ return fs.realpathSync(p);
218
+ }
219
+ catch {
220
+ throw new MuseIsolationError("invalid_input", `${label} must be an existing directory`);
221
+ }
222
+ }
223
+ function tempRoots() {
224
+ const roots = new Set(["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp", os.tmpdir()]);
225
+ for (const r of [...roots]) {
226
+ try {
227
+ roots.add(fs.realpathSync(r));
228
+ }
229
+ catch {
230
+ // Missing temp roots cannot host anything.
231
+ }
232
+ }
233
+ return [...roots];
234
+ }
235
+ function validateInput(input) {
236
+ if (input.role !== "developer" && input.role !== "reviewer") {
237
+ throw new MuseIsolationError("invalid_input", "role must be developer or reviewer");
238
+ }
239
+ const worktree = realDir(input.worktreePath, "worktreePath");
240
+ const baseDir = realDir(input.baseDir, "baseDir");
241
+ if (isInside(baseDir, worktree) || isInside(worktree, baseDir)) {
242
+ throw new MuseIsolationError("unsafe_path", "Per-attempt config must live outside the worktree");
243
+ }
244
+ // The Muse sandbox leaves temp dirs writable (probe 6), so neither the worktree nor the
245
+ // config can be protected from the worker there.
246
+ for (const tmp of tempRoots()) {
247
+ if (isInside(worktree, tmp) || isInside(baseDir, tmp)) {
248
+ throw new MuseIsolationError("unsafe_path", "Worktree and per-attempt config must not be under a temp dir");
249
+ }
250
+ }
251
+ const { url, deckId, workspace } = input.agentDeck ?? {};
252
+ try {
253
+ const parsed = new URL(url);
254
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
255
+ throw new Error("scheme");
256
+ if (parsed.username || parsed.password)
257
+ throw new Error("userinfo");
258
+ }
259
+ catch {
260
+ throw new MuseIsolationError("invalid_input", "agentDeck.url must be an http(s) URL without credentials");
261
+ }
262
+ if (!deckId?.trim() || !workspace?.trim()) {
263
+ throw new MuseIsolationError("invalid_input", "agentDeck.deckId and agentDeck.workspace are required");
264
+ }
265
+ let workspaceReal;
266
+ try {
267
+ workspaceReal = fs.realpathSync(workspace);
268
+ }
269
+ catch {
270
+ // Reported below.
271
+ }
272
+ if (workspaceReal !== worktree) {
273
+ throw new MuseIsolationError("invalid_input", "agentDeck.workspace must be the attempt's worktree");
274
+ }
275
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.sessionId ?? "")) {
276
+ throw new MuseIsolationError("invalid_input", "sessionId must be a UUID");
277
+ }
278
+ if (!Number.isInteger(input.maxModelSteps) || input.maxModelSteps < 1) {
279
+ throw new MuseIsolationError("invalid_input", "maxModelSteps must be a positive integer");
280
+ }
281
+ if (typeof input.prompt !== "string" || input.prompt.trim() === "" || input.prompt.startsWith("-")) {
282
+ throw new MuseIsolationError("invalid_input", "prompt must be non-empty and must not start with '-'");
283
+ }
284
+ const cred = input.credential;
285
+ if (cred?.kind === "auth-file") {
286
+ if (!path.isAbsolute(cred.path) || !fs.existsSync(cred.path) || !fs.statSync(cred.path).isFile()) {
287
+ throw new MuseIsolationError("invalid_input", "credential.path must be an existing auth file");
288
+ }
289
+ }
290
+ else if (cred?.kind === "api-key") {
291
+ if (typeof cred.apiKey !== "string" || cred.apiKey.trim() === "") {
292
+ throw new MuseIsolationError("invalid_input", "credential.apiKey must be non-empty");
293
+ }
294
+ }
295
+ else {
296
+ throw new MuseIsolationError("invalid_input", "credential is required");
297
+ }
298
+ return { worktree, baseDir };
299
+ }
300
+ export function createRedactor(secrets) {
301
+ const values = secrets.filter((s) => s.length > 0).sort((a, b) => b.length - a.length);
302
+ return (text) => {
303
+ let out = text;
304
+ for (const v of values)
305
+ out = out.split(v).join("[REDACTED]");
306
+ return out
307
+ .replace(/(META_API_KEY\s*=\s*)\S+/g, "$1[REDACTED]")
308
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1[REDACTED]");
309
+ };
310
+ }
311
+ function writePrivate(file, content) {
312
+ fs.writeFileSync(file, content, { mode: 0o600, flag: "wx" });
313
+ }
314
+ function deepFreeze(value) {
315
+ if (value && typeof value === "object") {
316
+ for (const v of Object.values(value))
317
+ deepFreeze(v);
318
+ Object.freeze(value);
319
+ }
320
+ return value;
321
+ }
322
+ /** Copies the fields verify() depends on; malformed shapes are left for validateInput to reject. */
323
+ function snapshotInput(raw) {
324
+ const deck = raw.agentDeck && typeof raw.agentDeck === "object" ? raw.agentDeck : undefined;
325
+ const cred = raw.credential && typeof raw.credential === "object" ? raw.credential : undefined;
326
+ return deepFreeze({
327
+ ...raw,
328
+ agentDeck: deck ? { url: deck.url, deckId: deck.deckId, workspace: deck.workspace } : raw.agentDeck,
329
+ credential: cred ? { ...cred } : raw.credential,
330
+ env: raw.env ? { ...raw.env } : undefined,
331
+ });
332
+ }
333
+ function sameJson(a, b) {
334
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
335
+ }
336
+ /**
337
+ * Validates, refuses unenforceable restrictions, then writes the per-attempt dir (0700) with
338
+ * settings.json (0600) and an auth link. Throws before spawn; leaves nothing behind on failure.
339
+ *
340
+ * Always uses the pinned `NOT_177_EVIDENCE`, under which both roles are currently refused. There is
341
+ * deliberately no runtime seam that accepts other evidence: the enforced-path tests load a
342
+ * source-rewritten copy of this file instead (see muse-config.test.ts).
343
+ */
344
+ export function prepareMuseAttempt(input) {
345
+ return prepareWithEvidence(input, NOT_177_EVIDENCE);
346
+ }
347
+ // Not exported: evidence must never be caller-supplied.
348
+ function prepareWithEvidence(rawInput, evidence) {
349
+ // Verification later runs against this snapshot, never against the caller's mutable object.
350
+ const input = snapshotInput(rawInput);
351
+ const missing = unenforceableRestrictions(input.role, evidence);
352
+ if (missing.length > 0) {
353
+ throw new MuseIsolationError("unenforceable_restriction", `Muse cannot mechanically enforce required restrictions for ${input.role}: ${missing.join(", ")}`, missing);
354
+ }
355
+ const { worktree, baseDir } = validateInput(input);
356
+ const agentDeck = input.agentDeck;
357
+ const credentialKind = input.credential.kind;
358
+ const settings = deepFreeze(buildMuseSettings(agentDeck, evidence));
359
+ assertMuseSettings(settings, agentDeck, evidence);
360
+ const apiKey = input.credential.kind === "api-key" ? input.credential.apiKey : undefined;
361
+ const argv = buildMuseArgv({
362
+ role: input.role,
363
+ sessionId: input.sessionId,
364
+ maxModelSteps: input.maxModelSteps,
365
+ prompt: input.prompt,
366
+ apiKeyStdin: apiKey !== undefined,
367
+ });
368
+ const root = fs.mkdtempSync(path.join(baseDir, ATTEMPT_PREFIX));
369
+ // Terminal only once the dir is confirmed gone, so a failed rmSync can be retried.
370
+ let cleaned = false;
371
+ const removeRoot = (requireSentinel) => {
372
+ if (cleaned)
373
+ return;
374
+ // Only a dir this module created (prefix) directly under baseDir is removed.
375
+ if (path.dirname(root) !== baseDir || !path.basename(root).startsWith(ATTEMPT_PREFIX))
376
+ return;
377
+ let present = true;
378
+ try {
379
+ fs.lstatSync(root);
380
+ }
381
+ catch (err) {
382
+ if (err.code !== "ENOENT")
383
+ throw err;
384
+ present = false;
385
+ }
386
+ if (present) {
387
+ // After construction the sentinel proves the dir is still ours; the dir mkdtemp just returned
388
+ // is ours by construction even if the sentinel was never written.
389
+ if (requireSentinel && !fs.existsSync(path.join(root, SENTINEL)))
390
+ return;
391
+ // rmSync unlinks the auth symlink without following it.
392
+ fs.rmSync(root, { recursive: true, force: true });
393
+ }
394
+ cleaned = true;
395
+ };
396
+ const cleanup = () => removeRoot(true);
397
+ const abortConstruction = (original) => {
398
+ try {
399
+ removeRoot(false);
400
+ }
401
+ catch (rmErr) {
402
+ const reason = original instanceof Error ? original.message : "unknown error";
403
+ const rmReason = rmErr instanceof Error ? rmErr.message : "unknown error";
404
+ throw new MuseIsolationError("cleanup_failed", `Muse attempt setup failed (${reason}) and its dir ${root} could not be removed: ${rmReason}`);
405
+ }
406
+ throw original;
407
+ };
408
+ const settingsPath = path.join(root, "config", "muse", "settings.json");
409
+ const approved = deepFreeze({
410
+ cwd: worktree,
411
+ argv: [...argv],
412
+ env: buildMuseEnv(root, input.env),
413
+ stdin: apiKey === undefined ? undefined : `${apiKey}\n`,
414
+ });
415
+ const verify = (launch = attempt) => {
416
+ if (launch.cwd !== approved.cwd) {
417
+ throw new MuseIsolationError("invalid_argv", "Muse must be launched with cwd set to the assigned worktree");
418
+ }
419
+ if (fs.realpathSync(launch.cwd) !== worktree) {
420
+ throw new MuseIsolationError("invalid_argv", "Muse launch cwd no longer resolves to the assigned worktree");
421
+ }
422
+ if (!sameJson([...launch.argv], approved.argv)) {
423
+ throw new MuseIsolationError("invalid_argv", "Muse argv differs from the approved launch argv");
424
+ }
425
+ if (!sameJson(launch.env, approved.env)) {
426
+ throw new MuseIsolationError("invalid_argv", "Muse env differs from the approved launch env");
427
+ }
428
+ if (launch.stdin !== approved.stdin) {
429
+ throw new MuseIsolationError("invalid_argv", "Muse stdin differs from the approved launch stdin");
430
+ }
431
+ const expectDir = (dir) => {
432
+ if ((fs.statSync(dir).mode & 0o077) !== 0)
433
+ throw new MuseIsolationError("unsafe_path", "Attempt dir is not private");
434
+ };
435
+ let onDisk;
436
+ try {
437
+ expectDir(root);
438
+ if ((fs.statSync(settingsPath).mode & 0o077) !== 0) {
439
+ throw new MuseIsolationError("unsafe_path", "settings.json is not private");
440
+ }
441
+ onDisk = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
442
+ }
443
+ catch (err) {
444
+ if (err instanceof MuseIsolationError)
445
+ throw err;
446
+ throw new MuseIsolationError("invalid_settings", "Muse settings.json is missing or unreadable");
447
+ }
448
+ assertMuseSettings(onDisk, agentDeck, evidence);
449
+ const entries = fs.readdirSync(path.dirname(settingsPath)).sort();
450
+ const allowed = credentialKind === "auth-file" ? ["auth.json", "settings.json"] : ["settings.json"];
451
+ if (entries.join() !== allowed.join()) {
452
+ throw new MuseIsolationError("invalid_settings", "Unexpected files in the per-attempt Muse config dir");
453
+ }
454
+ assertMuseArgv([...approved.argv], input.role);
455
+ };
456
+ try {
457
+ fs.chmodSync(root, 0o700);
458
+ writePrivate(path.join(root, SENTINEL), "");
459
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true, mode: 0o700 });
460
+ fs.mkdirSync(path.join(root, "data"), { recursive: true, mode: 0o700 });
461
+ writePrivate(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
462
+ if (input.credential.kind === "auth-file") {
463
+ fs.symlinkSync(input.credential.path, path.join(path.dirname(settingsPath), "auth.json"));
464
+ }
465
+ }
466
+ catch (err) {
467
+ return abortConstruction(err);
468
+ }
469
+ const attempt = Object.freeze({
470
+ root,
471
+ settingsPath,
472
+ settings,
473
+ cwd: approved.cwd,
474
+ argv: approved.argv,
475
+ env: approved.env,
476
+ stdin: approved.stdin,
477
+ redact: createRedactor(apiKey ? [apiKey] : []),
478
+ verify,
479
+ cleanup,
480
+ });
481
+ try {
482
+ verify();
483
+ }
484
+ catch (err) {
485
+ return abortConstruction(err);
486
+ }
487
+ return attempt;
488
+ }
@@ -0,0 +1,5 @@
1
+ // NOT-180: production entry point for per-attempt native Muse Code configuration.
2
+ //
3
+ // The implementation lives in muse-config-core.ts, which pins `prepareMuseAttempt` to
4
+ // `NOT_177_EVIDENCE`. No module in the build exports a preparer that accepts caller-supplied evidence.
5
+ export { AGENT_DECK_DENIED_TOOLS, AGENT_DECK_READ_TOOLS, AGENT_DECK_SERVER, MUSE_MODEL, MuseIsolationError, NOT_177_EVIDENCE, assertMuseArgv, assertMuseSettings, buildMuseArgv, buildMuseEnv, buildMuseSettings, createRedactor, defaultOperatorAuthPath, prepareMuseAttempt, unenforceableRestrictions, } from "./muse-config-core.js";
@@ -0,0 +1,521 @@
1
+ import { test, after, mock } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { assertMuseArgv, buildMuseArgv, prepareMuseAttempt as prepareMuseAttemptPinned, unenforceableRestrictions, } from "./muse-config.js";
7
+ import { pathToFileURL } from "node:url";
8
+ // The Muse sandbox leaves temp dirs writable, so the module refuses them: fixtures live under $HOME.
9
+ const SCRATCH = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-config-test-"));
10
+ after(() => fs.rmSync(SCRATCH, { recursive: true, force: true }));
11
+ const ENFORCED = { mcp_tool_allowlist_enforcement: true, cron_tool_disable: true };
12
+ // Production code has no seam that accepts other enforcement evidence. The enforced paths are exercised
13
+ // against a hypothetical build by loading a source-rewritten copy of the core with the pinned evidence
14
+ // flipped to true. The copy lives in SCRATCH (outside src/ and dist/), so it never ships.
15
+ async function loadEnforcedCore() {
16
+ const source = fs.readFileSync(path.join(import.meta.dirname, "muse-config-core.ts"), "utf8");
17
+ const literal = /(mcp_tool_allowlist_enforcement|cron_tool_disable): false,/g;
18
+ assert.equal(source.match(literal)?.length, 2, "pinned evidence literal changed; update the test rewrite");
19
+ const copy = path.join(SCRATCH, "muse-config-core.enforced.ts");
20
+ fs.writeFileSync(copy, source.replace(literal, "$1: true,"));
21
+ return import(pathToFileURL(copy).href);
22
+ }
23
+ const enforcedCore = await loadEnforcedCore();
24
+ const prepareMuseAttempt = (i) => enforcedCore.prepareMuseAttempt(i);
25
+ const SESSION = "11111111-2222-4333-8444-555555555555";
26
+ const API_KEY = "mk-live-SECRET-0123456789abcdef";
27
+ const DECK_URL = "http://127.0.0.1:1110/mcp";
28
+ function fixture() {
29
+ const dir = fs.mkdtempSync(path.join(SCRATCH, "case-"));
30
+ const worktree = path.join(dir, "worktree");
31
+ const baseDir = path.join(dir, "attempts");
32
+ const operatorConfigHome = path.join(dir, "operator-config");
33
+ fs.mkdirSync(worktree);
34
+ fs.mkdirSync(baseDir);
35
+ fs.mkdirSync(path.join(operatorConfigHome, "muse"), { recursive: true });
36
+ const operatorAuth = path.join(operatorConfigHome, "muse", "auth.json");
37
+ fs.writeFileSync(operatorAuth, '{"token":"operator-owned"}');
38
+ return { worktree, baseDir, operatorAuth, operatorConfigHome };
39
+ }
40
+ function input(fx, role, over = {}) {
41
+ return {
42
+ role,
43
+ worktreePath: fx.worktree,
44
+ baseDir: fx.baseDir,
45
+ agentDeck: { url: DECK_URL, deckId: "deck-123", workspace: fx.worktree },
46
+ credential: { kind: "auth-file", path: fx.operatorAuth },
47
+ sessionId: SESSION,
48
+ prompt: "Implement the ticket",
49
+ maxModelSteps: 300,
50
+ env: { PATH: "/usr/bin", HOME: os.homedir() },
51
+ ...over,
52
+ };
53
+ }
54
+ function code(fn) {
55
+ try {
56
+ fn();
57
+ }
58
+ catch (err) {
59
+ // The enforced-core copy has its own MuseIsolationError class, so match by name.
60
+ assert.ok(err instanceof Error && err.name === "MuseIsolationError", String(err));
61
+ return err.code;
62
+ }
63
+ return undefined;
64
+ }
65
+ function walk(dir) {
66
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
67
+ const p = path.join(dir, e.name);
68
+ return e.isDirectory() ? walk(p) : [p];
69
+ });
70
+ }
71
+ test("with NOT-177 evidence both roles fail before anything is written", () => {
72
+ for (const role of ["developer", "reviewer"]) {
73
+ const fx = fixture();
74
+ let thrown;
75
+ try {
76
+ prepareMuseAttemptPinned(input(fx, role));
77
+ }
78
+ catch (err) {
79
+ thrown = err;
80
+ }
81
+ assert.equal(thrown?.code, "unenforceable_restriction", role);
82
+ assert.deepEqual([...thrown.capabilities], ["mcp_tool_allowlist_enforcement", "cron_tool_disable"]);
83
+ assert.deepEqual(fs.readdirSync(fx.baseDir), []);
84
+ }
85
+ assert.deepEqual(unenforceableRestrictions("developer", ENFORCED), []);
86
+ assert.deepEqual(unenforceableRestrictions("reviewer", { ...ENFORCED, cron_tool_disable: false }), [
87
+ "cron_tool_disable",
88
+ ]);
89
+ });
90
+ test("developer: only the assigned Agent Deck surface, sandbox on, no unrestricted flags", () => {
91
+ const fx = fixture();
92
+ const attempt = prepareMuseAttempt(input(fx, "developer"));
93
+ try {
94
+ assert.ok(!attempt.root.startsWith(fx.worktree), "config lives outside the worktree");
95
+ const onDisk = JSON.parse(fs.readFileSync(attempt.settingsPath, "utf8"));
96
+ assert.deepEqual(Object.keys(onDisk).sort(), ["mcpServers", "run", "runtime_capabilities", "schema_version"]);
97
+ assert.deepEqual(Object.keys(onDisk.mcpServers), ["agent-deck"]);
98
+ const server = onDisk.mcpServers["agent-deck"];
99
+ assert.equal(server.mode, "required");
100
+ assert.equal("required" in server, false, "mode and required together drop the MCP block");
101
+ assert.deepEqual(server.headers, {
102
+ "x-agent-deck-deck-id": "deck-123",
103
+ "x-agent-deck-workspace": fs.realpathSync(fx.worktree),
104
+ });
105
+ assert.deepEqual(server.enabled_tools, ["get_bound_deck", "get_playbook", "list_service_tools", "bind_workspace"]);
106
+ assert.deepEqual(server.disabled_tools, ["call_service_tool"]);
107
+ assert.deepEqual(onDisk.run, { workflow_trigger_mode: "off", subagent_delegation_mode: "off" });
108
+ for (const tool of ["cron_create", "cron_list", "cron_delete"]) {
109
+ assert.deepEqual(onDisk.runtime_capabilities[`tool:${tool}`], { enabled: false });
110
+ }
111
+ const flag = (f) => attempt.argv[attempt.argv.indexOf(f) + 1];
112
+ assert.equal(attempt.argv[0], "exec");
113
+ assert.equal(flag("--approval-mode"), "never");
114
+ assert.equal(flag("--sandbox-network"), "restricted");
115
+ assert.equal(flag("--model"), "muse-spark-1.3-contributor");
116
+ assert.equal(flag("--session-id"), SESSION);
117
+ assert.equal(attempt.argv.at(-1), "Implement the ticket");
118
+ for (const bad of ["--yolo", "--disable-sandbox", "--disable-approval", "--trust-workspace", "-w", "--worktree", "--disable-write", "--disable-shell"]) {
119
+ assert.equal(attempt.argv.includes(bad), false, bad);
120
+ }
121
+ assert.equal(fs.statSync(attempt.root).mode & 0o777, 0o700);
122
+ assert.equal(fs.statSync(attempt.settingsPath).mode & 0o777, 0o600);
123
+ assert.equal(attempt.env.XDG_CONFIG_HOME, path.join(attempt.root, "config"));
124
+ assert.equal(attempt.env.XDG_DATA_HOME, path.join(attempt.root, "data"));
125
+ assert.equal(attempt.env.MUSE_NO_AUTO_UPDATE, "1");
126
+ assert.equal(attempt.stdin, undefined);
127
+ assert.doesNotThrow(() => attempt.verify());
128
+ }
129
+ finally {
130
+ attempt.cleanup();
131
+ }
132
+ });
133
+ test("reviewer: read-only flags and the same deck reads, mutation denied", () => {
134
+ const fx = fixture();
135
+ const attempt = prepareMuseAttempt(input(fx, "reviewer"));
136
+ try {
137
+ assert.ok(attempt.argv.includes("--disable-write"));
138
+ assert.ok(attempt.argv.includes("--disable-shell"));
139
+ const server = attempt.settings.mcpServers["agent-deck"];
140
+ assert.equal(server.enabled_tools?.includes("call_service_tool"), false);
141
+ assert.deepEqual(server.disabled_tools, ["call_service_tool"]);
142
+ assert.deepEqual(Object.keys(attempt.settings.mcpServers), ["agent-deck"]);
143
+ }
144
+ finally {
145
+ attempt.cleanup();
146
+ }
147
+ // A reviewer argv without the read-only flags is refused, as is a developer with them.
148
+ assert.equal(code(() => assertMuseArgv(buildMuseArgv({ role: "developer", sessionId: SESSION, maxModelSteps: 5, prompt: "x" }), "reviewer")), "invalid_argv");
149
+ assert.equal(code(() => assertMuseArgv(buildMuseArgv({ role: "reviewer", sessionId: SESSION, maxModelSteps: 5, prompt: "x" }), "developer")), "invalid_argv");
150
+ });
151
+ test("argv guard refuses every trust/sandbox/orchestration widening flag", () => {
152
+ const base = buildMuseArgv({ role: "developer", sessionId: SESSION, maxModelSteps: 5, prompt: "x" });
153
+ for (const bad of ["--yolo", "--disable-sandbox", "--disable-approval", "--trust-workspace", "-w", "--worktree=nested", "--preset", "--agents", "--permission-profile"]) {
154
+ const argv = [...base.slice(0, -1), bad, "x"];
155
+ assert.equal(code(() => assertMuseArgv(argv, "developer")), "invalid_argv", bad);
156
+ }
157
+ const relaxed = base.map((a) => (a === "restricted" ? "proxy-only" : a));
158
+ assert.equal(code(() => assertMuseArgv(relaxed, "developer")), "invalid_argv");
159
+ const prompted = input(fixture(), "developer", { prompt: "--yolo" });
160
+ assert.equal(code(() => prepareMuseAttempt(prompted)), "invalid_input");
161
+ });
162
+ test("ambient Muse config, env, and workspace files cannot expand the attempt", () => {
163
+ const fx = fixture();
164
+ const ambientSettings = path.join(fx.operatorConfigHome, "muse", "settings.json");
165
+ const ambientBody = JSON.stringify({
166
+ schema_version: 1,
167
+ mcpServers: { evil: { type: "streamable-http", url: "http://evil.invalid/mcp", mode: "optional" } },
168
+ run: { workflow_trigger_mode: "auto", subagent_delegation_mode: "auto" },
169
+ });
170
+ fs.writeFileSync(ambientSettings, ambientBody);
171
+ fs.writeFileSync(path.join(fx.worktree, ".mcp.json"), '{"mcpServers":{"workspace-evil":{"command":"sh"}}}');
172
+ const attempt = prepareMuseAttempt(input(fx, "developer", {
173
+ env: {
174
+ PATH: "/usr/bin",
175
+ HOME: os.homedir(),
176
+ XDG_CONFIG_HOME: fx.operatorConfigHome,
177
+ XDG_DATA_HOME: "/operator/data",
178
+ META_API_KEY: API_KEY,
179
+ MUSE_PRESET: "yolo",
180
+ MUSE_NO_AUTO_UPDATE: "0",
181
+ CODEX_HOME: "/operator/codex",
182
+ LC_ALL: "C",
183
+ },
184
+ }));
185
+ try {
186
+ assert.deepEqual(Object.keys(attempt.settings.mcpServers), ["agent-deck"]);
187
+ assert.equal(attempt.env.XDG_CONFIG_HOME, path.join(attempt.root, "config"));
188
+ assert.equal(attempt.env.XDG_DATA_HOME, path.join(attempt.root, "data"));
189
+ assert.equal(attempt.env.MUSE_NO_AUTO_UPDATE, "1");
190
+ for (const dropped of ["META_API_KEY", "MUSE_PRESET", "CODEX_HOME"])
191
+ assert.equal(dropped in attempt.env, false, dropped);
192
+ assert.equal(attempt.env.LC_ALL, "C");
193
+ assert.equal(attempt.argv.includes("--trust-workspace"), false);
194
+ assert.ok(attempt.argv.includes("--no-foreign-personal-context"));
195
+ assert.ok(!fs.readFileSync(attempt.settingsPath, "utf8").includes("evil"));
196
+ // Someone widening the per-attempt config after the fact is caught before spawn.
197
+ const good = fs.readFileSync(attempt.settingsPath, "utf8");
198
+ const tamper = (mut) => {
199
+ const s = JSON.parse(good);
200
+ mut(s);
201
+ fs.writeFileSync(attempt.settingsPath, JSON.stringify(s));
202
+ return code(() => attempt.verify());
203
+ };
204
+ assert.equal(tamper((s) => (s.mcpServers.extra = { type: "streamable-http", url: DECK_URL, mode: "required" })), "invalid_settings");
205
+ assert.equal(tamper((s) => (s.run.subagent_delegation_mode = "auto")), "invalid_settings");
206
+ assert.equal(tamper((s) => (s.mcpServers["agent-deck"].disabled_tools = [])), "invalid_settings");
207
+ assert.equal(tamper((s) => delete s.run), "invalid_settings");
208
+ fs.writeFileSync(attempt.settingsPath, good);
209
+ fs.writeFileSync(path.join(path.dirname(attempt.settingsPath), "config.toml"), "x");
210
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
211
+ fs.rmSync(path.join(path.dirname(attempt.settingsPath), "config.toml"));
212
+ fs.writeFileSync(attempt.settingsPath, "{not json");
213
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
214
+ fs.rmSync(attempt.settingsPath);
215
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
216
+ }
217
+ finally {
218
+ attempt.cleanup();
219
+ }
220
+ assert.equal(fs.readFileSync(ambientSettings, "utf8"), ambientBody);
221
+ });
222
+ test("required Agent Deck server: optional mode, bad url, and missing identity fail before spawn", () => {
223
+ const fx = fixture();
224
+ const attempt = prepareMuseAttempt(input(fx, "developer"));
225
+ try {
226
+ const s = JSON.parse(fs.readFileSync(attempt.settingsPath, "utf8"));
227
+ s.mcpServers["agent-deck"].mode = "optional";
228
+ fs.writeFileSync(attempt.settingsPath, JSON.stringify(s));
229
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
230
+ s.mcpServers["agent-deck"].mode = "required";
231
+ s.mcpServers["agent-deck"].required = true;
232
+ fs.writeFileSync(attempt.settingsPath, JSON.stringify(s));
233
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
234
+ }
235
+ finally {
236
+ attempt.cleanup();
237
+ }
238
+ const bad = [
239
+ { agentDeck: { url: "not a url", deckId: "d", workspace: fx.worktree } },
240
+ { agentDeck: { url: "file:///etc/passwd", deckId: "d", workspace: fx.worktree } },
241
+ { agentDeck: { url: "http://u:p@127.0.0.1:1110/mcp", deckId: "d", workspace: fx.worktree } },
242
+ { agentDeck: { url: DECK_URL, deckId: " ", workspace: fx.worktree } },
243
+ { agentDeck: { url: DECK_URL, deckId: "d", workspace: SCRATCH } },
244
+ { sessionId: "not-a-uuid" },
245
+ { maxModelSteps: 0 },
246
+ { credential: { kind: "auth-file", path: path.join(fx.baseDir, "missing.json") } },
247
+ { credential: { kind: "api-key", apiKey: " " } },
248
+ ];
249
+ for (const over of bad) {
250
+ assert.equal(code(() => prepareMuseAttempt(input(fx, "developer", over))), "invalid_input", JSON.stringify(over));
251
+ }
252
+ assert.deepEqual(fs.readdirSync(fx.baseDir), [], "nothing is written when validation fails");
253
+ });
254
+ test("worktree and config must be separate, and neither may sit under a temp dir", () => {
255
+ const fx = fixture();
256
+ assert.equal(code(() => prepareMuseAttempt(input(fx, "developer", { baseDir: fx.worktree }))), "unsafe_path");
257
+ const inside = path.join(fx.worktree, "attempts");
258
+ fs.mkdirSync(inside);
259
+ assert.equal(code(() => prepareMuseAttempt(input(fx, "developer", { baseDir: inside }))), "unsafe_path");
260
+ const tmpWorktree = fs.mkdtempSync(path.join(os.tmpdir(), "muse-config-tmp-"));
261
+ try {
262
+ const agentDeck = { url: DECK_URL, deckId: "d", workspace: tmpWorktree };
263
+ assert.equal(code(() => prepareMuseAttempt(input(fx, "developer", { worktreePath: tmpWorktree, agentDeck }))), "unsafe_path");
264
+ assert.equal(code(() => prepareMuseAttempt(input(fx, "developer", { baseDir: tmpWorktree }))), "unsafe_path");
265
+ }
266
+ finally {
267
+ fs.rmSync(tmpWorktree, { recursive: true, force: true });
268
+ }
269
+ });
270
+ test("cleanup removes ephemeral config and auth link but never operator-owned state", () => {
271
+ const fx = fixture();
272
+ const attempt = prepareMuseAttempt(input(fx, "developer"));
273
+ const link = path.join(path.dirname(attempt.settingsPath), "auth.json");
274
+ assert.ok(fs.lstatSync(link).isSymbolicLink());
275
+ assert.equal(fs.readFileSync(link, "utf8"), '{"token":"operator-owned"}');
276
+ fs.writeFileSync(path.join(attempt.root, "data", "session.jsonl"), "muse wrote this");
277
+ const other = path.join(fx.baseDir, "someone-elses-dir");
278
+ fs.mkdirSync(other);
279
+ attempt.cleanup();
280
+ attempt.cleanup();
281
+ assert.equal(fs.existsSync(attempt.root), false);
282
+ assert.equal(fs.readFileSync(fx.operatorAuth, "utf8"), '{"token":"operator-owned"}');
283
+ assert.ok(fs.existsSync(other));
284
+ assert.ok(fs.existsSync(fx.worktree));
285
+ assert.deepEqual(fs.readdirSync(fx.worktree), [], "nothing is written into the worktree");
286
+ // Without the sentinel the dir is not ours to delete.
287
+ const foreign = prepareMuseAttempt(input(fx, "reviewer"));
288
+ fs.rmSync(path.join(foreign.root, ".dealer-muse-attempt"));
289
+ foreign.cleanup();
290
+ assert.ok(fs.existsSync(foreign.root));
291
+ });
292
+ test("cleanup is retryable: a transient rmSync failure does not strand the attempt dir", () => {
293
+ const fx = fixture();
294
+ const attempt = prepareMuseAttempt(input(fx, "developer"));
295
+ const link = path.join(path.dirname(attempt.settingsPath), "auth.json");
296
+ const realRm = fs.rmSync;
297
+ const rm = mock.method(fs, "rmSync", () => {
298
+ throw Object.assign(new Error("EBUSY: transient"), { code: "EBUSY" });
299
+ });
300
+ try {
301
+ assert.throws(() => attempt.cleanup(), /EBUSY/);
302
+ assert.throws(() => attempt.cleanup(), /EBUSY/, "a failed cleanup is not terminal");
303
+ assert.ok(fs.existsSync(attempt.settingsPath));
304
+ assert.ok(fs.lstatSync(link).isSymbolicLink());
305
+ }
306
+ finally {
307
+ rm.mock.restore();
308
+ }
309
+ assert.equal(fs.rmSync, realRm);
310
+ attempt.cleanup();
311
+ assert.equal(fs.existsSync(attempt.root), false);
312
+ assert.equal(fs.readFileSync(fx.operatorAuth, "utf8"), '{"token":"operator-owned"}');
313
+ assert.doesNotThrow(() => attempt.cleanup());
314
+ });
315
+ test("cleanup treats an already-removed dir as done", () => {
316
+ const fx = fixture();
317
+ const attempt = prepareMuseAttempt(input(fx, "developer"));
318
+ fs.rmSync(attempt.root, { recursive: true });
319
+ assert.doesNotThrow(() => attempt.cleanup());
320
+ assert.doesNotThrow(() => attempt.cleanup());
321
+ });
322
+ test("a failure before the sentinel is written still removes the new attempt dir", () => {
323
+ const fx = fixture();
324
+ const realWrite = fs.writeFileSync;
325
+ const write = mock.method(fs, "writeFileSync", ((file, ...rest) => {
326
+ if (String(file).endsWith(".dealer-muse-attempt"))
327
+ throw new Error("ENOSPC: injected");
328
+ return realWrite(file, ...rest);
329
+ }));
330
+ try {
331
+ assert.throws(() => prepareMuseAttempt(input(fx, "developer")), /ENOSPC: injected/);
332
+ }
333
+ finally {
334
+ write.mock.restore();
335
+ }
336
+ assert.deepEqual(fs.readdirSync(fx.baseDir), [], "no sentinel-less attempt dir is left behind");
337
+ assert.equal(fs.readFileSync(fx.operatorAuth, "utf8"), '{"token":"operator-owned"}');
338
+ });
339
+ test("if setup fails and the new dir cannot be removed, the failure names the stranded dir", () => {
340
+ const fx = fixture();
341
+ const realWrite = fs.writeFileSync;
342
+ const write = mock.method(fs, "writeFileSync", ((file, ...rest) => {
343
+ if (String(file).endsWith("settings.json"))
344
+ throw new Error("EIO: injected");
345
+ return realWrite(file, ...rest);
346
+ }));
347
+ const rm = mock.method(fs, "rmSync", () => {
348
+ throw new Error("EBUSY: transient");
349
+ });
350
+ let thrown;
351
+ try {
352
+ try {
353
+ prepareMuseAttempt(input(fx, "developer", { credential: { kind: "api-key", apiKey: API_KEY } }));
354
+ }
355
+ catch (err) {
356
+ thrown = err;
357
+ }
358
+ }
359
+ finally {
360
+ write.mock.restore();
361
+ rm.mock.restore();
362
+ }
363
+ assert.equal(thrown?.code, "cleanup_failed");
364
+ const [stranded] = fs.readdirSync(fx.baseDir);
365
+ assert.ok(stranded);
366
+ assert.ok(thrown.message.includes(stranded));
367
+ assert.ok(!thrown.message.includes(API_KEY));
368
+ // Nothing tracks it any more, so the operator removes it; the sentinel makes it identifiable.
369
+ assert.ok(fs.existsSync(path.join(fx.baseDir, stranded, ".dealer-muse-attempt")));
370
+ });
371
+ test("an unwritable base dir fails without leaving a partial attempt", () => {
372
+ const fx = fixture();
373
+ fs.chmodSync(fx.baseDir, 0o500);
374
+ try {
375
+ assert.throws(() => prepareMuseAttempt(input(fx, "developer")));
376
+ }
377
+ finally {
378
+ fs.chmodSync(fx.baseDir, 0o700);
379
+ }
380
+ assert.deepEqual(fs.readdirSync(fx.baseDir), []);
381
+ });
382
+ test("api key never reaches argv, env, disk, or error text; redact scrubs it", () => {
383
+ const fx = fixture();
384
+ const attempt = prepareMuseAttempt(input(fx, "developer", { credential: { kind: "api-key", apiKey: API_KEY } }));
385
+ try {
386
+ assert.equal(attempt.stdin, `${API_KEY}\n`);
387
+ assert.ok(attempt.argv.includes("--api-key-stdin"));
388
+ assert.ok(!JSON.stringify(attempt.argv).includes(API_KEY));
389
+ assert.ok(!JSON.stringify(attempt.env).includes(API_KEY));
390
+ for (const file of walk(attempt.root))
391
+ assert.ok(!fs.readFileSync(file, "utf8").includes(API_KEY), file);
392
+ assert.ok(!fs.existsSync(path.join(path.dirname(attempt.settingsPath), "auth.json")));
393
+ assert.doesNotThrow(() => attempt.verify());
394
+ const leaked = `fatal: key ${API_KEY} rejected; META_API_KEY=other-secret-value; Authorization: Bearer abcdef1234567890`;
395
+ const scrubbed = attempt.redact(leaked);
396
+ assert.ok(!scrubbed.includes(API_KEY));
397
+ assert.ok(!scrubbed.includes("other-secret-value"));
398
+ assert.ok(!scrubbed.includes("abcdef1234567890"));
399
+ }
400
+ finally {
401
+ attempt.cleanup();
402
+ }
403
+ for (const over of [{ sessionId: "bad" }, { prompt: "-x" }, {}]) {
404
+ try {
405
+ // The `{}` case goes through the pinned entry point, which refuses on NOT-177 evidence.
406
+ const prepare = Object.keys(over).length === 0 ? prepareMuseAttemptPinned : prepareMuseAttempt;
407
+ prepare(input(fx, "developer", { credential: { kind: "api-key", apiKey: API_KEY }, ...over }));
408
+ assert.fail("expected refusal");
409
+ }
410
+ catch (err) {
411
+ assert.ok(err instanceof Error && err.name === "MuseIsolationError");
412
+ assert.ok(!err.message.includes(API_KEY));
413
+ assert.ok(!String(err.stack).includes(API_KEY));
414
+ }
415
+ }
416
+ assert.deepEqual(fs.readdirSync(fx.baseDir), []);
417
+ });
418
+ test("callers cannot supply enforcement evidence: an injected evidence field is ignored", () => {
419
+ for (const role of ["developer", "reviewer"]) {
420
+ const fx = fixture();
421
+ const forged = { ...input(fx, role), evidence: ENFORCED };
422
+ assert.equal(code(() => prepareMuseAttemptPinned(forged)), "unenforceable_restriction", role);
423
+ assert.deepEqual(fs.readdirSync(fx.baseDir), []);
424
+ }
425
+ });
426
+ test("launch contract: cwd is the real worktree and argv/env/cwd/stdin must match exactly", () => {
427
+ const fx = fixture();
428
+ const link = path.join(fx.baseDir, "worktree-link");
429
+ fs.symlinkSync(fx.worktree, link);
430
+ const attempt = prepareMuseAttempt(input(fx, "developer", { worktreePath: link }));
431
+ try {
432
+ assert.equal(attempt.cwd, fs.realpathSync(fx.worktree));
433
+ assert.doesNotThrow(() => attempt.verify());
434
+ assert.doesNotThrow(() => attempt.verify({ cwd: attempt.cwd, argv: [...attempt.argv], env: { ...attempt.env }, stdin: attempt.stdin }));
435
+ // Returned data is immutable.
436
+ assert.throws(() => attempt.argv.push("--yolo"));
437
+ assert.throws(() => (attempt.env.XDG_CONFIG_HOME = "/ambient"));
438
+ assert.throws(() => (attempt.cwd = "/"));
439
+ assert.throws(() => (attempt.settings.mcpServers.extra = {}));
440
+ // A consumer that builds its own launch is checked against the approved one.
441
+ const launch = { cwd: attempt.cwd, argv: [...attempt.argv], env: { ...attempt.env }, stdin: attempt.stdin };
442
+ const without = (flag, n = 1) => {
443
+ const i = launch.argv.indexOf(flag);
444
+ return [...launch.argv.slice(0, i), ...launch.argv.slice(i + n)];
445
+ };
446
+ const widened = {
447
+ "spawn from server dir": { ...launch, cwd: process.cwd() },
448
+ "spawn from parent dir": { ...launch, cwd: path.dirname(fx.worktree) },
449
+ "drop --disable-web-tools": { ...launch, argv: without("--disable-web-tools") },
450
+ "drop --approval-judge off": { ...launch, argv: without("--approval-judge", 2) },
451
+ "add --base-url": { ...launch, argv: [...launch.argv.slice(0, -1), "--base-url", "http://evil.invalid", launch.argv.at(-1)] },
452
+ "duplicate flag": { ...launch, argv: [...launch.argv.slice(0, -1), "--json", launch.argv.at(-1)] },
453
+ "change prompt": { ...launch, argv: [...launch.argv.slice(0, -1), "other prompt"] },
454
+ "ambient XDG_CONFIG_HOME": { ...launch, env: { ...launch.env, XDG_CONFIG_HOME: fx.operatorConfigHome } },
455
+ "ambient XDG_DATA_HOME": { ...launch, env: { ...launch.env, XDG_DATA_HOME: "/operator/data" } },
456
+ "extra env": { ...launch, env: { ...launch.env, META_API_KEY: API_KEY } },
457
+ "dropped env": { ...launch, env: Object.fromEntries(Object.entries(launch.env).filter(([k]) => k !== "MUSE_NO_AUTO_UPDATE")) },
458
+ "injected stdin": { ...launch, stdin: "key\n" },
459
+ };
460
+ for (const [name, bad] of Object.entries(widened)) {
461
+ assert.equal(code(() => attempt.verify(bad)), "invalid_argv", name);
462
+ }
463
+ }
464
+ finally {
465
+ attempt.cleanup();
466
+ }
467
+ });
468
+ test("verification is snapshotted: mutating the original input cannot retarget the MCP server", () => {
469
+ const fx = fixture();
470
+ const original = input(fx, "developer");
471
+ const attempt = prepareMuseAttempt(original);
472
+ try {
473
+ // Attacker rewrites the caller's objects and settings.json to a different deck / URL.
474
+ original.agentDeck.url = "http://evil.invalid/mcp";
475
+ original.agentDeck.deckId = "deck-evil";
476
+ original.credential = { kind: "api-key", apiKey: API_KEY };
477
+ original.role = "reviewer";
478
+ const evil = JSON.parse(fs.readFileSync(attempt.settingsPath, "utf8"));
479
+ evil.mcpServers["agent-deck"].url = "http://evil.invalid/mcp";
480
+ evil.mcpServers["agent-deck"].headers["x-agent-deck-deck-id"] = "deck-evil";
481
+ fs.writeFileSync(attempt.settingsPath, JSON.stringify(evil));
482
+ assert.equal(code(() => attempt.verify()), "invalid_settings");
483
+ // Restoring the approved settings passes again, so verify compares against the snapshot.
484
+ evil.mcpServers["agent-deck"].url = DECK_URL;
485
+ evil.mcpServers["agent-deck"].headers["x-agent-deck-deck-id"] = "deck-123";
486
+ fs.writeFileSync(attempt.settingsPath, JSON.stringify(evil));
487
+ assert.doesNotThrow(() => attempt.verify());
488
+ }
489
+ finally {
490
+ attempt.cleanup();
491
+ }
492
+ });
493
+ test("no runtime export can produce a launchable attempt from caller-supplied evidence", async () => {
494
+ for (const mod of ["./muse-config.js", "./muse-config-core.js"]) {
495
+ const exported = (await import(mod));
496
+ assert.equal("prepareWithEvidence" in exported, false, mod);
497
+ assert.equal("museConfigTesting" in exported, false, mod);
498
+ // Only the pinned preparer returns a MuseAttempt-producing function; it takes exactly one argument.
499
+ for (const [name, value] of Object.entries(exported)) {
500
+ if (typeof value === "function" && /^prepare/.test(name))
501
+ assert.equal(value.length, 1, `${mod}#${name}`);
502
+ }
503
+ }
504
+ // The pinned core cannot be talked into success with a forged evidence argument or field.
505
+ const core = (await import("./muse-config-core.js"));
506
+ const fx = fixture();
507
+ const forged = () => core.prepareMuseAttempt(input(fx, "developer"), ENFORCED);
508
+ assert.equal(code(forged), "unenforceable_restriction");
509
+ assert.deepEqual(fs.readdirSync(fx.baseDir), []);
510
+ });
511
+ test("only muse-config.ts and tests import muse-config-core", () => {
512
+ const srcRoot = path.resolve(import.meta.dirname, "..");
513
+ const offenders = walk(srcRoot).filter((f) => {
514
+ if (!f.endsWith(".ts") || f.endsWith(".test.ts"))
515
+ return false;
516
+ if (path.basename(f) === "muse-config.ts" || path.basename(f) === "muse-config-core.ts")
517
+ return false;
518
+ return /muse-config-core/.test(fs.readFileSync(f, "utf8"));
519
+ });
520
+ assert.deepEqual(offenders, []);
521
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.1.1",
25
+ "@agent-dealer/shared": "1.1.2",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/shared",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dealer",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Human control plane for agent execution — queue, plan approval, audit",
5
5
  "license": "MIT",
6
6
  "type": "module",