@clawdreyhepburn/carapace 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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@clawdreyhepburn/carapace",
3
+ "version": "0.1.0",
4
+ "description": "Immutable policy boundaries for MCP tool access. Powered by Cedar + Cedarling WASM.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "openclaw": {
8
+ "extensions": [
9
+ "./src/index.ts"
10
+ ]
11
+ },
12
+ "scripts": {
13
+ "dev": "tsx watch src/index.ts",
14
+ "build": "tsc",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest"
17
+ },
18
+ "dependencies": {
19
+ "@anthropic-ai/sdk": "^0.30.0",
20
+ "@janssenproject/cedarling_wasm": "^1.15.0",
21
+ "@modelcontextprotocol/sdk": "^1.12.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "tsx": "^4.19.0",
26
+ "typescript": "^5.7.0",
27
+ "vitest": "^3.0.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ }
32
+ }
@@ -0,0 +1,585 @@
1
+ /**
2
+ * Cedarling-powered Cedar engine for MCP tool authorization.
3
+ *
4
+ * Uses Gluu's Cedarling WASM module for proper Cedar evaluation,
5
+ * JWT validation, and the Policy Store format. Falls back to the
6
+ * homebrew engine if WASM loading fails.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname } from "node:path";
14
+ import type {
15
+ Logger,
16
+ AuthzRequest,
17
+ CedarDecision,
18
+ VerifyResult,
19
+ CedarSchemaInfo,
20
+ SchemaEntity,
21
+ SchemaAttribute,
22
+ SchemaAction,
23
+ } from "./types.js";
24
+
25
+ // Cedarling WASM types (from @janssenproject/cedarling_wasm)
26
+ interface CedarlingInstance {
27
+ authorize_unsigned(request: any): Promise<any>;
28
+ pop_logs(): any[];
29
+ }
30
+
31
+ interface CedarlingWasm {
32
+ initSync(opts: { module: Buffer }): void;
33
+ init(config: any): Promise<CedarlingInstance>;
34
+ }
35
+
36
+ interface PolicyStoreEntry {
37
+ description?: string;
38
+ creation_date?: string;
39
+ policy_content: string; // base64-encoded Cedar policy
40
+ }
41
+
42
+ interface PolicyStoreJson {
43
+ cedar_version: string;
44
+ policy_stores: {
45
+ [id: string]: {
46
+ name: string;
47
+ description: string;
48
+ policies: { [id: string]: PolicyStoreEntry };
49
+ schema: string; // base64-encoded Cedar JSON schema
50
+ trusted_issuers: Record<string, unknown>;
51
+ };
52
+ };
53
+ }
54
+
55
+ export interface CedarlingEngineOpts {
56
+ policyDir: string;
57
+ defaultPolicy: "deny-all" | "allow-all";
58
+ verify: boolean;
59
+ logger: Logger;
60
+ namespace?: string; // Cedar namespace, default "Jans"
61
+ agentEntityType?: string; // Principal entity type, default "Workload"
62
+ }
63
+
64
+ export class CedarlingEngine {
65
+ private policyDir: string;
66
+ private defaultPolicy: "deny-all" | "allow-all";
67
+ private shouldVerify: boolean;
68
+ private logger: Logger;
69
+ private namespace: string;
70
+ private agentEntityType: string;
71
+
72
+ // Cedarling state
73
+ private cedarling: CedarlingInstance | null = null;
74
+ private wasmModule: CedarlingWasm | null = null;
75
+
76
+ // Policy/schema storage (mirrors disk, used to rebuild policy store)
77
+ private policies: Map<string, { effect: string; raw: string }> = new Map();
78
+ private schemaJson: any = null;
79
+ private schemaRaw: string = "";
80
+
81
+ constructor(opts: CedarlingEngineOpts) {
82
+ this.policyDir = opts.policyDir.replace("~", homedir());
83
+ this.defaultPolicy = opts.defaultPolicy;
84
+ this.shouldVerify = opts.verify;
85
+ this.logger = opts.logger;
86
+ this.namespace = opts.namespace ?? "Jans";
87
+ this.agentEntityType = opts.agentEntityType ?? "Workload";
88
+ }
89
+
90
+ async init(): Promise<void> {
91
+ mkdirSync(this.policyDir, { recursive: true });
92
+
93
+ // Try to load Cedarling WASM
94
+ try {
95
+ await this.loadWasm();
96
+ } catch (err: any) {
97
+ this.logger.warn(`Cedarling WASM not available, falling back to basic engine: ${err.message}`);
98
+ }
99
+
100
+ // Load existing policies from disk
101
+ this.loadPoliciesFromDisk();
102
+
103
+ // Generate default schema if none exists
104
+ const schemaPath = join(this.policyDir, "schema.json");
105
+ if (!existsSync(schemaPath)) {
106
+ this.writeDefaultSchema();
107
+ }
108
+ this.schemaRaw = readFileSync(schemaPath, "utf-8");
109
+ try {
110
+ this.schemaJson = JSON.parse(this.schemaRaw);
111
+ } catch {
112
+ this.logger.warn("Failed to parse schema.json");
113
+ }
114
+
115
+ // Create Cedarling instance
116
+ await this.rebuildCedarling();
117
+
118
+ this.logger.info(
119
+ `Cedarling engine initialized: ${this.policies.size} policies, ` +
120
+ `WASM ${this.cedarling ? "active" : "unavailable"}`
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Authorize a request using Cedarling WASM.
126
+ */
127
+ async authorize(request: AuthzRequest): Promise<CedarDecision> {
128
+ if (!this.cedarling) {
129
+ // Fallback: basic string matching (same as homebrew engine)
130
+ return this.authorizeBasic(request);
131
+ }
132
+
133
+ try {
134
+ // Build the principal ID from the request
135
+ const principalId = request.principal
136
+ .replace(/.*::"/g, "")
137
+ .replace(/"$/, "");
138
+
139
+ const resourceId = request.resource
140
+ .replace(/.*::"/g, "")
141
+ .replace(/"$/, "");
142
+
143
+ const actionName = request.action
144
+ .replace(/.*::"/g, "")
145
+ .replace(/"$/, "");
146
+
147
+ const result = await this.cedarling.authorize_unsigned({
148
+ principals: [
149
+ {
150
+ cedar_entity_mapping: {
151
+ entity_type: `${this.namespace}::${this.agentEntityType}`,
152
+ id: principalId,
153
+ },
154
+ name: principalId,
155
+ },
156
+ ],
157
+ action: `${this.namespace}::Action::"${actionName}"`,
158
+ resource: {
159
+ cedar_entity_mapping: {
160
+ entity_type: `${this.namespace}::Tool`,
161
+ id: resourceId,
162
+ },
163
+ ...(request.context ?? {}),
164
+ },
165
+ context: request.context ?? {},
166
+ });
167
+
168
+ const decision = result.decision ? "allow" : "deny";
169
+ const resultJson = JSON.parse(result.json_string());
170
+
171
+ // Extract reasons from all principals
172
+ const reasons: string[] = [];
173
+ if (resultJson.principals) {
174
+ for (const [princName, princResult] of Object.entries(resultJson.principals) as any) {
175
+ const diag = princResult.diagnostics;
176
+ if (diag?.reason) {
177
+ for (const r of diag.reason) {
178
+ reasons.push(`${princResult.decision ? "permit" : "deny"}: ${r}`);
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ return { decision: decision as "allow" | "deny", reasons };
185
+ } catch (err: any) {
186
+ this.logger.error(`Cedarling authorize error: ${err.message}`);
187
+ return { decision: "deny", reasons: [`cedarling error: ${err.message}`] };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Enable a tool by adding a permit policy and rebuilding Cedarling.
193
+ */
194
+ enableTool(qualifiedName: string): void {
195
+ const policyId = `tool-enable-${qualifiedName.replace(/\//g, "-")}`;
196
+ const raw = `permit(\n principal is ${this.namespace}::${this.agentEntityType},\n action == ${this.namespace}::Action::"call_tool",\n resource == ${this.namespace}::Tool::"${qualifiedName}"\n);`;
197
+
198
+ // Remove any disable policy
199
+ const disableId = `tool-disable-${qualifiedName.replace(/\//g, "-")}`;
200
+ this.removePolicyFile(disableId);
201
+
202
+ this.writePolicyFile(policyId, raw);
203
+ this.policies.set(policyId, { effect: "permit", raw });
204
+ this.rebuildCedarling().catch(() => {});
205
+ this.logger.info(`Enabled tool: ${qualifiedName}`);
206
+ }
207
+
208
+ /**
209
+ * Disable a tool by adding a forbid policy and rebuilding Cedarling.
210
+ */
211
+ disableTool(qualifiedName: string): void {
212
+ const policyId = `tool-disable-${qualifiedName.replace(/\//g, "-")}`;
213
+ const raw = `forbid(\n principal,\n action == ${this.namespace}::Action::"call_tool",\n resource == ${this.namespace}::Tool::"${qualifiedName}"\n);`;
214
+
215
+ // Remove any enable policy
216
+ const enableId = `tool-enable-${qualifiedName.replace(/\//g, "-")}`;
217
+ this.removePolicyFile(enableId);
218
+
219
+ this.writePolicyFile(policyId, raw);
220
+ this.policies.set(policyId, { effect: "forbid", raw });
221
+ this.rebuildCedarling().catch(() => {});
222
+ this.logger.info(`Disabled tool: ${qualifiedName}`);
223
+ }
224
+
225
+ /**
226
+ * Check if a tool is enabled (synchronous check against local policy state).
227
+ * Checks both specific tool policies AND blanket policies (those without a specific tool name).
228
+ */
229
+ isToolEnabled(qualifiedName: string): boolean {
230
+ let hasPermit = false;
231
+ let hasForbid = false;
232
+ for (const [, policy] of this.policies) {
233
+ // Check if policy specifically references this tool
234
+ const refersToTool = policy.raw.includes(`"${qualifiedName}"`);
235
+ // Check if policy is a blanket policy (no specific Tool:: reference)
236
+ const isBlanket = !policy.raw.includes('Tool::"');
237
+
238
+ if (refersToTool || isBlanket) {
239
+ if (policy.effect === "permit") hasPermit = true;
240
+ if (policy.effect === "forbid") hasForbid = true;
241
+ }
242
+ }
243
+ return hasPermit && !hasForbid;
244
+ }
245
+
246
+ savePolicy(id: string, raw: string): void {
247
+ const effect = raw.trimStart().startsWith("forbid") ? "forbid" : "permit";
248
+ this.writePolicyFile(id, raw);
249
+ this.policies.set(id, { effect, raw });
250
+ this.rebuildCedarling().catch(() => {});
251
+ this.logger.info(`Saved policy: ${id}`);
252
+ }
253
+
254
+ deletePolicy(id: string): boolean {
255
+ if (!this.policies.has(id)) return false;
256
+ this.removePolicyFile(id);
257
+ this.policies.delete(id);
258
+ this.rebuildCedarling().catch(() => {});
259
+ this.logger.info(`Deleted policy: ${id}`);
260
+ return true;
261
+ }
262
+
263
+ getPolicies(): Array<{ id: string; effect: string; raw: string }> {
264
+ return [...this.policies.entries()].map(([id, p]) => ({ id, ...p }));
265
+ }
266
+
267
+ getSchema(): CedarSchemaInfo {
268
+ return {
269
+ ...this.parseSchemaForGui(this.schemaRaw),
270
+ raw: this.schemaRaw,
271
+ };
272
+ }
273
+
274
+ saveSchema(raw: string): void {
275
+ const schemaPath = join(this.policyDir, "schema.json");
276
+ writeFileSync(schemaPath, raw, "utf-8");
277
+ this.schemaRaw = raw;
278
+ try {
279
+ this.schemaJson = JSON.parse(raw);
280
+ } catch {}
281
+ this.rebuildCedarling().catch(() => {});
282
+ this.logger.info("Schema updated");
283
+ }
284
+
285
+ async verify(): Promise<VerifyResult> {
286
+ const start = Date.now();
287
+
288
+ if (!this.cedarling) {
289
+ return {
290
+ ok: true,
291
+ issues: ["Cedarling WASM not loaded — cannot verify"],
292
+ durationMs: Date.now() - start,
293
+ };
294
+ }
295
+
296
+ // Verification: try a dummy authorize request. If the policy store loaded,
297
+ // schemas and policies are valid.
298
+ try {
299
+ await this.cedarling.authorize_unsigned({
300
+ principals: [
301
+ {
302
+ cedar_entity_mapping: {
303
+ entity_type: `${this.namespace}::${this.agentEntityType}`,
304
+ id: "__verify_probe__",
305
+ },
306
+ },
307
+ ],
308
+ action: `${this.namespace}::Action::"call_tool"`,
309
+ resource: {
310
+ cedar_entity_mapping: {
311
+ entity_type: `${this.namespace}::Tool`,
312
+ id: "__verify_probe__",
313
+ },
314
+ },
315
+ context: {},
316
+ });
317
+ return { ok: true, issues: [], durationMs: Date.now() - start };
318
+ } catch (err: any) {
319
+ return {
320
+ ok: false,
321
+ issues: [err.message],
322
+ durationMs: Date.now() - start,
323
+ };
324
+ }
325
+ }
326
+
327
+ // ── Private: WASM loading ──
328
+
329
+ private async loadWasm(): Promise<void> {
330
+ const mod = await import("@janssenproject/cedarling_wasm");
331
+ this.wasmModule = mod;
332
+
333
+ // Find the WASM binary
334
+ const modPath = fileURLToPath(import.meta.resolve("@janssenproject/cedarling_wasm"));
335
+ const wasmPath = join(dirname(modPath), "cedarling_wasm_bg.wasm");
336
+ const wasmBytes = readFileSync(wasmPath);
337
+
338
+ mod.initSync({ module: wasmBytes });
339
+ this.logger.info("Cedarling WASM loaded successfully");
340
+ }
341
+
342
+ /**
343
+ * Rebuild the Cedarling instance from current policies and schema.
344
+ * Called after any policy or schema change.
345
+ */
346
+ private async rebuildCedarling(): Promise<void> {
347
+ if (!this.wasmModule) return;
348
+
349
+ try {
350
+ const policyStore = this.buildPolicyStore();
351
+ const config = {
352
+ CEDARLING_APPLICATION_NAME: "Carapace",
353
+ CEDARLING_POLICY_STORE_LOCAL: JSON.stringify(policyStore),
354
+ CEDARLING_LOG_TYPE: "off",
355
+ CEDARLING_USER_AUTHZ: "disabled",
356
+ CEDARLING_WORKLOAD_AUTHZ: "enabled",
357
+ CEDARLING_JWT_SIG_VALIDATION: "disabled",
358
+ CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED: ["ES256"],
359
+ CEDARLING_ID_TOKEN_TRUST_MODE: "strict",
360
+ CEDARLING_MAPPING_WORKLOAD: `${this.namespace}::${this.agentEntityType}`,
361
+ // Check if the workload principal got ALLOW
362
+ CEDARLING_PRINCIPAL_BOOLEAN_OPERATION: {
363
+ or: [
364
+ { "===": [{ var: `${this.namespace}::${this.agentEntityType}` }, "ALLOW"] },
365
+ ],
366
+ },
367
+ };
368
+
369
+ this.cedarling = await this.wasmModule.init(config);
370
+ this.logger.debug?.("Cedarling instance rebuilt");
371
+ } catch (err: any) {
372
+ this.logger.error(`Failed to rebuild Cedarling: ${err.message}`);
373
+ // Don't null out cedarling — keep the old instance if it exists
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Build a Cedarling Policy Store JSON from current policies and schema.
379
+ */
380
+ private buildPolicyStore(): PolicyStoreJson {
381
+ const policies: Record<string, PolicyStoreEntry> = {};
382
+ for (const [id, policy] of this.policies) {
383
+ policies[id] = {
384
+ description: id,
385
+ creation_date: new Date().toISOString(),
386
+ policy_content: Buffer.from(policy.raw).toString("base64"),
387
+ };
388
+ }
389
+
390
+ // If no policies and default is allow-all, add a default permit
391
+ if (Object.keys(policies).length === 0 && this.defaultPolicy === "allow-all") {
392
+ const raw = `permit(\n principal is ${this.namespace}::${this.agentEntityType},\n action,\n resource\n);`;
393
+ policies["default-allow"] = {
394
+ description: "Default allow-all policy",
395
+ creation_date: new Date().toISOString(),
396
+ policy_content: Buffer.from(raw).toString("base64"),
397
+ };
398
+ }
399
+
400
+ // If no policies at all, add a dummy deny to keep Cedarling happy
401
+ // (empty policy sets can cause issues)
402
+ if (Object.keys(policies).length === 0) {
403
+ const raw = `forbid(\n principal,\n action,\n resource\n) when { false };`;
404
+ policies["__default_deny__"] = {
405
+ description: "Default deny placeholder",
406
+ creation_date: new Date().toISOString(),
407
+ policy_content: Buffer.from(raw).toString("base64"),
408
+ };
409
+ }
410
+
411
+ const schemaB64 = Buffer.from(
412
+ this.schemaRaw || JSON.stringify(this.buildDefaultSchemaJson())
413
+ ).toString("base64");
414
+
415
+ return {
416
+ cedar_version: "v4.0.0",
417
+ policy_stores: {
418
+ mcp: {
419
+ name: "Carapace",
420
+ description: "Auto-generated policy store for MCP tool authorization",
421
+ policies,
422
+ schema: schemaB64,
423
+ trusted_issuers: {},
424
+ },
425
+ },
426
+ };
427
+ }
428
+
429
+ // ── Private: disk I/O ──
430
+
431
+ private loadPoliciesFromDisk(): void {
432
+ this.policies.clear();
433
+ if (!existsSync(this.policyDir)) return;
434
+ for (const file of readdirSync(this.policyDir)) {
435
+ if (!file.endsWith(".cedar")) continue;
436
+ const path = join(this.policyDir, file);
437
+ const raw = readFileSync(path, "utf-8");
438
+ const id = file.replace(".cedar", "");
439
+ const effect = raw.trimStart().startsWith("forbid") ? "forbid" : "permit";
440
+ this.policies.set(id, { effect, raw });
441
+ }
442
+ }
443
+
444
+ private writePolicyFile(id: string, raw: string): void {
445
+ writeFileSync(join(this.policyDir, `${id}.cedar`), raw, "utf-8");
446
+ }
447
+
448
+ private removePolicyFile(id: string): void {
449
+ const path = join(this.policyDir, `${id}.cedar`);
450
+ if (existsSync(path)) unlinkSync(path);
451
+ this.policies.delete(id);
452
+ }
453
+
454
+ private writeDefaultSchema(): void {
455
+ const schema = this.buildDefaultSchemaJson();
456
+ const schemaPath = join(this.policyDir, "schema.json");
457
+ writeFileSync(schemaPath, JSON.stringify(schema, null, 2), "utf-8");
458
+ this.logger.info("Created default Cedar JSON schema");
459
+ }
460
+
461
+ private buildDefaultSchemaJson(): Record<string, any> {
462
+ return {
463
+ [this.namespace]: {
464
+ entityTypes: {
465
+ [this.agentEntityType]: {
466
+ shape: {
467
+ type: "Record",
468
+ attributes: {
469
+ name: {
470
+ type: "EntityOrCommon",
471
+ name: "String",
472
+ required: false,
473
+ },
474
+ },
475
+ },
476
+ },
477
+ Tool: {
478
+ shape: {
479
+ type: "Record",
480
+ attributes: {
481
+ server: {
482
+ type: "EntityOrCommon",
483
+ name: "String",
484
+ required: false,
485
+ },
486
+ name: {
487
+ type: "EntityOrCommon",
488
+ name: "String",
489
+ required: false,
490
+ },
491
+ },
492
+ },
493
+ },
494
+ },
495
+ actions: {
496
+ call_tool: {
497
+ appliesTo: {
498
+ principalTypes: [this.agentEntityType],
499
+ resourceTypes: ["Tool"],
500
+ context: { type: "Record", attributes: {} },
501
+ },
502
+ },
503
+ list_tools: {
504
+ appliesTo: {
505
+ principalTypes: [this.agentEntityType],
506
+ resourceTypes: ["Tool"],
507
+ context: { type: "Record", attributes: {} },
508
+ },
509
+ },
510
+ },
511
+ },
512
+ };
513
+ }
514
+
515
+ // ── Private: basic fallback ──
516
+
517
+ private authorizeBasic(request: AuthzRequest): CedarDecision {
518
+ let hasPermit = false;
519
+ let hasForbid = false;
520
+ const reasons: string[] = [];
521
+
522
+ for (const [id, policy] of this.policies) {
523
+ // Simple: check if resource appears in the policy
524
+ const resourceId = request.resource.replace(/.*::"/g, "").replace(/"$/, "");
525
+ if (!policy.raw.includes(`"${resourceId}"`)) continue;
526
+
527
+ if (policy.effect === "forbid") {
528
+ hasForbid = true;
529
+ reasons.push(`forbid: ${id}`);
530
+ } else {
531
+ hasPermit = true;
532
+ reasons.push(`permit: ${id}`);
533
+ }
534
+ }
535
+
536
+ if (hasForbid) return { decision: "deny", reasons };
537
+ if (hasPermit) return { decision: "allow", reasons };
538
+ return { decision: "deny", reasons: ["no matching permit policy"] };
539
+ }
540
+
541
+ // ── Private: schema parsing for GUI ──
542
+
543
+ private parseSchemaForGui(raw: string): { entities: SchemaEntity[]; actions: SchemaAction[] } {
544
+ const entities: SchemaEntity[] = [];
545
+ const actions: SchemaAction[] = [];
546
+
547
+ try {
548
+ const schema = typeof raw === "string" ? JSON.parse(raw) : raw;
549
+ const ns = schema[this.namespace];
550
+ if (!ns) return { entities, actions };
551
+
552
+ // Entity types
553
+ if (ns.entityTypes) {
554
+ for (const [name, def] of Object.entries(ns.entityTypes) as any) {
555
+ const attrs: SchemaAttribute[] = [];
556
+ if (def.shape?.attributes) {
557
+ for (const [aName, aDef] of Object.entries(def.shape.attributes) as any) {
558
+ attrs.push({
559
+ name: aName,
560
+ type: aDef.name || aDef.type || "unknown",
561
+ optional: aDef.required === false,
562
+ });
563
+ }
564
+ }
565
+ entities.push({ name, parents: [], attributes: attrs });
566
+ }
567
+ }
568
+
569
+ // Actions
570
+ if (ns.actions) {
571
+ for (const [name, def] of Object.entries(ns.actions) as any) {
572
+ actions.push({
573
+ name,
574
+ principalTypes: def.appliesTo?.principalTypes ?? [],
575
+ resourceTypes: def.appliesTo?.resourceTypes ?? [],
576
+ });
577
+ }
578
+ }
579
+ } catch (err) {
580
+ this.logger.debug?.(`Schema parse error: ${err}`);
581
+ }
582
+
583
+ return { entities, actions };
584
+ }
585
+ }