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,649 @@
1
+ // `mandat` command-line interface.
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import { parseArgs } from "./args.js";
7
+ import { STARTER_POLICY, STARTER_TESTS, readmeSnippet } from "./templates.js";
8
+ import { compilePolicy, runPolicyTests, PolicyError } from "../policy/engine.js";
9
+ import { formatHtml, formatSarif, formatText, shouldFail } from "../scan/findings.js";
10
+ import { scanRemote } from "../scan/remote.js";
11
+ import { scanConfigFile, scanSource } from "../scan/static.js";
12
+ import { buildLock, diffLock } from "../guard/pinning.js";
13
+ import { readLedger, verifyLedger, queryRecords } from "../ledger/ledger.js";
14
+ import { buildReport, reportToMarkdown } from "../ledger/report.js";
15
+ import { describe, toOcsf, toOtelSpan } from "../ledger/export.js";
16
+ import { createControlPlane } from "../controlplane/server.js";
17
+ import { hashApiKey, loadConfig } from "../controlplane/config.js";
18
+ import { Bridge } from "../bridge.js";
19
+ import { clearSession, currentIdToken, discover, loadSession, login, openSystemBrowser, saveSession } from "../oidc.js";
20
+ import { readJson, writeJsonAtomic } from "../util.js";
21
+ const VERSION = "0.1.0";
22
+ const BOOLEANS = ["version", "login", "no-browser", "disable-dev-idp", "active", "check", "force", "json", "tainted", "help", "h", "follow", "frozen", "no-color", "shadow"];
23
+ const HELP = `mandat ${VERSION} — authorization, delegation and audit for AI agent actions
24
+
25
+ Setup
26
+ mandat init [--resource URL] [--force] Create config, starter policy, keys and API credentials
27
+ mandat serve [--config mandat.config.json] Run the control plane (STS, approvals, ledger, console)
28
+
29
+ Scan (US-1)
30
+ mandat scan <url> [--active] Check a remote MCP server's authorization surface
31
+ mandat scan-config [files...] [--src DIR] Static scan of MCP client configs (auto-detects known paths) and server source
32
+ --format text|json|sarif|html --out FILE --fail-on critical|high|medium|low|none (default high)
33
+
34
+ Tool pinning (US-9)
35
+ mandat pin <url> [--token T] [--lock mandat.lock.json] [--check] Pin / verify tool definitions of an MCP server
36
+
37
+ Policy
38
+ mandat policy check [mandat.policy.json]
39
+ mandat policy test [policy] [tests.json] Run decision tests (writes mandat.policy.test.json on first run)
40
+ mandat policy eval [policy] --tool T --args JSON [--agent A] [--tainted]
41
+ mandat bench [policy] PDP latency (p50/p99)
42
+
43
+ Agents, approvals, revocation (talk to the control plane: --url, --key or MANDAT_URL / MANDAT_API_KEY)
44
+ mandat agents list
45
+ mandat agents add <id> --tools a,b [--resources URL,...] [--versions 1.0.0,...] [--name N]
46
+ mandat approvals list [--status pending]
47
+ mandat approvals approve|deny <id> [--comment C]
48
+ mandat revoke --agent ID [--version V] | --user SUB | --task ID | --tenant T | --jti ID [--reason R]
49
+ mandat token --agent ID --secret S --user SUB --resource URL [--tools a,b] [--version V] Dev: mint a task token
50
+
51
+ Real users (OIDC: Auth0, Okta, Entra ID, Keycloak)
52
+ mandat idp add --issuer URL --client-id ID [--tenant-claim CLAIM] [--disable-dev-idp] Trust an IdP (edits mandat.config.json)
53
+ mandat idp list
54
+ mandat login --issuer URL --client-id ID [--port 8765] [--organization ORG] [--no-browser] Browser login (PKCE)
55
+ mandat login status | mandat logout
56
+
57
+ Connect real agents (Claude Code, Codex, Cursor, Claude Desktop)
58
+ mandat bridge --url MCP_URL --agent ID [--mandat URL] [--login | --user SUB] [--version V] [--tools a,b]
59
+ stdio MCP server that acts as agent ID and refreshes task tokens automatically.
60
+ --login acts for the user signed in with "mandat login"; --user uses the dev IdP.
61
+ Secret: env MANDAT_AGENT_SECRET (or --secret). Raw IdP token instead: env MANDAT_SUBJECT_TOKEN.
62
+
63
+ Ledger (US-6, US-8)
64
+ mandat ledger verify [file] Verify the hash chain
65
+ mandat ledger tail [file] [-n 20]
66
+ mandat ledger report [file] [--from ISO] [--to ISO] [--format md|json] [--retention DAYS]
67
+ mandat ledger export [file] --format ocsf|otel
68
+ `;
69
+ export async function main(argv) {
70
+ const args = parseArgs(argv, BOOLEANS);
71
+ const [cmd, sub] = args._;
72
+ if (args.bool("version") || cmd === "version") {
73
+ console.log(VERSION);
74
+ return 0;
75
+ }
76
+ if (!cmd || args.bool("help") || args.bool("h")) {
77
+ process.stdout.write(HELP);
78
+ return 0;
79
+ }
80
+ switch (cmd) {
81
+ case "init": return init(args);
82
+ case "serve": return serve(args);
83
+ case "scan": return scan(args);
84
+ case "scan-config": return scanConfig(args);
85
+ case "pin": return pin(args);
86
+ case "policy": return policyCmd(sub, args);
87
+ case "bench": return bench(args);
88
+ case "ledger": return ledgerCmd(sub, args);
89
+ case "agents": return agentsCmd(sub, args);
90
+ case "approvals": return approvalsCmd(sub, args);
91
+ case "revoke": return revoke(args);
92
+ case "token": return token(args);
93
+ case "bridge": return bridge(args);
94
+ case "login": return loginCmd(args);
95
+ case "logout":
96
+ console.log(clearSession() ? "✓ Logged out" : "Not logged in.");
97
+ return 0;
98
+ case "idp": return idpCmd(sub, args);
99
+ default:
100
+ console.error(`Unknown command '${cmd}'. Run \`mandat --help\`.`);
101
+ return 2;
102
+ }
103
+ }
104
+ // ---------- init / serve ----------
105
+ function newKey() {
106
+ return "mdt_" + randomBytes(24).toString("base64url");
107
+ }
108
+ function init(args) {
109
+ const dir = resolve(args.str("dir", "."));
110
+ const resource = args.str("resource", "http://127.0.0.1:7401/mcp");
111
+ const port = args.num("port", 7400);
112
+ const issuer = args.str("issuer", `http://127.0.0.1:${port}`);
113
+ const cfgPath = join(dir, "mandat.config.json");
114
+ if (existsSync(cfgPath) && !args.bool("force")) {
115
+ console.error(`${cfgPath} already exists (use --force to overwrite).`);
116
+ return 1;
117
+ }
118
+ const keys = { admin: newKey(), approver: newKey(), auditor: newKey(), service: newKey() };
119
+ const config = {
120
+ issuer,
121
+ port,
122
+ host: "127.0.0.1",
123
+ dataDir: ".mandat",
124
+ policyFile: "mandat.policy.json",
125
+ devIdp: true,
126
+ tokenTtlSeconds: 300,
127
+ maxTokenTtlSeconds: 900,
128
+ trustedIssuers: [],
129
+ apiKeys: [
130
+ { id: "admin", hash: hashApiKey(keys.admin), roles: ["admin", "approver", "auditor"] },
131
+ { id: "alice", hash: hashApiKey(keys.approver), roles: ["approver"] },
132
+ { id: "auditor", hash: hashApiKey(keys.auditor), roles: ["auditor"] },
133
+ { id: "mcp-guard", hash: hashApiKey(keys.service), roles: ["service"] },
134
+ ],
135
+ approvals: { defaultTimeout: "10m", log: true },
136
+ ledger: { retentionDays: 365 },
137
+ };
138
+ mkdirSync(join(dir, ".mandat"), { recursive: true });
139
+ writeJsonAtomic(cfgPath, config);
140
+ if (!existsSync(join(dir, "mandat.policy.json")) || args.bool("force"))
141
+ writeJsonAtomic(join(dir, "mandat.policy.json"), STARTER_POLICY);
142
+ if (!existsSync(join(dir, "mandat.policy.test.json")) || args.bool("force"))
143
+ writeJsonAtomic(join(dir, "mandat.policy.test.json"), STARTER_TESTS);
144
+ const credPath = join(dir, ".mandat", "credentials.json");
145
+ writeFileSync(credPath, JSON.stringify({ url: issuer, keys }, null, 2) + "\n", { mode: 0o600 });
146
+ const gi = join(dir, ".gitignore");
147
+ const giText = existsSync(gi) ? readFileSync(gi, "utf8") : "";
148
+ if (!/^\.mandat\/?$/m.test(giText))
149
+ appendFileSync(gi, (giText && !giText.endsWith("\n") ? "\n" : "") + ".mandat/\n");
150
+ console.log(`✓ Created mandat.config.json, mandat.policy.json, mandat.policy.test.json
151
+ ✓ API keys written to .mandat/credentials.json (git-ignored; shown once below)
152
+
153
+ admin ${keys.admin}
154
+ approver ${keys.approver} (identity "alice")
155
+ auditor ${keys.auditor}
156
+ service ${keys.service} (for Guards in your MCP servers)
157
+
158
+ Next:
159
+ 1. mandat policy test # check the starter policy
160
+ 2. mandat serve # control plane + console at ${issuer}/console
161
+ 3. mandat agents add support-bot --tools "lookup_*,refund,send_email" --resources ${resource}
162
+ ${readmeSnippet(resource, issuer)}`);
163
+ return 0;
164
+ }
165
+ async function serve(args) {
166
+ const cfgPath = args.str("config", "mandat.config.json");
167
+ if (!existsSync(cfgPath)) {
168
+ console.error(`${cfgPath} not found — run \`mandat init\` first.`);
169
+ return 1;
170
+ }
171
+ const cfg = loadConfig(cfgPath);
172
+ if (args.str("port"))
173
+ cfg.port = args.num("port", cfg.port);
174
+ const cp = createControlPlane(cfg);
175
+ const addr = await cp.listen();
176
+ console.log(`Mandat control plane listening on ${addr}
177
+ issuer ${cfg.issuer}
178
+ console ${cfg.issuer}/console
179
+ token ${cfg.issuer}/oauth/token (RFC 8693 token exchange)
180
+ jwks ${cfg.issuer}/.well-known/jwks.json
181
+ ledger ${cp.ledger.path} (${cp.ledger.length} records)${cfg.devIdp ? "\n ⚠ dev IdP enabled (POST /dev/login) — disable `devIdp` in production" : ""}`);
182
+ await new Promise((r) => { process.once("SIGINT", r); process.once("SIGTERM", r); });
183
+ await cp.close();
184
+ return 0;
185
+ }
186
+ // ---------- scanning ----------
187
+ function emit(reports, args) {
188
+ const fmt = args.str("format", "text");
189
+ const text = fmt === "json" ? JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2)
190
+ : fmt === "sarif" ? formatSarif(reports)
191
+ : fmt === "html" ? formatHtml(reports)
192
+ : formatText(reports, !args.bool("no-color") && !args.str("out") && process.stdout.isTTY);
193
+ const out = args.str("out");
194
+ if (out) {
195
+ writeFileSync(out, text);
196
+ console.error(`Report written to ${out}`);
197
+ }
198
+ else
199
+ process.stdout.write(text.endsWith("\n") ? text : text + "\n");
200
+ return shouldFail(reports, args.str("fail-on", "high")) ? 1 : 0;
201
+ }
202
+ async function scan(args) {
203
+ const target = args._[1];
204
+ if (!target) {
205
+ console.error("Usage: mandat scan <url> [--active]");
206
+ return 2;
207
+ }
208
+ return emit([await scanRemote(target, { active: args.bool("active") })], args);
209
+ }
210
+ function knownConfigPaths() {
211
+ const home = homedir();
212
+ const appdata = process.env.APPDATA ?? join(home, "AppData", "Roaming");
213
+ return [
214
+ join(appdata, "Claude", "claude_desktop_config.json"),
215
+ join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
216
+ join(home, ".config", "Claude", "claude_desktop_config.json"),
217
+ join(home, ".cursor", "mcp.json"),
218
+ join(home, ".codeium", "windsurf", "mcp_config.json"),
219
+ join(home, ".claude.json"),
220
+ ".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json",
221
+ ].filter((p) => existsSync(p));
222
+ }
223
+ function scanConfig(args) {
224
+ const files = args._.slice(1);
225
+ const reports = [];
226
+ const targets = files.length ? files : args.str("src") ? [] : knownConfigPaths();
227
+ for (const f of targets)
228
+ reports.push(scanConfigFile(f));
229
+ const src = args.str("src");
230
+ if (src)
231
+ reports.push(scanSource(src));
232
+ if (!reports.length) {
233
+ console.error("No MCP config files found. Pass paths explicitly or --src DIR.");
234
+ return 2;
235
+ }
236
+ return emit(reports, args);
237
+ }
238
+ // ---------- pinning ----------
239
+ async function pin(args) {
240
+ const target = args._[1];
241
+ if (!target) {
242
+ console.error("Usage: mandat pin <url> [--token T] [--lock FILE] [--check]");
243
+ return 2;
244
+ }
245
+ const lockPath = args.str("lock", "mandat.lock.json");
246
+ const tok = args.str("token") ?? process.env.MANDAT_TOKEN;
247
+ const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", ...(tok ? { authorization: `Bearer ${tok}` } : {}) };
248
+ const post = async (body, sid) => {
249
+ const r = await fetch(target, { method: "POST", headers: { ...headers, ...(sid ? { "mcp-session-id": sid } : {}) }, body: JSON.stringify(body) });
250
+ if (!r.ok)
251
+ throw new Error(`${r.status} ${r.statusText}${r.status === 401 ? " — pass --token" : ""}`);
252
+ return r;
253
+ };
254
+ let tools;
255
+ try {
256
+ const init = await post({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2026-07-28", capabilities: {}, clientInfo: { name: "mandat-pin", version: VERSION } } });
257
+ const sid = init.headers.get("mcp-session-id");
258
+ await init.text();
259
+ const res = await post({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, sid);
260
+ const text = await res.text();
261
+ const json = text.trimStart().startsWith("{") ? JSON.parse(text) : JSON.parse(text.split("\n").find((l) => l.startsWith("data:") && l.includes("tools")).slice(5));
262
+ tools = json.result.tools;
263
+ }
264
+ catch (e) {
265
+ console.error(`Could not list tools: ${e.message}`);
266
+ return 1;
267
+ }
268
+ if (args.bool("check")) {
269
+ if (!existsSync(lockPath)) {
270
+ console.error(`${lockPath} not found — run without --check first.`);
271
+ return 1;
272
+ }
273
+ const d = diffLock(readJson(lockPath), tools);
274
+ if (d.clean) {
275
+ console.log(`✓ ${tools.length} tool definitions match ${lockPath}`);
276
+ return 0;
277
+ }
278
+ console.log(`✗ Tool definitions drifted from ${lockPath}:`);
279
+ for (const c of d.changed)
280
+ console.log(` changed ${c.name}\n before: ${c.description_before ?? ""}\n after: ${c.description_after ?? ""}`);
281
+ for (const a of d.added)
282
+ console.log(` added ${a}`);
283
+ for (const r of d.removed)
284
+ console.log(` removed ${r}`);
285
+ return 1;
286
+ }
287
+ writeJsonAtomic(lockPath, buildLock(tools, target));
288
+ console.log(`✓ Pinned ${tools.length} tools of ${target} to ${lockPath}`);
289
+ return 0;
290
+ }
291
+ // ---------- policy ----------
292
+ function loadPolicyFile(path) {
293
+ return compilePolicy(readJson(path));
294
+ }
295
+ function policyCmd(sub, args) {
296
+ const file = args._[2] ?? "mandat.policy.json";
297
+ try {
298
+ if (sub === "check") {
299
+ const p = loadPolicyFile(file);
300
+ console.log(`✓ ${file} is valid — ${p.doc.rules.length} rules, ${Object.keys(p.doc.tools ?? {}).length} tool classifications, version ${p.version}`);
301
+ return 0;
302
+ }
303
+ if (sub === "test") {
304
+ const p = loadPolicyFile(file);
305
+ const testsPath = args._[3] ?? file.replace(/\.json$/, ".test.json");
306
+ if (!existsSync(testsPath)) {
307
+ console.error(`${testsPath} not found.`);
308
+ return 1;
309
+ }
310
+ const results = runPolicyTests(p, readJson(testsPath));
311
+ for (const r of results)
312
+ console.log(`${r.pass ? "✓" : "✗"} ${r.name}${r.pass ? "" : ` — expected ${r.expected}, got ${r.actual} (${r.reasons.join("; ")})`}`);
313
+ const failed = results.filter((r) => !r.pass).length;
314
+ console.log(`\n${results.length - failed}/${results.length} passed`);
315
+ return failed ? 1 : 0;
316
+ }
317
+ if (sub === "eval") {
318
+ const p = loadPolicyFile(file);
319
+ const d = p.evaluate({
320
+ principal: { sub: args.str("user", "cli-user") },
321
+ agent: { id: args.str("agent", "cli-agent") },
322
+ tool: { name: args.str("tool", "") },
323
+ args: JSON.parse(args.str("args", "{}")),
324
+ session: { tainted: args.bool("tainted") },
325
+ });
326
+ console.log(JSON.stringify(d, null, 2));
327
+ return 0;
328
+ }
329
+ }
330
+ catch (e) {
331
+ console.error(e instanceof PolicyError ? e.message : `Error: ${e.message}`);
332
+ return 1;
333
+ }
334
+ console.error("Usage: mandat policy check|test|eval [policy]");
335
+ return 2;
336
+ }
337
+ function bench(args) {
338
+ const p = loadPolicyFile(args._[1] ?? "mandat.policy.json");
339
+ const inputs = [
340
+ { tool: "refund", args: { amount: 20, currency: "EUR" } },
341
+ { tool: "refund", args: { amount: 200, currency: "EUR" } },
342
+ { tool: "send_email", args: { to: ["a@example.com", "b@x.test"], subject: "s", body: "b" } },
343
+ { tool: "lookup_order", args: { order_id: "1" } },
344
+ ];
345
+ const n = args.num("n", 100_000);
346
+ const lat = [];
347
+ for (let i = 0; i < n; i++) {
348
+ const inp = inputs[i % inputs.length];
349
+ const t = process.hrtime.bigint();
350
+ p.evaluate({ principal: { sub: "u" }, agent: { id: "a" }, tool: { name: inp.tool }, args: inp.args, session: { tainted: i % 2 === 0 } });
351
+ lat.push(Number(process.hrtime.bigint() - t) / 1000);
352
+ }
353
+ lat.sort((a, b) => a - b);
354
+ const q = (x) => lat[Math.floor(x * (lat.length - 1))].toFixed(1);
355
+ console.log(`${n} decisions: p50 ${q(0.5)} µs · p99 ${q(0.99)} µs · p99.9 ${q(0.999)} µs · max ${lat.at(-1).toFixed(1)} µs (target p99 < 5000 µs)`);
356
+ return 0;
357
+ }
358
+ // ---------- ledger ----------
359
+ function ledgerPath(args) {
360
+ if (args._[2])
361
+ return args._[2];
362
+ if (existsSync("mandat.config.json"))
363
+ return join(loadConfig("mandat.config.json").dataDir, "ledger.jsonl");
364
+ return ".mandat/ledger.jsonl";
365
+ }
366
+ function ledgerCmd(sub, args) {
367
+ const path = ledgerPath(args);
368
+ if (sub === "verify") {
369
+ const v = verifyLedger(path);
370
+ if (v.ok)
371
+ console.log(`✓ ${path}: hash chain intact — ${v.count} records${v.count ? `, ${v.first_ts} → ${v.last_ts}` : ""}\n head ${v.head}`);
372
+ else
373
+ console.log(`✗ ${path}: ${v.error}`);
374
+ return v.ok ? 0 : 1;
375
+ }
376
+ if (sub === "tail") {
377
+ for (const r of queryRecords(readLedger(path), { limit: args.num("n", 20) }).reverse())
378
+ console.log(`${r.ts} #${r.seq} ${describe(r)}`);
379
+ return 0;
380
+ }
381
+ if (sub === "report") {
382
+ const data = buildReport(readLedger(path), { from: args.str("from"), to: args.str("to"), retentionDays: args.str("retention") ? args.num("retention", 0) : undefined });
383
+ const text = args.str("format") === "json" ? JSON.stringify(data, null, 2) : reportToMarkdown(data);
384
+ const out = args.str("out");
385
+ if (out) {
386
+ writeFileSync(out, text);
387
+ console.error(`Report written to ${out}`);
388
+ }
389
+ else
390
+ console.log(text);
391
+ return data.integrity.ok ? 0 : 1;
392
+ }
393
+ if (sub === "export") {
394
+ const fmt = args.str("format", "ocsf");
395
+ for (const r of readLedger(path))
396
+ console.log(JSON.stringify(fmt === "otel" ? toOtelSpan(r) : toOcsf(r)));
397
+ return 0;
398
+ }
399
+ console.error("Usage: mandat ledger verify|tail|report|export [file]");
400
+ return 2;
401
+ }
402
+ // ---------- control plane API ----------
403
+ function cpTarget(args, role = "admin") {
404
+ let url = args.str("url") ?? process.env.MANDAT_URL;
405
+ let key = args.str("key") ?? process.env.MANDAT_API_KEY;
406
+ const credPath = ".mandat/credentials.json";
407
+ if ((!url || !key) && existsSync(credPath)) {
408
+ const c = readJson(credPath);
409
+ url ??= c.url;
410
+ key ??= c.keys[role] ?? c.keys.admin;
411
+ }
412
+ if (!url || !key)
413
+ throw new Error("Control plane URL / API key missing: pass --url and --key, set MANDAT_URL / MANDAT_API_KEY, or run in a directory initialised with `mandat init`.");
414
+ return { url: url.replace(/\/$/, ""), key: key };
415
+ }
416
+ async function cp(args, method, path, body, role) {
417
+ const t = cpTarget(args, role);
418
+ const res = await fetch(t.url + path, {
419
+ method,
420
+ headers: { authorization: `Bearer ${t.key}`, ...(body ? { "content-type": "application/json" } : {}) },
421
+ body: body ? JSON.stringify(body) : undefined,
422
+ });
423
+ const data = await res.json().catch(() => ({}));
424
+ if (!res.ok)
425
+ throw new Error(`${res.status}: ${data.error_description ?? data.error ?? res.statusText}`);
426
+ return data;
427
+ }
428
+ async function agentsCmd(sub, args) {
429
+ try {
430
+ if (!sub || sub === "list") {
431
+ const { agents } = await cp(args, "GET", "/api/agents");
432
+ if (!agents.length)
433
+ console.log("No agents registered.");
434
+ for (const a of agents)
435
+ console.log(`${a.revoked ? "⛔" : "●"} ${a.id.padEnd(20)} tools: ${a.allowed_tools.join(", ")} resources: ${a.allowed_resources?.join(", ") || "any"} versions: ${a.versions?.join(", ") || "any"}`);
436
+ return 0;
437
+ }
438
+ if (sub === "add") {
439
+ const id = args._[2];
440
+ if (!id || !args.list("tools").length) {
441
+ console.error("Usage: mandat agents add <id> --tools a,b [--resources URL] [--versions V]");
442
+ return 2;
443
+ }
444
+ const r = await cp(args, "POST", "/api/agents", { id, name: args.str("name"), allowed_tools: args.list("tools"), allowed_resources: args.list("resources"), versions: args.list("versions") });
445
+ console.log(`✓ Registered agent ${r.id}\n client_id ${r.id}\n client_secret ${r.client_secret}\n (store the secret now — it is not shown again)`);
446
+ return 0;
447
+ }
448
+ }
449
+ catch (e) {
450
+ console.error(e.message);
451
+ return 1;
452
+ }
453
+ console.error("Usage: mandat agents list|add");
454
+ return 2;
455
+ }
456
+ async function approvalsCmd(sub, args) {
457
+ try {
458
+ if (!sub || sub === "list") {
459
+ const status = args.str("status", "pending");
460
+ const { approvals } = await cp(args, "GET", `/api/approvals${status === "all" ? "" : `?status=${status}`}`, undefined, "approver");
461
+ if (!approvals.length)
462
+ console.log(`No ${status} approvals.`);
463
+ for (const a of approvals) {
464
+ const c = a.context;
465
+ console.log(`${a.id} ${a.status.padEnd(8)} ${c.agent.id} → ${c.tool} for ${c.principal.email ?? c.principal.sub} rule=${c.rule} ${a.votes.length}/${a.required} votes expires ${a.expires_at}`);
466
+ console.log(` args: ${JSON.stringify(c.args)}${c.session_tainted ? " ⚠ tainted session" : ""}`);
467
+ }
468
+ return 0;
469
+ }
470
+ if (sub === "approve" || sub === "deny") {
471
+ const id = args._[2];
472
+ if (!id) {
473
+ console.error(`Usage: mandat approvals ${sub} <id>`);
474
+ return 2;
475
+ }
476
+ const r = await cp(args, "POST", `/api/approvals/${id}/${sub}`, { comment: args.str("comment") }, "approver");
477
+ console.log(`✓ ${r.id} is now ${r.status} (${r.votes.filter((v) => v.decision === "approve").length}/${r.required} approvals)`);
478
+ return 0;
479
+ }
480
+ }
481
+ catch (e) {
482
+ console.error(e.message);
483
+ return 1;
484
+ }
485
+ console.error("Usage: mandat approvals list|approve|deny");
486
+ return 2;
487
+ }
488
+ async function revoke(args) {
489
+ const pairs = [["agent", "agent"], ["user", "subject"], ["task", "task"], ["tenant", "tenant"], ["jti", "jti"]];
490
+ const hit = pairs.find(([flag]) => args.str(flag));
491
+ if (!hit) {
492
+ console.error("Usage: mandat revoke --agent ID [--version V] | --user SUB | --task ID | --tenant T | --jti ID [--reason R]");
493
+ return 2;
494
+ }
495
+ const type = hit[0] === "agent" && args.str("version") ? "agent_version" : hit[1];
496
+ try {
497
+ const t0 = Date.now();
498
+ const r = await cp(args, "POST", "/api/revocations", { type, value: args.str(hit[0]), version: args.str("version"), reason: args.str("reason") });
499
+ console.log(`✓ Revoked ${r.type} ${r.value}${r.version ? "@" + r.version : ""} (${r.id}) in ${Date.now() - t0} ms; guards enforce it within ~2 s.${r.blocks_issuance ? " New tokens are blocked until lifted." : ""}`);
500
+ return 0;
501
+ }
502
+ catch (e) {
503
+ console.error(e.message);
504
+ return 1;
505
+ }
506
+ }
507
+ async function token(args) {
508
+ const t = cpTarget(args);
509
+ const agent = args.str("agent"), secret = args.str("secret"), user = args.str("user"), resource = args.str("resource");
510
+ if (!agent || !secret || !user || !resource) {
511
+ console.error("Usage: mandat token --agent ID --secret S --user SUB --resource URL [--tools a,b] [--version V]");
512
+ return 2;
513
+ }
514
+ const login = await fetch(`${t.url}/dev/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sub: user, email: args.str("email"), tenant: args.str("tenant") }) });
515
+ if (!login.ok) {
516
+ console.error(`dev login failed: ${login.status}`);
517
+ return 1;
518
+ }
519
+ const { id_token } = (await login.json());
520
+ const form = new URLSearchParams({
521
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
522
+ subject_token: id_token,
523
+ subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
524
+ resource,
525
+ });
526
+ if (args.list("tools").length)
527
+ form.set("authorization_details", JSON.stringify([{ type: "mandat_tool_call", tools: args.list("tools") }]));
528
+ if (args.str("version"))
529
+ form.set("mandat_agent_version", args.str("version"));
530
+ const res = await fetch(`${t.url}/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: "Basic " + Buffer.from(`${encodeURIComponent(agent)}:${encodeURIComponent(secret)}`).toString("base64") }, body: form });
531
+ const body = await res.json();
532
+ if (!res.ok) {
533
+ console.error(JSON.stringify(body));
534
+ return 1;
535
+ }
536
+ console.log(args.bool("json") ? JSON.stringify(body, null, 2) : body.access_token);
537
+ return 0;
538
+ }
539
+ async function bridge(args) {
540
+ const url = args.str("url");
541
+ const agent = args.str("agent");
542
+ const secret = args.str("secret") ?? process.env.MANDAT_AGENT_SECRET;
543
+ if (!url || !agent || !secret) {
544
+ console.error("Usage: mandat bridge --url MCP_URL --agent ID [--mandat URL] [--user SUB] (secret in MANDAT_AGENT_SECRET)");
545
+ return 2;
546
+ }
547
+ const b = new Bridge({
548
+ url,
549
+ mandatUrl: args.str("mandat") ?? process.env.MANDAT_URL ?? "http://127.0.0.1:7400",
550
+ agentId: agent,
551
+ agentSecret: secret,
552
+ agentVersion: args.str("version"),
553
+ subjectToken: process.env.MANDAT_SUBJECT_TOKEN,
554
+ subjectTokenProvider: args.bool("login") ? () => currentIdToken() : undefined,
555
+ user: { sub: args.str("user", "user:local"), email: args.str("email"), tenant: args.str("tenant") },
556
+ tools: args.list("tools"),
557
+ });
558
+ await b.run();
559
+ return 0;
560
+ }
561
+ async function loginCmd(args) {
562
+ if (args._[1] === "status") {
563
+ const s = loadSession();
564
+ if (!s) {
565
+ console.log("Not logged in.");
566
+ return 1;
567
+ }
568
+ const left = s.expires_at - Math.floor(Date.now() / 1000);
569
+ console.log(`Logged in as ${s.user.email ?? s.user.sub} (${s.user.sub})
570
+ issuer ${s.issuer}
571
+ client ${s.client_id}
572
+ id token ${left > 0 ? `valid ${Math.round(left / 60)} min` : "expired"}${s.refresh_token ? ", refresh token stored" : ", no refresh token (log in again when it expires)"}`);
573
+ return 0;
574
+ }
575
+ const issuer = args.str("issuer") ?? process.env.MANDAT_IDP_ISSUER;
576
+ const clientId = args.str("client-id") ?? process.env.MANDAT_IDP_CLIENT_ID;
577
+ if (!issuer || !clientId) {
578
+ console.error("Usage: mandat login --issuer https://<tenant>.auth0.com/ --client-id <native app client id> [--port 8765]");
579
+ return 2;
580
+ }
581
+ const port = args.num("port", 8765);
582
+ console.error(`Opening the browser to sign in. Callback: http://127.0.0.1:${port}/callback`);
583
+ try {
584
+ const s = await login({
585
+ issuer, clientId, port,
586
+ extraParams: args.str("organization") ? { organization: args.str("organization") } : undefined,
587
+ openBrowser: (url) => {
588
+ console.error(`If the browser does not open, visit:
589
+ ${url}`);
590
+ if (!args.bool("no-browser"))
591
+ openSystemBrowser(url);
592
+ },
593
+ });
594
+ saveSession(s);
595
+ console.log(`✓ Logged in as ${s.user.email ?? s.user.sub} (${s.user.sub})`);
596
+ if (!s.refresh_token)
597
+ console.log(" Note: no refresh token was issued; run `mandat login` again when the session expires.");
598
+ return 0;
599
+ }
600
+ catch (e) {
601
+ console.error(`Login failed: ${e.message}`);
602
+ return 1;
603
+ }
604
+ }
605
+ async function idpCmd(sub, args) {
606
+ const cfgPath = args.str("config", "mandat.config.json");
607
+ if (!existsSync(cfgPath)) {
608
+ console.error(`${cfgPath} not found — run \`mandat init\` first.`);
609
+ return 1;
610
+ }
611
+ // Edit the raw file (not loadConfig) so ${env:…} placeholders are preserved.
612
+ const raw = readJson(cfgPath);
613
+ if (!sub || sub === "list") {
614
+ const list = raw.trustedIssuers ?? [];
615
+ if (!list.length)
616
+ console.log("No trusted identity providers.");
617
+ for (const t of list)
618
+ console.log(`● ${t.issuer}
619
+ audience ${JSON.stringify(t.audience)} jwks ${t.jwks_uri}${t.tenant_claim ? ` tenant claim ${t.tenant_claim}` : ""}`);
620
+ console.log(`Dev IdP: ${raw.devIdp ? "ENABLED (anyone can mint user tokens)" : "disabled"}`);
621
+ return 0;
622
+ }
623
+ if (sub === "add") {
624
+ const issuer = args.str("issuer");
625
+ const clientId = args.str("client-id");
626
+ if (!issuer || !clientId) {
627
+ console.error("Usage: mandat idp add --issuer URL --client-id ID [--tenant-claim CLAIM] [--disable-dev-idp]");
628
+ return 2;
629
+ }
630
+ let meta;
631
+ try {
632
+ meta = await discover(issuer);
633
+ }
634
+ catch (e) {
635
+ console.error(e.message);
636
+ return 1;
637
+ }
638
+ const entry = { issuer: meta.issuer, jwks_uri: meta.jwks_uri, audience: clientId, ...(args.str("tenant-claim") ? { tenant_claim: args.str("tenant-claim") } : {}) };
639
+ raw.trustedIssuers = [...(raw.trustedIssuers ?? []).filter((t) => t.issuer !== meta.issuer), entry];
640
+ if (args.bool("disable-dev-idp"))
641
+ raw.devIdp = false;
642
+ writeJsonAtomic(cfgPath, raw);
643
+ console.log(`✓ Trusting ${meta.issuer} (ID tokens for client ${clientId})${args.bool("disable-dev-idp") ? "; dev IdP disabled" : ""}
644
+ Restart \`mandat serve\` to apply.`);
645
+ return 0;
646
+ }
647
+ console.error("Usage: mandat idp add|list");
648
+ return 2;
649
+ }