@brutalsystems/muster 0.7.11 → 0.7.14

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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The `run`, `list`, `stop` and `output` tools exactly as the MCP server
3
+ * advertises them.
4
+ *
5
+ * Extracted from `src/muster.ts` so it can be ASSERTED against `runSchema`
6
+ * rather than kept in step by hand. One request shape had two hand-maintained
7
+ * descriptions and nothing checked that they agreed, and four fields drifted
8
+ * across two branches: `level`, then `identity`, `model` and `plugin`. Because
9
+ * the schema is `additionalProperties: false`, an omitted key is not merely
10
+ * undocumented — a conformant client is told it is INVALID.
11
+ * `test/mcp-run-schema.test.ts` is what ends that class.
12
+ */
13
+ export const MCP_TOOLS = [
14
+ {
15
+ name: "run",
16
+ description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ runtime: {
21
+ type: "string",
22
+ enum: ["codex", "claude", "opencode"],
23
+ },
24
+ prompt: { type: "string", minLength: 1 },
25
+ cwd: { type: "string" },
26
+ kind: {
27
+ type: "string",
28
+ enum: ["session", "task"],
29
+ default: "session",
30
+ },
31
+ host: {
32
+ type: "string",
33
+ enum: ["auto", "tmux", "pty", "macos-terminal"],
34
+ },
35
+ open: {
36
+ type: "boolean",
37
+ description: "Open the tmux session in a terminal viewer (macOS only).",
38
+ },
39
+ terminal: {
40
+ type: "string",
41
+ enum: ["auto", "terminal", "iterm2", "ghostty"],
42
+ description: "Viewer app; requires open. Auto uses Terminal.app.",
43
+ },
44
+ permissions: {
45
+ type: "string",
46
+ enum: ["auto", "deny", "bypass"],
47
+ description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
48
+ },
49
+ sandbox: {
50
+ type: "string",
51
+ enum: ["read-only", "workspace-write", "full-access"],
52
+ description: "Defaults to config (read-only). Full access requires config authorization.",
53
+ },
54
+ level: {
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.",
58
+ },
59
+ mcp: {
60
+ type: "array",
61
+ items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
62
+ description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
63
+ },
64
+ project: {
65
+ type: "string",
66
+ pattern: "^[a-zA-Z0-9_-]+$",
67
+ description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
68
+ },
69
+ requestKey: {
70
+ type: "string",
71
+ minLength: 1,
72
+ maxLength: 200,
73
+ description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
74
+ },
75
+ plugin: {
76
+ type: "array",
77
+ items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
78
+ description: "Configured OpenCode plugin names. Omit for session defaults; [] disables all. OpenCode only.",
79
+ },
80
+ model: {
81
+ type: "string",
82
+ minLength: 1,
83
+ description: "PROVIDER/MODEL, overriding the configured [opencode] model. OpenCode only.",
84
+ },
85
+ identity: {
86
+ type: "string",
87
+ pattern: "^[a-zA-Z0-9_-]+$",
88
+ description: "A named identity created by `muster setup-identity`; the agent runs as that account instead of inheriting the ambient environment. A non-local requester is bounded by its profile's identities.",
89
+ },
90
+ args: { type: "array", items: { type: "string" } },
91
+ },
92
+ required: ["runtime", "prompt"],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ {
97
+ name: "list",
98
+ description: "List Muster-owned sessions and tasks, including host capabilities.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: { kind: { type: "string", enum: ["session", "task"] } },
102
+ additionalProperties: false,
103
+ },
104
+ },
105
+ {
106
+ name: "stop",
107
+ description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: { id: { type: "string", minLength: 1 } },
111
+ required: ["id"],
112
+ additionalProperties: false,
113
+ },
114
+ },
115
+ {
116
+ name: "output",
117
+ description: "Read captured output from a task run.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: { id: { type: "string", minLength: 1 } },
121
+ required: ["id"],
122
+ additionalProperties: false,
123
+ },
124
+ },
125
+ ];
126
+ /**
127
+ * runSchema keys deliberately absent from the advertised `run` inputSchema.
128
+ *
129
+ * Empty today, and it stays here rather than being implicit: adding a field to
130
+ * runSchema without either advertising it or naming it here — with a reason —
131
+ * fails the drift test. That is the point. An entry is a decision that MCP
132
+ * callers may not set the field, not a place to park an oversight.
133
+ */
134
+ export const RUN_MCP_OMITTED = [];
package/dist/muster.js CHANGED
@@ -6,11 +6,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { z } from "zod";
8
8
  import { Muster } from "./run.js";
9
- import { runSchema } from "./guard.js";
10
9
  import { delay } from "./identity/processes.js";
11
10
  import { formatHuman, humanColorEnabled } from "./format.js";
12
11
  import { checkPlugins, npmRegistry } from "./doctor.js";
13
- import { loadConfig } from "./config.js";
12
+ import { loadConfig, musterHome } from "./config.js";
13
+ import { describeIdentities, setupIdentity } from "./identity-cli.js";
14
+ import { MCP_TOOLS } from "./mcp-tools.js";
14
15
  const listSchema = z
15
16
  .object({ kind: z.enum(["session", "task"]).optional() })
16
17
  .strict();
@@ -22,11 +23,17 @@ const help = `Usage:
22
23
  muster stop <id> [--format json|human]
23
24
  muster output <id>
24
25
  muster doctor [--format json|human]
26
+ muster setup-identity --identity NAME --agent <codex|claude|opencode> [--token-env VAR]
27
+ muster identities [--format json|human]
25
28
  muster mcp
26
29
  muster --version
27
30
 
28
31
  run also accepts --format json|human, --permissions auto|deny|bypass,
29
32
  and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
33
+ --level read|work|open names a legal permissions and sandbox pair: read is
34
+ deny + read-only, work is auto + workspace-write, open is bypass + full-access
35
+ and still requires allow_dangerous_flags. Naming both a level and either flag
36
+ is an error rather than a precedence rule.
30
37
  --open displays a tmux session on macOS. --terminal accepts auto (Terminal.app),
31
38
  terminal, iterm2, or ghostty, and requires --open.
32
39
  --mcp NAME is repeatable and replaces configured session defaults; --no-mcp
@@ -58,6 +65,27 @@ MCP: install deliberately in the one authorized session, never at user scope.
58
65
  const text = (value) => ({
59
66
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
60
67
  });
68
+ /**
69
+ * Human rendering for `identities`, kept local rather than added to
70
+ * format.ts's RecordView/DoctorView: an identity record shares no field with
71
+ * either of those shapes.
72
+ */
73
+ function identitiesHuman(view, color) {
74
+ const paint = (value, code) => color ? `[${code}m${value}` : value;
75
+ const lines = [];
76
+ if (!view.identities.length)
77
+ lines.push("No identities.");
78
+ for (const identity of view.identities) {
79
+ const code = identity.auth.state === "configured" ? 32 : 31;
80
+ lines.push(`${paint(identity.name, 1)} (${identity.agent}) — ${paint(identity.auth.state, code)}: ${identity.auth.detail}`);
81
+ }
82
+ if (view.staleGrants.length) {
83
+ lines.push("");
84
+ for (const grant of view.staleGrants)
85
+ lines.push(paint(`stale grant: profile ${grant.profile} names identity ${grant.identity}, which does not exist`, 33));
86
+ }
87
+ return lines.join("\n") + "\n";
88
+ }
61
89
  async function main() {
62
90
  const argv = process.argv.slice(2);
63
91
  if (argv[0] === "--help" || argv[0] === "-h") {
@@ -86,106 +114,20 @@ async function main() {
86
114
  if (mcp) {
87
115
  const server = new Server({ name: "muster", version }, { capabilities: { tools: {} } });
88
116
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
89
- tools: [
90
- {
91
- name: "run",
92
- description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
93
- inputSchema: {
94
- type: "object",
95
- properties: {
96
- runtime: {
97
- type: "string",
98
- enum: ["codex", "claude", "opencode"],
99
- },
100
- prompt: { type: "string", minLength: 1 },
101
- cwd: { type: "string" },
102
- kind: {
103
- type: "string",
104
- enum: ["session", "task"],
105
- default: "session",
106
- },
107
- host: {
108
- type: "string",
109
- enum: ["auto", "tmux", "pty", "macos-terminal"],
110
- },
111
- open: {
112
- type: "boolean",
113
- description: "Open the tmux session in a terminal viewer (macOS only).",
114
- },
115
- terminal: {
116
- type: "string",
117
- enum: ["auto", "terminal", "iterm2", "ghostty"],
118
- description: "Viewer app; requires open. Auto uses Terminal.app.",
119
- },
120
- permissions: {
121
- type: "string",
122
- enum: ["auto", "deny", "bypass"],
123
- description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
124
- },
125
- sandbox: {
126
- type: "string",
127
- enum: ["read-only", "workspace-write", "full-access"],
128
- description: "Defaults to config (read-only). Full access requires config authorization.",
129
- },
130
- mcp: {
131
- type: "array",
132
- items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
133
- description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
134
- },
135
- project: {
136
- type: "string",
137
- pattern: "^[a-zA-Z0-9_-]+$",
138
- description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
139
- },
140
- requestKey: {
141
- type: "string",
142
- minLength: 1,
143
- maxLength: 200,
144
- description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
145
- },
146
- args: { type: "array", items: { type: "string" } },
147
- },
148
- required: ["runtime", "prompt"],
149
- additionalProperties: false,
150
- },
151
- },
152
- {
153
- name: "list",
154
- description: "List Muster-owned sessions and tasks, including host capabilities.",
155
- inputSchema: {
156
- type: "object",
157
- properties: { kind: { type: "string", enum: ["session", "task"] } },
158
- additionalProperties: false,
159
- },
160
- },
161
- {
162
- name: "stop",
163
- description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
164
- inputSchema: {
165
- type: "object",
166
- properties: { id: { type: "string", minLength: 1 } },
167
- required: ["id"],
168
- additionalProperties: false,
169
- },
170
- },
171
- {
172
- name: "output",
173
- description: "Read captured output from a task run.",
174
- inputSchema: {
175
- type: "object",
176
- properties: { id: { type: "string", minLength: 1 } },
177
- required: ["id"],
178
- additionalProperties: false,
179
- },
180
- },
181
- ],
117
+ tools: MCP_TOOLS,
182
118
  }));
183
119
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
184
120
  try {
185
121
  const args = params.arguments ?? {};
186
122
  switch (params.name) {
187
123
  case "run":
188
- return text(await muster.run(runSchema.parse(args), `mcp:${server.getClientVersion()?.name ?? "unknown"}`));
124
+ return text(await muster.run(args, {
125
+ kind: "local",
126
+ // The client's self-reported name was never authentication; it
127
+ // is a label on a local requester, which is what it always was
128
+ // in substance.
129
+ label: `mcp:${server.getClientVersion()?.name ?? "unknown"}`,
130
+ }));
189
131
  case "list":
190
132
  return text(await muster.list(listSchema.parse(args).kind));
191
133
  case "stop":
@@ -229,6 +171,7 @@ async function main() {
229
171
  format: { type: "string" },
230
172
  permissions: { type: "string" },
231
173
  sandbox: { type: "string" },
174
+ level: { type: "string" },
232
175
  mcp: { type: "string", multiple: true },
233
176
  "no-mcp": { type: "boolean" },
234
177
  plugin: { type: "string", multiple: true },
@@ -236,6 +179,9 @@ async function main() {
236
179
  "no-plugin": { type: "boolean" },
237
180
  "request-key": { type: "string" },
238
181
  project: { type: "string" },
182
+ identity: { type: "string" },
183
+ agent: { type: "string" },
184
+ "token-env": { type: "string" },
239
185
  },
240
186
  });
241
187
  const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
@@ -257,6 +203,8 @@ async function main() {
257
203
  throw new Error("format must be json or human");
258
204
  if (command === "output" && parsed.format !== undefined)
259
205
  throw new Error("output prints raw task text; --format applies to run, list and stop");
206
+ if (command === "setup-identity" && parsed.format !== undefined)
207
+ throw new Error("setup-identity always prints JSON; --format applies to run, list, stop, doctor and identities");
260
208
  const print = (value) => process.stdout.write(format === "human"
261
209
  ? formatHuman(value, humanColorEnabled(process.stdout.isTTY, process.env))
262
210
  : JSON.stringify(value) + "\n");
@@ -281,6 +229,34 @@ async function main() {
281
229
  throw new Error("list takes no positional arguments");
282
230
  print(await muster.list(listSchema.parse(values).kind));
283
231
  }
232
+ else if (command === "setup-identity") {
233
+ if (positionals.length)
234
+ throw new Error("setup-identity takes no positional arguments");
235
+ const { identity, agent, "token-env": tokenEnv, } = values;
236
+ if (!identity || !agent)
237
+ throw new Error("setup-identity requires --identity and --agent");
238
+ // Not routed through print(): its RecordView/DoctorView shapes are for
239
+ // run/list/stop/doctor, and an identity record shares no field with
240
+ // either, so this always prints JSON.
241
+ process.stdout.write(JSON.stringify(await setupIdentity({
242
+ home: musterHome(),
243
+ name: identity,
244
+ agent: agent,
245
+ ...(tokenEnv ? { tokenEnv } : {}),
246
+ })) + "\n");
247
+ }
248
+ else if (command === "identities") {
249
+ if (positionals.length)
250
+ throw new Error("identities takes no positional arguments");
251
+ const cfg = await loadConfig();
252
+ const view = await describeIdentities(musterHome(), process.env, Object.fromEntries(Object.entries(cfg.requester_profiles).map(([n, p]) => [
253
+ n,
254
+ p.identities,
255
+ ])));
256
+ process.stdout.write(format === "human"
257
+ ? identitiesHuman(view, humanColorEnabled(process.stdout.isTTY, process.env))
258
+ : JSON.stringify(view) + "\n");
259
+ }
284
260
  else if (command === "doctor") {
285
261
  if (positionals.length || Object.keys(values).length)
286
262
  throw new Error("doctor takes no arguments");
package/dist/registry.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { processRef, isSame, delay, } from "./identity/processes.js";
4
+ import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
5
+ import { LOCAL, requesterKey } from "./requester.js";
5
6
  /**
6
7
  * A request key arrived again with different parameters. Distinct from a launch
7
8
  * failure: nothing was attempted, and the caller reused a key it should have
@@ -80,7 +81,11 @@ export class Registry {
80
81
  if (["starting", "running"].includes(entry.status) &&
81
82
  entry.root &&
82
83
  !(await isSame(entry.root))) {
83
- if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
84
+ // The recorded descendants are only those seen during launch, and a
85
+ // reparented one is not among them at all. Ask the group before
86
+ // concluding the session ended: it is the signal that survives both.
87
+ if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
88
+ !(entry.group && (await groupAlive(entry.group))))
84
89
  entry.status = "exited";
85
90
  }
86
91
  }
@@ -108,19 +113,28 @@ export class Registry {
108
113
  * first would reject a retry of a launch that succeeded, which reads as a
109
114
  * failure and invites a third attempt.
110
115
  */
111
- async reserve(req, cap, idem) {
116
+ async reserve(req, cap, idem, requester = LOCAL) {
112
117
  const owner = await processRef(process.pid);
113
118
  if (!owner)
114
119
  throw new Error("Cannot identify Muster owner process");
120
+ const scope = requesterKey(requester);
115
121
  return this.transaction(async (entries) => {
116
122
  if (idem) {
117
- const prior = entries.find((e) => e.requestKey === idem.requestKey);
123
+ // Scoped by requester: two machines may legitimately choose the same
124
+ // request key, and deduping one into the other would hand a caller a
125
+ // session it never asked for.
126
+ const prior = entries.find((e) => e.requestKey === idem.requestKey &&
127
+ requesterKey(e.requester ?? LOCAL) === scope);
118
128
  if (prior) {
119
129
  if (prior.paramsFingerprint !== idem.paramsFingerprint)
120
130
  throw new IdempotencyKeyReuse(idem.requestKey, prior.paramsFingerprint ?? "none", idem.paramsFingerprint);
121
131
  return { entry: structuredClone(prior), deduped: true };
122
132
  }
123
133
  }
134
+ // Admission seam: `scope` is the per-requester key a future per-requester
135
+ // cap and locally-reserved portion would count against, available here
136
+ // inside the transaction where the accounting has to happen. The cap is
137
+ // global today.
124
138
  if (entries.filter((e) => e.status === "starting" || e.status === "running")
125
139
  .length >= cap)
126
140
  throw new Error(`Concurrency cap reached (${cap})`);
@@ -131,6 +145,7 @@ export class Registry {
131
145
  launchId: id,
132
146
  ...(idem ?? {}),
133
147
  status: "starting",
148
+ requester,
134
149
  createdAt: new Date().toISOString(),
135
150
  owner,
136
151
  };
@@ -147,6 +162,8 @@ export class Registry {
147
162
  const entry = byLaunch ?? durable[0];
148
163
  if (!entry)
149
164
  throw new Error(`Unknown launch ${id}`);
165
+ if ("requester" in patch)
166
+ throw new Error("requester is recorded once and cannot be updated");
150
167
  if (patch.id &&
151
168
  entries.some((e) => e !== entry && e.runtime === entry.runtime && e.id === patch.id))
152
169
  throw new Error("Durable identity already registered");
@@ -0,0 +1,106 @@
1
+ import { LOCAL, requesterKey } from "./requester.js";
2
+ /** A remote tuple with no enrolment. Absence of a grant is a denial. */
3
+ export class RequesterNotEnrolled extends Error {
4
+ authority;
5
+ subjectPrefix;
6
+ constructor(authority, subjectPrefix) {
7
+ super(`No enrolment for requester ${authority}/${subjectPrefix} — add a [[requesters]] entry to config.toml`);
8
+ this.authority = authority;
9
+ this.subjectPrefix = subjectPrefix;
10
+ this.name = "RequesterNotEnrolled";
11
+ }
12
+ }
13
+ /**
14
+ * Enrolled, but the request exceeds the profile. `ceiling` is what a transport
15
+ * maps to a stable code, so nothing downstream matches on message text.
16
+ */
17
+ export class RequesterPolicyRefusal extends Error {
18
+ ceiling;
19
+ constructor(ceiling, message) {
20
+ super(message);
21
+ this.ceiling = ceiling;
22
+ this.name = "RequesterPolicyRefusal";
23
+ }
24
+ }
25
+ /** The receiver is misconfigured. Not the requester's fault, so not a refusal. */
26
+ export class RequesterConfigError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "RequesterConfigError";
30
+ }
31
+ }
32
+ /** Ordered weakest to strongest, so a ceiling is one index comparison. */
33
+ const LEVEL_ORDER = ["read", "work", "open"];
34
+ const GRADE_ORDER = ["none", "tool-policy", "kernel"];
35
+ export function resolveRequesterPolicy(requester, config) {
36
+ if (requester.kind === "local")
37
+ return {
38
+ profile: undefined,
39
+ level: undefined,
40
+ minEnforcement: undefined,
41
+ logMode: "full",
42
+ identities: undefined,
43
+ };
44
+ const enrolment = config.requesters.find((e) => e.authority === requester.authority && e.subject === requester.subject);
45
+ if (!enrolment)
46
+ throw new RequesterNotEnrolled(requester.authority, requester.subject.slice(0, 4));
47
+ const profile = config.requester_profiles[enrolment.profile];
48
+ if (!profile)
49
+ throw new RequesterConfigError(`Enrolment ${enrolment.authority}/${enrolment.subject} names unknown profile ${enrolment.profile}`);
50
+ return {
51
+ profile,
52
+ level: profile.level,
53
+ minEnforcement: profile.min_enforcement,
54
+ // Not a profile setting: the exposure is the receiver's to carry and the
55
+ // requester's to avoid, so a profile may not opt into full logging.
56
+ logMode: "metadata",
57
+ identities: profile.identities,
58
+ };
59
+ }
60
+ export function assertLevelWithinCeiling(requested, policy) {
61
+ if (!policy.level)
62
+ return;
63
+ if (LEVEL_ORDER.indexOf(requested) > LEVEL_ORDER.indexOf(policy.level))
64
+ throw new RequesterPolicyRefusal("level", `Level ${requested} exceeds this requester's ceiling of ${policy.level}`);
65
+ }
66
+ /**
67
+ * May this requester act on this entry?
68
+ *
69
+ * Asymmetric on purpose. A local requester is the operator and may stop or read
70
+ * anything on the machine. A remote requester may act only on launches it asked
71
+ * for — an entry with no recorded requester predates the field and reads as
72
+ * local, so it is protected rather than open.
73
+ */
74
+ export function assertMayAct(entry, requester) {
75
+ if (requester.kind === "local")
76
+ return;
77
+ if (requesterKey(entry.requester ?? LOCAL) !== requesterKey(requester))
78
+ throw new RequesterPolicyRefusal("not-owner", "this launch belongs to a different requester");
79
+ }
80
+ export function assertEnforcementMeetsMinimum(runtime, grade, policy) {
81
+ if (!policy.minEnforcement)
82
+ return;
83
+ if (GRADE_ORDER.indexOf(grade) < GRADE_ORDER.indexOf(policy.minEnforcement))
84
+ throw new RequesterPolicyRefusal("enforcement", `Runtime ${runtime} offers ${grade} enforcement and this requester requires ${policy.minEnforcement}`);
85
+ }
86
+ /**
87
+ * Bound, do not pin — the same shape as allowed_roots, level and
88
+ * min_enforcement. A local requester is the operator and may name anything.
89
+ */
90
+ export function resolveIdentityForRequest(requested, policy) {
91
+ if (!policy.identities)
92
+ return requested;
93
+ const granted = policy.identities;
94
+ if (requested) {
95
+ if (!granted.includes(requested))
96
+ throw new RequesterPolicyRefusal("identity", granted.length
97
+ ? `identity ${requested} is not granted to this requester (granted: ${granted.join(", ")})`
98
+ : `this requester is granted no identity, so it may not name one`);
99
+ return requested;
100
+ }
101
+ if (granted.length === 1)
102
+ return granted[0];
103
+ if (granted.length > 1)
104
+ throw new RequesterPolicyRefusal("identity", `this requester must name one of: ${granted.join(", ")}`);
105
+ return undefined;
106
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Opaque, and free of control characters. The key encoding must never be able
4
+ * to confuse two identities, and a NUL or other control byte inside a field is
5
+ * the way that happens.
6
+ */
7
+ const opaqueField = (max) => z
8
+ .string()
9
+ .min(1)
10
+ .max(max)
11
+ .regex(/^[ -~]+$/, "must not contain control characters");
12
+ export const authoritySchema = opaqueField(200);
13
+ export const subjectSchema = opaqueField(400);
14
+ /**
15
+ * Who asked for a launch.
16
+ *
17
+ * `authority` names the identity namespace that authenticated `subject`;
18
+ * `subject` is a stable opaque identifier within it. Muster treats both as
19
+ * opaque and hardcodes no namespace, so the spelling an integrator adopts is
20
+ * configuration rather than a Muster release.
21
+ *
22
+ * Muster does not authenticate its caller. Anything able to import Muster or
23
+ * exec its binary already runs as the owner account. The value here is least
24
+ * privilege and honest provenance for a trusted local invoker that has already
25
+ * authenticated a remote peer — not a defence against a compromised same-user
26
+ * process.
27
+ */
28
+ export const requesterSchema = z.discriminatedUnion("kind", [
29
+ z
30
+ .object({
31
+ kind: z.literal("local"),
32
+ label: z.string().min(1).max(200).optional(),
33
+ })
34
+ .strict(),
35
+ z
36
+ .object({
37
+ kind: z.literal("remote"),
38
+ authority: authoritySchema,
39
+ subject: subjectSchema,
40
+ label: z.string().min(1).max(200).optional(),
41
+ })
42
+ .strict(),
43
+ ]);
44
+ export const LOCAL = { kind: "local" };
45
+ /**
46
+ * The policy and quota key. `label` is not a parameter, which is what keeps a
47
+ * caller-chosen display string structurally incapable of affecting policy.
48
+ */
49
+ export function requesterKey(r) {
50
+ return r.kind === "local"
51
+ ? "local"
52
+ : JSON.stringify(["remote", r.authority, r.subject]);
53
+ }
54
+ /** For humans and log lines only; never a key. */
55
+ export function requesterLabel(r) {
56
+ if (r.label)
57
+ return r.label;
58
+ return r.kind === "local" ? "local" : `${r.authority}/${r.subject}`;
59
+ }