diff --git a/README.md b/README.md index 3ea0df9..392891a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This repository contains a number of directories: - [cedar-policy-mcp-schema-generator](./rust/cedar-policy-mcp-schema-generator/) : A crate for auto-generating a Cedar Schema for an MCP Server's tool descriptions. * [js](./js/) which contains JavaScript packages that enable Agents to make use of Cedar and its Analysis Capabilities. - [cedar-analysis-mcp-server](./js/cedar-analysis-mcp-server) : A package that creates an MCP server that exposes an interface for Agents to use [Cedar's analysis capabilities](https://github.com/cedar-policy/cedar-spec/tree/main/cedar-lean-cli#analysis). + - [protect-mcp-cedar-integration](./js/protect-mcp-cedar-integration) : Example showing auto-generation of Cedar schemas from MCP tool descriptions for runtime policy enforcement via [protect-mcp](https://www.npmjs.com/package/protect-mcp). ## Security diff --git a/js/protect-mcp-cedar-integration/README.md b/js/protect-mcp-cedar-integration/README.md new file mode 100644 index 0000000..0587225 --- /dev/null +++ b/js/protect-mcp-cedar-integration/README.md @@ -0,0 +1,207 @@ +# Cedar Schema Integration for MCP Tool Governance + +TypeScript integration that auto-generates Cedar authorization schemas from MCP tool descriptions and uses them for runtime policy enforcement. + +Built on [protect-mcp](https://www.npmjs.com/package/protect-mcp), the security gateway for MCP servers and Claude Code. This example shows how to generate Cedar schemas from MCP `tools/list`, enabling typed policies that reference tool input attributes. + +## What this does + +1. **Reads MCP tool descriptions** (JSON Schema for each tool's inputs) +2. **Generates a Cedar schema** with per-tool actions and typed input context +3. **Passes the schema to `@cedar-policy/cedar-wasm`** for validated policy evaluation +4. **Every allow/deny decision produces an Ed25519-signed receipt** (IETF Internet-Draft: `draft-farley-acta-signed-receipts`) + +## Schema generation + +The schema generator maps MCP tool descriptions to Cedar entity/action types: + +| MCP Tool | Cedar Action | Cedar Context | +|---|---|---| +| `read_file(path: string)` | `Action::"read_file"` | `context.input.path: String` | +| `execute_command(command: string, args: string[])` | `Action::"execute_command"` | `context.input.command: String, context.input.args: Set` | + +All per-tool actions are children of a blanket `Action::"MCP::Tool::call"` action, so policies can match individual tools or all tools: + +```cedar +// Match all tool calls +forbid(principal, action == Action::"MCP::Tool::call", resource); + +// Match specific tool with typed input +permit(principal, action == Action::"read_file", resource) +when { context.input.path like "./workspace/*" }; +``` + +### JSON Schema to Cedar type mapping + +| JSON Schema | Cedar Type | +|---|---| +| `string` | `String` | +| `integer` / `number` | `Long` | +| `boolean` | `Bool` | +| `array` | `Set` | +| `object` (with properties) | Record type | + +Required properties are mapped without `?`. Optional properties are mapped with `?`. + +## Quick start + +### With Claude Code (recommended) + +```bash +npx protect-mcp init-hooks # generates Cedar policies + hook config + signing keys +npx protect-mcp serve # starts hook server with schema-validated Cedar evaluation +``` + +### Programmatic usage + +```typescript +import { generateCedarSchema, evaluateCedar, loadCedarPolicies } from 'protect-mcp'; + +// MCP tools (from tools/list response) +const tools = [ + { + name: 'read_file', + description: 'Read a file', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, + { + name: 'execute_command', + description: 'Run a shell command', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string' }, + args: { type: 'array', items: { type: 'string' } }, + }, + required: ['command'], + }, + }, +]; + +// Generate Cedar schema from tool descriptions +const schema = generateCedarSchema(tools); +console.log(schema.schemaText); // Human-readable .cedarschema +console.log(schema.schemaJson); // JSON for Cedar WASM + +// Load Cedar policies from disk +const policies = loadCedarPolicies('./policies'); + +// Evaluate with schema validation +const result = await evaluateCedar(policies, { + tool: 'read_file', + tier: 'unknown', + toolInput: { path: './workspace/README.md' }, +}, { schemaJson: schema.schemaJson }); + +console.log(result); +// { allowed: true, metadata: { policy_digest: 'a3f8...' } } +``` + +## Generated schema example + +For the tools above, `generateCedarSchema()` produces: + +```cedarschema +namespace ScopeBlind { + + entity Agent = { + "tier": String, + "agent_id": String? + }; + + entity Tool; + + type read_file_Input = { + "path": String + }; + + type execute_command_Input = { + "command": String, + "args": Set? + }; + + action "read_file" in [Action::"MCP::Tool::call"] appliesTo { + principal: [Agent], + resource: [Tool], + context: { + "input": read_file_Input, + "tier": String + } + }; + + action "execute_command" in [Action::"MCP::Tool::call"] appliesTo { + principal: [Agent], + resource: [Tool], + context: { + "input": execute_command_Input, + "tier": String + } + }; + + action "MCP::Tool::call" appliesTo { + principal: [Agent], + resource: [Tool], + context: { + "tier": String + } + }; + +} +``` + +## Compatibility with cedar-policy-mcp-schema-generator (Rust) + +This TypeScript implementation is compatible with the [Rust schema generator](../rust/cedar-policy-mcp-schema-generator/): + +- Same entity model: `Agent` (principal), `Tool` (resource) +- Same action hierarchy: per-tool actions as children of `MCP::Tool::call` +- Same type mapping: JSON Schema → Cedar types +- Schema stubs generated by `generateSchemaStub()` use `@mcp_principal` / `@mcp_resource` annotations + +The TypeScript version is designed for Node.js environments where the Rust binary is not available (e.g., `npx protect-mcp` installs). For environments with Rust tooling, the Rust generator provides additional features (union types, tagged entities, output schemas). + +## Example Cedar policies + +```cedar +// Block shell execution (prevents prompt injection attacks) +@id("block-shell") +forbid( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"execute_command" +); + +// Allow reads only within workspace +@id("workspace-reads") +permit( + principal, + action == Action::"read_file", + resource +) +when { context.input.path like "./workspace/*" }; + +// Rate-limit write operations for untrusted agents +@id("untrusted-write-limit") +forbid( + principal, + action == Action::"write_file", + resource +) +when { context.tier == "unknown" }; +``` + +## Links + +- [protect-mcp on npm](https://www.npmjs.com/package/protect-mcp) (v0.5.2, MIT) +- [Source: cedar-schema.ts](https://github.com/scopeblind/scopeblind-gateway/blob/main/src/cedar-schema.ts) +- [Source: cedar-evaluator.ts](https://github.com/scopeblind/scopeblind-gateway/blob/main/src/cedar-evaluator.ts) +- [IETF Internet-Draft: draft-farley-acta-signed-receipts](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) +- [ScopeBlind docs](https://scopeblind.com/docs/protect-mcp) + +## License + +Apache-2.0 (matches this repository) diff --git a/js/protect-mcp-cedar-integration/example.mjs b/js/protect-mcp-cedar-integration/example.mjs new file mode 100644 index 0000000..c4338d8 --- /dev/null +++ b/js/protect-mcp-cedar-integration/example.mjs @@ -0,0 +1,114 @@ +/** + * Example: Generate a Cedar schema from MCP tool descriptions. + * + * This demonstrates how protect-mcp auto-generates typed Cedar + * authorization schemas from MCP tools/list responses, enabling + * policies that reference tool input attributes. + * + * Run: node example.mjs + */ + +import { generateCedarSchema, generateSchemaStub } from 'protect-mcp'; + +// ── Sample MCP tools (from a typical tools/list response) ── + +const tools = [ + { + name: 'read_file', + description: 'Read the contents of a file at the given path', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Absolute path to file' }, + encoding: { type: 'string', description: 'File encoding (default: utf-8)' }, + }, + required: ['path'], + }, + }, + { + name: 'write_file', + description: 'Write content to a file', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + create_dirs: { type: 'boolean', description: 'Create parent directories' }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'execute_command', + description: 'Execute a shell command', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string' }, + args: { type: 'array', items: { type: 'string' } }, + timeout_ms: { type: 'integer', description: 'Timeout in milliseconds' }, + working_directory: { type: 'string' }, + }, + required: ['command'], + }, + }, + { + name: 'search_web', + description: 'Search the web for information', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + max_results: { type: 'integer' }, + }, + required: ['query'], + }, + }, + { + name: 'get_status', + description: 'Get server status (no inputs)', + // No inputSchema — tools with no parameters + }, +]; + +// ── Generate the Cedar schema ── + +console.log('=== Cedar Schema Generation from MCP Tools ===\n'); + +const result = generateCedarSchema(tools, { + namespace: 'MyMcpServer', + includeTier: true, + includeAgentId: true, + includeTimestamp: true, +}); + +console.log(`Generated schema for ${result.toolCount} tools: ${result.tools.join(', ')}\n`); +console.log('--- .cedarschema (human-readable) ---\n'); +console.log(result.schemaText); + +console.log('--- Schema JSON (for Cedar WASM) ---\n'); +console.log(JSON.stringify(result.schemaJson, null, 2)); + +// ── Generate a schema stub ── + +console.log('\n--- Schema stub (for customization) ---\n'); +console.log(generateSchemaStub('MyMcpServer')); + +console.log('\n=== Policies enabled by this schema ===\n'); +console.log(`With this schema, you can write Cedar policies like: + + // Allow reads only within workspace + permit(principal, action == Action::"read_file", resource) + when { context.input.path like "./workspace/*" }; + + // Block shell execution entirely + forbid(principal, action == Action::"execute_command", resource); + + // Allow web search with result limits + permit(principal, action == Action::"search_web", resource) + when { context.input.max_results <= 10 }; + + // Block writes for untrusted agents + forbid(principal, action == Action::"write_file", resource) + when { context.tier == "unknown" }; +`); diff --git a/js/protect-mcp-cedar-integration/package.json b/js/protect-mcp-cedar-integration/package.json new file mode 100644 index 0000000..99d5a5d --- /dev/null +++ b/js/protect-mcp-cedar-integration/package.json @@ -0,0 +1,14 @@ +{ + "name": "protect-mcp-cedar-integration", + "version": "0.1.0", + "description": "Example: auto-generating Cedar schemas from MCP tool descriptions for runtime policy enforcement", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "generate-schema": "node example.mjs", + "test": "node test.mjs" + }, + "dependencies": { + "protect-mcp": "^0.5.2" + } +} diff --git a/js/protect-mcp-cedar-integration/policies/mcp-governance.cedar b/js/protect-mcp-cedar-integration/policies/mcp-governance.cedar new file mode 100644 index 0000000..c828e92 --- /dev/null +++ b/js/protect-mcp-cedar-integration/policies/mcp-governance.cedar @@ -0,0 +1,64 @@ +// Cedar policies for MCP tool governance +// These policies use the auto-generated schema from protect-mcp's +// generateCedarSchema() to enforce typed, attribute-aware access control. +// +// Usage: +// npx protect-mcp --cedar ./policies --enforce -- node mcp-server.js +// +// Every allow/deny decision produces an Ed25519-signed receipt. + +// ── Read operations: allow within workspace ── + +@id("allow-workspace-reads") +permit( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"read_file" +); + +// ── Write operations: require identified agent ── + +@id("block-anonymous-writes") +forbid( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"write_file" +) +when { + context.tier == "unknown" +}; + +// ── Shell execution: blocked entirely ── +// Prevents prompt injection attacks (CVE-2025-6514 "Clinejection") + +@id("block-shell-execution") +forbid( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"execute_command" +); + +@id("block-bash") +forbid( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"bash" +); + +// ── Web search: allowed for all agents ── + +@id("allow-web-search") +permit( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"search_web" +); + +// ── Status checks: allowed (read-only, no side effects) ── + +@id("allow-status") +permit( + principal, + action == Action::"MCP::Tool::call", + resource == Tool::"get_status" +); diff --git a/js/protect-mcp-cedar-integration/test.mjs b/js/protect-mcp-cedar-integration/test.mjs new file mode 100644 index 0000000..ba5b221 --- /dev/null +++ b/js/protect-mcp-cedar-integration/test.mjs @@ -0,0 +1,136 @@ +/** + * Tests for Cedar schema generation from MCP tool descriptions. + * + * Run: node test.mjs + */ + +import { generateCedarSchema, generateSchemaStub } from 'protect-mcp'; +import assert from 'node:assert'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed++; + } catch (err) { + console.log(` FAIL ${name}: ${err.message}`); + failed++; + } +} + +console.log('\nCedar Schema Generation Tests\n'); + +// ── Sample tools ── + +const tools = [ + { + name: 'read_file', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, + { + name: 'execute_command', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string' }, + args: { type: 'array', items: { type: 'string' } }, + timeout: { type: 'integer' }, + }, + required: ['command'], + }, + }, + { name: 'get_status' }, // No input schema +]; + +// ── Tests ── + +test('generates schema with correct tool count', () => { + const result = generateCedarSchema(tools); + assert.strictEqual(result.toolCount, 3); +}); + +test('includes all tool names', () => { + const result = generateCedarSchema(tools); + assert.deepStrictEqual(result.tools, ['read_file', 'execute_command', 'get_status']); +}); + +test('schema text contains namespace', () => { + const result = generateCedarSchema(tools, { namespace: 'TestNS' }); + assert.ok(result.schemaText.includes('namespace TestNS {')); +}); + +test('schema text contains per-tool actions', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('action "read_file"')); + assert.ok(result.schemaText.includes('action "execute_command"')); + assert.ok(result.schemaText.includes('action "get_status"')); +}); + +test('schema text contains blanket action', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('action "MCP::Tool::call"')); +}); + +test('maps string to String', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('"path": String')); +}); + +test('maps integer to Long', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('"timeout": Long')); +}); + +test('maps array to Set', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('Set')); +}); + +test('includes Agent and Tool entities', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaText.includes('entity Agent')); + assert.ok(result.schemaText.includes('entity Tool;')); +}); + +test('schema JSON has correct structure', () => { + const result = generateCedarSchema(tools); + assert.ok(result.schemaJson['ScopeBlind']); + const ns = result.schemaJson['ScopeBlind']; + assert.ok(ns.entityTypes); + assert.ok(ns.actions); +}); + +test('schema JSON contains all actions', () => { + const result = generateCedarSchema(tools); + const ns = result.schemaJson['ScopeBlind']; + assert.ok(ns.actions['read_file']); + assert.ok(ns.actions['execute_command']); + assert.ok(ns.actions['get_status']); + assert.ok(ns.actions['MCP::Tool::call']); +}); + +test('handles tools with no input schema', () => { + const result = generateCedarSchema([{ name: 'ping' }]); + assert.strictEqual(result.toolCount, 1); + assert.ok(result.schemaText.includes('action "ping"')); +}); + +test('generateSchemaStub produces valid stub', () => { + const stub = generateSchemaStub('TestNS'); + assert.ok(stub.includes('namespace TestNS {')); + assert.ok(stub.includes('entity Agent')); + assert.ok(stub.includes('entity Tool')); + assert.ok(stub.includes('cedar-for-agents')); +}); + +// ── Summary ── + +console.log(`\n${passed} passed, ${failed} failed\n`); +if (failed > 0) process.exit(1);