@brutalsystems/muster 2.0.0 → 3.0.1

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
@@ -64,10 +64,10 @@ models; behaviour against real ones is verified by hand and recorded in
64
64
  npm install -g --allow-scripts=@brutalsystems/muster,node-pty @brutalsystems/muster
65
65
 
66
66
  # A session: an agent you can reach, addressed once it is genuinely alive.
67
- muster run claude --prompt 'Review this project' --cwd . --level work
67
+ muster run claude --prompt 'Review this project' --cwd .
68
68
 
69
69
  # A task: runs once, captures output, advertises nothing.
70
- muster run codex --kind task --prompt 'Summarise the failing tests' --level read
70
+ muster run codex --kind task --prompt 'Summarise the failing tests' --read-only
71
71
  muster output <id>
72
72
 
73
73
  # What is running, and stopping it.
@@ -75,15 +75,26 @@ muster list --format human
75
75
  muster stop <id>
76
76
  ```
77
77
 
78
- `--level` names a containment pair. `read` can look but not change; `work` can
79
- edit its working directory, except muster's own home (`~/.muster`); `open`
80
- removes the sandbox and needs `allow_dangerous_flags` in config.
81
-
82
- | Level | The agent asks before acting | What it may touch |
83
- | ------ | ---------------------------- | --------------------------- |
84
- | `read` | always | nothing on disk |
85
- | `work` | no | its cwd, except `~/.muster` |
86
- | `open` | no | everything |
78
+ **A Claude launch with no containment flag opens exactly as you would open it
79
+ by hand** in that profile and folder. It gets your rules and CLAUDE.md, your
80
+ permissions, hooks, status line, plugins, MCP servers and settings `env`.
81
+ Muster adds nothing and replaces nothing. The one thing it refuses is a profile
82
+ whose default permission mode is bypass. Codex and OpenCode stay read-only by
83
+ default.
84
+
85
+ When you want muster's guarantee instead, **seal** the launch. A sealed launch
86
+ runs on muster's own settings in place of the profile's, so it loads no rules
87
+ or CLAUDE.md:
88
+
89
+ | Flag | The agent asks before acting | What it may touch |
90
+ | ---------------- | ---------------------------- | --------------------------- |
91
+ | `--read-only` | always | nothing on disk |
92
+ | `--write-here` | no | its cwd, except `~/.muster` |
93
+ | `--unrestricted` | no | everything |
94
+
95
+ `--unrestricted` removes the sandbox and needs `allow_dangerous_flags` in
96
+ config. `--level read|work|open` is the older spelling of the same three and
97
+ still works.
87
98
 
88
99
  Claude asks to trust a directory the first time. That is a one-time review you
89
100
  do yourself — or `--options auto-approve-path`, which records it for you after
package/dist/config.js CHANGED
@@ -10,8 +10,13 @@ import { identityNameSchema } from "./identity-store.js";
10
10
  import { authoritySchema, subjectSchema } from "./requester.js";
11
11
  import { RequesterConfigError } from "./requester-policy.js";
12
12
  /**
13
- * What a remote requester is permitted here. Every default is the narrowest
14
- * value, so the shortest profile is the most restrictive one.
13
+ * What a remote requester is permitted here.
14
+ *
15
+ * `level` defaults to `normal`: an enrolled remote caller gets a launch like
16
+ * the operator's own, as a local one does (Mike, 2026-10-14). Naming `read` or
17
+ * `work` is how a profile caps it. `min_enforcement` follows the level it is
18
+ * written beside — `none` for a normal profile, which muster does not enforce,
19
+ * and `kernel` for a capped one — unless the profile names it.
15
20
  *
16
21
  * `allowed_roots` is the one key with no safe default: the global
17
22
  * `allowed_roots` defaults to empty, which `assertAllowedWorkspace` reads as
@@ -29,8 +34,8 @@ import { RequesterConfigError } from "./requester-policy.js";
29
34
  export const requesterProfileSchema = z
30
35
  .object({
31
36
  allowed_roots: z.array(z.string().min(1)).min(1),
32
- level: z.enum(["read", "work"]).default("read"),
33
- min_enforcement: z.enum(["kernel", "tool-policy"]).default("kernel"),
37
+ level: z.enum(["read", "work", "normal"]).default("normal"),
38
+ min_enforcement: z.enum(["kernel", "tool-policy", "none"]).optional(),
34
39
  env_allow: z.array(z.string().min(1)).default([]),
35
40
  /**
36
41
  * Baseline overrides, e.g. a PATH that does not depend on the accepting
@@ -42,7 +47,18 @@ export const requesterProfileSchema = z
42
47
  /** Which identities this requester may launch under. Empty refuses any. */
43
48
  identities: z.array(identityNameSchema).default([]),
44
49
  })
45
- .strict();
50
+ .strict()
51
+ .transform((p) => ({
52
+ ...p,
53
+ min_enforcement: p.min_enforcement ?? (p.level === "normal" ? "none" : "kernel"),
54
+ }))
55
+ // A normal launch is the profile's own settings with nothing of muster's on
56
+ // top, so its enforcement grade is `none`. A profile that grants it and keeps
57
+ // a stricter floor would refuse every launch at its own default level, which
58
+ // is a configuration error worth reporting at load rather than at launch.
59
+ .refine((p) => p.level !== "normal" || p.min_enforcement === "none", {
60
+ message: 'level = "normal" hands the launch to the agent\'s own settings, which muster does not enforce; set min_enforcement = "none" alongside it',
61
+ });
46
62
  /** Binding an authenticated `(authority, subject)` to a profile. The grant. */
47
63
  export const requesterEnrolmentSchema = z
48
64
  .object({
@@ -90,7 +106,10 @@ export const configSchema = z
90
106
  projects: z
91
107
  .record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
92
108
  .default({}),
93
- permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
109
+ // No defaults: naming neither is how a Claude launch knows to open as a
110
+ // person would open it. `resolvePermissions` supplies deny/read-only for
111
+ // everything else.
112
+ permissions: z.enum(["auto", "deny", "bypass"]).optional(),
94
113
  sandbox: z
95
114
  .enum([
96
115
  "read-only",
@@ -98,7 +117,7 @@ export const configSchema = z
98
117
  "full-access",
99
118
  "danger-full-access",
100
119
  ])
101
- .default("read-only"),
120
+ .optional(),
102
121
  default_mcp: z.array(mcpName).default([]),
103
122
  mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
104
123
  default_plugins: z.array(pluginName).default([]),
@@ -164,7 +183,7 @@ export async function loadConfig(home = musterHome()) {
164
183
  throw new RequesterConfigError(`Duplicate requester enrolment for ${e.authority}/${e.subject}`);
165
184
  seen.add(key);
166
185
  }
167
- if ((["danger-full-access", "full-access"].includes(result.sandbox) ||
186
+ if ((["danger-full-access", "full-access"].includes(result.sandbox ?? "") ||
168
187
  result.permissions === "bypass") &&
169
188
  !result.allow_dangerous_flags)
170
189
  throw new Error("full-access or bypass requires allow_dangerous_flags in config.toml");
@@ -1,6 +1,7 @@
1
1
  import { AGENTS } from "./agents.js";
2
2
  export function enforcementGrade(runtime, resolved) {
3
- if (resolved.sandbox === "full-access")
3
+ // Normal: the agent's own settings decide, and muster imposes nothing.
4
+ if (resolved.sandbox === "full-access" || resolved.sandbox === "normal")
4
5
  return "none";
5
6
  return AGENTS[runtime].enforcement;
6
7
  }
package/dist/format.js CHANGED
@@ -54,10 +54,16 @@ function recordHuman(record, color) {
54
54
  lines.push(field("Directory", clean(record.cwd)));
55
55
  if (record.identity)
56
56
  lines.push(field("Identity", clean(record.identity)));
57
- if (record.permissions)
58
- lines.push(field("Permissions", clean(record.permissions)));
59
- if (record.sandbox)
60
- lines.push(field("Sandbox", clean(record.sandbox)));
57
+ // A normal launch has no permissions or sandbox of muster's to report; one
58
+ // line saying whose settings apply reads better than "normal" twice.
59
+ if (record.permissions === "normal")
60
+ lines.push(field("Settings", "the profile's own, as if opened by hand"));
61
+ else {
62
+ if (record.permissions)
63
+ lines.push(field("Permissions", clean(record.permissions)));
64
+ if (record.sandbox)
65
+ lines.push(field("Sandbox", clean(record.sandbox)));
66
+ }
61
67
  // Only when known: a null means the child chose its own model, and "-" in a
62
68
  // human listing would read as a value rather than an absence. The source is
63
69
  // shown because "pinned by the caller" and "fell back to the configured
package/dist/guard.js CHANGED
@@ -48,7 +48,7 @@ export const runSchema = z
48
48
  .regex(/^[a-zA-Z0-9_-]+$/)
49
49
  .optional(),
50
50
  requestKey: z.string().min(1).max(200).optional(),
51
- level: z.enum(["read", "work", "open"]).optional(),
51
+ level: z.enum(["read", "work", "normal", "open"]).optional(),
52
52
  /**
53
53
  * Named things muster does on the caller's behalf. Closed set, unlike the
54
54
  * pass-through after `--`: see src/options.ts.
@@ -59,15 +59,32 @@ export const runSchema = z
59
59
  .strict();
60
60
  /**
61
61
  * A level is a spelling of a legal `permissions` + `sandbox` pair, not a second
62
- * policy path — `test/level.test.ts` asserts the equivalence for all three.
62
+ * policy path — `test/level.test.ts` asserts the equivalence for every level.
63
63
  * The two legal combinations no level names, `deny` + `workspace-write` and
64
64
  * `deny` + `full-access`, stay reachable through the flags.
65
+ *
66
+ * The CLI spells them `--read-only`, `--write-here` and `--unrestricted`;
67
+ * `normal` has no flag because it is what a Claude launch naming none gets.
65
68
  */
66
69
  export const LEVELS = {
67
70
  read: { permissions: "deny", sandbox: "read-only" },
68
71
  work: { permissions: "auto", sandbox: "workspace-write" },
72
+ normal: { permissions: "normal", sandbox: "normal" },
69
73
  open: { permissions: "bypass", sandbox: "full-access" },
70
74
  };
75
+ /** The CLI's flag for each level, for messages that tell a caller what to type. */
76
+ export const LEVEL_FLAGS = {
77
+ read: "--read-only",
78
+ work: "--write-here",
79
+ open: "--unrestricted",
80
+ };
81
+ /** Only Claude has a launch muster can leave entirely to the profile so far. */
82
+ function assertNormalSupported(runtime) {
83
+ if (runtime !== "claude")
84
+ throw new Error(`a normal launch (the agent's own settings, as if opened by hand) is ` +
85
+ `supported for claude only; for ${runtime} pass --read-only, ` +
86
+ `--write-here or --unrestricted`);
87
+ }
71
88
  /**
72
89
  * Checked against raw input before parsing, like `project` and `cwd`: once
73
90
  * defaults have been applied a caller's value is indistinguishable from one
@@ -83,14 +100,26 @@ export function assertLevelExclusive(input) {
83
100
  }
84
101
  export function resolvePermissions(req, config) {
85
102
  if (req.level) {
103
+ if (req.level === "normal")
104
+ assertNormalSupported(req.runtime);
86
105
  const pair = { ...LEVELS[req.level] };
87
106
  if ((pair.permissions === "bypass" || pair.sandbox === "full-access") &&
88
107
  !config.allow_dangerous_flags)
89
108
  throw new Error("bypass or full-access requires allow_dangerous_flags=true in config.toml");
90
109
  return pair;
91
110
  }
92
- const permissions = req.permissions ?? config.permissions;
93
- const configuredSandbox = req.sandbox ?? config.sandbox;
111
+ // Nothing named anywhere: a Claude launch opens as a person would open it.
112
+ // The other runtimes keep the sealed read-only default until they have a
113
+ // normal launch of their own. A config that names either key is an
114
+ // operator's explicit default and is honoured as before.
115
+ if (req.permissions === undefined &&
116
+ req.sandbox === undefined &&
117
+ config.permissions === undefined &&
118
+ config.sandbox === undefined &&
119
+ req.runtime === "claude")
120
+ return { ...LEVELS.normal };
121
+ const permissions = req.permissions ?? config.permissions ?? "deny";
122
+ const configuredSandbox = req.sandbox ?? config.sandbox ?? "read-only";
94
123
  const sandbox = configuredSandbox === "danger-full-access"
95
124
  ? "full-access"
96
125
  : configuredSandbox;
@@ -187,7 +216,7 @@ export function assertMusterHomeProtectable(runtime, cwd, policy, home) {
187
216
  `is inside muster's own home (${home}), which holds the config every ` +
188
217
  `later launch obeys. OpenCode's permissions are tool policy and cannot ` +
189
218
  `keep its shell out of one folder, so launch it in a folder that does ` +
190
- `not contain muster's home, or at --level read.`);
219
+ `not contain muster's home, or with --read-only.`);
191
220
  }
192
221
  /** Whether `inner` is `outer` or below it, by path segments, not characters. */
193
222
  function contains(outer, inner) {
@@ -530,6 +559,12 @@ musterHome = defaultMusterHome(env)) {
530
559
  // builders, which carry the model in their config overlay.
531
560
  const model = resolved ? ["--model", resolved.model] : [];
532
561
  const policy = resolvePermissions(req, config);
562
+ if (policy.permissions === "normal") {
563
+ // `resolvePermissions` refuses this outside claude; restated so the codex
564
+ // branch below can never read `normal` as one of its own modes.
565
+ assertNormalSupported(req.runtime);
566
+ return normalClaudeArgs(req, mcp, bridgePath, env, model, extra);
567
+ }
533
568
  if (req.runtime === "codex")
534
569
  return [
535
570
  "codex",
@@ -658,6 +693,68 @@ musterHome = defaultMusterHome(env)) {
658
693
  req.prompt,
659
694
  ];
660
695
  }
696
+ /**
697
+ * A Claude launch exactly as a person would start it in this profile and
698
+ * directory: every setting source (so rules, CLAUDE.md, the env and autoMode
699
+ * blocks, permissions, hooks, the status line, plugins and the profile's own
700
+ * MCP servers), no permission mode of muster's, no sandbox of muster's.
701
+ *
702
+ * Muster adds only what the caller named: `--mcp` servers ride alongside the
703
+ * profile's (not instead of them — no `--strict-mcp-config`), and configured
704
+ * `default_mcp` is not applied because the profile already brings its own.
705
+ *
706
+ * The one thing it refuses is a profile or project that makes bypass the
707
+ * default: a muster launch never starts in bypass unless `--unrestricted`
708
+ * asked for it.
709
+ */
710
+ function normalClaudeArgs(req, mcp, bridgePath, env, model, extra) {
711
+ assertNoDefaultBypass(claudeConfigDir(env), req.cwd);
712
+ return [
713
+ "claude",
714
+ ...(req.kind === "task" ? ["-p"] : []),
715
+ ...(mcp.length
716
+ ? ["--mcp-config", JSON.stringify(claudeMcpConfig(mcp, bridgePath))]
717
+ : []),
718
+ ...model,
719
+ ...extra,
720
+ "--",
721
+ req.prompt,
722
+ ];
723
+ }
724
+ /**
725
+ * The setting files a hand launch reads for `permissions.defaultMode`, lowest
726
+ * precedence first. Managed policy is above all three and is the operator's
727
+ * organisation's to set, so it is not second-guessed here.
728
+ */
729
+ export function claudeSettingFiles(configDir, cwd) {
730
+ return [
731
+ join(configDir, "settings.json"),
732
+ join(cwd, ".claude", "settings.json"),
733
+ join(cwd, ".claude", "settings.local.json"),
734
+ ];
735
+ }
736
+ /** Refuses a normal launch whose effective default mode is bypassPermissions. */
737
+ export function assertNoDefaultBypass(configDir, cwd) {
738
+ let mode, from;
739
+ for (const file of claudeSettingFiles(configDir, cwd)) {
740
+ let parsed;
741
+ try {
742
+ parsed = JSON.parse(readFileSync(file, "utf8"));
743
+ }
744
+ catch {
745
+ // Missing or unreadable: Claude skips it too.
746
+ continue;
747
+ }
748
+ const value = parsed
749
+ ?.permissions?.defaultMode;
750
+ if (value !== undefined)
751
+ [mode, from] = [value, file];
752
+ }
753
+ if (mode === "bypassPermissions")
754
+ throw new Error(`${from} sets permissions.defaultMode to bypassPermissions; muster ` +
755
+ `will not start a launch in bypass by default. Pass --unrestricted ` +
756
+ `to ask for it.`);
757
+ }
661
758
  /** A path as given and as resolved, once each. An unresolvable path (it does
662
759
  * not exist yet) is denied as given — nothing can be written through a
663
760
  * missing directory anyway. */
package/dist/help.js CHANGED
@@ -51,16 +51,28 @@ export const COMMANDS = [
51
51
  "--terminal APP",
52
52
  "deprecated, removed in v2: pass the app to --open instead",
53
53
  ],
54
+ [
55
+ "--read-only",
56
+ "sealed: muster's own settings replace the profile's, and the agent can change nothing",
57
+ ],
58
+ [
59
+ "--write-here",
60
+ "sealed: muster's own settings replace the profile's, and the agent can write only inside the launch folder, never muster's own home",
61
+ ],
62
+ [
63
+ "--unrestricted",
64
+ "no sandbox and no permission checks; needs allow_dangerous_flags in config.toml",
65
+ ],
54
66
  [
55
67
  "--level LEVEL",
56
- "read, work, or open: a legal permissions and sandbox pair; work writes the launch folder except muster's own home",
68
+ "older spelling: read, work or open are the three flags above; normal is the claude default",
57
69
  ],
58
- ["--permissions MODE", "auto, deny, or bypass"],
59
- ["--sandbox MODE", "read-only, workspace-write, or full-access"],
70
+ ["--permissions MODE", "auto, deny, or bypass (sealed)"],
71
+ ["--sandbox MODE", "read-only, workspace-write, or full-access (sealed)"],
60
72
  ["--mcp NAME", "repeatable; replaces configured defaults"],
61
73
  [
62
74
  "--options NAME",
63
- "repeatable; muster-side launch options — auto-approve-path records the launch directory as trusted so its one-time dialog does not block the launch (claude and codex only, and refused if the directory ships hooks or MCP servers); accept-bypass-warning accepts Claude's one-time Bypass Permissions warning so a --level open launch can start (claude with bypass permissions only)",
75
+ "repeatable; muster-side launch options — auto-approve-path records the launch directory as trusted so its one-time dialog does not block the launch (claude and codex only, and refused if the directory ships hooks or MCP servers); accept-bypass-warning accepts Claude's one-time Bypass Permissions warning so an --unrestricted launch can start (claude with bypass permissions only)",
64
76
  ],
65
77
  ["--no-mcp", "launch with no MCP servers"],
66
78
  ["--plugin NAME", "repeatable; replaces configured defaults (OpenCode)"],
@@ -89,12 +101,15 @@ export const COMMANDS = [
89
101
  ["-- RUNTIME_OPTIONS", "everything after -- is passed to the runtime"],
90
102
  ],
91
103
  notes: [
92
- "Naming both --level and --permissions or --sandbox is an error rather than a precedence rule.",
104
+ "With none of --read-only, --write-here or --unrestricted, claude opens exactly as you would open it by hand in that folder: your profile's rules, CLAUDE.md, permissions, hooks, plugins, MCP servers and env. Muster refuses only a profile whose default mode is bypass. codex and opencode stay read-only by default.",
105
+ "A sealed launch does not read your profile's or the project's settings, so it also loads no rules or CLAUDE.md.",
106
+ "Use one of the three flags, --level, or --permissions/--sandbox; naming two is an error rather than a precedence rule.",
93
107
  "A pty launch keeps this process running and Ctrl-C stops it; a tmux launch survives this process exiting.",
94
108
  ],
95
109
  examples: [
96
110
  "muster run claude --prompt 'Review this project' --cwd .",
97
- "muster run codex --identity work --level work --prompt 'Ship it'",
111
+ "muster run claude --read-only --prompt 'Audit this' --cwd .",
112
+ "muster run codex --identity work --write-here --prompt 'Ship it'",
98
113
  ],
99
114
  },
100
115
  {
@@ -102,6 +102,19 @@ export function titleOptions(session, title) {
102
102
  * TERM Ghostty, iTerm2 and Terminal.app report; a terminal that does not
103
103
  * support the request ignores it.
104
104
  *
105
+ * `progressbar` lets tmux pass a pane's progress (OSC 9;4, which Claude Code
106
+ * sends while it works) to the attached terminal, and it has to be declared
107
+ * here rather than left to tmux. Muster starts the agent before any terminal
108
+ * attaches, so its first turn has usually set progress by then; tmux learns
109
+ * Ghostty's `progressbar` only from the XTVERSION reply after attaching, and
110
+ * does not then send the progress it is already holding, so the pane shows no
111
+ * busy state for the whole of that turn. Declared up front, the held state is
112
+ * replayed on attach. It matches any client whose TERM is `xterm-ghostty`
113
+ * (what Ghostty and Scotty set), whether or not that client ever answers
114
+ * XTVERSION, rather than `xterm*`: Terminal.app has no progress bar and iTerm2
115
+ * has read OSC 9 as a notification. It reaches clients that attach after the
116
+ * next launch on a server, not clients already attached.
117
+ *
105
118
  * `-q` because these name options newer than some tmux still installed
106
119
  * (`extended-keys-format` arrived in 3.5, `terminal-features` in 3.2), and an
107
120
  * unknown option is an error that would abort the whole command list and with
@@ -113,6 +126,7 @@ export const SERVER_OPTIONS = [
113
126
  ["-s", "extended-keys", "always"],
114
127
  ["-s", "extended-keys-format", "csi-u"],
115
128
  ["-s", "terminal-features[90]", "xterm*:extkeys"],
129
+ ["-s", "terminal-features[91]", "xterm-ghostty:progressbar"],
116
130
  ];
117
131
  /** `SERVER_OPTIONS` as `;`-terminated `set-option` commands, to lead a
118
132
  * command list. */
package/dist/mcp-tools.js CHANGED
@@ -44,17 +44,17 @@ export const MCP_TOOLS = [
44
44
  permissions: {
45
45
  type: "string",
46
46
  enum: ["auto", "deny", "bypass"],
47
- description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
47
+ description: "Seals the launch. Defaults to config, else deny (claude: normal when config names neither). Auto requires workspace-write; bypass requires authorized full-access.",
48
48
  },
49
49
  sandbox: {
50
50
  type: "string",
51
51
  enum: ["read-only", "workspace-write", "full-access"],
52
- description: "Defaults to config (read-only). Full access requires config authorization.",
52
+ description: "Seals the launch. Defaults to config, else read-only (claude: normal when config names neither). Full access requires config authorization.",
53
53
  },
54
54
  level: {
55
55
  type: "string",
56
- enum: ["read", "work", "open"],
57
- description: "A spelling of a legal permissions/sandbox pair: read is deny + read-only, work is auto + workspace-write, open is bypass + full-access. Naming a level and either flag is an error.",
56
+ enum: ["read", "work", "normal", "open"],
57
+ description: "normal (claude's default, claude only) opens the agent exactly as a person would by hand: the profile's rules, CLAUDE.md, permissions, hooks, plugins and MCP servers. read (deny + read-only), work (auto + workspace-write) and open (bypass + full-access) are sealed: muster's settings replace the profile's, so no rules or CLAUDE.md load. Naming a level and permissions or sandbox is an error.",
58
58
  },
59
59
  mcp: {
60
60
  type: "array",
package/dist/mcp.js CHANGED
@@ -55,8 +55,13 @@ export function toolGate(enabled) {
55
55
  };
56
56
  }
57
57
  export function selectMcp(req, config) {
58
- // Personal defaults apply to sessions, not unattended one-shot tasks.
59
- const names = req.mcp ?? (req.kind === "session" ? config.default_mcp : []);
58
+ // Personal defaults apply to sessions, not unattended one-shot tasks, and not
59
+ // to a normal launch: the profile already brings its own MCP servers, and a
60
+ // second copy of one under muster's bridge would register the session twice.
61
+ const names = req.mcp ??
62
+ (req.kind === "session" && req.level !== "normal"
63
+ ? config.default_mcp
64
+ : []);
60
65
  return [...new Set(names)].map((name) => {
61
66
  const server = Object.hasOwn(config.mcp_servers, name)
62
67
  ? config.mcp_servers[name]
package/dist/muster.js CHANGED
@@ -203,6 +203,9 @@ async function main() {
203
203
  permissions: { type: "string" },
204
204
  sandbox: { type: "string" },
205
205
  level: { type: "string" },
206
+ "read-only": { type: "boolean" },
207
+ "write-here": { type: "boolean" },
208
+ unrestricted: { type: "boolean" },
206
209
  mcp: { type: "string", multiple: true },
207
210
  options: { type: "string", multiple: true },
208
211
  "no-mcp": { type: "boolean" },
@@ -223,7 +226,23 @@ async function main() {
223
226
  interactive: { type: "boolean" },
224
227
  },
225
228
  });
226
- const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, ...values } = parsed;
229
+ const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, "read-only": readOnly, "write-here": writeHere, unrestricted, ...values } = parsed;
230
+ // The plain spellings of the three sealed levels. Each is a level, so the
231
+ // existing exclusivity with --permissions/--sandbox applies unchanged once
232
+ // it is folded into `values.level`.
233
+ const levelFlags = [
234
+ ["--read-only", readOnly, "read"],
235
+ ["--write-here", writeHere, "work"],
236
+ ["--unrestricted", unrestricted, "open"],
237
+ ].filter(([, set]) => set);
238
+ if (levelFlags.length && command !== "run")
239
+ throw new Error(`${levelFlags[0][0]} applies only to run`);
240
+ if (levelFlags.length > 1)
241
+ throw new Error(`use one of ${levelFlags.map(([name]) => name).join(", ")}, not several`);
242
+ if (levelFlags.length && values.level !== undefined)
243
+ throw new Error(`use ${levelFlags[0][0]} or --level, not both`);
244
+ if (levelFlags.length)
245
+ values.level = levelFlags[0][2];
227
246
  // Guarded like --request-key and --no-mcp below: a flag that applies to one
228
247
  // command and is silently ignored elsewhere misleads, and --home silently
229
248
  // reading the real registry is the one that would mislead most.
package/dist/options.js CHANGED
@@ -33,7 +33,7 @@ export function assertOptionsSupported(runtime, options, permissions) {
33
33
  throw new Error(`--options accept-bypass-warning is not supported for ${runtime}: only Claude shows a bypass-permissions warning`);
34
34
  if (option === "accept-bypass-warning" &&
35
35
  permissions.permissions !== "bypass")
36
- throw new Error(`--options accept-bypass-warning requires bypass permissions (--level open or --permissions bypass); this launch resolves to ${permissions.permissions}`);
36
+ throw new Error(`--options accept-bypass-warning requires bypass permissions (--unrestricted or --permissions bypass); this launch resolves to ${permissions.permissions}`);
37
37
  }
38
38
  }
39
39
  /**
@@ -30,7 +30,7 @@ export class RequesterConfigError extends Error {
30
30
  }
31
31
  }
32
32
  /** Ordered weakest to strongest, so a ceiling is one index comparison. */
33
- const LEVEL_ORDER = ["read", "work", "open"];
33
+ const LEVEL_ORDER = ["read", "work", "normal", "open"];
34
34
  const GRADE_ORDER = ["none", "tool-policy", "kernel"];
35
35
  export function resolveRequesterPolicy(requester, config) {
36
36
  if (requester.kind === "local")
package/dist/run.js CHANGED
@@ -188,7 +188,13 @@ export class Muster {
188
188
  // defaults. So the profile is the floor here as well as the ceiling. A
189
189
  // local requester has no `policy.level`, which leaves this undefined and
190
190
  // local behaviour untouched.
191
- const level = req.level ?? policy.level;
191
+ // A profile's normal default applies to claude only, the one runtime with
192
+ // a normal launch; any other runtime gets the read default it would get
193
+ // locally, rather than a refusal for a level the caller never named.
194
+ const level = req.level ??
195
+ (policy.level === "normal" && req.runtime !== "claude"
196
+ ? "read"
197
+ : policy.level);
192
198
  if (level)
193
199
  assertLevelWithinCeiling(level, policy);
194
200
  return { req, policy, level };
@@ -250,7 +256,7 @@ export class Muster {
250
256
  * that happens, so there is one: nothing below derives containment from
251
257
  * `req`.
252
258
  */
253
- const effective = level ? { ...req, level } : req;
259
+ const requested = level ? { ...req, level } : req;
254
260
  if (req.kind === "task" && req.host)
255
261
  throw new Error("host applies only to session launches");
256
262
  if (req.terminal && !req.open)
@@ -260,7 +266,13 @@ export class Muster {
260
266
  if (req.open &&
261
267
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
262
268
  throw new Error("--open requires a tmux session on macOS");
263
- const permissions = resolvePermissions(effective, this.config);
269
+ const permissions = resolvePermissions(requested, this.config);
270
+ // A Claude launch naming nothing resolves to normal without having asked
271
+ // for it by name; spelled out here so everything below — MCP defaults,
272
+ // the argv, the record — sees one answer rather than re-deriving it.
273
+ const effective = permissions.permissions === "normal"
274
+ ? { ...requested, level: "normal" }
275
+ : requested;
264
276
  // Refused here, with the other checks that cost nothing to fail: an option
265
277
  // the runtime cannot honour must not leave a registry entry behind.
266
278
  assertOptionsSupported(req.runtime, req.options, permissions);
@@ -292,7 +304,10 @@ export class Muster {
292
304
  if (req.open &&
293
305
  (!driver?.open || !(await driver.openAvailable?.(terminal))))
294
306
  throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
295
- if (req.runtime === "claude")
307
+ // Managed policy is refused because muster cannot prove its own inline
308
+ // settings win over it. A normal launch has no settings of muster's to
309
+ // prove, and obeys managed policy exactly as a hand launch does.
310
+ if (req.runtime === "claude" && permissions.permissions !== "normal")
296
311
  await assertClaudePolicy(this.hostEnv);
297
312
  if (req.runtime === "opencode")
298
313
  await assertOpenCodeVersion(this.hostEnv, "1.18.31", Date.now() + 4000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {