@brutalsystems/muster 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -492,6 +492,30 @@ Every launch also records how strongly its containment was imposed:
492
492
  | `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
493
493
  | `none` | `full-access` — the sandbox is deliberately disabled. |
494
494
 
495
+ #### What a sandboxed launch may write
496
+
497
+ Muster names the write roots rather than leaving them to default. Under
498
+ `workspace-write` a launched Claude session may write its cwd, `$TMPDIR`, and
499
+ **its own configuration directory** — the `CLAUDE_CONFIG_DIR` the child will
500
+ actually use, which an identity launch or a requester profile's `env_defaults`
501
+ may relocate. That last root is granted because an agent's memory lives there:
502
+ without it every Bash write to memory failed with `EPERM` while the in-process
503
+ file tools succeeded, which reads from inside the session as a path bug rather
504
+ than a boundary.
505
+
506
+ Under `read` nothing is writable, including the config dir. Under `full-access`
507
+ there is no sandbox to grant into.
508
+
509
+ One path stays denied that muster cannot open: **`$CLAUDE_CONFIG_DIR/projects/`**
510
+ (and `shell-snapshots/`). Claude Code refuses writes there in a sandboxed Bash
511
+ regardless of what muster grants — naming those directories explicitly in
512
+ `allowWrite` changes nothing, which is presumably deliberate, since a session's
513
+ own transcripts live there. Project-scoped memory under
514
+ `projects/<cwd-slug>/memory` is therefore reachable only through the file tools,
515
+ which are in-process and not kernel-sandboxed. Note that `work` implies auto
516
+ mode, which asks the model to prefer shell commands for file edits, so a session
517
+ saving project-scoped memory has to be told to use `Write`/`Edit` instead.
518
+
495
519
  ### Requester profiles and enrolments
496
520
 
497
521
  A non-local caller — one reached through a transport such as Tin Can rather
package/dist/guard.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
3
4
  import { realpath } from "node:fs/promises";
4
- import { isAbsolute, relative, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
7
  import { mcpName, claudeMcpConfig } from "./mcp.js";
6
8
  import { pluginName } from "./plugins.js";
7
9
  import { RequesterConfigError } from "./requester-policy.js";
@@ -316,7 +318,47 @@ export function runtimeArgs(req) {
316
318
  }
317
319
  return result;
318
320
  }
319
- export function launchArgs(req, config, mcp = [], bridgePath) {
321
+ /**
322
+ * The config dir the launched agent will actually use. `launchEnv`'s blocklist
323
+ * is /^(CLAUDECODE|CLAUDE_CODE_|...)/, which does not match CLAUDE_CONFIG_DIR,
324
+ * so the child inherits whatever the caller set and this resolves to the same
325
+ * dir the child will open.
326
+ */
327
+ export function claudeConfigDir(env) {
328
+ const configured = env.CLAUDE_CONFIG_DIR;
329
+ if (configured && configured.trim().length > 0)
330
+ return resolve(configured);
331
+ return join(env.HOME ?? homedir(), ".claude");
332
+ }
333
+ /**
334
+ * The keys of a profile's own settings.json that name its skill set rather than
335
+ * its policy. `--setting-sources ""` is what keeps `permissions`, `sandbox` and
336
+ * `hooks` muster's alone — forwarding those would let a settings file re-widen
337
+ * what `assertClaudePolicy` refuses to take on trust — so only these two are
338
+ * carried across, and muster's keys are spread after them and win regardless.
339
+ */
340
+ const forwardedSettings = ["enabledPlugins", "extraKnownMarketplaces"];
341
+ function profilePluginSettings(configDir) {
342
+ let parsed;
343
+ try {
344
+ parsed = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8"));
345
+ }
346
+ catch {
347
+ // No settings file, unreadable, or not JSON: a launch with built-in skills
348
+ // only is degraded but correct, and failing here would take the sandbox fix
349
+ // down with it.
350
+ return {};
351
+ }
352
+ if (parsed === null || typeof parsed !== "object")
353
+ return {};
354
+ const source = parsed;
355
+ const out = {};
356
+ for (const key of forwardedSettings)
357
+ if (source[key] !== undefined)
358
+ out[key] = source[key];
359
+ return out;
360
+ }
361
+ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env) {
320
362
  // Before `runtimeArgs`, so this function owns every model error on its own
321
363
  // rather than by its caller's discipline: `Muster.launch` happens to resolve
322
364
  // first, but `launchArgs` is exported and an input carrying both a model
@@ -357,7 +399,9 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
357
399
  : policy.permissions === "bypass"
358
400
  ? "bypassPermissions"
359
401
  : "dontAsk";
402
+ const configDir = claudeConfigDir(env);
360
403
  const settings = {
404
+ ...profilePluginSettings(configDir),
361
405
  disableAllHooks: true,
362
406
  permissions: {
363
407
  defaultMode: mode,
@@ -374,7 +418,17 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
374
418
  autoAllowBashIfSandboxed: true,
375
419
  allowUnsandboxedCommands: false,
376
420
  excludedCommands: [],
377
- filesystem: { denyWrite: readonly ? ["/"] : [] },
421
+ filesystem: {
422
+ denyWrite: readonly ? ["/"] : [],
423
+ // The agent's own memory lives under the config dir, outside the cwd
424
+ // that Claude Code defaults the write root to, so Bash writes to it
425
+ // failed with EPERM while the in-process file tools succeeded (#80).
426
+ // Granted only under workspace-write: read-only must stay read-only,
427
+ // and full-access has no sandbox to grant into.
428
+ ...(policy.sandbox === "workspace-write"
429
+ ? { allowWrite: [configDir] }
430
+ : {}),
431
+ },
378
432
  },
379
433
  };
380
434
  return [
package/dist/run.js CHANGED
@@ -240,7 +240,7 @@ export class Muster {
240
240
  if (req.runtime === "opencode")
241
241
  runtimeArgs(effective);
242
242
  else
243
- argv = launchArgs(effective, this.config);
243
+ argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv);
244
244
  const driver = req.kind === "session"
245
245
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
246
246
  : undefined;
@@ -389,7 +389,10 @@ export class Muster {
389
389
  await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
390
390
  }
391
391
  if (req.runtime !== "opencode")
392
- argv = launchArgs(effective, this.config, prepared.servers, bridgePath);
392
+ // `runtimeEnv`, not the host's: an identity launch relocates
393
+ // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
394
+ // the dir the child will actually open, not the one Muster runs under.
395
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
393
396
  if (req.runtime === "codex") {
394
397
  codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
395
398
  argv.splice(1, 0, ...codexPolicy);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {