mandat 0.0.0-stage → 0.1.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.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +293 -3
  3. package/dist/approvals/approvals.d.ts +136 -0
  4. package/dist/approvals/approvals.js +262 -0
  5. package/dist/bridge.d.ts +38 -0
  6. package/dist/bridge.js +117 -0
  7. package/dist/cli/args.d.ts +9 -0
  8. package/dist/cli/args.js +39 -0
  9. package/dist/cli/bin.d.ts +2 -0
  10. package/dist/cli/bin.js +3 -0
  11. package/dist/cli/main.d.ts +1 -0
  12. package/dist/cli/main.js +649 -0
  13. package/dist/cli/templates.d.ts +91 -0
  14. package/dist/cli/templates.js +57 -0
  15. package/dist/client.d.ts +54 -0
  16. package/dist/client.js +88 -0
  17. package/dist/controlplane/config.d.ts +48 -0
  18. package/dist/controlplane/config.js +33 -0
  19. package/dist/controlplane/console/.claude-flow/neural/patterns.json +402 -0
  20. package/dist/controlplane/console/.claude-flow/neural/stats.json +6 -0
  21. package/dist/controlplane/console/.claude-flow/policy/ledger-anchors.json +3 -0
  22. package/dist/controlplane/console/.claude-flow/policy/state.json +54 -0
  23. package/dist/controlplane/console/app.css +93 -0
  24. package/dist/controlplane/console/app.js +330 -0
  25. package/dist/controlplane/console/index.html +70 -0
  26. package/dist/controlplane/server.d.ts +17 -0
  27. package/dist/controlplane/server.js +427 -0
  28. package/dist/guard/guard.d.ts +154 -0
  29. package/dist/guard/guard.js +290 -0
  30. package/dist/guard/pinning.d.ts +46 -0
  31. package/dist/guard/pinning.js +49 -0
  32. package/dist/guard/remote.d.ts +78 -0
  33. package/dist/guard/remote.js +185 -0
  34. package/dist/index.d.ts +43 -0
  35. package/dist/index.js +67 -0
  36. package/dist/ledger/export.d.ts +27 -0
  37. package/dist/ledger/export.js +169 -0
  38. package/dist/ledger/ledger.d.ts +117 -0
  39. package/dist/ledger/ledger.js +139 -0
  40. package/dist/ledger/redact.d.ts +13 -0
  41. package/dist/ledger/redact.js +73 -0
  42. package/dist/ledger/report.d.ts +71 -0
  43. package/dist/ledger/report.js +128 -0
  44. package/dist/mcp/protect.d.ts +8 -0
  45. package/dist/mcp/protect.js +68 -0
  46. package/dist/mcp/server.d.ts +49 -0
  47. package/dist/mcp/server.js +211 -0
  48. package/dist/oidc.d.ts +52 -0
  49. package/dist/oidc.js +194 -0
  50. package/dist/policy/engine.d.ts +135 -0
  51. package/dist/policy/engine.js +243 -0
  52. package/dist/policy/expr.d.ts +56 -0
  53. package/dist/policy/expr.js +506 -0
  54. package/dist/scan/findings.d.ts +32 -0
  55. package/dist/scan/findings.js +105 -0
  56. package/dist/scan/remote.d.ts +7 -0
  57. package/dist/scan/remote.js +225 -0
  58. package/dist/scan/static.d.ts +15 -0
  59. package/dist/scan/static.js +245 -0
  60. package/dist/store.d.ts +14 -0
  61. package/dist/store.js +40 -0
  62. package/dist/sts/jwt.d.ts +64 -0
  63. package/dist/sts/jwt.js +168 -0
  64. package/dist/sts/revocation.d.ts +38 -0
  65. package/dist/sts/revocation.js +52 -0
  66. package/dist/sts/sts.d.ts +83 -0
  67. package/dist/sts/sts.js +239 -0
  68. package/dist/util.d.ts +28 -0
  69. package/dist/util.js +137 -0
  70. package/package.json +47 -4
@@ -0,0 +1,427 @@
1
+ // Mandat control plane: STS + approvals + revocation + policy distribution + ledger + console.
2
+ // The data plane (Guard) never needs it on the decision path except for approvals.
3
+ import { createServer } from "node:http";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { hashApiKey } from "./config.js";
8
+ import { Sts, OAuthError, hashSecret, GRANT_TOKEN_EXCHANGE } from "../sts/sts.js";
9
+ import { loadOrCreateSigningKey } from "../sts/jwt.js";
10
+ import { JsonCollection } from "../store.js";
11
+ import { FileLedger, queryRecords, verifyLedger } from "../ledger/ledger.js";
12
+ import { OtlpExporter, toOcsf, toOtelSpan } from "../ledger/export.js";
13
+ import { buildReport, reportToMarkdown } from "../ledger/report.js";
14
+ import { ApprovalError, ApprovalService, ConsoleLogNotifier, SlackNotifier, WebhookNotifier, handleSlackInteraction, verifySlackSignature } from "../approvals/approvals.js";
15
+ import { compilePolicy, PolicyError } from "../policy/engine.js";
16
+ import { HttpError, readBody, readJson, randomId, safeEqual, sendJson, writeJsonAtomic, parseDuration, nowSeconds } from "../util.js";
17
+ const CONSOLE_DIR = fileURLToPath(new URL("./console/", import.meta.url));
18
+ const asset = (name) => readFileSync(join(CONSOLE_DIR, name), "utf8");
19
+ const REVOCATION_TYPES = ["jti", "agent", "agent_version", "subject", "task", "tenant"];
20
+ export function createControlPlane(config) {
21
+ const issuer = config.issuer.replace(/\/$/, "");
22
+ const consoleUrl = `${issuer}/console`;
23
+ const key = loadOrCreateSigningKey(join(config.dataDir, "keys"));
24
+ const ledger = new FileLedger(join(config.dataDir, "ledger.jsonl"));
25
+ const agents = new JsonCollection(join(config.dataDir, "agents.json"));
26
+ const revocations = new JsonCollection(join(config.dataDir, "revocations.json"));
27
+ const otlp = config.ledger?.otlp ? new OtlpExporter({ ...config.ledger.otlp, serviceName: "mandat-control-plane" }) : null;
28
+ if (otlp)
29
+ ledger.onAppend((r) => otlp.push(r));
30
+ // Ids already in the ledger, so retried ingest batches are not duplicated (at-least-once -> exactly-once).
31
+ const seen = new Set(ledger.read().map((r) => r.id));
32
+ ledger.onAppend((r) => seen.add(r.id));
33
+ if (!existsSync(config.policyFile))
34
+ throw new Error(`Policy file not found: ${config.policyFile} (run \`mandat init\`)`);
35
+ let policyDoc = readJson(config.policyFile);
36
+ let policy = compilePolicy(policyDoc);
37
+ const notifiers = [];
38
+ if (config.approvals?.log !== false)
39
+ notifiers.push(new ConsoleLogNotifier());
40
+ if (config.approvals?.slack?.webhookUrl)
41
+ notifiers.push(new SlackNotifier({ webhookUrl: config.approvals.slack.webhookUrl, consoleUrl }));
42
+ for (const w of config.approvals?.webhooks ?? [])
43
+ notifiers.push(new WebhookNotifier({ ...w, consoleUrl }));
44
+ const approvals = new ApprovalService({ path: join(config.dataDir, "approvals.json"), notifiers, ledger });
45
+ const sts = new Sts({
46
+ issuer,
47
+ key,
48
+ agents: () => agents.all(),
49
+ revocations: () => revocations.all(),
50
+ trustedIssuers: config.trustedIssuers ?? [],
51
+ devIdp: config.devIdp,
52
+ defaultTtlSeconds: config.tokenTtlSeconds,
53
+ maxTtlSeconds: config.maxTokenTtlSeconds,
54
+ onIssued: (t) => ledger.append({
55
+ type: "token.issued",
56
+ source: "control-plane",
57
+ principal: { sub: t.sub, tenant: t.tenant },
58
+ agent: { id: t.agent, version: t.agent_version },
59
+ actor_chain: t.actor_chain,
60
+ task_id: t.task_id,
61
+ token_jti: t.jti,
62
+ details: { aud: t.aud, exp: new Date(t.exp * 1000).toISOString(), authorization_details: t.authorization_details },
63
+ }),
64
+ });
65
+ const keys = config.apiKeys;
66
+ const authenticate = (req, roles) => {
67
+ const h = req.headers.authorization;
68
+ const token = h?.startsWith("Bearer ") ? h.slice(7).trim() : undefined;
69
+ if (!token)
70
+ throw new HttpError(401, "unauthorized", "API key required", { "www-authenticate": 'Bearer realm="mandat"' });
71
+ const hash = hashApiKey(token);
72
+ const k = keys.find((x) => safeEqual(x.hash, hash));
73
+ if (!k)
74
+ throw new HttpError(401, "unauthorized", "Invalid API key");
75
+ if (!k.roles.some((r) => roles.includes(r)))
76
+ throw new HttpError(403, "forbidden", `Requires role: ${roles.join(" or ")}`);
77
+ return k;
78
+ };
79
+ const adminEvent = (type, by, details) => ledger.append({ type, source: "control-plane", principal: { sub: by }, details });
80
+ const asMetadata = () => ({
81
+ issuer,
82
+ token_endpoint: `${issuer}/oauth/token`,
83
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
84
+ grant_types_supported: [GRANT_TOKEN_EXCHANGE],
85
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
86
+ response_types_supported: [],
87
+ code_challenge_methods_supported: ["S256"],
88
+ authorization_response_iss_parameter_supported: true,
89
+ client_id_metadata_document_supported: false,
90
+ authorization_details_types_supported: ["mandat_tool_call"],
91
+ token_endpoint_auth_signing_alg_values_supported: [key.alg],
92
+ service_documentation: `${issuer}/console`,
93
+ mandat_api: `${issuer}/api`,
94
+ });
95
+ async function route(req, res) {
96
+ const url = new URL(req.url ?? "/", issuer);
97
+ const p = url.pathname;
98
+ const m = req.method ?? "GET";
99
+ const json = async () => {
100
+ const text = await readBody(req);
101
+ try {
102
+ return (text ? JSON.parse(text) : {});
103
+ }
104
+ catch {
105
+ throw new HttpError(400, "invalid_json", "Body must be JSON");
106
+ }
107
+ };
108
+ // ----- public -----
109
+ if (m === "GET" && p === "/favicon.ico") {
110
+ res.writeHead(204).end();
111
+ return;
112
+ }
113
+ if (m === "GET" && p === "/healthz")
114
+ return sendJson(res, 200, { ok: true, ledger: { records: ledger.length, head: ledger.headHash } });
115
+ if (m === "GET" && (p === "/.well-known/oauth-authorization-server" || p === "/.well-known/openid-configuration"))
116
+ return sendJson(res, 200, asMetadata(), { "cache-control": "public, max-age=300" });
117
+ if (m === "GET" && p === "/.well-known/jwks.json")
118
+ return sendJson(res, 200, { keys: [key.publicJwk] }, { "cache-control": "public, max-age=300" });
119
+ if (m === "GET" && (p === "/" || p === "/console")) {
120
+ res.writeHead(200, {
121
+ "content-type": "text/html; charset=utf-8",
122
+ "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'",
123
+ "x-frame-options": "DENY",
124
+ "referrer-policy": "no-referrer",
125
+ });
126
+ res.end(asset("index.html"));
127
+ return;
128
+ }
129
+ if (m === "GET" && p === "/console.js") {
130
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
131
+ res.end(asset("app.js"));
132
+ return;
133
+ }
134
+ if (m === "GET" && p === "/console.css") {
135
+ res.writeHead(200, { "content-type": "text/css; charset=utf-8" });
136
+ res.end(asset("app.css"));
137
+ return;
138
+ }
139
+ if (m === "POST" && p === "/oauth/token") {
140
+ const form = new URLSearchParams(await readBody(req));
141
+ let id = form.get("client_id") ?? undefined;
142
+ let secret = form.get("client_secret") ?? undefined;
143
+ const basic = req.headers.authorization?.match(/^Basic\s+(.+)$/i)?.[1];
144
+ if (basic) {
145
+ const [u, s] = Buffer.from(basic, "base64").toString("utf8").split(":");
146
+ id = decodeURIComponent(u ?? "");
147
+ secret = decodeURIComponent(s ?? "");
148
+ }
149
+ try {
150
+ return sendJson(res, 200, await sts.exchange(form, { id, secret }), { pragma: "no-cache" });
151
+ }
152
+ catch (e) {
153
+ if (e instanceof OAuthError)
154
+ return sendJson(res, e.status, { error: e.error, error_description: e.message }, e.status === 401 ? { "www-authenticate": 'Basic realm="mandat"' } : {});
155
+ throw e;
156
+ }
157
+ }
158
+ if (m === "POST" && p === "/dev/login") {
159
+ if (!config.devIdp)
160
+ throw new HttpError(404, "not_found", "Dev IdP disabled");
161
+ const b = await json();
162
+ if (!b.sub)
163
+ throw new HttpError(400, "invalid_request", "sub is required");
164
+ return sendJson(res, 200, { id_token: sts.devLogin({ sub: b.sub, email: b.email, name: b.name, tenant: b.tenant }), token_type: "urn:ietf:params:oauth:token-type:id_token" });
165
+ }
166
+ if (m === "POST" && p === "/slack/interactions") {
167
+ const raw = await readBody(req);
168
+ const secret = config.approvals?.slack?.signingSecret;
169
+ if (!secret || !verifySlackSignature(raw, req.headers["x-slack-request-timestamp"], req.headers["x-slack-signature"], secret))
170
+ throw new HttpError(401, "bad_signature", "Invalid Slack signature");
171
+ return sendJson(res, 200, handleSlackInteraction(approvals, raw, consoleUrl));
172
+ }
173
+ if (!p.startsWith("/api/"))
174
+ throw new HttpError(404, "not_found", "Not found");
175
+ // ----- API -----
176
+ if (m === "GET" && p === "/api/whoami") {
177
+ const k = authenticate(req, ["admin", "approver", "auditor", "service"]);
178
+ return sendJson(res, 200, { id: k.id, roles: k.roles, issuer });
179
+ }
180
+ if (p === "/api/revocations") {
181
+ if (m === "GET") {
182
+ authenticate(req, ["service", "admin", "auditor"]);
183
+ return sendJson(res, 200, { revocations: revocations.all(), now: nowSeconds() });
184
+ }
185
+ if (m === "POST") {
186
+ const k = authenticate(req, ["admin"]);
187
+ const b = await json();
188
+ if (!b.type || !REVOCATION_TYPES.includes(b.type))
189
+ throw new HttpError(400, "invalid_request", `type must be one of ${REVOCATION_TYPES.join(", ")}`);
190
+ if (!b.value)
191
+ throw new HttpError(400, "invalid_request", "value is required");
192
+ if (b.type === "agent_version" && !b.version)
193
+ throw new HttpError(400, "invalid_request", "version is required for agent_version");
194
+ const r = {
195
+ id: randomId("rev_", 8), type: b.type, value: b.value, version: b.version,
196
+ issued_before: nowSeconds(), blocks_issuance: b.block_issuance ?? b.type !== "jti",
197
+ created_at: new Date().toISOString(), reason: b.reason, by: k.id,
198
+ };
199
+ revocations.put(r);
200
+ adminEvent("revocation.created", k.id, { ...r });
201
+ return sendJson(res, 201, r);
202
+ }
203
+ }
204
+ const revMatch = /^\/api\/revocations\/([\w-]+)$/.exec(p);
205
+ if (revMatch && m === "DELETE") {
206
+ const k = authenticate(req, ["admin"]);
207
+ const r = revocations.update(revMatch[1], (x) => ({ ...x, blocks_issuance: false }));
208
+ if (!r)
209
+ throw new HttpError(404, "not_found", "Revocation not found");
210
+ adminEvent("revocation.lifted", k.id, { id: r.id, type: r.type, value: r.value, note: "New tokens may be issued; tokens issued before the revocation stay invalid." });
211
+ return sendJson(res, 200, r);
212
+ }
213
+ if (p === "/api/policy") {
214
+ if (m === "GET") {
215
+ authenticate(req, ["service", "admin", "auditor"]);
216
+ const etag = `"${policy.version}"`;
217
+ if (req.headers["if-none-match"] === etag) {
218
+ res.writeHead(304, { etag }).end();
219
+ return;
220
+ }
221
+ return sendJson(res, 200, { version: policy.version, policy: policyDoc }, { etag });
222
+ }
223
+ if (m === "PUT") {
224
+ const k = authenticate(req, ["admin"]);
225
+ const b = await json();
226
+ let next;
227
+ try {
228
+ next = compilePolicy(b.policy);
229
+ }
230
+ catch (e) {
231
+ if (e instanceof PolicyError)
232
+ throw new HttpError(422, "invalid_policy", e.message);
233
+ throw e;
234
+ }
235
+ const before = policy.version;
236
+ const beforeRules = new Set(policyDoc.rules.map((r) => r.id));
237
+ const afterRules = new Set(b.policy.rules.map((r) => r.id));
238
+ writeJsonAtomic(config.policyFile, b.policy);
239
+ policyDoc = b.policy;
240
+ policy = next;
241
+ adminEvent("policy.changed", k.id, {
242
+ from_version: before, to_version: next.version, comment: b.comment,
243
+ rules_added: [...afterRules].filter((x) => !beforeRules.has(x)),
244
+ rules_removed: [...beforeRules].filter((x) => !afterRules.has(x)),
245
+ });
246
+ return sendJson(res, 200, { version: next.version });
247
+ }
248
+ }
249
+ if (p === "/api/agents") {
250
+ if (m === "GET") {
251
+ authenticate(req, ["admin", "auditor", "approver"]);
252
+ const revs = revocations.all();
253
+ return sendJson(res, 200, {
254
+ agents: agents.all().map(({ secret_hash: _s, ...a }) => ({
255
+ ...a,
256
+ revoked: revs.some((r) => r.blocks_issuance && (r.type === "agent" || r.type === "agent_version") && r.value === a.id),
257
+ })),
258
+ });
259
+ }
260
+ if (m === "POST") {
261
+ const k = authenticate(req, ["admin"]);
262
+ const b = await json();
263
+ if (!b.id || !/^[a-z0-9][a-z0-9._-]{1,62}$/.test(b.id))
264
+ throw new HttpError(400, "invalid_request", "id must match ^[a-z0-9][a-z0-9._-]{1,62}$");
265
+ if (agents.get(b.id))
266
+ throw new HttpError(409, "conflict", "Agent already exists");
267
+ if (!Array.isArray(b.allowed_tools) || !b.allowed_tools.length)
268
+ throw new HttpError(400, "invalid_request", "allowed_tools is required (least privilege)");
269
+ const secret = `mdt_as_${randomId("", 24)}`;
270
+ const rec = {
271
+ id: b.id, name: b.name, owner: b.owner ?? k.id, secret_hash: hashSecret(secret),
272
+ allowed_tools: b.allowed_tools, allowed_resources: b.allowed_resources ?? [], versions: b.versions ?? [],
273
+ max_ttl_seconds: b.max_ttl_seconds, created_at: new Date().toISOString(),
274
+ };
275
+ agents.put(rec);
276
+ adminEvent("agent.registered", k.id, { agent: rec.id, allowed_tools: rec.allowed_tools, allowed_resources: rec.allowed_resources, versions: rec.versions });
277
+ const { secret_hash: _s, ...pub } = rec;
278
+ return sendJson(res, 201, { ...pub, client_secret: secret, note: "Store the client_secret now; it is not shown again." });
279
+ }
280
+ }
281
+ const agentMatch = /^\/api\/agents\/([\w.-]+)$/.exec(p);
282
+ if (agentMatch && m === "PATCH") {
283
+ const k = authenticate(req, ["admin"]);
284
+ const b = await json();
285
+ const allowed = ["name", "allowed_tools", "allowed_resources", "versions", "max_ttl_seconds", "disabled"];
286
+ const patch = Object.fromEntries(Object.entries(b).filter(([kk]) => allowed.includes(kk)));
287
+ const r = agents.update(agentMatch[1], (a) => ({ ...a, ...patch }));
288
+ if (!r)
289
+ throw new HttpError(404, "not_found", "Agent not found");
290
+ adminEvent("agent.updated", k.id, { agent: r.id, changes: patch });
291
+ const { secret_hash: _s, ...pub } = r;
292
+ return sendJson(res, 200, pub);
293
+ }
294
+ if (p === "/api/approvals") {
295
+ if (m === "POST") {
296
+ authenticate(req, ["service"]);
297
+ const b = await json();
298
+ if (!b.binding || !b.context?.tool || !b.context?.principal?.sub)
299
+ throw new HttpError(400, "invalid_request", "binding and context are required");
300
+ const timeoutMs = Math.min(b.timeoutMs ?? parseDuration(config.approvals?.defaultTimeout ?? "10m"), 24 * 3_600_000);
301
+ return sendJson(res, 201, await approvals.create({ ...b, timeoutMs, required: b.required ?? 1, channels: b.channels ?? [] }));
302
+ }
303
+ if (m === "GET") {
304
+ authenticate(req, ["approver", "admin", "auditor", "service"]);
305
+ const status = url.searchParams.get("status");
306
+ return sendJson(res, 200, { approvals: approvals.list(status || undefined).slice(0, 200) });
307
+ }
308
+ }
309
+ const apr = /^\/api\/approvals\/([\w-]+)(?:\/(approve|deny|consume))?$/.exec(p);
310
+ if (apr) {
311
+ const [, id, action] = apr;
312
+ if (!action && m === "GET") {
313
+ authenticate(req, ["approver", "admin", "auditor", "service"]);
314
+ const wait = Math.min(Number(url.searchParams.get("wait") ?? 0) || 0, 30_000);
315
+ const r = wait ? await approvals.wait(id, wait) : approvals.get(id);
316
+ if (!r)
317
+ throw new HttpError(404, "not_found", "Approval not found");
318
+ return sendJson(res, 200, r);
319
+ }
320
+ if ((action === "approve" || action === "deny") && m === "POST") {
321
+ const k = authenticate(req, ["approver", "admin"]);
322
+ const b = await json();
323
+ return sendJson(res, 200, approvals.decide(id, { by: k.id, decision: action, via: "api", comment: b.comment }));
324
+ }
325
+ if (action === "consume" && m === "POST") {
326
+ authenticate(req, ["service"]);
327
+ return sendJson(res, 200, { consumed: approvals.consume(id) });
328
+ }
329
+ }
330
+ if (m === "POST" && p === "/api/ledger/ingest") {
331
+ const k = authenticate(req, ["service"]);
332
+ const b = await json();
333
+ if (!Array.isArray(b.records) || b.records.length > 1000)
334
+ throw new HttpError(400, "invalid_request", "records: array of <= 1000");
335
+ let appended = 0;
336
+ for (const r of b.records) {
337
+ if (r.id && seen.has(r.id))
338
+ continue;
339
+ if (!r.type)
340
+ continue;
341
+ ledger.append({ ...r, source: r.source ?? k.id });
342
+ appended++;
343
+ }
344
+ return sendJson(res, 200, { appended, head: ledger.headHash });
345
+ }
346
+ if (m === "GET" && p === "/api/ledger") {
347
+ authenticate(req, ["auditor", "admin", "approver"]);
348
+ const q = Object.fromEntries(url.searchParams);
349
+ q.limit = Math.min(Number(url.searchParams.get("limit") ?? 100) || 100, 1000);
350
+ return sendJson(res, 200, { records: queryRecords(ledger.read(), q), head: ledger.headHash, count: ledger.length });
351
+ }
352
+ if (m === "GET" && p === "/api/ledger/verify") {
353
+ authenticate(req, ["auditor", "admin"]);
354
+ return sendJson(res, 200, verifyLedger(ledger.path));
355
+ }
356
+ if (m === "GET" && p === "/api/ledger/export") {
357
+ authenticate(req, ["auditor", "admin"]);
358
+ const fmt = url.searchParams.get("format") ?? "ocsf";
359
+ const recs = queryRecords(ledger.read(), { ...Object.fromEntries(url.searchParams), order: "asc" });
360
+ const map = fmt === "otel" ? toOtelSpan : fmt === "raw" ? (r) => r : toOcsf;
361
+ res.writeHead(200, { "content-type": "application/x-ndjson", "content-disposition": `attachment; filename="mandat-${fmt}.ndjson"` });
362
+ res.end(recs.map((r) => JSON.stringify(map(r))).join("\n") + "\n");
363
+ return;
364
+ }
365
+ if (m === "GET" && p === "/api/report") {
366
+ authenticate(req, ["auditor", "admin"]);
367
+ const data = buildReport(ledger.read(), { from: url.searchParams.get("from") ?? undefined, to: url.searchParams.get("to") ?? undefined, retentionDays: config.ledger?.retentionDays });
368
+ if (url.searchParams.get("format") === "json")
369
+ return sendJson(res, 200, data);
370
+ res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
371
+ res.end(reportToMarkdown(data));
372
+ return;
373
+ }
374
+ if (m === "GET" && p === "/api/stats") {
375
+ authenticate(req, ["auditor", "admin", "approver"]);
376
+ const recs = ledger.read();
377
+ const since = new Date(Date.now() - 24 * 3_600_000).toISOString();
378
+ const calls = recs.filter((r) => r.type === "agent.tool_call" && r.ts >= since);
379
+ return sendJson(res, 200, {
380
+ last_24h: {
381
+ calls: calls.length,
382
+ allowed: calls.filter((c) => c.decision?.final === "allow").length,
383
+ denied: calls.filter((c) => c.decision?.final === "deny").length,
384
+ pending: calls.filter((c) => c.decision?.final === "pending").length,
385
+ p99_decision_us: percentile(calls.map((c) => c.decision?.latency_us ?? 0), 0.99),
386
+ },
387
+ pending_approvals: approvals.list("pending").length,
388
+ agents: agents.all().length,
389
+ active_revocations: revocations.filter((r) => r.blocks_issuance).length,
390
+ policy_version: policy.version,
391
+ ledger: { records: ledger.length, head: ledger.headHash },
392
+ });
393
+ }
394
+ throw new HttpError(404, "not_found", "Not found");
395
+ }
396
+ const server = createServer(async (req, res) => {
397
+ try {
398
+ await route(req, res);
399
+ }
400
+ catch (e) {
401
+ if (e instanceof HttpError)
402
+ return sendJson(res, e.status, { error: e.code, error_description: e.message }, e.headers);
403
+ if (e instanceof ApprovalError)
404
+ return sendJson(res, e.status, { error: "approval_error", error_description: e.message });
405
+ console.error("[mandat] internal error:", e);
406
+ return sendJson(res, 500, { error: "server_error", error_description: "Internal error" });
407
+ }
408
+ });
409
+ return {
410
+ server, sts, approvals, ledger, key, config,
411
+ listen: () => new Promise((resolve, reject) => server.once("error", (e) => reject(e.code === "EADDRINUSE" ? new Error(`Port ${config.port} is already in use (another \`mandat serve\`?). Use --port or change "port" in the config.`) : e)).listen(config.port, config.host ?? "127.0.0.1", () => {
412
+ const addr = server.address();
413
+ resolve(typeof addr === "object" && addr ? `http://${config.host ?? "127.0.0.1"}:${addr.port}` : issuer);
414
+ })),
415
+ close: async () => {
416
+ approvals.close();
417
+ await otlp?.close();
418
+ await new Promise((r) => { server.close(() => r()); server.closeAllConnections(); });
419
+ },
420
+ };
421
+ }
422
+ function percentile(xs, p) {
423
+ if (!xs.length)
424
+ return 0;
425
+ const s = [...xs].sort((a, b) => a - b);
426
+ return s[Math.min(s.length - 1, Math.floor(p * s.length))];
427
+ }
@@ -0,0 +1,154 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import { type JwtPayload } from "../sts/jwt.ts";
3
+ import { type Revocation } from "../sts/revocation.ts";
4
+ import { type AuthorizationDetail, type CompiledPolicy, type Decision } from "../policy/engine.ts";
5
+ import { type ApprovalRequest, type ApprovalService, type NewApproval } from "../approvals/approvals.ts";
6
+ import { type RedactionOptions } from "../ledger/redact.ts";
7
+ import type { ActionRecord, LedgerSink } from "../ledger/ledger.ts";
8
+ import { type DriftReport, type ToolDefinition } from "./pinning.ts";
9
+ export interface PolicySource {
10
+ current(): CompiledPolicy;
11
+ }
12
+ export interface RevocationSource {
13
+ check(claims: JwtPayload): Revocation | null;
14
+ /** True when the revocation list could not be refreshed recently (control plane unreachable). */
15
+ stale(): boolean;
16
+ }
17
+ export interface ApprovalBroker {
18
+ request(n: NewApproval): Promise<ApprovalRequest>;
19
+ wait(id: string, timeoutMs: number): Promise<ApprovalRequest | undefined>;
20
+ consume(id: string): Promise<boolean>;
21
+ }
22
+ export declare class StaticPolicy implements PolicySource {
23
+ private p;
24
+ constructor(p: CompiledPolicy);
25
+ current(): CompiledPolicy;
26
+ set(p: CompiledPolicy): void;
27
+ }
28
+ export declare class LocalRevocations implements RevocationSource {
29
+ private list;
30
+ constructor(list: () => Iterable<Revocation>);
31
+ check(claims: JwtPayload): Revocation | null;
32
+ stale(): boolean;
33
+ }
34
+ export declare class LocalApprovalBroker implements ApprovalBroker {
35
+ private svc;
36
+ constructor(svc: ApprovalService);
37
+ request(n: NewApproval): Promise<ApprovalRequest>;
38
+ wait(id: string, ms: number): Promise<ApprovalRequest | undefined>;
39
+ consume(id: string): Promise<boolean>;
40
+ }
41
+ export interface VerifiedAuth {
42
+ token: string;
43
+ claims: JwtPayload;
44
+ principal: {
45
+ sub: string;
46
+ tenant: string | null;
47
+ email: string | null;
48
+ };
49
+ agent: {
50
+ id: string;
51
+ version: string | null;
52
+ prompt_hash: string | null;
53
+ };
54
+ actor_chain: string[];
55
+ task_id: string | null;
56
+ session_id: string | null;
57
+ authorization_details: AuthorizationDetail[] | null;
58
+ }
59
+ export interface GuardOptions {
60
+ /** Canonical URL of this MCP server; tokens must carry it in `aud`. */
61
+ resource: string;
62
+ /** Mandat issuer (control plane URL). */
63
+ issuer: string;
64
+ /** JWKS of the issuer. Default `${issuer}/.well-known/jwks.json`. Ignored if `publicKey` is set. */
65
+ jwksUri?: string;
66
+ publicKey?: KeyObject;
67
+ policy: PolicySource;
68
+ revocations: RevocationSource;
69
+ approvals: ApprovalBroker;
70
+ ledger: LedgerSink;
71
+ /** "shadow": decide and log, but never block (for rolling out new policies). */
72
+ mode?: "enforce" | "shadow";
73
+ /** How long a tool call blocks waiting for a human before returning "pending". Default 20 s. */
74
+ approvalWaitMs?: number;
75
+ pinning?: {
76
+ lockfile: string;
77
+ mode?: "warn" | "block";
78
+ frozen?: boolean;
79
+ };
80
+ redaction?: RedactionOptions;
81
+ /** Name of this guard instance in ledger records. */
82
+ source?: string;
83
+ /** Called on drift, denials etc. Default: console.warn. */
84
+ log?: (msg: string) => void;
85
+ }
86
+ export interface CallContext {
87
+ auth: VerifiedAuth;
88
+ tool: string;
89
+ args: Record<string, unknown>;
90
+ /** Transport-level session (e.g. Mcp-Session-Id). Falls back to token session / task id. */
91
+ sessionId?: string | null;
92
+ }
93
+ export declare class AuthError extends Error {
94
+ status: number;
95
+ code: string;
96
+ constructor(code: string, message: string, status?: number);
97
+ }
98
+ export declare class GuardDenied extends Error {
99
+ code: "denied" | "approval_pending" | "approval_denied" | "revoked" | "drift" | "fail_closed";
100
+ decision: Decision | null;
101
+ record: ActionRecord | void;
102
+ approvalId?: string;
103
+ constructor(code: GuardDenied["code"], message: string, decision: Decision | null, record: ActionRecord | void, approvalId?: string);
104
+ }
105
+ interface SessionState {
106
+ tainted: boolean;
107
+ taint_sources: string[];
108
+ calls: number;
109
+ touched: number;
110
+ }
111
+ export declare class Guard {
112
+ readonly opts: GuardOptions;
113
+ private jwks;
114
+ private sessions;
115
+ private toolDefs;
116
+ private blockedTools;
117
+ private log;
118
+ constructor(opts: GuardOptions);
119
+ /** RFC 9728 Protected Resource Metadata for this server. */
120
+ protectedResourceMetadata(extra?: Record<string, unknown>): Record<string, unknown>;
121
+ /** Verifies a bearer token. Throws AuthError (map to HTTP 401/403 + WWW-Authenticate). */
122
+ verify(token: string | undefined): Promise<VerifiedAuth>;
123
+ /**
124
+ * OAuthTokenVerifier for the official MCP SDK's `requireBearerAuth`. Pass the SDK's InvalidTokenError
125
+ * so failures become proper 401 responses.
126
+ */
127
+ sdkTokenVerifier(errors?: {
128
+ InvalidTokenError?: new (msg: string) => Error;
129
+ }): {
130
+ verifyAccessToken: (token: string) => Promise<{
131
+ token: string;
132
+ clientId: string;
133
+ scopes: string[];
134
+ expiresAt: number | undefined;
135
+ resource: URL;
136
+ extra: {
137
+ mandat: VerifiedAuth;
138
+ };
139
+ }>;
140
+ };
141
+ /** Register the server's tool definitions: computes hashes and checks them against the pin lockfile. */
142
+ registerTools(defs: ToolDefinition[]): DriftReport | null;
143
+ /**
144
+ * Authorises and executes one tool call. Returns the handler's result, or throws GuardDenied.
145
+ * Exactly one `agent.tool_call` record is written per invocation.
146
+ */
147
+ execute<T>(ctx: CallContext, handler: () => Promise<T> | T): Promise<T>;
148
+ /** Session taint state (for tests / introspection). */
149
+ sessionState(key: string): SessionState | undefined;
150
+ private session;
151
+ private redactArgs;
152
+ private append;
153
+ }
154
+ export {};