@brutalsystems/muster 0.11.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/TINCAN_LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Mike Williams
3
+ Copyright (c) 2026 BrutalSystems
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/dist/agents.js ADDED
@@ -0,0 +1,47 @@
1
+ import { abortOpenCodeSession } from "./stop/opencode.js";
2
+ import { refreshClaude, refreshCodex, refreshOpenCode, } from "./refresh/index.js";
3
+ import { setCodexWorkspaceTrust, setWorkspaceTrust } from "./identity-copy.js";
4
+ /**
5
+ * The model flags Muster resolves itself. Listed as `consumed` for the runtimes
6
+ * whose model Muster owns, so a caller may NAME a model without it reaching the
7
+ * child's argv twice.
8
+ *
9
+ * Exactly these two spellings. Anything else a caller passes after `--` is in
10
+ * neither list and is refused, which is what keeps `--config=sandbox_mode=...`
11
+ * an error rather than something quietly dropped.
12
+ */
13
+ export const MODEL_FLAGS = ["--model", "-m"];
14
+ /**
15
+ * One row per runtime. `Record<Runtime, …>` is the point: a fourth agent is a
16
+ * compile error until every column is filled, where the scattered
17
+ * `runtime === ?` checks this replaced would simply have been incomplete.
18
+ */
19
+ export const AGENTS = {
20
+ claude: {
21
+ configHomeVar: "CLAUDE_CONFIG_DIR",
22
+ enforcement: "kernel",
23
+ runtimeOptions: { forwarded: ["--effort"], consumed: MODEL_FLAGS },
24
+ trustWorkspace: setWorkspaceTrust,
25
+ beforeStop: null,
26
+ refreshMetadata: refreshClaude,
27
+ },
28
+ codex: {
29
+ configHomeVar: "CODEX_HOME",
30
+ enforcement: "kernel",
31
+ runtimeOptions: { forwarded: [], consumed: MODEL_FLAGS },
32
+ trustWorkspace: setCodexWorkspaceTrust,
33
+ beforeStop: null,
34
+ refreshMetadata: refreshCodex,
35
+ },
36
+ opencode: {
37
+ configHomeVar: null,
38
+ enforcement: "tool-policy",
39
+ runtimeOptions: {
40
+ forwarded: ["--model", "-m", "--agent", "--variant"],
41
+ consumed: [],
42
+ },
43
+ trustWorkspace: null,
44
+ beforeStop: abortOpenCodeSession,
45
+ refreshMetadata: refreshOpenCode,
46
+ },
47
+ };
@@ -3,6 +3,31 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+ export function claudeMcpConfig(servers, bridgePath) {
8
+ if (servers.length && !bridgePath)
9
+ throw new Error("Missing MCP bridge configuration");
10
+ return {
11
+ mcpServers: Object.fromEntries(servers.map(({ name, config }) => [
12
+ name,
13
+ {
14
+ type: "stdio",
15
+ command: process.execPath,
16
+ args: [
17
+ fileURLToPath(new URL("../dist/mcp-bridge.js", import.meta.url)),
18
+ bridgePath,
19
+ name,
20
+ ],
21
+ env: Object.fromEntries([
22
+ ...config.env_vars,
23
+ ...(config.bearer_token_env_var
24
+ ? [config.bearer_token_env_var]
25
+ : []),
26
+ ].map((key) => [key, "${" + key + "}"])),
27
+ },
28
+ ])),
29
+ };
30
+ }
6
31
  /** Claude's CLI cannot report all effective enterprise policy before starting.
7
32
  * Refuse detected managed sources rather than pretending inline settings win. */
8
33
  export async function assertClaudePolicy(env) {
@@ -1,8 +1,26 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import TOML from "@iarna/toml";
4
- import { codexMcpConfig } from "./mcp.js";
5
4
  const exec = promisify(execFile);
5
+ export function codexMcpConfig(server) {
6
+ const c = server.config;
7
+ return {
8
+ ...(c.command
9
+ ? { command: c.command, args: c.args, env: c.env, env_vars: c.env_vars }
10
+ : {
11
+ url: c.url,
12
+ ...(c.bearer_token_env_var
13
+ ? { bearer_token_env_var: c.bearer_token_env_var }
14
+ : {}),
15
+ }),
16
+ enabled: true,
17
+ required: server.required,
18
+ enabled_tools: c.tools,
19
+ disabled_tools: [],
20
+ startup_timeout_sec: c.startup_timeout_sec,
21
+ default_tools_approval_mode: "approve",
22
+ };
23
+ }
6
24
  export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = [],
7
25
  /**
8
26
  * True when the launch itself will pass `--ignore-user-config`, as task
@@ -1,5 +1,6 @@
1
+ import { AGENTS } from "./agents.js";
1
2
  export function enforcementGrade(runtime, resolved) {
2
3
  if (resolved.sandbox === "full-access")
3
4
  return "none";
4
- return runtime === "opencode" ? "tool-policy" : "kernel";
5
+ return AGENTS[runtime].enforcement;
5
6
  }
package/dist/guard.js CHANGED
@@ -4,8 +4,11 @@ import { readFileSync } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import { isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { claudeConfigDir } from "./identity/claude.js";
7
- import { mcpName, claudeMcpConfig } from "./mcp.js";
7
+ import { mcpName } from "./mcp.js";
8
+ import { claudeMcpConfig } from "./claude-policy.js";
8
9
  import { pluginName } from "./plugins.js";
10
+ import { AGENTS, MODEL_FLAGS } from "./agents.js";
11
+ import { LAUNCH_OPTIONS } from "./options.js";
9
12
  import { RequesterConfigError } from "./requester-policy.js";
10
13
  import { parseDuration } from "./duration.js";
11
14
  export const runSchema = z
@@ -41,6 +44,11 @@ export const runSchema = z
41
44
  .optional(),
42
45
  requestKey: z.string().min(1).max(200).optional(),
43
46
  level: z.enum(["read", "work", "open"]).optional(),
47
+ /**
48
+ * Named things muster does on the caller's behalf. Closed set, unlike the
49
+ * pass-through after `--`: see src/options.ts.
50
+ */
51
+ options: z.array(z.enum(LAUNCH_OPTIONS)).default([]),
44
52
  args: z.array(z.string()).default([]),
45
53
  })
46
54
  .strict();
@@ -166,7 +174,6 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
166
174
  ]))
167
175
  .digest("hex");
168
176
  }
169
- const MODEL_FLAGS = ["--model", "-m"];
170
177
  /**
171
178
  * The flag half of any pass-through arg, lowercased with `_` mapped to `-`.
172
179
  * Extracted from `runtimeArgs`, which already normalised this way inline, and
@@ -234,22 +241,6 @@ export function resolveModel(req, config) {
234
241
  ? null
235
242
  : { model: configured, source: "config", origin: "config" };
236
243
  }
237
- /**
238
- * Per runtime: which pass-through options ride to the child, and which are
239
- * recognised but delivered by `resolveModel` instead. A consumed flag must not
240
- * be refused — naming a model in `args` is still a supported way to ask, and
241
- * it is the only way that worked before `--model` reached these runtimes.
242
- * OpenCode is deliberately absent from `consumed`: it already forwards a model
243
- * from `args` onto its argv and `test/opencode-policy.test.ts` pins that.
244
- */
245
- const RUNTIME_OPTIONS = {
246
- claude: { forwarded: ["--effort"], consumed: MODEL_FLAGS },
247
- codex: { forwarded: [], consumed: MODEL_FLAGS },
248
- opencode: {
249
- forwarded: ["--model", "-m", "--agent", "--variant"],
250
- consumed: [],
251
- },
252
- };
253
244
  /** 30 minutes. Long enough that a trust prompt or a coffee does not cost a
254
245
  * session; short enough that an orphan dies within the hour. */
255
246
  export const DEFAULT_IDLE_TIMEOUT = 1800;
@@ -295,7 +286,7 @@ export function resolveLifecycle(req, config, reapable) {
295
286
  }
296
287
  export function runtimeArgs(req) {
297
288
  const result = [];
298
- const { forwarded, consumed } = RUNTIME_OPTIONS[req.runtime];
289
+ const { forwarded, consumed } = AGENTS[req.runtime].runtimeOptions;
299
290
  for (let i = 0; i < req.args.length; i++) {
300
291
  const arg = req.args[i], flag = normaliseFlag(arg);
301
292
  const keep = forwarded.includes(flag);
package/dist/help.js CHANGED
@@ -58,6 +58,10 @@ export const COMMANDS = [
58
58
  ["--permissions MODE", "auto, deny, or bypass"],
59
59
  ["--sandbox MODE", "read-only, workspace-write, or full-access"],
60
60
  ["--mcp NAME", "repeatable; replaces configured defaults"],
61
+ [
62
+ "--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)",
64
+ ],
61
65
  ["--no-mcp", "launch with no MCP servers"],
62
66
  ["--plugin NAME", "repeatable; replaces configured defaults (OpenCode)"],
63
67
  ["--no-plugin", "launch with no plugins"],
@@ -0,0 +1,59 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { command } from "./processes.js";
3
+ /**
4
+ * The key a workspace's trust is recorded under.
5
+ *
6
+ * NOT the launch directory. Claude Code 2.1.274 has two gates and they disagree
7
+ * about how far they will look, so only one key satisfies both:
8
+ *
9
+ * - The **dialog** gate walks up from the launch directory looking for any
10
+ * trusted ancestor, but stops AT the git repo root — a trusted `/` does not
11
+ * reach inside a repo.
12
+ * - The **settings** gate, which decides whether `.claude/settings.json` is
13
+ * honoured, tests one exact key with no walking at all, and that key is the
14
+ * repo root.
15
+ *
16
+ * So writing the launch directory silences the dialog and still leaves the
17
+ * workspace untrusted for settings. Claude says so itself, from a subdirectory
18
+ * of an untrusted repo:
19
+ *
20
+ * Ignoring 1 permissions.allow entry from .claude/settings.json: this
21
+ * workspace has not been trusted ... set projects["<repo root>"]
22
+ *
23
+ * Writing the repo root satisfies both: the settings gate matches it exactly,
24
+ * and the dialog gate's walk reaches it before hitting its ceiling.
25
+ *
26
+ * A linked worktree keys on its MAIN checkout, which is why this asks git for
27
+ * the common directory rather than the top level — verified against a real
28
+ * linked worktree, whose `.git` is a file rather than a directory.
29
+ *
30
+ * Outside a repository, or with no usable git, the answer is the directory
31
+ * itself: the dialog gate then walks freely and the settings gate wants exactly
32
+ * this path.
33
+ */
34
+ export async function workspaceTrustKey(cwd) {
35
+ const resolved = await realpath(cwd).catch(() => cwd);
36
+ try {
37
+ // `--git-common-dir`, not `--show-toplevel`: a linked worktree's top level
38
+ // is the worktree, while Claude keys on the checkout that owns the repo.
39
+ const common = (await command("git", [
40
+ "-C",
41
+ resolved,
42
+ "rev-parse",
43
+ "--path-format=absolute",
44
+ "--git-common-dir",
45
+ ], 2000)).trim();
46
+ if (!common)
47
+ return resolved;
48
+ // <main checkout>/.git for a normal repo and for a linked worktree alike.
49
+ // A bare repo has no checkout to trust, so it falls through to the cwd.
50
+ const base = common.replace(/\/\.git\/?$/, "");
51
+ if (base === common)
52
+ return resolved;
53
+ return await realpath(base).catch(() => base);
54
+ }
55
+ catch {
56
+ // Not a repository, or git unavailable: the directory is its own key.
57
+ return resolved;
58
+ }
59
+ }
package/dist/mcp-tools.js CHANGED
@@ -61,6 +61,11 @@ export const MCP_TOOLS = [
61
61
  items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
62
62
  description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
63
63
  },
64
+ options: {
65
+ type: "array",
66
+ items: { type: "string", enum: ["auto-approve-path"] },
67
+ description: "Muster-side launch options. auto-approve-path records the launch directory as trusted in the profile the agent will use, so its one-time dialog does not block the launch; refused for a runtime with no trust gate, and refused if the directory ships hooks or MCP servers. Not expressible remotely.",
68
+ },
64
69
  project: {
65
70
  type: "string",
66
71
  pattern: "^[a-zA-Z0-9_-]+$",
package/dist/mcp.js CHANGED
@@ -1,4 +1,3 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { z } from "zod";
3
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
3
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -162,46 +161,3 @@ export async function prepareMcp(selected, env, cwd, deadline) {
162
161
  },
163
162
  };
164
163
  }
165
- export function codexMcpConfig(server) {
166
- const c = server.config;
167
- return {
168
- ...(c.command
169
- ? { command: c.command, args: c.args, env: c.env, env_vars: c.env_vars }
170
- : {
171
- url: c.url,
172
- ...(c.bearer_token_env_var
173
- ? { bearer_token_env_var: c.bearer_token_env_var }
174
- : {}),
175
- }),
176
- enabled: true,
177
- required: server.required,
178
- enabled_tools: c.tools,
179
- disabled_tools: [],
180
- startup_timeout_sec: c.startup_timeout_sec,
181
- default_tools_approval_mode: "approve",
182
- };
183
- }
184
- export function claudeMcpConfig(servers, bridgePath) {
185
- if (servers.length && !bridgePath)
186
- throw new Error("Missing MCP bridge configuration");
187
- return {
188
- mcpServers: Object.fromEntries(servers.map(({ name, config }) => [
189
- name,
190
- {
191
- type: "stdio",
192
- command: process.execPath,
193
- args: [
194
- fileURLToPath(new URL("../dist/mcp-bridge.js", import.meta.url)),
195
- bridgePath,
196
- name,
197
- ],
198
- env: Object.fromEntries([
199
- ...config.env_vars,
200
- ...(config.bearer_token_env_var
201
- ? [config.bearer_token_env_var]
202
- : []),
203
- ].map((key) => [key, "${" + key + "}"])),
204
- },
205
- ])),
206
- };
207
- }
package/dist/muster.js CHANGED
@@ -169,6 +169,7 @@ async function main() {
169
169
  sandbox: { type: "string" },
170
170
  level: { type: "string" },
171
171
  mcp: { type: "string", multiple: true },
172
+ options: { type: "string", multiple: true },
172
173
  "no-mcp": { type: "boolean" },
173
174
  plugin: { type: "string", multiple: true },
174
175
  model: { type: "string" },
@@ -0,0 +1,63 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { AGENTS } from "./agents.js";
4
+ /**
5
+ * Options are things MUSTER does on the caller's behalf, which is what
6
+ * separates them from the pass-through after `--`.
7
+ *
8
+ * A pass-through reaches the runtime verbatim and is refused unless the agent's
9
+ * row allow-lists it. An option is a muster concept that each agent expresses
10
+ * in its own way, or cannot express at all — so the set is closed, and naming
11
+ * one an agent has no answer for is an error rather than a silent no-op.
12
+ */
13
+ export const LAUNCH_OPTIONS = ["auto-approve-path"];
14
+ /**
15
+ * Refuses an option the runtime cannot honour, before anything is reserved or
16
+ * spawned. `auto-approve-path` records that the launch directory is trusted, so
17
+ * it needs an agent with a trust gate to record it in; OpenCode has none, and
18
+ * accepting the flag there would promise something muster cannot deliver.
19
+ */
20
+ export function assertOptionsSupported(runtime, options) {
21
+ for (const option of options)
22
+ if (option === "auto-approve-path" && !AGENTS[runtime].trustWorkspace)
23
+ throw new Error(`--options auto-approve-path is not supported for ${runtime}: it has no workspace-trust gate`);
24
+ }
25
+ /**
26
+ * What a directory ships that would configure a session it is trusted by.
27
+ *
28
+ * Trusting a folder lets its own `.claude/settings.json` configure the session,
29
+ * hooks included, and hooks run without asking — which is the reason the trust
30
+ * dialog exists at all. Auto-approving without looking would be answering a
31
+ * question nobody read, so a directory carrying either of these is reported and
32
+ * the launch refuses rather than trusting it quietly.
33
+ *
34
+ * A `settings.json` WITHOUT hooks is not a finding: it configures the editor,
35
+ * not the session's ability to run commands.
36
+ *
37
+ * Any read failure is silence rather than a finding. This decides whether to
38
+ * refuse a launch, and refusing because a file could not be parsed would stop
39
+ * work for a reason that is not about risk.
40
+ */
41
+ export async function screenWorkspace(cwd) {
42
+ const findings = [];
43
+ try {
44
+ const raw = await readFile(join(cwd, ".claude", "settings.json"), "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (parsed !== null &&
47
+ typeof parsed === "object" &&
48
+ "hooks" in parsed &&
49
+ Object.keys(parsed.hooks ?? {}).length)
50
+ findings.push("hooks in .claude/settings.json");
51
+ }
52
+ catch {
53
+ // No file, unreadable, or not JSON: nothing established either way.
54
+ }
55
+ try {
56
+ if ((await stat(join(cwd, ".mcp.json"))).isFile())
57
+ findings.push("MCP servers in .mcp.json");
58
+ }
59
+ catch {
60
+ // Absent is the common case and not a finding.
61
+ }
62
+ return findings;
63
+ }
@@ -0,0 +1,66 @@
1
+ import { CodexRpc } from "../codex-rpc.js";
2
+ import { OpenCodeHttp } from "../opencode-http.js";
3
+ import { codexReachable } from "../reach/codex.js";
4
+ import { openCodeReachable } from "../reach/opencode.js";
5
+ import { claudeReachable } from "../reach/claude.js";
6
+ import { resolveClaude } from "../identity/claude.js";
7
+ import { openCodeEndpointOwnedBy } from "../identity/opencode.js";
8
+ import { isSame } from "../identity/processes.js";
9
+ /**
10
+ * Live metadata for a running session, one implementation per agent.
11
+ *
12
+ * These pass the same test `trustWorkspace` and `beforeStop` passed: the inputs
13
+ * match. Each takes the entry, the environment the agent's own configuration
14
+ * lives in, and a deadline, and each reads from the entry whatever it needs —
15
+ * a server URL, a configuration home, nothing at all. Readiness could not be a
16
+ * slot for the opposite reason: it needs a socket path, a live RPC handle and a
17
+ * server URL, which is three different inputs wearing one name.
18
+ *
19
+ * Throwing is how "unreachable" is reported; the caller turns it into that
20
+ * state. Each implementation owns any connection it opens.
21
+ */
22
+ export async function refreshCodex(entry, env, deadline) {
23
+ // The connection is opened and closed here rather than by the caller, so a
24
+ // refresh cannot leak one by taking an early path out.
25
+ const rpc = new CodexRpc(env, entry.cwd);
26
+ try {
27
+ return await codexReachable(rpc, entry.id, deadline);
28
+ }
29
+ finally {
30
+ await rpc.close();
31
+ }
32
+ }
33
+ export async function refreshOpenCode(entry, _env, deadline) {
34
+ if (!entry.server_url)
35
+ throw new Error("session endpoint missing");
36
+ if (!entry.root)
37
+ throw new Error("session has no root process");
38
+ // Checked BEFORE and AFTER the read, which is not belt-and-braces: a loopback
39
+ // port is a shared resource and a pid can be reused, so an endpoint that was
40
+ // ours when the read started may belong to something else by the time it
41
+ // finishes, and metadata from a stranger is worse than none.
42
+ const owned = async () => (await isSame(entry.root)) &&
43
+ (await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline));
44
+ if (!(await owned()))
45
+ throw new Error("session endpoint is not owned by the launch");
46
+ const metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), {
47
+ id: entry.id,
48
+ pid: entry.root.pid,
49
+ rawName: null,
50
+ cwd: entry.cwd,
51
+ serverUrl: entry.server_url,
52
+ }, deadline);
53
+ if (!(await owned()))
54
+ throw new Error("session endpoint ownership changed during refresh");
55
+ return metadata;
56
+ }
57
+ export async function refreshClaude(entry, env, deadline) {
58
+ if (!entry.root)
59
+ throw new Error("session has no root process");
60
+ const identity = await resolveClaude(entry.root.pid, env, deadline);
61
+ if (!identity ||
62
+ identity.id !== entry.id ||
63
+ !(await claudeReachable(identity.socketPath, deadline)))
64
+ throw new Error("session unreachable");
65
+ return identity;
66
+ }