@brutalsystems/muster 1.2.2 → 3.0.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
@@ -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.
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.
81
84
 
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 |
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
@@ -114,6 +125,35 @@ What that verification covers is in [Verification](docs/verification.md).
114
125
  | [MCP servers and plugins](docs/mcp.md) | tools for launched agents, and Muster's own MCP server |
115
126
  | [Verification](docs/verification.md) | what is tested, what is checked by hand |
116
127
 
128
+ ## Use with Claude Code
129
+
130
+ 1. **Install the plugin.** Its `muster` skill teaches a Claude session to use
131
+ Muster well: choosing a kind and level, reaching and stopping what it
132
+ launched, and the traps. It is versioned with Muster, so it describes the
133
+ release it came from.
134
+
135
+ ```sh
136
+ claude plugin marketplace add BrutalSystems/muster
137
+ claude plugin install muster@muster
138
+ ```
139
+
140
+ Or, inside a session, `/plugin marketplace add BrutalSystems/muster` and
141
+ then `/plugin install muster@muster`.
142
+
143
+ 2. **Register the MCP server** for the current Claude profile:
144
+
145
+ ```sh
146
+ muster mcp install
147
+ ```
148
+
149
+ It is read-only by default (`list`, `output`), so registering it at user
150
+ scope grants no launch authority. `muster mcp install --allow-launch` adds
151
+ `run`, `stop` and `title`, and every session in that profile can then
152
+ launch and stop sessions. `muster mcp status` shows which you have.
153
+ Details: [MCP installation](docs/mcp.md#mcp-installation).
154
+
155
+ 3. **Restart Claude Code** (or start a new session) to load it.
156
+
117
157
  ## Keeping it up to date
118
158
 
119
159
  Publishing a release does not touch an installed copy: `muster --version` keeps
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
  {
@@ -176,13 +191,22 @@ export const COMMANDS = [
176
191
  },
177
192
  {
178
193
  name: "mcp",
179
- summary: "start the MCP server on stdio",
180
- synopsis: "muster mcp",
181
- options: [],
194
+ summary: "serve MCP on stdio, or register it with Claude Code",
195
+ synopsis: "muster mcp [--allow-launch] | mcp install|uninstall|status",
196
+ options: [
197
+ ["--allow-launch", "also expose run, stop and title (launch authority)"],
198
+ ["--force", "install: replace a different existing registration"],
199
+ ],
182
200
  notes: [
183
- "Exposes run, list, stop and output. No arguments does the same when stdin is not a terminal, which is what an MCP client provides.",
201
+ "Read-only by default: exposes list and output only. --allow-launch is the deliberate grant. No arguments starts the read-only server when stdin is not a terminal, which is what an MCP client provides.",
202
+ "install registers the server with Claude Code at user scope for the current profile (CLAUDE_CONFIG_DIR) via `claude mcp add`. Without --allow-launch it asks at a terminal, defaulting to no; without a terminal it does not grant. Restart Claude Code afterwards.",
203
+ ],
204
+ examples: [
205
+ "muster mcp",
206
+ "muster mcp install",
207
+ "muster mcp install --allow-launch --force",
208
+ "muster mcp status",
184
209
  ],
185
- examples: ["muster mcp"],
186
210
  },
187
211
  ];
188
212
  export function topLevelHelp() {
@@ -204,7 +228,7 @@ export function topLevelHelp() {
204
228
  // Kept here rather than moved to the README: this is a safety statement
205
229
  // about who gets launch authority, not reference detail, and it is only
206
230
  // load-bearing where someone is about to install.
207
- ...wrap("MCP: install deliberately in the one authorized session, never at user scope.", WIDTH),
231
+ ...wrap("MCP: `muster mcp install` registers a read-only server with Claude Code. Launch authority is granted only by --allow-launch.", WIDTH),
208
232
  ];
209
233
  return lines.join("\n") + "\n";
210
234
  }
@@ -6,13 +6,14 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
- import { mcpServerSchema, mcpName, mcpTransport } from "./mcp.js";
9
+ import { mcpServerSchema, mcpName, mcpTransport, toolGate } from "./mcp.js";
10
10
  async function main() {
11
11
  const [path, name] = process.argv.slice(2);
12
12
  if (!path || !name || !mcpName.safeParse(name).success)
13
13
  throw new Error("Invalid MCP bridge arguments");
14
14
  const definitions = JSON.parse(await readFile(path, "utf8"));
15
15
  const config = mcpServerSchema.parse(definitions[name]);
16
+ const gate = toolGate(config.tools);
16
17
  const client = new Client({ name: "muster-tool-bridge", version: "1" }, { capabilities: {} });
17
18
  const server = new Server({ name: `muster-${name}`, version: "1" }, { capabilities: { tools: {} } });
18
19
  let shutdown;
@@ -59,13 +60,13 @@ async function main() {
59
60
  let cursor;
60
61
  do {
61
62
  const page = await client.listTools(cursor ? { cursor } : {});
62
- tools.push(...page.tools.filter((t) => config.tools.includes(t.name)));
63
+ tools.push(...gate.expose(page.tools));
63
64
  cursor = page.nextCursor;
64
65
  } while (cursor);
65
66
  return { tools };
66
67
  });
67
68
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
68
- if (!config.tools.includes(params.name))
69
+ if (!gate.allows(params.name))
69
70
  return {
70
71
  isError: true,
71
72
  content: [
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `muster mcp install | uninstall | status` — registering Muster's own MCP
3
+ * server with Claude Code at user scope, for the profile `CLAUDE_CONFIG_DIR`
4
+ * names (or the default one).
5
+ *
6
+ * Every write goes through `claude mcp add` / `claude mcp remove`, so Muster
7
+ * never edits Claude's configuration itself. Reading is different: the current
8
+ * definition is read straight from the profile's `.claude.json`, because
9
+ * `claude mcp get` health-checks a server by starting it and reports whichever
10
+ * scope wins, where this needs the user-scope entry exactly as written.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { access, constants, readFile, realpath } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { delimiter, join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+ import { LAUNCH_TOOLS, mcpToolNames } from "./mcp-tools.js";
18
+ export const SERVER_NAME = "muster";
19
+ export const ALLOW_LAUNCH = "--allow-launch";
20
+ export const LAUNCH_QUESTION = "Let this Claude launch and stop sessions? [y/N] ";
21
+ export const MCP_SUBCOMMANDS = ["install", "uninstall", "status"];
22
+ /** The first executable called `name` on PATH, as PATH spells it. */
23
+ export async function onPath(name, env) {
24
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
25
+ if (!dir)
26
+ continue;
27
+ const candidate = join(dir, name);
28
+ try {
29
+ await access(candidate, constants.X_OK);
30
+ return candidate;
31
+ }
32
+ catch { }
33
+ }
34
+ return undefined;
35
+ }
36
+ /**
37
+ * The command a client should run to reach this muster. The installed `muster`
38
+ * bin when the one on PATH is this very script — its PATH spelling, not its
39
+ * resolved target, so a package-manager link survives an upgrade. Otherwise
40
+ * (a source checkout, or a different install first on PATH) this node and this
41
+ * script, absolutely, so the registration runs what the person just ran.
42
+ */
43
+ export async function selfInvocation(env, script = process.argv[1], node = process.execPath) {
44
+ const resolved = await realpath(script);
45
+ const found = await onPath("muster", env);
46
+ if (found && (await realpath(found).catch(() => undefined)) === resolved)
47
+ return { command: found, args: [] };
48
+ return { command: node, args: [resolved] };
49
+ }
50
+ export function claudeConfigFile(env) {
51
+ return join(env.CLAUDE_CONFIG_DIR || env.HOME || homedir(), ".claude.json");
52
+ }
53
+ /** The user-scope `muster` entry, or undefined when there is none. */
54
+ export async function registered(env) {
55
+ const file = claudeConfigFile(env);
56
+ let raw;
57
+ try {
58
+ raw = await readFile(file, "utf8");
59
+ }
60
+ catch (e) {
61
+ if (e.code === "ENOENT")
62
+ return undefined;
63
+ throw e;
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ throw new Error(`Cannot read ${file}: it is not valid JSON`);
71
+ }
72
+ const entry = parsed?.mcpServers?.[SERVER_NAME];
73
+ if (!entry || typeof entry !== "object")
74
+ return undefined;
75
+ return {
76
+ command: typeof entry.command === "string" ? entry.command : "",
77
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
78
+ };
79
+ }
80
+ export function desired(self, allowLaunch) {
81
+ return {
82
+ command: self.command,
83
+ args: [...self.args, "mcp", ...(allowLaunch ? [ALLOW_LAUNCH] : [])],
84
+ };
85
+ }
86
+ const same = (a, b) => a.command === b.command &&
87
+ a.args.length === b.args.length &&
88
+ a.args.every((arg, i) => arg === b.args[i]);
89
+ const quote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : JSON.stringify(s);
90
+ export const render = (d) => [d.command, ...d.args].map(quote).join(" ");
91
+ const launchAllowed = (d) => d.args.includes(ALLOW_LAUNCH);
92
+ function toolsLine(allowLaunch) {
93
+ return allowLaunch
94
+ ? `Tools: ${mcpToolNames(true).join(", ")} — this Claude can launch and stop sessions.`
95
+ : `Tools: ${mcpToolNames(false).join(", ")} — read-only; it cannot launch or stop anything.`;
96
+ }
97
+ async function defaultRunClaude(binary, args, env) {
98
+ return new Promise((resolve) => {
99
+ execFile(binary, args, { env, timeout: 60_000 }, (error, stdout, stderr) => resolve({
100
+ code: error ? (typeof error.code === "number" ? error.code : 1) : 0,
101
+ stdout: String(stdout),
102
+ stderr: String(stderr),
103
+ }));
104
+ });
105
+ }
106
+ async function claude(deps) {
107
+ const binary = await onPath("claude", deps.env);
108
+ if (!binary)
109
+ throw new Error("`claude` is not on PATH. Install Claude Code, or put it on PATH, and run this again.");
110
+ const run = deps.runClaude ?? defaultRunClaude;
111
+ return async (args, what) => {
112
+ const result = await run(binary, args, deps.env);
113
+ if (result.code !== 0)
114
+ throw new Error(`\`claude ${args.join(" ")}\` failed while ${what}: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
115
+ return result;
116
+ };
117
+ }
118
+ async function install(opts, deps) {
119
+ const run = await claude(deps);
120
+ let allowLaunch = opts.allowLaunch;
121
+ // Default no, and no question at all without a terminal: the grant is made
122
+ // by a person typing the flag or answering yes, never by a script's silence.
123
+ if (!allowLaunch && deps.ask)
124
+ allowLaunch = /^y(es)?$/i.test((await deps.ask(LAUNCH_QUESTION)).trim());
125
+ const want = desired(await deps.self(), allowLaunch);
126
+ const file = claudeConfigFile(deps.env);
127
+ const have = await registered(deps.env);
128
+ if (have && same(have, want)) {
129
+ deps.write(`Already registered at user scope in ${file}; nothing changed.\n ${render(want)}\n${toolsLine(allowLaunch)}\n`);
130
+ return;
131
+ }
132
+ if (have && !opts.force)
133
+ throw new Error(`A different "${SERVER_NAME}" MCP server is already registered at user scope in ${file}:\n` +
134
+ ` have: ${render(have)}\n want: ${render(want)}\n` +
135
+ `Run again with --force to replace it, or \`muster mcp uninstall\` first.`);
136
+ if (have)
137
+ await run(["mcp", "remove", "--scope", "user", SERVER_NAME], "removing the existing definition");
138
+ await run([
139
+ "mcp",
140
+ "add",
141
+ "--scope",
142
+ "user",
143
+ SERVER_NAME,
144
+ "--",
145
+ want.command,
146
+ ...want.args,
147
+ ], "registering the server");
148
+ deps.write(`${have ? "Replaced" : "Registered"} the "${SERVER_NAME}" MCP server at user scope in ${file}.\n` +
149
+ ` ${render(want)}\n${toolsLine(allowLaunch)}\n` +
150
+ "Restart Claude Code (or start a new session) to load it.\n");
151
+ }
152
+ async function uninstall(deps) {
153
+ const run = await claude(deps);
154
+ const file = claudeConfigFile(deps.env);
155
+ if (!(await registered(deps.env))) {
156
+ deps.write(`No "${SERVER_NAME}" MCP server at user scope in ${file}; nothing to remove.\n`);
157
+ return;
158
+ }
159
+ await run(["mcp", "remove", "--scope", "user", SERVER_NAME], "removing the server");
160
+ deps.write(`Removed the "${SERVER_NAME}" MCP server from user scope in ${file}.\n` +
161
+ "Restart Claude Code (or start a new session) to drop it.\n");
162
+ }
163
+ async function status(deps) {
164
+ const file = claudeConfigFile(deps.env);
165
+ const have = await registered(deps.env);
166
+ if (!have) {
167
+ deps.write(`Not registered: no "${SERVER_NAME}" MCP server at user scope in ${file}.\n` +
168
+ "Run `muster mcp install` to add it.\n");
169
+ return;
170
+ }
171
+ const allowed = launchAllowed(have);
172
+ deps.write(`Registered at user scope in ${file}.\n ${render(have)}\n` +
173
+ `Launch allowed: ${allowed ? "yes" : "no"}\n${toolsLine(allowed)}\n`);
174
+ }
175
+ /** `muster mcp <subcommand> ...`, already known to name a subcommand. */
176
+ export async function mcpCommand(argv, deps) {
177
+ const [sub, ...rest] = argv;
178
+ const { values, positionals } = parseArgs({
179
+ args: rest,
180
+ allowPositionals: true,
181
+ strict: true,
182
+ options: {
183
+ "allow-launch": { type: "boolean" },
184
+ force: { type: "boolean" },
185
+ },
186
+ });
187
+ if (positionals.length)
188
+ throw new Error(`mcp ${sub} takes no positional arguments`);
189
+ if (sub !== "install" && (values["allow-launch"] || values.force))
190
+ throw new Error(`--allow-launch and --force apply only to mcp install`);
191
+ if (sub === "install")
192
+ return install({
193
+ allowLaunch: Boolean(values["allow-launch"]),
194
+ force: Boolean(values.force),
195
+ }, deps);
196
+ if (sub === "uninstall")
197
+ return uninstall(deps);
198
+ if (sub === "status")
199
+ return status(deps);
200
+ throw new Error(`Unknown mcp subcommand: ${sub}`);
201
+ }
202
+ /** The refusal for a tool the running server does not expose. */
203
+ export function notEnabled(name) {
204
+ return LAUNCH_TOOLS.includes(name)
205
+ ? `Tool ${name} is not enabled: this Muster MCP server is read-only. Start it with \`muster mcp --allow-launch\` (or register it with \`muster mcp install --allow-launch\`) to expose ${LAUNCH_TOOLS.join(", ")}.`
206
+ : `Unknown tool ${name}`;
207
+ }
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",
@@ -165,3 +165,20 @@ export const MCP_TOOLS = [
165
165
  * callers may not set the field, not a place to park an oversight.
166
166
  */
167
167
  export const RUN_MCP_OMITTED = [];
168
+ /**
169
+ * What `muster mcp` serves by default: reading what Muster already owns.
170
+ * Nothing here can start, stop or change a run, so registering the server
171
+ * where every Claude session sees it grants no launch authority.
172
+ */
173
+ export const READ_ONLY_TOOLS = ["list", "output"];
174
+ /**
175
+ * Added by `muster mcp --allow-launch`, the deliberate grant. `title` is here
176
+ * rather than above because it changes a running session, and the read-only
177
+ * set is defined as changing nothing.
178
+ */
179
+ export const LAUNCH_TOOLS = ["run", "stop", "title"];
180
+ export function mcpToolNames(allowLaunch) {
181
+ return allowLaunch
182
+ ? [...READ_ONLY_TOOLS, ...LAUNCH_TOOLS]
183
+ : [...READ_ONLY_TOOLS];
184
+ }
package/dist/mcp.js CHANGED
@@ -39,9 +39,29 @@ export const mcpServerSchema = z
39
39
  message: "args, env and env_vars require a stdio server",
40
40
  });
41
41
  });
42
+ /**
43
+ * One allowlist, applied to both halves of an MCP server: what `tools/list`
44
+ * advertises and what `tools/call` will dispatch. Used by the Claude bridge
45
+ * (the operator's selected tools of an upstream server) and by Muster's own
46
+ * server (read-only unless started with `--allow-launch`). Filtering the
47
+ * listing alone is not enough — a client can call a name it was never shown —
48
+ * so every call is checked against the same set.
49
+ */
50
+ export function toolGate(enabled) {
51
+ const names = new Set(enabled);
52
+ return {
53
+ expose: (tools) => tools.filter((tool) => names.has(tool.name)),
54
+ allows: (name) => names.has(name),
55
+ };
56
+ }
42
57
  export function selectMcp(req, config) {
43
- // Personal defaults apply to sessions, not unattended one-shot tasks.
44
- 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
+ : []);
45
65
  return [...new Set(names)].map((name) => {
46
66
  const server = Object.hasOwn(config.mcp_servers, name)
47
67
  ? config.mcp_servers[name]
package/dist/muster.js CHANGED
@@ -12,7 +12,9 @@ import { checkPlugins, npmRegistry } from "./doctor.js";
12
12
  import { loadConfig, musterHome } from "./config.js";
13
13
  import { describeIdentities, interactiveSetup, setupIdentity, } from "./identity-cli.js";
14
14
  import { IdentityError } from "./identity-store.js";
15
- import { MCP_TOOLS } from "./mcp-tools.js";
15
+ import { MCP_TOOLS, mcpToolNames } from "./mcp-tools.js";
16
+ import { toolGate } from "./mcp.js";
17
+ import { ALLOW_LAUNCH, MCP_SUBCOMMANDS, mcpCommand, notEnabled, selfInvocation, } from "./mcp-install.js";
16
18
  import { normalizeOpenFlag, usesTerminalFlag } from "./guard.js";
17
19
  import { commandHelp, topLevelHelp } from "./help.js";
18
20
  import { promptForMissing, terminalReader } from "./prompt.js";
@@ -75,8 +77,28 @@ async function main() {
75
77
  // driven by hand in a terminal to debug it.
76
78
  const bareAtTerminal = argv.length === 0 && Boolean(process.stdin.isTTY);
77
79
  const mcp = !bareAtTerminal && (argv.length === 0 || argv[0] === "mcp");
78
- if (argv[0] === "mcp" && argv.length > 1)
79
- throw new Error("mcp takes no arguments");
80
+ // `muster mcp install|uninstall|status` registers the server with Claude
81
+ // Code rather than serving it. Handled before Muster.create(): none of them
82
+ // touches a run.
83
+ if (argv[0] === "mcp" &&
84
+ MCP_SUBCOMMANDS.includes(argv[1] ?? "")) {
85
+ if (argv.includes("--help") || argv.includes("-h")) {
86
+ process.stdout.write(commandHelp("mcp"));
87
+ return;
88
+ }
89
+ await mcpCommand(argv.slice(1), {
90
+ env: process.env,
91
+ write: (s) => process.stdout.write(s),
92
+ ...(process.stdin.isTTY ? { ask: terminalReader() } : {}),
93
+ self: () => selfInvocation(process.env),
94
+ });
95
+ return;
96
+ }
97
+ // Read-only unless launching is granted by the flag. Bare `muster` down a
98
+ // pipe is the same server, so it is read-only too.
99
+ const allowLaunch = argv[0] === "mcp" && argv[1] === ALLOW_LAUNCH;
100
+ if (argv[0] === "mcp" && argv.length > (allowLaunch ? 2 : 1))
101
+ throw new Error(`mcp takes ${ALLOW_LAUNCH}, or one of ${MCP_SUBCOMMANDS.join(", ")}`);
80
102
  const muster = await Muster.create();
81
103
  let shutdown;
82
104
  const close = () => (shutdown ??= (async () => {
@@ -107,10 +129,16 @@ async function main() {
107
129
  });
108
130
  if (mcp) {
109
131
  const server = new Server({ name: "muster", version }, { capabilities: { tools: {} } });
132
+ const gate = toolGate(mcpToolNames(allowLaunch));
110
133
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
111
- tools: MCP_TOOLS,
134
+ tools: gate.expose(MCP_TOOLS),
112
135
  }));
113
136
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
137
+ if (!gate.allows(params.name))
138
+ return {
139
+ content: [{ type: "text", text: notEnabled(params.name) }],
140
+ isError: true,
141
+ };
114
142
  try {
115
143
  const args = params.arguments ?? {};
116
144
  switch (params.name) {
@@ -175,6 +203,9 @@ async function main() {
175
203
  permissions: { type: "string" },
176
204
  sandbox: { type: "string" },
177
205
  level: { type: "string" },
206
+ "read-only": { type: "boolean" },
207
+ "write-here": { type: "boolean" },
208
+ unrestricted: { type: "boolean" },
178
209
  mcp: { type: "string", multiple: true },
179
210
  options: { type: "string", multiple: true },
180
211
  "no-mcp": { type: "boolean" },
@@ -195,7 +226,23 @@ async function main() {
195
226
  interactive: { type: "boolean" },
196
227
  },
197
228
  });
198
- 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];
199
246
  // Guarded like --request-key and --no-mcp below: a flag that applies to one
200
247
  // command and is silently ignored elsewhere misleads, and --home silently
201
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": "1.2.2",
3
+ "version": "3.0.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -21,7 +21,7 @@
21
21
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
22
22
  "prepack": "npm run build",
23
23
  "prepublishOnly": "npm test",
24
- "version": "node scripts/stamp-release-notes.mjs && git add RELEASE_NOTES.md",
24
+ "version": "node scripts/stamp-release-notes.mjs && node scripts/stamp-plugin-version.mjs && git add RELEASE_NOTES.md plugins/claude-code/.claude-plugin/plugin.json",
25
25
  "postversion": "git push origin HEAD --follow-tags"
26
26
  },
27
27
  "dependencies": {