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.
- package/LICENSE +202 -0
- package/README.md +293 -3
- package/dist/approvals/approvals.d.ts +136 -0
- package/dist/approvals/approvals.js +262 -0
- package/dist/bridge.d.ts +38 -0
- package/dist/bridge.js +117 -0
- package/dist/cli/args.d.ts +9 -0
- package/dist/cli/args.js +39 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +3 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +649 -0
- package/dist/cli/templates.d.ts +91 -0
- package/dist/cli/templates.js +57 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +88 -0
- package/dist/controlplane/config.d.ts +48 -0
- package/dist/controlplane/config.js +33 -0
- package/dist/controlplane/console/.claude-flow/neural/patterns.json +402 -0
- package/dist/controlplane/console/.claude-flow/neural/stats.json +6 -0
- package/dist/controlplane/console/.claude-flow/policy/ledger-anchors.json +3 -0
- package/dist/controlplane/console/.claude-flow/policy/state.json +54 -0
- package/dist/controlplane/console/app.css +93 -0
- package/dist/controlplane/console/app.js +330 -0
- package/dist/controlplane/console/index.html +70 -0
- package/dist/controlplane/server.d.ts +17 -0
- package/dist/controlplane/server.js +427 -0
- package/dist/guard/guard.d.ts +154 -0
- package/dist/guard/guard.js +290 -0
- package/dist/guard/pinning.d.ts +46 -0
- package/dist/guard/pinning.js +49 -0
- package/dist/guard/remote.d.ts +78 -0
- package/dist/guard/remote.js +185 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +67 -0
- package/dist/ledger/export.d.ts +27 -0
- package/dist/ledger/export.js +169 -0
- package/dist/ledger/ledger.d.ts +117 -0
- package/dist/ledger/ledger.js +139 -0
- package/dist/ledger/redact.d.ts +13 -0
- package/dist/ledger/redact.js +73 -0
- package/dist/ledger/report.d.ts +71 -0
- package/dist/ledger/report.js +128 -0
- package/dist/mcp/protect.d.ts +8 -0
- package/dist/mcp/protect.js +68 -0
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.js +211 -0
- package/dist/oidc.d.ts +52 -0
- package/dist/oidc.js +194 -0
- package/dist/policy/engine.d.ts +135 -0
- package/dist/policy/engine.js +243 -0
- package/dist/policy/expr.d.ts +56 -0
- package/dist/policy/expr.js +506 -0
- package/dist/scan/findings.d.ts +32 -0
- package/dist/scan/findings.js +105 -0
- package/dist/scan/remote.d.ts +7 -0
- package/dist/scan/remote.js +225 -0
- package/dist/scan/static.d.ts +15 -0
- package/dist/scan/static.js +245 -0
- package/dist/store.d.ts +14 -0
- package/dist/store.js +40 -0
- package/dist/sts/jwt.d.ts +64 -0
- package/dist/sts/jwt.js +168 -0
- package/dist/sts/revocation.d.ts +38 -0
- package/dist/sts/revocation.js +52 -0
- package/dist/sts/sts.d.ts +83 -0
- package/dist/sts/sts.js +239 -0
- package/dist/util.d.ts +28 -0
- package/dist/util.js +137 -0
- package/package.json +47 -4
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Mandat Guard: the Policy Enforcement Point embedded in an MCP server (or any tool-executing service).
|
|
2
|
+
//
|
|
3
|
+
// For every tool call:
|
|
4
|
+
// 1. verify the per-task bearer token (iss, aud = this resource, exp, typ at+jwt, signature via JWKS)
|
|
5
|
+
// 2. reject revoked tokens / agents / versions / users
|
|
6
|
+
// 3. reject tools whose definitions drifted from the pinned lockfile (block mode)
|
|
7
|
+
// 4. evaluate policy (PDP, in-process) with args, delegation chain, capability and session taint
|
|
8
|
+
// 5. require_approval -> create/await a human approval bound to this exact call
|
|
9
|
+
// 6. run the handler, then write ONE hash-chained Action Record with decision + outcome
|
|
10
|
+
import { JwksClient, JwtError, verifyJwt } from "../sts/jwt.js";
|
|
11
|
+
import { actorChain, matchRevocation } from "../sts/revocation.js";
|
|
12
|
+
import { HIGH_RISK } from "../policy/engine.js";
|
|
13
|
+
import { approvalBinding } from "../approvals/approvals.js";
|
|
14
|
+
import { redact } from "../ledger/redact.js";
|
|
15
|
+
import { checkPins, toolHash } from "./pinning.js";
|
|
16
|
+
export class StaticPolicy {
|
|
17
|
+
p;
|
|
18
|
+
constructor(p) { this.p = p; }
|
|
19
|
+
current() { return this.p; }
|
|
20
|
+
set(p) { this.p = p; }
|
|
21
|
+
}
|
|
22
|
+
export class LocalRevocations {
|
|
23
|
+
list;
|
|
24
|
+
constructor(list) { this.list = list; }
|
|
25
|
+
check(claims) { return matchRevocation(claims, this.list()); }
|
|
26
|
+
stale() { return false; }
|
|
27
|
+
}
|
|
28
|
+
export class LocalApprovalBroker {
|
|
29
|
+
svc;
|
|
30
|
+
constructor(svc) { this.svc = svc; }
|
|
31
|
+
request(n) { return this.svc.create(n); }
|
|
32
|
+
wait(id, ms) { return this.svc.wait(id, ms); }
|
|
33
|
+
async consume(id) { return this.svc.consume(id); }
|
|
34
|
+
}
|
|
35
|
+
export class AuthError extends Error {
|
|
36
|
+
status;
|
|
37
|
+
code;
|
|
38
|
+
constructor(code, message, status = 401) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class GuardDenied extends Error {
|
|
45
|
+
code;
|
|
46
|
+
decision;
|
|
47
|
+
record;
|
|
48
|
+
approvalId;
|
|
49
|
+
constructor(code, message, decision, record, approvalId) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.decision = decision;
|
|
53
|
+
this.record = record;
|
|
54
|
+
this.approvalId = approvalId;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const SESSION_TTL_MS = 6 * 3_600_000;
|
|
58
|
+
export class Guard {
|
|
59
|
+
opts;
|
|
60
|
+
jwks;
|
|
61
|
+
sessions = new Map();
|
|
62
|
+
toolDefs = new Map();
|
|
63
|
+
blockedTools = new Set();
|
|
64
|
+
log;
|
|
65
|
+
constructor(opts) {
|
|
66
|
+
this.opts = { mode: "enforce", approvalWaitMs: 20_000, ...opts };
|
|
67
|
+
this.jwks = opts.publicKey ? null : new JwksClient(opts.jwksUri ?? `${opts.issuer.replace(/\/$/, "")}/.well-known/jwks.json`);
|
|
68
|
+
this.log = opts.log ?? ((m) => console.warn(`[mandat] ${m}`));
|
|
69
|
+
}
|
|
70
|
+
/** RFC 9728 Protected Resource Metadata for this server. */
|
|
71
|
+
protectedResourceMetadata(extra = {}) {
|
|
72
|
+
return {
|
|
73
|
+
resource: this.opts.resource,
|
|
74
|
+
authorization_servers: [this.opts.issuer],
|
|
75
|
+
bearer_methods_supported: ["header"],
|
|
76
|
+
resource_signing_alg_values_supported: ["ES256"],
|
|
77
|
+
authorization_details_types_supported: ["mandat_tool_call"],
|
|
78
|
+
resource_name: this.opts.source ?? "MCP server protected by Mandat",
|
|
79
|
+
...extra,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Verifies a bearer token. Throws AuthError (map to HTTP 401/403 + WWW-Authenticate). */
|
|
83
|
+
async verify(token) {
|
|
84
|
+
if (!token)
|
|
85
|
+
throw new AuthError("invalid_token", "Bearer token required");
|
|
86
|
+
let claims;
|
|
87
|
+
try {
|
|
88
|
+
claims = await verifyJwt(token, this.opts.publicKey ? () => this.opts.publicKey : (h) => this.jwks.getKey(h.kid), { issuer: this.opts.issuer, audience: this.opts.resource, typ: "at+jwt", requireExp: true });
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e instanceof JwtError)
|
|
92
|
+
throw new AuthError("invalid_token", e.message);
|
|
93
|
+
throw new AuthError("invalid_token", `Token verification failed: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
const chain = actorChain(claims);
|
|
96
|
+
if (!chain.length)
|
|
97
|
+
throw new AuthError("invalid_token", "Token has no `act` claim: not a delegated agent token");
|
|
98
|
+
const m = (claims.mandat ?? {});
|
|
99
|
+
const revoked = this.opts.revocations.check(claims);
|
|
100
|
+
if (revoked)
|
|
101
|
+
throw new AuthError("invalid_token", `Token revoked (${revoked.type}: ${revoked.value}${revoked.reason ? ` - ${revoked.reason}` : ""})`);
|
|
102
|
+
return {
|
|
103
|
+
token,
|
|
104
|
+
claims,
|
|
105
|
+
principal: { sub: String(claims.sub), tenant: m.tenant ?? null, email: claims.email ?? null },
|
|
106
|
+
agent: { id: chain[0].sub, version: m.agent_version ?? chain[0].mandat_agent_version ?? null, prompt_hash: m.prompt_hash ?? null },
|
|
107
|
+
actor_chain: chain.map((a) => a.sub),
|
|
108
|
+
task_id: m.task_id ?? null,
|
|
109
|
+
session_id: m.session_id ?? null,
|
|
110
|
+
authorization_details: claims.authorization_details ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* OAuthTokenVerifier for the official MCP SDK's `requireBearerAuth`. Pass the SDK's InvalidTokenError
|
|
115
|
+
* so failures become proper 401 responses.
|
|
116
|
+
*/
|
|
117
|
+
sdkTokenVerifier(errors = {}) {
|
|
118
|
+
return {
|
|
119
|
+
verifyAccessToken: async (token) => {
|
|
120
|
+
try {
|
|
121
|
+
const auth = await this.verify(token);
|
|
122
|
+
return {
|
|
123
|
+
token,
|
|
124
|
+
clientId: auth.agent.id,
|
|
125
|
+
scopes: typeof auth.claims.scope === "string" ? auth.claims.scope.split(" ") : [],
|
|
126
|
+
expiresAt: auth.claims.exp,
|
|
127
|
+
resource: new URL(this.opts.resource),
|
|
128
|
+
extra: { mandat: auth },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
throw errors.InvalidTokenError ? new errors.InvalidTokenError(e.message) : e;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Register the server's tool definitions: computes hashes and checks them against the pin lockfile. */
|
|
138
|
+
registerTools(defs) {
|
|
139
|
+
for (const d of defs)
|
|
140
|
+
this.toolDefs.set(d.name, toolHash(d));
|
|
141
|
+
if (!this.opts.pinning)
|
|
142
|
+
return null;
|
|
143
|
+
const res = checkPins(this.opts.pinning.lockfile, defs, { mode: this.opts.pinning.mode ?? "warn", frozen: this.opts.pinning.frozen, server: this.opts.resource });
|
|
144
|
+
this.blockedTools = res.blocked;
|
|
145
|
+
if (!res.drift.clean) {
|
|
146
|
+
const msg = `Tool definition drift: changed=[${res.drift.changed.map((c) => c.name)}] added=[${res.drift.added}] removed=[${res.drift.removed}]` +
|
|
147
|
+
(res.blocked.size ? ` — blocked until re-pinned: ${[...res.blocked].join(", ")}` : "");
|
|
148
|
+
this.log(msg);
|
|
149
|
+
this.append({ type: "tool.drift", tool: { name: "*", server: this.opts.resource }, details: { ...res.drift, mode: this.opts.pinning.mode ?? "warn" } });
|
|
150
|
+
}
|
|
151
|
+
return res.drift;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Authorises and executes one tool call. Returns the handler's result, or throws GuardDenied.
|
|
155
|
+
* Exactly one `agent.tool_call` record is written per invocation.
|
|
156
|
+
*/
|
|
157
|
+
async execute(ctx, handler) {
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const policy = this.opts.policy.current();
|
|
160
|
+
const meta = policy.toolMeta(ctx.tool);
|
|
161
|
+
const sessionKey = ctx.sessionId ?? ctx.auth.session_id ?? ctx.auth.task_id ?? ctx.auth.claims.jti ?? "anonymous";
|
|
162
|
+
const session = this.session(sessionKey);
|
|
163
|
+
const shadow = this.opts.mode === "shadow";
|
|
164
|
+
const base = (decision, final) => ({
|
|
165
|
+
type: "agent.tool_call",
|
|
166
|
+
source: this.opts.source,
|
|
167
|
+
principal: ctx.auth.principal,
|
|
168
|
+
agent: ctx.auth.agent,
|
|
169
|
+
actor_chain: ctx.auth.actor_chain,
|
|
170
|
+
task_id: ctx.auth.task_id,
|
|
171
|
+
token_jti: ctx.auth.claims.jti ?? null,
|
|
172
|
+
session: { id: sessionKey, tainted: session.tainted, taint_sources: session.taint_sources },
|
|
173
|
+
tool: { name: ctx.tool, server: this.opts.resource, risk: meta.risk ?? null, definition_hash: this.toolDefs.get(ctx.tool) ?? null },
|
|
174
|
+
args: this.redactArgs(ctx.args, [...(decision?.obligations ?? [])]),
|
|
175
|
+
decision: decision
|
|
176
|
+
? { effect: decision.effect, final, rules: decision.matched, reasons: decision.reasons, obligations: decision.obligations, policy_version: decision.policy_version, latency_us: Math.round(decision.latency_us), mode: this.opts.mode }
|
|
177
|
+
: { effect: "deny", final, rules: [], reasons: [], mode: this.opts.mode },
|
|
178
|
+
tags: decision?.obligations.filter((o) => o.type === "annotate").flatMap((o) => o.tags ?? []),
|
|
179
|
+
});
|
|
180
|
+
const deny = (code, reason, decision, extra = {}) => {
|
|
181
|
+
const rec = this.append({ ...base(decision, "deny"), ...extra, outcome: { status: "blocked", error: reason, duration_ms: Date.now() - started } });
|
|
182
|
+
throw new GuardDenied(code, reason, decision, rec, extra.approval?.id);
|
|
183
|
+
};
|
|
184
|
+
// Re-check revocation on every call (token may have been revoked after verify).
|
|
185
|
+
const revoked = this.opts.revocations.check(ctx.auth.claims);
|
|
186
|
+
if (revoked)
|
|
187
|
+
deny("revoked", `Token revoked (${revoked.type}: ${revoked.value})`, null);
|
|
188
|
+
if (this.blockedTools.has(ctx.tool) && !shadow)
|
|
189
|
+
deny("drift", `Tool '${ctx.tool}' changed since it was pinned; re-approve its definition with \`mandat pin\``, null);
|
|
190
|
+
const decision = policy.evaluate({
|
|
191
|
+
principal: { ...ctx.auth.principal, ...pickClaims(ctx.auth.claims) },
|
|
192
|
+
agent: ctx.auth.agent,
|
|
193
|
+
actor_chain: ctx.auth.actor_chain.map((sub) => ({ sub })),
|
|
194
|
+
tool: { name: ctx.tool, server: this.opts.resource },
|
|
195
|
+
args: ctx.args,
|
|
196
|
+
session: { id: sessionKey, tainted: session.tainted, taint_sources: session.taint_sources, calls: session.calls },
|
|
197
|
+
task: { id: ctx.auth.task_id, authorization_details: ctx.auth.authorization_details },
|
|
198
|
+
});
|
|
199
|
+
// Fail closed for high-risk tools when the control plane (revocations) is unreachable.
|
|
200
|
+
if (this.opts.revocations.stale() && meta.risk && HIGH_RISK.includes(meta.risk) && !shadow)
|
|
201
|
+
deny("fail_closed", `Control plane unreachable: '${meta.risk}' tools fail closed`, decision);
|
|
202
|
+
let approvalInfo;
|
|
203
|
+
if (!shadow && decision.effect === "deny")
|
|
204
|
+
deny("denied", decision.reasons.join("; "), decision);
|
|
205
|
+
if (!shadow && decision.effect === "require_approval") {
|
|
206
|
+
const a = decision.approval;
|
|
207
|
+
let req;
|
|
208
|
+
try {
|
|
209
|
+
req = await this.opts.approvals.request({
|
|
210
|
+
binding: approvalBinding({ sub: ctx.auth.principal.sub, agent: ctx.auth.agent.id, tool: ctx.tool, server: this.opts.resource, args: ctx.args }),
|
|
211
|
+
required: a.approvers,
|
|
212
|
+
timeoutMs: a.timeoutMs,
|
|
213
|
+
channels: a.channels,
|
|
214
|
+
context: {
|
|
215
|
+
tool: ctx.tool, server: this.opts.resource, risk: meta.risk ?? null,
|
|
216
|
+
args: this.redactArgs(ctx.args, decision.obligations),
|
|
217
|
+
principal: ctx.auth.principal, agent: ctx.auth.agent, actor_chain: ctx.auth.actor_chain,
|
|
218
|
+
task_id: ctx.auth.task_id, rule: a.rule, reasons: decision.reasons, session_tainted: session.tainted,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (req.status === "pending")
|
|
222
|
+
req = await this.opts.approvals.wait(req.id, this.opts.approvalWaitMs);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
deny("fail_closed", `Approval service unavailable: ${e.message}`, decision);
|
|
226
|
+
}
|
|
227
|
+
const r = req;
|
|
228
|
+
approvalInfo = { id: r.id, status: r.status, required: r.required, approvers: r.votes.filter((v) => v.decision === "approve").map((v) => v.by) };
|
|
229
|
+
if (r.status === "pending") {
|
|
230
|
+
const rec = this.append({ ...base(decision, "pending"), approval: approvalInfo, outcome: { status: "pending", duration_ms: Date.now() - started } });
|
|
231
|
+
throw new GuardDenied("approval_pending", `Human approval required (request ${r.id}, expires ${r.expires_at}). Retry the same call once it is approved.`, decision, rec, r.id);
|
|
232
|
+
}
|
|
233
|
+
if (r.status !== "approved")
|
|
234
|
+
deny("approval_denied", `Approval ${r.id} was ${r.status}`, decision, { approval: approvalInfo });
|
|
235
|
+
if (!(await this.opts.approvals.consume(r.id)))
|
|
236
|
+
deny("approval_denied", `Approval ${r.id} was already used`, decision, { approval: approvalInfo });
|
|
237
|
+
}
|
|
238
|
+
session.calls++;
|
|
239
|
+
try {
|
|
240
|
+
const result = await handler();
|
|
241
|
+
if (meta.taints && !session.tainted) {
|
|
242
|
+
session.tainted = true;
|
|
243
|
+
session.taint_sources.push(ctx.tool);
|
|
244
|
+
}
|
|
245
|
+
this.append({ ...base(decision, "allow"), approval: approvalInfo, outcome: { status: "success", duration_ms: Date.now() - started } });
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
this.append({ ...base(decision, "allow"), approval: approvalInfo, outcome: { status: "error", error: e.message?.slice(0, 500), duration_ms: Date.now() - started } });
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Session taint state (for tests / introspection). */
|
|
254
|
+
sessionState(key) { return this.sessions.get(key); }
|
|
255
|
+
session(key) {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
let s = this.sessions.get(key);
|
|
258
|
+
if (!s) {
|
|
259
|
+
if (this.sessions.size > 10_000) {
|
|
260
|
+
for (const [k, v] of this.sessions)
|
|
261
|
+
if (now - v.touched > SESSION_TTL_MS)
|
|
262
|
+
this.sessions.delete(k);
|
|
263
|
+
}
|
|
264
|
+
s = { tainted: false, taint_sources: [], calls: 0, touched: now };
|
|
265
|
+
this.sessions.set(key, s);
|
|
266
|
+
}
|
|
267
|
+
s.touched = now;
|
|
268
|
+
return s;
|
|
269
|
+
}
|
|
270
|
+
redactArgs(args, obligations) {
|
|
271
|
+
const fields = obligations.filter((o) => o.type === "redact").flatMap((o) => o.fields ?? []);
|
|
272
|
+
return redact(args, { ...this.opts.redaction, fields: [...(this.opts.redaction?.fields ?? []), ...fields] });
|
|
273
|
+
}
|
|
274
|
+
append(rec) {
|
|
275
|
+
try {
|
|
276
|
+
return this.opts.ledger.append({ source: this.opts.source, ...rec });
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
this.log(`Ledger append failed: ${e.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Selected token claims exposed to policies as principal.* (never the raw token). */
|
|
284
|
+
function pickClaims(c) {
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const k of ["scope", "client_id", "email"])
|
|
287
|
+
if (c[k] !== undefined)
|
|
288
|
+
out[k] = c[k];
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ToolDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
inputSchema?: unknown;
|
|
6
|
+
outputSchema?: unknown;
|
|
7
|
+
annotations?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface LockFile {
|
|
10
|
+
version: 1;
|
|
11
|
+
server?: string;
|
|
12
|
+
pinned_at: string;
|
|
13
|
+
tools: Record<string, {
|
|
14
|
+
hash: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface DriftReport {
|
|
19
|
+
added: string[];
|
|
20
|
+
removed: string[];
|
|
21
|
+
changed: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
before: string;
|
|
24
|
+
after: string;
|
|
25
|
+
description_before?: string;
|
|
26
|
+
description_after?: string;
|
|
27
|
+
}>;
|
|
28
|
+
clean: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function toolHash(t: ToolDefinition): string;
|
|
31
|
+
export declare function buildLock(tools: ToolDefinition[], server?: string): LockFile;
|
|
32
|
+
export declare function diffLock(lock: LockFile, tools: ToolDefinition[]): DriftReport;
|
|
33
|
+
export interface PinCheckResult {
|
|
34
|
+
drift: DriftReport;
|
|
35
|
+
created: boolean;
|
|
36
|
+
blocked: Set<string>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compares tools against `lockPath`. Creates the lockfile on first use (trust-on-first-use) unless `frozen`.
|
|
40
|
+
* In "block" mode, changed and newly added tools are returned in `blocked`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function checkPins(lockPath: string, tools: ToolDefinition[], opts: {
|
|
43
|
+
mode: "warn" | "block";
|
|
44
|
+
frozen?: boolean;
|
|
45
|
+
server?: string;
|
|
46
|
+
}): PinCheckResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Tool definition pinning (rug-pull / tool-poisoning protection, US-9).
|
|
2
|
+
// A lockfile stores sha256(name, description, inputSchema, annotations) per tool. Any later change to a
|
|
3
|
+
// description or schema is "drift" and is reported (warn) or blocked until re-approved with `mandat pin`.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { canonicalJson, readJson, sha256, writeJsonAtomic } from "../util.js";
|
|
6
|
+
export function toolHash(t) {
|
|
7
|
+
return "sha256:" + sha256(canonicalJson({
|
|
8
|
+
name: t.name, title: t.title ?? null, description: t.description ?? null,
|
|
9
|
+
inputSchema: t.inputSchema ?? null, outputSchema: t.outputSchema ?? null, annotations: t.annotations ?? null,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
export function buildLock(tools, server) {
|
|
13
|
+
const out = { version: 1, server, pinned_at: new Date().toISOString(), tools: {} };
|
|
14
|
+
for (const t of [...tools].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
15
|
+
out.tools[t.name] = { hash: toolHash(t), description: t.description?.slice(0, 200) };
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
export function diffLock(lock, tools) {
|
|
20
|
+
const now = new Map(tools.map((t) => [t.name, t]));
|
|
21
|
+
const added = [...now.keys()].filter((n) => !lock.tools[n]);
|
|
22
|
+
const removed = Object.keys(lock.tools).filter((n) => !now.has(n));
|
|
23
|
+
const changed = [];
|
|
24
|
+
for (const [name, t] of now) {
|
|
25
|
+
const pinned = lock.tools[name];
|
|
26
|
+
if (!pinned)
|
|
27
|
+
continue;
|
|
28
|
+
const h = toolHash(t);
|
|
29
|
+
if (h !== pinned.hash)
|
|
30
|
+
changed.push({ name, before: pinned.hash, after: h, description_before: pinned.description, description_after: t.description?.slice(0, 200) });
|
|
31
|
+
}
|
|
32
|
+
return { added, removed, changed, clean: !added.length && !removed.length && !changed.length };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compares tools against `lockPath`. Creates the lockfile on first use (trust-on-first-use) unless `frozen`.
|
|
36
|
+
* In "block" mode, changed and newly added tools are returned in `blocked`.
|
|
37
|
+
*/
|
|
38
|
+
export function checkPins(lockPath, tools, opts) {
|
|
39
|
+
if (!existsSync(lockPath)) {
|
|
40
|
+
if (opts.frozen) {
|
|
41
|
+
return { drift: { added: tools.map((t) => t.name), removed: [], changed: [], clean: false }, created: false, blocked: new Set(opts.mode === "block" ? tools.map((t) => t.name) : []) };
|
|
42
|
+
}
|
|
43
|
+
writeJsonAtomic(lockPath, buildLock(tools, opts.server));
|
|
44
|
+
return { drift: { added: [], removed: [], changed: [], clean: true }, created: true, blocked: new Set() };
|
|
45
|
+
}
|
|
46
|
+
const drift = diffLock(readJson(lockPath), tools);
|
|
47
|
+
const blocked = new Set(opts.mode === "block" ? [...drift.changed.map((c) => c.name), ...drift.added] : []);
|
|
48
|
+
return { drift, created: false, blocked };
|
|
49
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type CompiledPolicy } from "../policy/engine.ts";
|
|
2
|
+
import { type Revocation } from "../sts/revocation.ts";
|
|
3
|
+
import type { JwtPayload } from "../sts/jwt.ts";
|
|
4
|
+
import type { ApprovalRequest, NewApproval } from "../approvals/approvals.ts";
|
|
5
|
+
import type { ActionRecord, LedgerSink, RecordInput } from "../ledger/ledger.ts";
|
|
6
|
+
import type { ApprovalBroker, PolicySource, RevocationSource } from "./guard.ts";
|
|
7
|
+
export interface ControlPlaneClientOptions {
|
|
8
|
+
url: string;
|
|
9
|
+
/** API key with the `service` role. */
|
|
10
|
+
apiKey: string;
|
|
11
|
+
}
|
|
12
|
+
/** Polls the control plane for revocations (default every 2 s, so revocation lands in <= ~3 s). */
|
|
13
|
+
export declare class RemoteRevocations implements RevocationSource {
|
|
14
|
+
private list;
|
|
15
|
+
private lastOk;
|
|
16
|
+
private timer;
|
|
17
|
+
private o;
|
|
18
|
+
private staleAfterMs;
|
|
19
|
+
constructor(o: ControlPlaneClientOptions, intervalMs?: number, staleAfterMs?: number);
|
|
20
|
+
refresh(): Promise<void>;
|
|
21
|
+
check(claims: JwtPayload): Revocation | null;
|
|
22
|
+
stale(): boolean;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
/** Policy pulled from the control plane (ETag polling) with an on-disk snapshot for cold starts offline. */
|
|
26
|
+
export declare class RemotePolicy implements PolicySource {
|
|
27
|
+
private compiled;
|
|
28
|
+
private etag;
|
|
29
|
+
private timer;
|
|
30
|
+
private o;
|
|
31
|
+
private snapshotPath;
|
|
32
|
+
onChange?: (p: CompiledPolicy) => void;
|
|
33
|
+
constructor(o: ControlPlaneClientOptions, opts?: {
|
|
34
|
+
snapshotPath?: string;
|
|
35
|
+
intervalMs?: number;
|
|
36
|
+
});
|
|
37
|
+
refresh(): Promise<void>;
|
|
38
|
+
current(): CompiledPolicy;
|
|
39
|
+
close(): void;
|
|
40
|
+
}
|
|
41
|
+
/** Local policy file, hot-reloaded on change. An invalid edit keeps the previous policy. */
|
|
42
|
+
export declare class FilePolicy implements PolicySource {
|
|
43
|
+
private compiled;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
onChange?: (p: CompiledPolicy) => void;
|
|
46
|
+
onError?: (e: Error) => void;
|
|
47
|
+
constructor(path: string, watch?: boolean);
|
|
48
|
+
current(): CompiledPolicy;
|
|
49
|
+
}
|
|
50
|
+
export declare class RemoteApprovalBroker implements ApprovalBroker {
|
|
51
|
+
private o;
|
|
52
|
+
constructor(o: ControlPlaneClientOptions);
|
|
53
|
+
request(n: NewApproval): Promise<ApprovalRequest>;
|
|
54
|
+
/** Long-polls in <= 25 s slices until decided or `timeoutMs` elapses. */
|
|
55
|
+
wait(id: string, timeoutMs: number): Promise<ApprovalRequest | undefined>;
|
|
56
|
+
consume(id: string): Promise<boolean>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ships records to the control plane ledger. Asynchronous and batched (off the critical path), with
|
|
60
|
+
* at-least-once delivery: a batch stays queued until acknowledged. Records carry a client id, so the
|
|
61
|
+
* control plane de-duplicates retries.
|
|
62
|
+
*/
|
|
63
|
+
export declare class HttpLedgerSink implements LedgerSink {
|
|
64
|
+
private queue;
|
|
65
|
+
private timer;
|
|
66
|
+
private flushing;
|
|
67
|
+
private o;
|
|
68
|
+
private local?;
|
|
69
|
+
lastError: string | null;
|
|
70
|
+
constructor(o: ControlPlaneClientOptions, opts?: {
|
|
71
|
+
flushIntervalMs?: number;
|
|
72
|
+
local?: LedgerSink;
|
|
73
|
+
});
|
|
74
|
+
append(rec: RecordInput): ActionRecord | void;
|
|
75
|
+
flush(): Promise<void>;
|
|
76
|
+
get pending(): number;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Guard <-> control plane clients. The data plane keeps working on its last snapshot when the
|
|
2
|
+
// control plane is down (policy from cache file; revocations marked stale -> high-risk tools fail closed).
|
|
3
|
+
import { existsSync, watchFile } from "node:fs";
|
|
4
|
+
import { compilePolicy } from "../policy/engine.js";
|
|
5
|
+
import { matchRevocation } from "../sts/revocation.js";
|
|
6
|
+
import { readJson, writeJsonAtomic } from "../util.js";
|
|
7
|
+
async function call(o, method, path, body, timeoutMs = 5000) {
|
|
8
|
+
const res = await fetch(o.url.replace(/\/$/, "") + path, {
|
|
9
|
+
method,
|
|
10
|
+
headers: { authorization: `Bearer ${o.apiKey}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
|
|
11
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
12
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
13
|
+
});
|
|
14
|
+
const text = await res.text();
|
|
15
|
+
const data = text ? JSON.parse(text) : {};
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new Error(`${method} ${path}: ${res.status} ${data.error_description ?? data.error ?? text}`);
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
/** Polls the control plane for revocations (default every 2 s, so revocation lands in <= ~3 s). */
|
|
21
|
+
export class RemoteRevocations {
|
|
22
|
+
list = [];
|
|
23
|
+
lastOk = 0;
|
|
24
|
+
timer;
|
|
25
|
+
o;
|
|
26
|
+
staleAfterMs;
|
|
27
|
+
constructor(o, intervalMs = 2000, staleAfterMs = 15_000) {
|
|
28
|
+
this.o = o;
|
|
29
|
+
this.staleAfterMs = staleAfterMs;
|
|
30
|
+
this.timer = setInterval(() => void this.refresh(), intervalMs);
|
|
31
|
+
this.timer.unref();
|
|
32
|
+
}
|
|
33
|
+
async refresh() {
|
|
34
|
+
try {
|
|
35
|
+
const r = await call(this.o, "GET", "/api/revocations", undefined, 3000);
|
|
36
|
+
this.list = r.revocations;
|
|
37
|
+
this.lastOk = Date.now();
|
|
38
|
+
}
|
|
39
|
+
catch { /* keep last snapshot; stale() reports it */ }
|
|
40
|
+
}
|
|
41
|
+
check(claims) { return matchRevocation(claims, this.list); }
|
|
42
|
+
stale() { return Date.now() - this.lastOk > this.staleAfterMs; }
|
|
43
|
+
close() { clearInterval(this.timer); }
|
|
44
|
+
}
|
|
45
|
+
/** Policy pulled from the control plane (ETag polling) with an on-disk snapshot for cold starts offline. */
|
|
46
|
+
export class RemotePolicy {
|
|
47
|
+
compiled = null;
|
|
48
|
+
etag = null;
|
|
49
|
+
timer;
|
|
50
|
+
o;
|
|
51
|
+
snapshotPath;
|
|
52
|
+
onChange;
|
|
53
|
+
constructor(o, opts = {}) {
|
|
54
|
+
this.o = o;
|
|
55
|
+
this.snapshotPath = opts.snapshotPath ?? null;
|
|
56
|
+
if (this.snapshotPath && existsSync(this.snapshotPath)) {
|
|
57
|
+
try {
|
|
58
|
+
this.compiled = compilePolicy(readJson(this.snapshotPath));
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore bad snapshot */ }
|
|
61
|
+
}
|
|
62
|
+
this.timer = setInterval(() => void this.refresh(), opts.intervalMs ?? 5000);
|
|
63
|
+
this.timer.unref();
|
|
64
|
+
}
|
|
65
|
+
async refresh() {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(this.o.url.replace(/\/$/, "") + "/api/policy", {
|
|
68
|
+
headers: { authorization: `Bearer ${this.o.apiKey}`, ...(this.etag ? { "if-none-match": this.etag } : {}) },
|
|
69
|
+
signal: AbortSignal.timeout(3000),
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 304 || !res.ok)
|
|
72
|
+
return;
|
|
73
|
+
const body = (await res.json());
|
|
74
|
+
const next = compilePolicy(body.policy);
|
|
75
|
+
this.compiled = next;
|
|
76
|
+
this.etag = res.headers.get("etag");
|
|
77
|
+
if (this.snapshotPath)
|
|
78
|
+
writeJsonAtomic(this.snapshotPath, body.policy);
|
|
79
|
+
this.onChange?.(next);
|
|
80
|
+
}
|
|
81
|
+
catch { /* keep last snapshot */ }
|
|
82
|
+
}
|
|
83
|
+
current() {
|
|
84
|
+
if (!this.compiled)
|
|
85
|
+
throw new Error("No policy available yet (control plane unreachable and no snapshot)");
|
|
86
|
+
return this.compiled;
|
|
87
|
+
}
|
|
88
|
+
close() { clearInterval(this.timer); }
|
|
89
|
+
}
|
|
90
|
+
/** Local policy file, hot-reloaded on change. An invalid edit keeps the previous policy. */
|
|
91
|
+
export class FilePolicy {
|
|
92
|
+
compiled;
|
|
93
|
+
path;
|
|
94
|
+
onChange;
|
|
95
|
+
onError;
|
|
96
|
+
constructor(path, watch = true) {
|
|
97
|
+
this.path = path;
|
|
98
|
+
this.compiled = compilePolicy(readJson(path));
|
|
99
|
+
if (watch) {
|
|
100
|
+
watchFile(path, { interval: 1000, persistent: false }, () => {
|
|
101
|
+
try {
|
|
102
|
+
this.compiled = compilePolicy(readJson(path));
|
|
103
|
+
this.onChange?.(this.compiled);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
(this.onError ?? ((err) => console.error(`[mandat] policy reload failed, keeping previous version: ${err.message}`)))(e);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
current() { return this.compiled; }
|
|
112
|
+
}
|
|
113
|
+
export class RemoteApprovalBroker {
|
|
114
|
+
o;
|
|
115
|
+
constructor(o) { this.o = o; }
|
|
116
|
+
request(n) {
|
|
117
|
+
return call(this.o, "POST", "/api/approvals", n);
|
|
118
|
+
}
|
|
119
|
+
/** Long-polls in <= 25 s slices until decided or `timeoutMs` elapses. */
|
|
120
|
+
async wait(id, timeoutMs) {
|
|
121
|
+
const deadline = Date.now() + timeoutMs;
|
|
122
|
+
let cur;
|
|
123
|
+
do {
|
|
124
|
+
const slice = Math.max(0, Math.min(25_000, deadline - Date.now()));
|
|
125
|
+
cur = await call(this.o, "GET", `/api/approvals/${encodeURIComponent(id)}?wait=${slice}`, undefined, slice + 5000);
|
|
126
|
+
} while (cur.status === "pending" && Date.now() < deadline);
|
|
127
|
+
return cur;
|
|
128
|
+
}
|
|
129
|
+
async consume(id) {
|
|
130
|
+
const r = await call(this.o, "POST", `/api/approvals/${encodeURIComponent(id)}/consume`, {});
|
|
131
|
+
return r.consumed;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Ships records to the control plane ledger. Asynchronous and batched (off the critical path), with
|
|
136
|
+
* at-least-once delivery: a batch stays queued until acknowledged. Records carry a client id, so the
|
|
137
|
+
* control plane de-duplicates retries.
|
|
138
|
+
*/
|
|
139
|
+
export class HttpLedgerSink {
|
|
140
|
+
queue = [];
|
|
141
|
+
timer;
|
|
142
|
+
flushing = null;
|
|
143
|
+
o;
|
|
144
|
+
local;
|
|
145
|
+
lastError = null;
|
|
146
|
+
constructor(o, opts = {}) {
|
|
147
|
+
this.o = o;
|
|
148
|
+
this.local = opts.local;
|
|
149
|
+
this.timer = setInterval(() => void this.flush(), opts.flushIntervalMs ?? 500);
|
|
150
|
+
this.timer.unref();
|
|
151
|
+
}
|
|
152
|
+
append(rec) {
|
|
153
|
+
const withId = { ...rec, id: rec.id ?? `rec_${crypto.randomUUID()}`, ts: rec.ts ?? new Date().toISOString() };
|
|
154
|
+
this.queue.push(withId);
|
|
155
|
+
if (this.queue.length > 100_000)
|
|
156
|
+
this.queue.shift();
|
|
157
|
+
return this.local?.append(withId);
|
|
158
|
+
}
|
|
159
|
+
flush() {
|
|
160
|
+
if (this.flushing)
|
|
161
|
+
return this.flushing;
|
|
162
|
+
if (!this.queue.length)
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
const run = async () => {
|
|
165
|
+
try {
|
|
166
|
+
while (this.queue.length) {
|
|
167
|
+
const batch = this.queue.slice(0, 200);
|
|
168
|
+
await call(this.o, "POST", "/api/ledger/ingest", { records: batch });
|
|
169
|
+
this.queue.splice(0, batch.length);
|
|
170
|
+
}
|
|
171
|
+
this.lastError = null;
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
this.lastError = e.message; // retried on the next tick
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
this.flushing = run().finally(() => { this.flushing = null; });
|
|
178
|
+
return this.flushing;
|
|
179
|
+
}
|
|
180
|
+
get pending() { return this.queue.length; }
|
|
181
|
+
async close() {
|
|
182
|
+
clearInterval(this.timer);
|
|
183
|
+
await this.flush();
|
|
184
|
+
}
|
|
185
|
+
}
|