diff --git a/CHANGELOG.md b/CHANGELOG.md index df9b57d..467dca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md) for the production-ready / experimental / future breakdown. +## v1.13.0 — MCP audit + challenge approvals are now mandatory (BREAKING) + +Removes the opt-in/unsafe MCP paths introduced in v1.12.0. **246 passing + 3 env-gated skipped.** + +- **No audit-less forward.** `McpProxy.GateAndForward` now **throws** unless the proxy is constructed with + an `auditStore` + `auditKeyProvider`; a real MCP side effect can never occur without a signed, + persisted record (still fail-closed if the write fails). A pure `Gate` decision (no forwarding) still + works without them. +- **No raw approvals.** Supplying approvals to `Gate`/`GateAndForward` without an `approvalService` + **throws** — there is no raw-node-id path. An MCP approval is always a server-issued challenge bound to + `{call fingerprint, tenant, expiry}`; mint it with `MintApprovalChallenge`. +- Callers updated accordingly: `IntentMesh.McpDemo` and the `IntentMesh.E2E` smoke now wire an audit store + + key provider + challenge service and approve via a minted challenge. + +**Migration:** construct `McpProxy` with `auditStore`, `auditKeyProvider`, `approvalService`, and +`tenantId` to forward/approve; replace any raw `"n1"` approval with `MintApprovalChallenge(call, …)`. + ## v1.12.0 — MCP audit/approval + policy hardening (sixth review pass) Closes a sixth external review (8 High + 3 Medium). **245 passing + 3 env-gated skipped.** diff --git a/Directory.Build.props b/Directory.Build.props index c4d9449..98ceab2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ Demos, tools, the web host, the E2E/bench runners, the template, and tests stay non-packable. --> - 1.12.0 + 1.13.0 Chad Sandor wyckit IntentMesh @@ -18,7 +18,7 @@ MIT false - v1.12.0 — sixth review pass: MCP pre-forward signed audit + challenge-bound approvals, pinned npx, direct run-query row cap, per-file delete verification, production auth guards, readyz write-probe, trust-scoped rate-limit key. See CHANGELOG.md. + v1.13.0 — BREAKING: McpProxy now MANDATES a signed audit sink to forward and a server-issued challenge service to approve (no audit-less forward, no raw-node-id approval). See CHANGELOG.md. true diff --git a/README.md b/README.md index 2502627..47bcc26 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ dotnet run --project src/IntentMesh.Cli -- --trace "plan my Friday and draft Sar dotnet run --project src/IntentMesh.Web # then open the printed localhost URL # tests -dotnet test IntentMesh.slnx # 245 passing (+3 env-gated skipped) +dotnet test IntentMesh.slnx # 246 passing (+3 env-gated skipped) ``` ### Wrap your own agent (the SDK on-ramp) @@ -190,7 +190,7 @@ v1.7 platform:** Phase 1 (clarity) ✓ · Phase 2 (signed artifacts, replay, con Phase 3 (Control Room v1) ✓ · Phase 4 (IntentBench 25/25) ✓ · Phase 5 (SDK + MCP proxy / OpenAPI import / real-adapter example) ✓ · Phase 6 (manifesto, whitepaper, landing) ✓. **v1.7** adds the adoptable platform surface (full-lifecycle SDK + host template, real-LLM-proposer hardening, -operator workflow, audit operations). **245 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** +operator workflow, audit operations). **246 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** **Proven vs. experimental vs. future (claims discipline).** [docs/MATURITY.md](docs/MATURITY.md) is the canonical statement: every *proven* claim has a passing test that would fail if it stopped being @@ -207,7 +207,7 @@ and the [CHANGELOG](CHANGELOG.md). ## Status Research prototype with a production-shaped core, **v1.8.0**. Symbolic layer: 7 TLMs, ~125 concepts, -7/7 round-trip verify; typed action contracts across four domains. **xUnit 245 passing (+3 env-gated skipped).** Five demo +7/7 round-trip verify; typed action contracts across four domains. **xUnit 246 passing (+3 env-gated skipped).** Five demo scenarios. See [docs/MATURITY.md](docs/MATURITY.md) for the proven / experimental / future breakdown. Delivered beyond v0.1: diff --git a/docs/MATURITY.md b/docs/MATURITY.md index c1b9475..888b36f 100644 --- a/docs/MATURITY.md +++ b/docs/MATURITY.md @@ -2,7 +2,7 @@ The single source of truth for **what is production-ready, what is experimental, and what is future work.** Every "proven" claim below is backed by a test that would fail if the claim stopped being -true (`dotnet test IntentMesh.slnx` — **245 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. +true (`dotnet test IntentMesh.slnx` — **246 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. > IntentMesh is a **research prototype with a production-shaped core**: the security kernel and its > guarantees are proven and stable; the *operational backends* around it (KMS, DB persistence, diff --git a/src/IntentMesh.E2E/Program.cs b/src/IntentMesh.E2E/Program.cs index 9b08693..b13bbec 100644 --- a/src/IntentMesh.E2E/Program.cs +++ b/src/IntentMesh.E2E/Program.cs @@ -47,7 +47,15 @@ // malicious one (injected exfil) is blocked and NEVER reaches the server. Uses a REAL MCP server // over stdio JSON-RPC (the bundled mcp-echo-server.js) when node is available; otherwise an // in-process fake — labeled, so the demo never overclaims. -var proxy = new McpProxy(runtime, Workspace.CreateDemo()); +// Forwarding requires a durable signed audit sink (pre-forward, fail-closed). Wire a temp store + the +// env/demo audit key + a challenge service (no approvals are used in this smoke, but the proxy is fully +// wired so the forward path matches production). +var e2eAuditKeys = AuditKeyProviders.FromEnvironment(); +var proxy = new McpProxy(runtime, Workspace.CreateDemo(), + auditStore: new FileRunArtifactStore(Path.Combine(Path.GetTempPath(), "intentmesh-e2e-audit")), + auditKeyProvider: e2eAuditKeys, + approvalService: new ApprovalChallengeService(e2eAuditKeys.GetKey()), + tenantId: "e2e"); // Strict mode for release/CI: require the REAL stdio MCP leg so the "full path" gate can't pass on // the in-process fake. Set INTENTMESH_REQUIRE_REAL_MCP=1 (CI does) to fail when node/echo-server is // unavailable instead of silently falling back. diff --git a/src/IntentMesh.Integrations/McpProxy.cs b/src/IntentMesh.Integrations/McpProxy.cs index 47e92e3..5027a11 100644 --- a/src/IntentMesh.Integrations/McpProxy.cs +++ b/src/IntentMesh.Integrations/McpProxy.cs @@ -124,15 +124,17 @@ public sealed class McpProxy /// Optional per-server mapping: given an inbound call, return the typed /// action + label to use, or null to fall through to the built-in mappings. This is how you /// cover every tool in a specific MCP server's manifest without editing the proxy. - /// When set (with ), every FORWARDED call - /// is persisted as a signed BEFORE the external call is made — a real MCP - /// side effect can never occur without a durable, signed audit record. Fail-closed: if the audit can't - /// be persisted, the call is NOT forwarded. - /// Signs the pre-forward audit bundle. - /// When set, MCP approvals are SERVER-ISSUED challenges bound to this - /// exact call (tool + canonical args) + tenant + expiry — not raw, replayable node ids. Mint one with - /// ; pass the returned token(s) as the approvals to - /// /. + /// REQUIRED to forward: persists a signed + /// here BEFORE the external call is made — a real MCP side effect can never + /// occur without a durable, signed audit record. Fail-closed: if the audit can't be persisted, the call + /// is NOT forwarded. A proxy constructed without it can still (a pure decision) but + /// throws. + /// Signs the pre-forward audit bundle (required alongside + /// ). + /// REQUIRED to approve: MCP approvals are SERVER-ISSUED challenges bound + /// to the exact call (tool + canonical args) + tenant + expiry — there is no raw-node-id path. Mint a + /// token with and pass it as the approval. Supplying approvals + /// without this service throws (a gated call simply stays blocked if nothing is approved). /// Tenant the proxy acts for (binds approval challenges + audit ownership). public McpProxy(IntentMeshRuntime runtime, Workspace workspace, string? allowedRoot = null, Func? customMapper = null, @@ -219,13 +221,17 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet? approvals = nu Status = NodeStatus.Resolved, }; - // 2. Resolve approvals. When an approvalService is configured, the inbound set is SERVER-ISSUED - // challenge TOKENS bound to {this call's fingerprint, tenant, expiry} — a raw, replayable node - // id ("n1") no longer approves anything. Each valid token contributes the node id it attests. - // Without an approvalService (programmatic/dev use), the set is treated as raw node ids. + // 2. Resolve approvals. An MCP approval is ALWAYS a SERVER-ISSUED challenge token bound to + // {this call's fingerprint, tenant, expiry} — there is NO raw-node-id path. Each valid token + // contributes the node id it attests; a "n1" string can never approve. Supplying approvals to a + // proxy with no approvalService is a configuration error (fail loud), not a silent raw-approve. var effectiveApprovals = approvals ?? (IReadOnlySet)new HashSet(); - if (_approvalService is not null) + if (effectiveApprovals.Count > 0) { + if (_approvalService is null) + throw new InvalidOperationException( + "MCP approvals must be server-issued challenges — construct McpProxy with an approvalService and pass " + + "tokens from MintApprovalChallenge. Raw node-id approvals are not accepted."); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var fingerprint = CallFingerprint(call); var verified = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -270,6 +276,13 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet? approvals = nu public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client, IReadOnlySet? approvals = null, IProgress? progress = null) { + // Forwarding a real MCP call is a side effect — it REQUIRES a durable signed audit sink. There is + // no audit-less forward path: a proxy not wired with one cannot forward. + if (_auditStore is null || _auditKeyProvider is null) + throw new InvalidOperationException( + "GateAndForward requires a durable audit sink — construct McpProxy with an auditStore + auditKeyProvider " + + "so every forwarded call is signed and persisted before it is made."); + var gate = Gate(call, approvals); progress?.Report($"gate: {(gate.Allowed ? "allowed" : "blocked")} — {gate.Reason}"); if (!gate.Allowed) @@ -278,26 +291,23 @@ public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client, return new McpForwardResult(gate, ServerResponse: null); } - // Durable signed audit BEFORE the external side effect: when an audit store is configured, persist - // a signed TraceBundle of the approved gate decision first. FAIL-CLOSED — if the audit can't be - // written, the call is NOT forwarded, so a real MCP side effect can never occur without a record. - if (_auditStore is not null) + // Durable signed audit BEFORE the external side effect: persist a signed TraceBundle of the + // approved gate decision first. FAIL-CLOSED — if the audit can't be written, the call is NOT + // forwarded, so a real MCP side effect can never occur without a record. + try { - try - { - var bundle = TraceBundleBuilder.From(gate.RunResult, new List(), _auditKeyProvider!); - var runId = _auditStore.Save(bundle); - if (_auditStore is FileRunArtifactStore fs) - fs.RecordOwner(runId, new RunOwner(_tenantId, _tenantId, DateTimeOffset.UtcNow.ToUnixTimeSeconds())); - progress?.Report($"audited {call.Tool} (run {runId})"); - } - catch (Exception ex) - { - progress?.Report($"blocked {call.Tool} — audit persistence failed, not forwarded"); - return new McpForwardResult( - gate with { Allowed = false, Reason = $"Approved, but NOT forwarded: pre-forward audit could not be persisted ({ex.Message})." }, - ServerResponse: null); - } + var bundle = TraceBundleBuilder.From(gate.RunResult, new List(), _auditKeyProvider); + var runId = _auditStore.Save(bundle); + if (_auditStore is FileRunArtifactStore fs) + fs.RecordOwner(runId, new RunOwner(_tenantId, _tenantId, DateTimeOffset.UtcNow.ToUnixTimeSeconds())); + progress?.Report($"audited {call.Tool} (run {runId})"); + } + catch (Exception ex) + { + progress?.Report($"blocked {call.Tool} — audit persistence failed, not forwarded"); + return new McpForwardResult( + gate with { Allowed = false, Reason = $"Approved, but NOT forwarded: pre-forward audit could not be persisted ({ex.Message})." }, + ServerResponse: null); } // Forward the NORMALIZED call: a filesystem path is rewritten to the exact canonical in-root diff --git a/src/IntentMesh.McpDemo/Program.cs b/src/IntentMesh.McpDemo/Program.cs index 593f4af..fa2ce43 100644 --- a/src/IntentMesh.McpDemo/Program.cs +++ b/src/IntentMesh.McpDemo/Program.cs @@ -31,7 +31,14 @@ using (client) { Console.WriteLine($"server tools: {string.Join(", ", client.ListTools())}\n"); - var proxy = new McpProxy(runtime, Workspace.CreateDemo(), allowedRoot: root); + // Forwarding requires a durable signed audit sink, and approvals are server-issued challenges — wire + // both (an audit dir under the sandbox root, the env/demo audit key, and a challenge service). + var auditKeys = AuditKeyProviders.FromEnvironment(); + var proxy = new McpProxy(runtime, Workspace.CreateDemo(), allowedRoot: root, + auditStore: new FileRunArtifactStore(Path.Combine(root, ".audit")), + auditKeyProvider: auditKeys, + approvalService: new ApprovalChallengeService(auditKeys.GetKey()), + tenantId: "demo"); void Show(string title, McpForwardResult r) { @@ -56,8 +63,11 @@ void Show(string title, McpForwardResult r) var writeCall = new McpToolCall("write_file", new Dictionary { ["path"] = Path.Combine(root, "out.txt"), ["content"] = "written through IntentMesh\n" }); Show("write_file out.txt (no approval)", proxy.GateAndForward(writeCall, client)); - // 4) The same write WITH approval — forwarded, the real server writes the file. - Show("write_file out.txt (approved)", proxy.GateAndForward(writeCall, client, new HashSet { "n1" })); + // 4) The same write WITH approval — a SERVER-ISSUED challenge bound to this exact call (not a raw + // node id). Forwarded; the real server writes the file. + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var approval = proxy.MintApprovalChallenge(writeCall, now, now + 300, Guid.NewGuid().ToString("N")); + Show("write_file out.txt (approved)", proxy.GateAndForward(writeCall, client, new HashSet { approval })); Console.WriteLine($" out.txt exists on disk: {File.Exists(Path.Combine(root, "out.txt"))}"); } return 0; diff --git a/tests/IntentMesh.Tests/E2ESliceTests.cs b/tests/IntentMesh.Tests/E2ESliceTests.cs index 777e498..0bae2fa 100644 --- a/tests/IntentMesh.Tests/E2ESliceTests.cs +++ b/tests/IntentMesh.Tests/E2ESliceTests.cs @@ -68,7 +68,11 @@ public void Full_path_llm_proposer_to_gate_to_persisted_signed_audit_to_replay() public void Real_tool_leg_forwards_benign_and_blocks_malicious_before_it_reaches_the_server() { var bundle = Bundle(); - var proxy = new McpProxy(new IntentMeshRuntime(bundle), Workspace.CreateDemo()); + // Forwarding mandates a signed audit sink; wire a temp store + the env/demo key + a challenge service. + var keys = AuditKeyProviders.FromEnvironment(); + var proxy = new McpProxy(new IntentMeshRuntime(bundle), Workspace.CreateDemo(), + auditStore: new FileRunArtifactStore(Path.Combine(Path.GetTempPath(), "im-e2eslice-" + Guid.NewGuid().ToString("N"))), + auditKeyProvider: keys, approvalService: new ApprovalChallengeService(keys.GetKey()), tenantId: "test"); var server = new RecordingMcpClient(); // Benign read → gated allowed → forwarded. diff --git a/tests/IntentMesh.Tests/IntegrationTests.cs b/tests/IntentMesh.Tests/IntegrationTests.cs index 3e21c2d..81d5f6a 100644 --- a/tests/IntentMesh.Tests/IntegrationTests.cs +++ b/tests/IntentMesh.Tests/IntegrationTests.cs @@ -26,8 +26,18 @@ public sealed class IntegrationTests // ── Shared helpers ──────────────────────────────────────────────────────── private static IntentMeshRuntime Runtime() => IntentMeshRuntime.Load(); + // McpProxy now MANDATES a signed audit sink to forward and a challenge service to approve — wire both + // in the shared helper so tests exercise the production shape. + private static readonly byte[] McpTestKeyBytes = Encoding.UTF8.GetBytes("mcp-test-key-0123456789abcdef01"); + private static readonly IAuditKeyProvider McpTestKeyProvider = new FixedKeyProvider("test", McpTestKeyBytes); + private static ApprovalChallengeService NewApprovalService() => new(McpTestKeyBytes); + private static McpProxy Proxy(IntentMeshRuntime? rt = null, Workspace? ws = null) - => new(rt ?? Runtime(), ws ?? Workspace.CreateDemo()); + => new(rt ?? Runtime(), ws ?? Workspace.CreateDemo(), + auditStore: new FileRunArtifactStore(TempRoot()), + auditKeyProvider: McpTestKeyProvider, + approvalService: NewApprovalService(), + tenantId: "test"); // ═══════════════════════════════════════════════════════════════════════════ // (a) McpProxy — blocks / gates dangerous send_email @@ -368,7 +378,9 @@ public void McpProxy_forwards_the_canonical_in_root_path_not_the_original_relati { string? forwarded = null; var client = new CapturingMcpClient(args => { args.TryGetValue("path", out forwarded); return "{}"; }); - var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + auditStore: new FileRunArtifactStore(TempRoot()), auditKeyProvider: McpTestKeyProvider, + approvalService: NewApprovalService(), tenantId: "test"); var fwd = proxy.GateAndForward(new McpToolCall("read_file", new Dictionary { ["path"] = "note.txt" }), client); @@ -434,14 +446,32 @@ public void McpProxy_fs_write_is_gated_then_allowed_with_approval() var root = TempRoot(); try { - var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + approvalService: NewApprovalService(), tenantId: "test"); var call = new McpToolCall("write_file", new Dictionary { ["path"] = Path.Combine(root, "out.txt"), ["content"] = "x" }); Assert.False(proxy.Gate(call).Allowed); // no approval → gated - Assert.True(proxy.Gate(call, new HashSet { "n1" }).Allowed); // approved → allowed + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // approve via a server-issued challenge + var token = proxy.MintApprovalChallenge(call, now, now + 300, "n"); + Assert.True(proxy.Gate(call, new HashSet { token }).Allowed); // challenge-bound approval → allowed } finally { Directory.Delete(root, true); } } + /// The audit sink and challenge service are MANDATORY for the dangerous operations: a proxy + /// not wired with them cannot forward (no audit-less side effect) and cannot accept a raw approval — + /// both throw rather than silently taking an unsafe path. A pure Gate decision still works. + [Fact] + public void Forwarding_requires_an_audit_sink_and_approvals_require_a_challenge_service() + { + var bare = new McpProxy(Runtime(), Workspace.CreateDemo()); // no audit store, no approval service + var read = new McpToolCall("read_calendar", new Dictionary { ["range"] = "Friday" }); + + Assert.Throws(() => bare.GateAndForward(read, new FakeMcpClient(() => "x"))); + Assert.Throws(() => bare.Gate(read, new HashSet { "n1" })); + + Assert.True(bare.Gate(read).Allowed); // a pure decision with no approvals still works + } + /// /// END-TO-END against the REAL @modelcontextprotocol/server-filesystem over stdio. Gated behind /// INTENTMESH_FS_E2E=1 because it downloads the npm package (kept off the default CI run); the @@ -465,7 +495,9 @@ public void McpProxy_wires_a_real_filesystem_mcp_server_end_to_end() Assert.Contains("read_file", tools); Assert.Contains("write_file", tools); - var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + auditStore: new FileRunArtifactStore(TempRoot()), auditKeyProvider: McpTestKeyProvider, + approvalService: NewApprovalService(), tenantId: "test"); // Allowed read → forwarded → real file content. var read = proxy.GateAndForward(new McpToolCall("read_file", new Dictionary { ["path"] = Path.Combine(root, "note.txt") }), client); @@ -481,7 +513,9 @@ public void McpProxy_wires_a_real_filesystem_mcp_server_end_to_end() // Write gated; with approval → forwarded → the real server writes the file. var writeCall = new McpToolCall("write_file", new Dictionary { ["path"] = Path.Combine(root, "out.txt"), ["content"] = "written via IntentMesh" }); Assert.False(proxy.GateAndForward(writeCall, client).Gate.Allowed); - var approved = proxy.GateAndForward(writeCall, client, new HashSet { "n1" }); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var approval = proxy.MintApprovalChallenge(writeCall, now, now + 300, "n"); + var approved = proxy.GateAndForward(writeCall, client, new HashSet { approval }); Assert.True(approved.Gate.Allowed); Assert.True(File.Exists(Path.Combine(root, "out.txt"))); }