@brutalsystems/muster 0.7.12 → 0.7.15
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 +264 -5
- package/dist/claude-policy.js +0 -2
- package/dist/config.js +64 -1
- package/dist/enforcement.js +5 -0
- package/dist/guard.js +103 -0
- package/dist/identity/claude.js +14 -1
- package/dist/identity-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +184 -0
- package/dist/identity-store.js +101 -0
- package/dist/log.js +82 -4
- package/dist/mcp-tools.js +134 -0
- package/dist/muster.js +72 -96
- package/dist/registry.js +15 -2
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +296 -47
- package/package.json +1 -1
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}[0m` : 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(
|
|
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
|
@@ -2,6 +2,7 @@ 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
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
|
|
@@ -112,19 +113,28 @@ export class Registry {
|
|
|
112
113
|
* first would reject a retry of a launch that succeeded, which reads as a
|
|
113
114
|
* failure and invites a third attempt.
|
|
114
115
|
*/
|
|
115
|
-
async reserve(req, cap, idem) {
|
|
116
|
+
async reserve(req, cap, idem, requester = LOCAL) {
|
|
116
117
|
const owner = await processRef(process.pid);
|
|
117
118
|
if (!owner)
|
|
118
119
|
throw new Error("Cannot identify Muster owner process");
|
|
120
|
+
const scope = requesterKey(requester);
|
|
119
121
|
return this.transaction(async (entries) => {
|
|
120
122
|
if (idem) {
|
|
121
|
-
|
|
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);
|
|
122
128
|
if (prior) {
|
|
123
129
|
if (prior.paramsFingerprint !== idem.paramsFingerprint)
|
|
124
130
|
throw new IdempotencyKeyReuse(idem.requestKey, prior.paramsFingerprint ?? "none", idem.paramsFingerprint);
|
|
125
131
|
return { entry: structuredClone(prior), deduped: true };
|
|
126
132
|
}
|
|
127
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.
|
|
128
138
|
if (entries.filter((e) => e.status === "starting" || e.status === "running")
|
|
129
139
|
.length >= cap)
|
|
130
140
|
throw new Error(`Concurrency cap reached (${cap})`);
|
|
@@ -135,6 +145,7 @@ export class Registry {
|
|
|
135
145
|
launchId: id,
|
|
136
146
|
...(idem ?? {}),
|
|
137
147
|
status: "starting",
|
|
148
|
+
requester,
|
|
138
149
|
createdAt: new Date().toISOString(),
|
|
139
150
|
owner,
|
|
140
151
|
};
|
|
@@ -151,6 +162,8 @@ export class Registry {
|
|
|
151
162
|
const entry = byLaunch ?? durable[0];
|
|
152
163
|
if (!entry)
|
|
153
164
|
throw new Error(`Unknown launch ${id}`);
|
|
165
|
+
if ("requester" in patch)
|
|
166
|
+
throw new Error("requester is recorded once and cannot be updated");
|
|
154
167
|
if (patch.id &&
|
|
155
168
|
entries.some((e) => e !== entry && e.runtime === entry.runtime && e.id === patch.id))
|
|
156
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
|
+
}
|