agent-dealer 1.1.0 → 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
+ });
@@ -29,12 +29,11 @@ function fallbackUntil(nowMs = Date.now()) {
29
29
  function capReason(runtime, detail) {
30
30
  return `${runtime} usage capped — ${detail}`;
31
31
  }
32
+ // `status` is the verdict on the plan window. `overageStatus` only says whether pay-as-you-go
33
+ // overage is available: orgs with overage disabled report "rejected" on every event of a healthy
34
+ // session (status "allowed", isUsingOverage false), so it must not open a deferral by itself.
32
35
  function isHardCapRateLimitInfo(info) {
33
- if (info.status === "rejected")
34
- return true;
35
- if (info.overageStatus === "rejected")
36
- return true;
37
- return false;
36
+ return info.status === "rejected";
38
37
  }
39
38
  function signalFromRateLimitInfo(runtime, info, evidence, nowMs) {
40
39
  if (!isHardCapRateLimitInfo(info))
@@ -26,6 +26,15 @@ test("Claude rate_limit_event rejected writes runtime_availability with resetsAt
26
26
  assert.equal(avail.until, cap.unavailableUntil);
27
27
  }
28
28
  });
29
+ test("allowed rate_limit_event with overage rejected (org disabled overage) is not a cap", () => {
30
+ const raw = [
31
+ '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1784283600,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled_until","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.17,"resetsAt":1784283600}}}}',
32
+ '{"type":"result","is_error":false,"result":"done"}',
33
+ ].join("\n");
34
+ assert.equal(detectUsageCapFromNdjson(raw, "claude_code"), null);
35
+ assert.equal(recordUsageCapFromEvents(parseNdjson(raw), "claude_code"), null);
36
+ assert.equal(runtimeAvailability("claude_code").available, true);
37
+ });
29
38
  test("allowed_warning rate_limit_event is ignored (hard cap only)", () => {
30
39
  const raw = [
31
40
  '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1784283600,"rateLimitType":"five_hour"}}',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.1.0",
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.0",
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.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/dist/start.js CHANGED
@@ -124,6 +124,24 @@ async function ensurePortAvailable(host, port, probe) {
124
124
  }
125
125
  return null;
126
126
  }
127
+ /**
128
+ * `--force` restart: stop the existing instance, then require the port to actually be free.
129
+ * `runStop()` can return 0 while a listener survives (an explicit AGENT_DEALER_HOME without run.json
130
+ * refuses to sweep the port), and `ensurePortAvailable` treats a healthy agent-dealer as available —
131
+ * so without this check the replacement server would launch onto an occupied port.
132
+ */
133
+ async function stopForForcedRestart(host, port) {
134
+ console.log("[agent-dealer] Restarting existing instance (--force) ...");
135
+ await runStop();
136
+ await new Promise((resolve) => setTimeout(resolve, 500));
137
+ if ((await probeAgentDealer(host, port)).up) {
138
+ console.error(`[agent-dealer] --force could not stop the agent-dealer still listening on ${host}:${port}.`);
139
+ console.error("[agent-dealer] It is not owned by this AGENT_DEALER_HOME, so it was left running.");
140
+ console.error(`[agent-dealer] Stop it from its own home, or kill it manually: lsof -ti :${port} -sTCP:LISTEN | xargs kill`);
141
+ return 1;
142
+ }
143
+ return null;
144
+ }
127
145
  export async function runStart(options = {}) {
128
146
  const envFile = loadProdEnvFile();
129
147
  const home = prodHomeDir();
@@ -142,9 +160,10 @@ export async function runStart(options = {}) {
142
160
  const probe = await probeAgentDealer(host, port);
143
161
  if (probe.up) {
144
162
  if (options.force) {
145
- console.log("[agent-dealer] Restarting existing instance (--force) ...");
146
- await runStop();
147
- await new Promise((resolve) => setTimeout(resolve, 500));
163
+ const stopError = await stopForForcedRestart(host, port);
164
+ if (stopError !== null) {
165
+ return stopError;
166
+ }
148
167
  }
149
168
  else {
150
169
  printRunningEndpoints(base, uiDist);
@@ -152,8 +171,7 @@ export async function runStart(options = {}) {
152
171
  return 0;
153
172
  }
154
173
  }
155
- const refreshedProbe = options.force ? await probeAgentDealer(host, port) : probe;
156
- const portError = await ensurePortAvailable(host, port, refreshedProbe);
174
+ const portError = await ensurePortAvailable(host, port, options.force ? await probeAgentDealer(host, port) : probe);
157
175
  if (portError !== null) {
158
176
  return portError;
159
177
  }
@@ -163,9 +181,10 @@ export async function runStart(options = {}) {
163
181
  const probe = await probeAgentDealer(host, port);
164
182
  if (probe.up) {
165
183
  if (options.force) {
166
- console.log("[agent-dealer] Restarting existing instance (--force) ...");
167
- await runStop();
168
- await new Promise((resolve) => setTimeout(resolve, 500));
184
+ const stopError = await stopForForcedRestart(host, port);
185
+ if (stopError !== null) {
186
+ return stopError;
187
+ }
169
188
  }
170
189
  else {
171
190
  printRunningEndpoints(base, uiDist);
@@ -173,8 +192,7 @@ export async function runStart(options = {}) {
173
192
  return 0;
174
193
  }
175
194
  }
176
- const refreshedProbe = options.force ? await probeAgentDealer(host, port) : probe;
177
- const portError = await ensurePortAvailable(host, port, refreshedProbe);
195
+ const portError = await ensurePortAvailable(host, port, options.force ? await probeAgentDealer(host, port) : probe);
178
196
  if (portError !== null) {
179
197
  return portError;
180
198
  }
package/dist/stop.js CHANGED
@@ -39,17 +39,25 @@ export async function runStop() {
39
39
  }
40
40
  clearRunState();
41
41
  }
42
+ // An explicit AGENT_DEALER_HOME means "only this home" (smoke tests, side-by-side installs).
43
+ // Its run.json is the sole proof of ownership, so never sweep the port: with no run state the
44
+ // port falls back to the bundled 2222, which belongs to the default install.
45
+ const isolatedHome = Boolean(process.env.AGENT_DEALER_HOME?.trim());
42
46
  let probe = await probeAgentDealer(host, port);
43
47
  if (probe.up) {
44
- for (const pid of listListeningPids(port)) {
45
- if (terminatePid(pid, `listener on :${port}`)) {
46
- stopped += 1;
48
+ if (!isolatedHome) {
49
+ for (const pid of listListeningPids(port)) {
50
+ if (terminatePid(pid, `listener on :${port}`)) {
51
+ stopped += 1;
52
+ }
47
53
  }
48
54
  }
49
- await waitForShutdown(host, port);
50
- probe = await probeAgentDealer(host, port);
55
+ if (!isolatedHome || state) {
56
+ await waitForShutdown(host, port);
57
+ probe = await probeAgentDealer(host, port);
58
+ }
51
59
  }
52
- if (probe.up) {
60
+ if (probe.up && (!isolatedHome || state)) {
53
61
  console.warn("[agent-dealer] agent-dealer still responds on configured port. Kill remaining processes manually:");
54
62
  console.warn(` lsof -ti :${port} -sTCP:LISTEN | xargs kill`);
55
63
  return 1;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { runStart } from "./start.js";
8
+ import { runStop } from "./stop.js";
9
+ const HEALTH_SERVER = `
10
+ const http = require("node:http");
11
+ const server = http.createServer((req, res) => {
12
+ res.setHeader("content-type", "application/json");
13
+ res.end(JSON.stringify({ ok: true }));
14
+ });
15
+ server.listen(0, "127.0.0.1", () => console.log(server.address().port));
16
+ `;
17
+ /** A separate process answering /health, standing in for an agent-dealer server. */
18
+ async function startFakeServer() {
19
+ const child = spawn(process.execPath, ["-e", HEALTH_SERVER], { stdio: ["ignore", "pipe", "inherit"] });
20
+ const port = await new Promise((resolve, reject) => {
21
+ child.once("error", reject);
22
+ child.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim())));
23
+ });
24
+ return { child, port };
25
+ }
26
+ function isAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function waitUntilDead(pid) {
36
+ for (let i = 0; i < 25; i += 1) {
37
+ if (!isAlive(pid))
38
+ return true;
39
+ await new Promise((resolve) => setTimeout(resolve, 100));
40
+ }
41
+ return false;
42
+ }
43
+ async function withEnv(env, fn) {
44
+ const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
45
+ for (const [key, value] of Object.entries(env)) {
46
+ if (value === undefined)
47
+ delete process.env[key];
48
+ else
49
+ process.env[key] = value;
50
+ }
51
+ try {
52
+ await fn();
53
+ }
54
+ finally {
55
+ for (const [key, value] of Object.entries(saved)) {
56
+ if (value === undefined)
57
+ delete process.env[key];
58
+ else
59
+ process.env[key] = value;
60
+ }
61
+ }
62
+ }
63
+ function tempHome() {
64
+ return fs.mkdtempSync(path.join(os.tmpdir(), "agent-dealer-stop-"));
65
+ }
66
+ test("stop with an explicit AGENT_DEALER_HOME and no run state never kills a listener on the fallback port", async () => {
67
+ const { child, port } = await startFakeServer();
68
+ const home = tempHome();
69
+ try {
70
+ await withEnv({ AGENT_DEALER_HOME: home, PORT: String(port), WEB_PORT: undefined }, async () => {
71
+ assert.equal(await runStop(), 0);
72
+ });
73
+ assert.equal(isAlive(child.pid), true, "the unrelated listener must survive an isolated-home stop");
74
+ }
75
+ finally {
76
+ child.kill("SIGKILL");
77
+ fs.rmSync(home, { recursive: true, force: true });
78
+ }
79
+ });
80
+ test("stop with an explicit AGENT_DEALER_HOME still terminates the pids recorded in its run.json", async () => {
81
+ const { child, port } = await startFakeServer();
82
+ const home = tempHome();
83
+ fs.writeFileSync(path.join(home, "run.json"), JSON.stringify({ host: "127.0.0.1", port, serverPid: child.pid, cliPid: 2 ** 22, startedAt: new Date().toISOString() }));
84
+ try {
85
+ await withEnv({ AGENT_DEALER_HOME: home, PORT: undefined, WEB_PORT: undefined }, async () => {
86
+ assert.equal(await runStop(), 0);
87
+ });
88
+ assert.equal(await waitUntilDead(child.pid), true);
89
+ }
90
+ finally {
91
+ child.kill("SIGKILL");
92
+ fs.rmSync(home, { recursive: true, force: true });
93
+ }
94
+ });
95
+ test("stop on the default home keeps sweeping the port when run.json is lost", async () => {
96
+ const { child, port } = await startFakeServer();
97
+ const home = tempHome();
98
+ try {
99
+ await withEnv({ AGENT_DEALER_HOME: undefined, HOME: home, USERPROFILE: home, PORT: String(port), WEB_PORT: undefined }, async () => {
100
+ assert.equal(await runStop(), 0);
101
+ });
102
+ assert.equal(await waitUntilDead(child.pid), true);
103
+ }
104
+ finally {
105
+ child.kill("SIGKILL");
106
+ fs.rmSync(home, { recursive: true, force: true });
107
+ }
108
+ });
109
+ async function assertForcedStartRefusesUnownedListener(options) {
110
+ const { child, port } = await startFakeServer();
111
+ const home = tempHome();
112
+ try {
113
+ await withEnv({ AGENT_DEALER_HOME: home, PORT: String(port), WEB_PORT: undefined }, async () => {
114
+ // The isolated-home stop leaves the listener up (no run.json = no proof of ownership).
115
+ // Forced start must fail instead of launching a second server onto the occupied port.
116
+ assert.equal(await runStart({ force: true, daemon: options.daemon, port }), 1);
117
+ });
118
+ assert.equal(isAlive(child.pid), true, "the unrelated listener must survive a forced start");
119
+ assert.equal(fs.existsSync(path.join(home, "run.json")), false, "no run state may be written for a server that never launched");
120
+ }
121
+ finally {
122
+ child.kill("SIGKILL");
123
+ fs.rmSync(home, { recursive: true, force: true });
124
+ }
125
+ }
126
+ test("start --force in the foreground fails when the isolated-home stop leaves a listener up", async () => {
127
+ await assertForcedStartRefusesUnownedListener({ daemon: false });
128
+ });
129
+ test("start --daemon --force fails when the isolated-home stop leaves a listener up", async () => {
130
+ await assertForcedStartRefusesUnownedListener({ daemon: true });
131
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dealer",
3
- "version": "1.1.0",
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",