@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 +50 -10
- package/dist/config.js +27 -8
- package/dist/enforcement.js +2 -1
- package/dist/format.js +10 -4
- package/dist/guard.js +102 -5
- package/dist/help.js +36 -12
- package/dist/mcp-bridge.js +4 -3
- package/dist/mcp-install.js +207 -0
- package/dist/mcp-tools.js +21 -4
- package/dist/mcp.js +22 -2
- package/dist/muster.js +52 -5
- package/dist/options.js +1 -1
- package/dist/requester-policy.js +1 -1
- package/dist/run.js +19 -4
- package/package.json +2 -2
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 .
|
|
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' --
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
|
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.
|
|
14
|
-
*
|
|
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("
|
|
33
|
-
min_enforcement: z.enum(["kernel", "tool-policy"]).
|
|
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
|
-
|
|
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
|
-
.
|
|
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");
|
package/dist/enforcement.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AGENTS } from "./agents.js";
|
|
2
2
|
export function enforcementGrade(runtime, resolved) {
|
|
3
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
if (record.
|
|
60
|
-
lines.push(field("
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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
|
}
|
package/dist/mcp-bridge.js
CHANGED
|
@@ -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(...
|
|
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 (!
|
|
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 (
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 (--
|
|
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
|
/**
|
package/dist/requester-policy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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": "
|
|
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": {
|