diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca5bcbd..a43a71c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,11 +47,18 @@ jobs: - name: Verify TLM bundle (round-trip + checksum) run: dotnet run --project src/IntentMesh.Tlm.Cli -c Release --no-build -- verify --root dataset - - name: Test + # Two separate test steps so the secret and the untrusted-npm execution NEVER share an environment: + # the live-LLM test gets ANTHROPIC_API_KEY but does NOT run npx; the real-filesystem E2E runs the + # pinned npx package but has NO secret in its env (a compromised package can't read the API key). + - name: Test (unit + live-LLM; no npx) env: - INTENTMESH_FS_E2E: '1' # run the REAL @modelcontextprotocol/server-filesystem path (node + network present) - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # live-LLM test runs when the secret is configured, else skips - run: dotnet test IntentMesh.slnx -c Release --no-build --nologo + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # live-LLM test runs when configured, else skips + run: dotnet test IntentMesh.slnx -c Release --no-build --nologo --filter "FullyQualifiedName!~McpProxy_wires_a_real_filesystem_mcp_server_end_to_end" + + - name: Real filesystem MCP E2E (pinned npx; NO secrets in env) + env: + INTENTMESH_FS_E2E: '1' # runs `npx -y @modelcontextprotocol/server-filesystem@` — note: no ANTHROPIC_API_KEY here + run: dotnet test IntentMesh.slnx -c Release --no-build --nologo --filter "FullyQualifiedName~McpProxy_wires_a_real_filesystem_mcp_server_end_to_end" - name: Policy fixtures run: dotnet run --project src/IntentMesh.Cli -c Release --no-build -- policy fixtures diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4a045..df9b57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md) for the production-ready / experimental / future breakdown. +## 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.** + +High: +- **MCP side effects require a durable signed audit.** `McpProxy.GateAndForward` can be wired with an + `IRunArtifactStore` + key provider; it persists a signed `TraceBundle` of the approved decision **before** + forwarding to the real server, and **fails closed** (does not forward) if the audit can't be written. +- **MCP approvals are challenge-bound.** With an `ApprovalChallengeService` configured, an MCP approval is + a server-issued challenge bound to `{call fingerprint (tool+canonical args), tenant, expiry}` — a raw, + replayable `n1` no longer approves, and a challenge for one call can't approve another. +- **Pinned, non-option npx.** `ConnectNpx` rejects option-shaped names (leading dash) and floating specs; + only a pinned, digit-led `name@1.2.3` is accepted. Call sites pin `@modelcontextprotocol/server-filesystem@2026.1.14`. +- **CI isolates the API key from npx.** The live-LLM test (with `ANTHROPIC_API_KEY`) and the real-filesystem + E2E (which runs `npx`) are now separate steps with disjoint environments — a compromised npm package can't + read the secret. +- **Production auth boundaries.** Trusted-proxy mode requires `INTENTMESH_PROXY_SECRET` in Production; the + legacy `INTENTMESH_WEB_TOKEN` no longer satisfies the Production auth guard and is gone from the quickstart. +- **`/readyz` probes persistence.** It now writes + atomically moves + deletes a temp file in the runs dir, + so it fails when the volume is read-only/full/unmounted — not merely when the directory is absent. +- **Direct run-query row cap.** A direct `RunQueryAction` (no `RowLimit` field) is now bounded by `db.RowCap` + at execution, the same cap a compiled plan must satisfy. +- **Per-file delete verification.** A new `pc-deletion-matches-approval` postcondition proves the deleted + set is exactly the approved file refs — not merely that a delete node ran. + +Medium: +- **Rate-limit key is trust-scoped.** `X-Forwarded-For` is honored only behind the trusted proxy (matching + `X-Proxy-Secret`); otherwise the socket IP is used, so a direct client can't rotate the header to evade limits. +- **Custom-mapper path forwarding.** `NormalizeForForward` rewrites a custom mapper's path arg (e.g. `target`, + `filepath`) to the canonical in-root path actually validated, not just the standard keys. +- **NuGet package signing** remains a documented residual (needs a code-signing certificate); provenance + attestation + SHA256SUMS ship today. + ## v1.11.0 — Service & integration hardening (fifth review pass) Closes a fifth external review (7 High + 4 Medium). **240 passing + 3 env-gated skipped.** diff --git a/Directory.Build.props b/Directory.Build.props index 16e9535..c4d9449 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.11.0 + 1.12.0 Chad Sandor wyckit IntentMesh @@ -18,7 +18,7 @@ MIT false - v1.11.0 — service hardening: server-mediated approvals on export, verify-before-rerun, per-file web approvals, fail-closed persistence, rate limiting, CSP/security headers, side-effecting-GET gating, MCP typed-path enforcement. See CHANGELOG.md. + 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. true diff --git a/README.md b/README.md index 15124a2..2502627 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 # 240 passing (+3 env-gated skipped) +dotnet test IntentMesh.slnx # 245 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). **240 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** +operator workflow, audit operations). **245 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 240 passing (+3 env-gated skipped).** Five demo +7/7 round-trip verify; typed action contracts across four domains. **xUnit 245 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/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 61fdbb8..b0e21f3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -12,13 +12,18 @@ docker build -f src/IntentMesh.Web/Dockerfile -t intentmesh-controlroom . docker run -p 8080:8080 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e INTENTMESH_AUDIT_KEY="$(openssl rand -base64 32)" \ - -e INTENTMESH_WEB_TOKEN="$(openssl rand -hex 24)" \ + -e INTENTMESH_AUTH_KEY="$(openssl rand -base64 32)" \ + -e INTENTMESH_PRINCIPALS=/run/secrets/principals.json \ -e "AllowedHosts=mesh.example.com" \ -e INTENTMESH_RUNS_DIR=/data/runs \ -v intentmesh-runs:/data/runs \ intentmesh-controlroom ``` +> Production uses **token mode** (above) or **trusted-proxy mode** (Mode B below). The legacy +> `INTENTMESH_WEB_TOKEN` is **not** accepted as a production auth boundary — the host refuses to start in +> Production unless token or proxy mode is configured. + **From source:** `dotnet run --project src/IntentMesh.Web` (Development; loopback, demo key). ## Required configuration (production) @@ -53,11 +58,11 @@ console snippet) or `printf '%s' "$KEY" | sha256sum`. | Variable | Purpose | |---|---| | `INTENTMESH_TRUSTED_PROXY=1` | Trust identity asserted by an upstream proxy/IdP instead of minting tokens. | -| `INTENTMESH_PROXY_SECRET` | Shared secret the proxy must present as `X-Proxy-Secret`; without it, asserted headers are honored only from loopback. Set this whenever the app is reachable from anything but the proxy. | +| `INTENTMESH_PROXY_SECRET` | Shared secret the proxy must present as `X-Proxy-Secret`. **Required in Production** (the host refuses to start in proxy mode without it) — without it, asserted headers would be honored from any loopback-presenting source. It also gates whether `X-Forwarded-For` is trusted for rate-limiting. | The proxy authenticates the user (OIDC, etc.) and forwards `X-Auth-Principal`, `X-Auth-Tenant`, and a comma-separated `X-Auth-Roles`. **The proxy MUST strip any client-supplied `X-Auth-*` / `X-Proxy-Secret` -headers** so a caller can't spoof identity. +/ `X-Forwarded-For` headers** so a caller can't spoof identity or its rate-limit bucket. ### Roles & isolation diff --git a/docs/MATURITY.md b/docs/MATURITY.md index 7cab1aa..c1b9475 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` — **240 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. +true (`dotnet test IntentMesh.slnx` — **245 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.Core/Adapters.cs b/src/IntentMesh.Core/Adapters.cs index 6d0ea32..cf8b15e 100644 --- a/src/IntentMesh.Core/Adapters.cs +++ b/src/IntentMesh.Core/Adapters.cs @@ -284,16 +284,23 @@ private static ExecutionResult RunQuery(string id, RunQueryAction r, Workspace w if (table is null) return ToolHost.Ok(id, $"No such table '{r.Table}'."); ws.Db.RanQueries.Add(r.Summary); + // Row-cap enforcement: a DIRECT run-query carries no RowLimit field, so it is bounded here by the + // SAME db.RowCap a compiled plan must satisfy — a direct query cannot scan/aggregate more rows than + // the policy cap. (A capped read can't be used to exfiltrate an unbounded table.) + var cap = ws.Db.RowCap; + var rows = table.Rows.Take(cap).ToList(); + var capNote = table.Rows.Count > cap ? $" (row cap {cap} applied to {table.Rows.Count} rows)" : ""; + // Aggregate signups by plan (non-sensitive columns only). int planCol = table.Columns.ToList().IndexOf("plan"); var agg = planCol >= 0 - ? string.Join(", ", table.Rows.GroupBy(row => row[planCol]).Select(g => $"{g.Key}: {g.Count()}")) - : $"{table.Rows.Count} rows"; + ? string.Join(", ", rows.GroupBy(row => row[planCol]).Select(g => $"{g.Key}: {g.Count()}")) + : $"{rows.Count} rows"; // Query results are retrieved content — scan for an embedded imperative. var proposed = new List(); - var effects = new List { $"result ({r.Summary}): {agg}", "no sensitive columns in the result" }; - foreach (var row in table.Rows) + var effects = new List { $"result ({r.Summary}){capNote}: {agg}", "no sensitive columns in the result" }; + foreach (var row in rows) foreach (var cell in row) if (cell.ToUpperInvariant().Contains("IGNORE PREVIOUS INSTRUCTIONS") || (cell.ToUpperInvariant().Contains("DROP") && cell.ToUpperInvariant().Contains("TABLE"))) diff --git a/src/IntentMesh.Core/PostconditionVerifier.cs b/src/IntentMesh.Core/PostconditionVerifier.cs index cb3f42e..3907135 100644 --- a/src/IntentMesh.Core/PostconditionVerifier.cs +++ b/src/IntentMesh.Core/PostconditionVerifier.cs @@ -98,6 +98,18 @@ void Add(string id, string expected, string actual, bool pass, string evidence) Add("pc-no-unapproved-deletion", "no file deleted without approval", ws.DeletedFiles.Count == 0 ? "none deleted" : (deleteApproved ? "deleted after approval" : "deleted WITHOUT approval!"), ok, $"deleted={ws.DeletedFiles.Count}, approved-node={deleteApproved}"); + + // Granular check: the set of files actually deleted must be EXACTLY a subset of the per-file + // refs that were approved (node.ApprovedRefs) — proving the adapter deleted only approved + // files, not merely that a delete node ran. Catches an over-deleting/buggy adapter. + var approvedRefs = graph.Nodes + .Where(n => n.Type == Kinds.DeleteFiles) + .SelectMany(n => n.ApprovedRefs) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var unapproved = ws.DeletedFiles.Where(f => !approvedRefs.Contains(f)).ToList(); + Add("pc-deletion-matches-approval", "every deleted file was individually approved", + unapproved.Count == 0 ? "exact match" : "deleted UNAPPROVED file(s)!", unapproved.Count == 0, + $"deleted -> {string.Join(", ", ws.DeletedFiles)}; approved refs -> {string.Join(", ", approvedRefs)}"); } // ── Dev-agent postconditions ──────────────────────────────────────── diff --git a/src/IntentMesh.Integrations/McpProxy.cs b/src/IntentMesh.Integrations/McpProxy.cs index 86af119..47e92e3 100644 --- a/src/IntentMesh.Integrations/McpProxy.cs +++ b/src/IntentMesh.Integrations/McpProxy.cs @@ -104,6 +104,10 @@ public sealed class McpProxy private readonly Workspace _workspace; private readonly string? _allowedRoot; private readonly Func? _customMapper; + private readonly IRunArtifactStore? _auditStore; + private readonly IAuditKeyProvider? _auditKeyProvider; + private readonly ApprovalChallengeService? _approvalService; + private readonly string _tenantId; /// /// A loaded IntentMeshRuntime. The caller controls which capabilities are @@ -120,13 +124,51 @@ 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 + /// /. + /// Tenant the proxy acts for (binds approval challenges + audit ownership). public McpProxy(IntentMeshRuntime runtime, Workspace workspace, string? allowedRoot = null, - Func? customMapper = null) + Func? customMapper = null, + IRunArtifactStore? auditStore = null, IAuditKeyProvider? auditKeyProvider = null, + ApprovalChallengeService? approvalService = null, string? tenantId = null) { _runtime = runtime; _workspace = workspace; _allowedRoot = allowedRoot; _customMapper = customMapper; + _auditStore = auditStore; + _auditKeyProvider = auditKeyProvider; + _approvalService = approvalService; + _tenantId = tenantId ?? "default"; + if (_auditStore is not null && _auditKeyProvider is null) + throw new ArgumentException("auditKeyProvider is required when auditStore is set (the pre-forward audit must be signed).", nameof(auditKeyProvider)); + } + + /// A deterministic fingerprint of a call (tool + canonical, key-sorted args) — the identity an + /// approval challenge is bound to, so a challenge for one (tool, args) can't approve a different call. + public static string CallFingerprint(McpToolCall call) + { + var sorted = new SortedDictionary(StringComparer.Ordinal); + foreach (var kv in call.Args) sorted[kv.Key] = kv.Value; + var canonical = call.Tool + "\n" + JsonSerializer.Serialize(sorted); + return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + + /// Mint a server-issued approval challenge bound to this exact call + tenant + expiry. The + /// returned token is the ONLY thing that approves the call's gated node — a raw node id cannot. + /// Requires the proxy to be constructed with an approvalService. + public string MintApprovalChallenge(McpToolCall call, long issuedAtUnix, long expiresAtUnix, string nonce) + { + if (_approvalService is null) + throw new InvalidOperationException("This proxy was not constructed with an approvalService."); + return _approvalService.Mint(CallFingerprint(call), "n1", _tenantId, issuedAtUnix, expiresAtUnix, nonce); } /// @@ -177,11 +219,27 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet? approvals = nu Status = NodeStatus.Resolved, }; - // 2. Run through the full IntentMesh pipeline using a one-node proposer (with any approvals). + // 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. + var effectiveApprovals = approvals ?? (IReadOnlySet)new HashSet(); + if (_approvalService is not null) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var fingerprint = CallFingerprint(call); + var verified = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var token in effectiveApprovals) + if (_approvalService.TryVerify(token, fingerprint, _tenantId, now, out var approvedNode)) + verified.Add(approvedNode); + effectiveApprovals = verified; + } + + // 3. Run through the full IntentMesh pipeline using a one-node proposer (with any approvals). // RunWith preserves the caller's capability grants + approval cap — a proxy built on a // capability-restricted runtime must gate under those same restrictions, not widen them. var proposer = new McpOneNodeProposer(node); - var result = _runtime.RunWith(proposer, $"mcp:{call.Tool}", _workspace, approvals ?? new HashSet()); + var result = _runtime.RunWith(proposer, $"mcp:{call.Tool}", _workspace, effectiveApprovals); // 3. Decide from the node's final status. ALLOW-LIST, not deny-list: forward ONLY if the node // actually proceeded (Allowed/Executed/Verified). Anything else — Blocked, NeedsConfirmation, @@ -219,6 +277,29 @@ public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client, progress?.Report($"blocked {call.Tool} — not forwarded"); 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) + { + 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); + } + } + // Forward the NORMALIZED call: a filesystem path is rewritten to the exact canonical in-root // path the gate validated, so the server can't re-resolve a relative/aliased original arg to a // different (possibly escaping) target than what was checked (time-of-check/time-of-use). @@ -244,6 +325,17 @@ private McpToolCall NormalizeForForward(McpToolCall call) bool hadPath = false; foreach (var key in new[] { "path", "source", "destination" }) if (args.TryGetValue(key, out var p) && !string.IsNullOrEmpty(p)) { args[key] = Resolve(p, root); hadPath = true; } + + // Custom-mapper paths: a per-server mapper may carry the path in a NON-standard arg (e.g. "target", + // "filepath"). The typed action already exposes the validated path(s); rewrite whichever raw arg + // holds that value to the exact canonical in-root path, so the forwarded call can't re-resolve a + // relative/aliased original to a different target than the gate checked. + foreach (var typed in TypedPaths(action)) + { + var canonical = Resolve(typed, root); + foreach (var key in args.Keys.ToList()) + if (!string.IsNullOrEmpty(args[key]) && Resolve(args[key], root) == canonical) { args[key] = canonical; hadPath = true; } + } // No-path filesystem tool under a sandbox: scope it explicitly to the root rather than letting the // server fall back to its own working directory (defense in depth over the server's own sandbox). if (!hadPath && !args.ContainsKey("paths")) diff --git a/src/IntentMesh.Integrations/McpStdioClient.cs b/src/IntentMesh.Integrations/McpStdioClient.cs index 002e745..38de266 100644 --- a/src/IntentMesh.Integrations/McpStdioClient.cs +++ b/src/IntentMesh.Integrations/McpStdioClient.cs @@ -197,9 +197,17 @@ public static McpStdioClient ConnectNpx(string package, params string[] serverAr private static readonly char[] ShellMeta = { ';', '|', '&', '$', '`', '\n', '\r', '>', '<', '(', ')', '{', '}', '"', '\'', '^', '%', '!', '*', '?' }; private static bool ContainsShellMeta(string s) => s.IndexOfAny(ShellMeta) >= 0; + + /// A package specifier accepted for npx -y execution. Hardened beyond shell-safety: + /// the name must START with an alphanumeric (so an option-shaped token like -rf or + /// --foo can never be passed as the "package"), and it MUST carry a PINNED, digit-led version + /// (name@1.2.3) — a floating spec (name, name@latest, name@^1) is rejected + /// so npx can't resolve to an unexpected newly-published build at run time. private static bool IsSafeNpmPackage(string p) => !string.IsNullOrWhiteSpace(p) && !ContainsShellMeta(p) && !p.Contains(' ') - && System.Text.RegularExpressions.Regex.IsMatch(p, @"^(@[a-z0-9._-]+/)?[a-z0-9._-]+(@[\w.\-]+)?$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + && System.Text.RegularExpressions.Regex.IsMatch( + p, @"^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*@\d+(\.\d+)*(-[a-z0-9.]+)?$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); /// Locate the bundled mcp-echo-server.js by walking up to the repo root. public static string EchoServerScript() diff --git a/src/IntentMesh.McpDemo/Program.cs b/src/IntentMesh.McpDemo/Program.cs index 2884c06..593f4af 100644 --- a/src/IntentMesh.McpDemo/Program.cs +++ b/src/IntentMesh.McpDemo/Program.cs @@ -21,7 +21,7 @@ catch (Exception ex) { Console.Error.WriteLine($"Load failed: {ex.Message}"); return 1; } McpStdioClient client; -try { client = McpStdioClient.ConnectNpx("@modelcontextprotocol/server-filesystem", root); } +try { client = McpStdioClient.ConnectNpx("@modelcontextprotocol/server-filesystem@2026.1.14", root); } catch (Exception ex) { Console.Error.WriteLine($"Could not start the filesystem MCP server (need node/npx + network): {ex.Message}"); diff --git a/src/IntentMesh.Web/Program.cs b/src/IntentMesh.Web/Program.cs index 83c254b..9175f82 100644 --- a/src/IntentMesh.Web/Program.cs +++ b/src/IntentMesh.Web/Program.cs @@ -11,17 +11,24 @@ var builder = WebApplication.CreateBuilder(args); -// Rate limiting (built into the shared framework — no extra package). Partitions by the forwarded -// client ip (X-Forwarded-For behind the reverse proxy, else the socket ip); a request with no -// resolvable client key (e.g. an in-process test server) is not limited. A global per-client cap -// covers run/replay/export/storage amplification; the stricter "auth" policy throttles credential -// brute force on POST /api/auth/token. +// Whether an upstream reverse proxy is trusted to assert identity / client ip, and the shared secret it +// must present. Read here (before the rate limiter) so the limiter can decide when X-Forwarded-For is +// trustworthy. (Re-read by the auth middleware below for the same trust decision.) +var trustedProxy = Environment.GetEnvironmentVariable("INTENTMESH_TRUSTED_PROXY") == "1"; +var proxySecret = Environment.GetEnvironmentVariable("INTENTMESH_PROXY_SECRET"); + +// Rate limiting (built into the shared framework — no extra package). Partitions by client: behind the +// trusted proxy the forwarded X-Forwarded-For client ip, otherwise the socket ip. X-Forwarded-For is +// trusted ONLY behind the proxy, so a direct client can't rotate that header to evade the limit. A +// request with no resolvable client key (e.g. an in-process test server) is not limited. A global +// per-client cap covers run/replay/export/storage amplification; the stricter "auth" policy throttles +// credential brute force on POST /api/auth/token. builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.Create(ctx => { - var key = ClientKey(ctx); + var key = ClientKey(ctx, trustedProxy, proxySecret); return key is null ? RateLimitPartition.GetNoLimiter("unkeyed") : RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions @@ -29,7 +36,7 @@ }); options.AddPolicy("auth", ctx => { - var key = ClientKey(ctx); + var key = ClientKey(ctx, trustedProxy, proxySecret); return key is null ? RateLimitPartition.GetNoLimiter("unkeyed") : RateLimitPartition.GetFixedWindowLimiter("auth:" + key, _ => new FixedWindowRateLimiterOptions @@ -81,11 +88,12 @@ var tokenSvc = new AuthTokenService(effectiveAuthKey); var challengeSvc = new ApprovalChallengeService(effectiveAuthKey); var principals = PrincipalStore.FromEnvironment(); -var trustedProxy = Environment.GetEnvironmentVariable("INTENTMESH_TRUSTED_PROXY") == "1"; -var proxySecret = Environment.GetEnvironmentVariable("INTENTMESH_PROXY_SECRET"); var webToken = Environment.GetEnvironmentVariable("INTENTMESH_WEB_TOKEN"); // legacy single-token (default tenant) var tokenMode = principals.Count > 0; -var authConfigured = tokenMode || trustedProxy || webToken is not null; +// A real (production-grade) auth boundary is token mode or trusted-proxy mode. The legacy shared +// WEB_TOKEN is a dev/local convenience and does NOT count as a production boundary. +var realAuthConfigured = tokenMode || trustedProxy; +var authConfigured = realAuthConfigured || webToken is not null; const long SessionTtlSeconds = 3600; // 1h session tokens const long ChallengeTtlSeconds = 600; // 10m approval challenges @@ -103,15 +111,26 @@ return; } -// Production safety #2: refuse to start without an authentication boundary — otherwise the /api surface -// would fall back to local-only/dev access on a public host. -if (app.Environment.IsProduction() && !authConfigured +// Production safety #2: refuse to start without a REAL authentication boundary. The legacy shared +// WEB_TOKEN does NOT qualify in Production — it's a single shared bearer with no per-principal identity. +if (app.Environment.IsProduction() && !realAuthConfigured + && Environment.GetEnvironmentVariable("INTENTMESH_ALLOW_INSECURE_AUTH") != "1") +{ + Console.Error.WriteLine( + "FATAL: refusing to start in Production without a real auth boundary. Provide INTENTMESH_PRINCIPALS + " + + "INTENTMESH_AUTH_KEY (token mode) or INTENTMESH_TRUSTED_PROXY=1 + INTENTMESH_PROXY_SECRET (proxy mode). " + + "The legacy INTENTMESH_WEB_TOKEN is dev-only. Set INTENTMESH_ALLOW_INSECURE_AUTH=1 for a deliberately local-only host."); + return; +} + +// Production safety #2b: trusted-proxy mode in Production MUST require a shared proxy secret — otherwise +// asserted X-Auth-* headers would be trusted from any loopback-presenting source. +if (app.Environment.IsProduction() && trustedProxy && string.IsNullOrEmpty(proxySecret) && Environment.GetEnvironmentVariable("INTENTMESH_ALLOW_INSECURE_AUTH") != "1") { Console.Error.WriteLine( - "FATAL: refusing to start in Production without authentication configured. Provide INTENTMESH_PRINCIPALS + " + - "INTENTMESH_AUTH_KEY (token mode) or INTENTMESH_TRUSTED_PROXY=1 (proxy mode), or set " + - "INTENTMESH_ALLOW_INSECURE_AUTH=1 for a deliberately local-only host."); + "FATAL: trusted-proxy mode in Production requires INTENTMESH_PROXY_SECRET — the upstream proxy must present " + + "it as X-Proxy-Secret so injected X-Auth-* headers are trusted only from the real proxy hop."); return; } @@ -236,9 +255,22 @@ // Liveness/readiness for orchestrators/proxies (no auth — they carry no run data). app.MapGet("/healthz", () => Results.Text("ok")); -app.MapGet("/readyz", () => runtime is not null && Directory.Exists(runsDir) - ? Results.Json(new { status = "ready", keyId = keyProvider.KeyId }) - : Results.StatusCode(503)); +// Readiness actually PROBES persistence: it writes and removes a temp file in the runs dir (the same +// kind of write + atomic move real persistence needs), so /readyz fails fast when the volume is +// read-only/full/unmounted — not merely when the directory is absent. +app.MapGet("/readyz", () => +{ + if (runtime is null) return Results.StatusCode(503); + try + { + var probe = Path.Combine(runsDir, ".readyz-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(probe, "ok"); + File.Move(probe, probe + ".moved", overwrite: true); // exercises the atomic-move path persistence uses + File.Delete(probe + ".moved"); + } + catch { return Results.StatusCode(503); } + return Results.Json(new { status = "ready", keyId = keyProvider.KeyId }); +}); // ── Authentication endpoints ─────────────────────────────────────────────────────────────────── /// Exchange an API key for a short-lived signed session token (built-in token mode only). @@ -498,14 +530,22 @@ static bool ConstTimeEq(string? a, string b) => !string.IsNullOrEmpty(a) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); -// Rate-limit partition key: the forwarded client ip (first X-Forwarded-For hop, set by the reverse -// proxy) if present, else the socket ip. Null when neither is resolvable (e.g. the in-process test -// server) — such requests are not rate-limited. -static string? ClientKey(HttpContext c) +// Rate-limit partition key. X-Forwarded-For is honored ONLY when the request comes through the trusted +// proxy (proxy mode + matching X-Proxy-Secret, or loopback when no secret is configured) — so a direct +// client cannot rotate that header to dodge the limit. Otherwise the socket ip is used. Null when +// neither is resolvable (e.g. the in-process test server) — such requests are not rate-limited. +static string? ClientKey(HttpContext c, bool trustedProxy, string? proxySecret) { - var fwd = c.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); - return c.Connection.RemoteIpAddress?.ToString(); + var remote = c.Connection.RemoteIpAddress; + var proxyTrusted = trustedProxy && (proxySecret is not null + ? ConstTimeEq(c.Request.Headers["X-Proxy-Secret"].ToString(), proxySecret) + : remote is null || IPAddress.IsLoopback(remote)); + if (proxyTrusted) + { + var fwd = c.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); + } + return remote?.ToString(); } static bool CanRead(AuthPrincipal p) diff --git a/tests/IntentMesh.Tests/ConfirmationTests.cs b/tests/IntentMesh.Tests/ConfirmationTests.cs index b24d1a0..9b1b3a1 100644 --- a/tests/IntentMesh.Tests/ConfirmationTests.cs +++ b/tests/IntentMesh.Tests/ConfirmationTests.cs @@ -58,6 +58,21 @@ public void Per_file_approval_deletes_only_the_approved_file() Assert.Contains(ws.Downloads, f => f.Name == "~$tempfile.tmp"); // it remains on disk } + [Fact] + public void Verification_proves_deleted_files_match_the_approved_refs() + { + var rt = Runtime(); + var delId = NodeId(rt.Run(Prompt2, Workspace.CreateDemo()), Kinds.DeleteFiles); + var ws = Workspace.CreateDemo(); + var r = rt.Run(Prompt2, ws, new HashSet { $"{delId}#old_installer_v3.exe" }); + + // The verifier independently proves the deleted set is EXACTLY the approved refs — not merely that + // a delete node ran (which would miss an over-deleting adapter). + var v = r.Verification.First(x => x.Id == "pc-deletion-matches-approval"); + Assert.True(v.Pass); + Assert.All(ws.DeletedFiles, f => Assert.Equal("old_installer_v3.exe", f)); + } + [Fact] public void Approving_deletion_actually_deletes_in_the_sandbox() { diff --git a/tests/IntentMesh.Tests/DataAgentTests.cs b/tests/IntentMesh.Tests/DataAgentTests.cs index 3b63b61..6ae3eab 100644 --- a/tests/IntentMesh.Tests/DataAgentTests.cs +++ b/tests/IntentMesh.Tests/DataAgentTests.cs @@ -15,6 +15,40 @@ public sealed class DataAgentTests private static string DeleteId(RunResult r) => r.Nodes.First(n => n.Type == Kinds.BuildQueryPlan && n.Label.Contains("delete")).Id; + private sealed class OneNodeProposer : IIntentProposer + { + private readonly IntentNode _n; + public OneNodeProposer(IntentNode n) => _n = n; + public ProposedPlan Propose(string prompt, Workspace ws) => + new(new[] { _n }, Array.Empty(), Array.Empty()); + } + + [Fact] + public void A_direct_run_query_is_bounded_by_the_row_cap() + { + var ws = Workspace.CreateDemo(); + var table = ws.Db.Tables.First(); + ws.Db.RowCap = 2; // lower the cap below the row count + while (table.Rows.Count <= 2) + table.Rows.Add(table.Columns.Select(_ => "x").ToArray()); + + // A DIRECT RunQueryAction (no RowLimit field) must still be bounded by db.RowCap at execution. + var node = new IntentNode + { + Id = "n1", + Type = Kinds.RunQuery, + Label = "direct query", + Action = new RunQueryAction(table.Name, "direct run-query"), + SourceText = "direct", + TrustSource = TrustSource.User, + Status = NodeStatus.Resolved, + }; + var r = Runtime().RunWith(new OneNodeProposer(node), "direct query", ws, new HashSet()); + + var exec = r.Execution.First(e => e.NodeId == "n1"); + Assert.Contains("row cap", string.Join(" ", exec.Effects), StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Data_contracts_are_registered() { diff --git a/tests/IntentMesh.Tests/IntegrationTests.cs b/tests/IntentMesh.Tests/IntegrationTests.cs index 2b1b8e6..3e21c2d 100644 --- a/tests/IntentMesh.Tests/IntegrationTests.cs +++ b/tests/IntentMesh.Tests/IntegrationTests.cs @@ -225,6 +225,95 @@ public void ConnectNpx_rejects_shell_metacharacters() Assert.Throws(() => McpStdioClient.ConnectNpx("@scope/pkg", "$(whoami)")); } + /// + /// ConnectNpx must reject option-shaped names (a leading dash) and floating/unpinned specs — only a + /// PINNED, digit-led version (name@1.2.3) is accepted, so npx can't resolve to an unexpected build. + /// + [Fact] + public void ConnectNpx_requires_a_pinned_non_option_package() + { + Assert.Throws(() => McpStdioClient.ConnectNpx("-rf@1.0.0")); // option-shaped + Assert.Throws(() => McpStdioClient.ConnectNpx("@modelcontextprotocol/server-filesystem")); // unpinned + Assert.Throws(() => McpStdioClient.ConnectNpx("server-filesystem@latest")); // floating tag + Assert.Throws(() => McpStdioClient.ConnectNpx("server-filesystem@^1.0")); // range + } + + private sealed class ThrowingStore : IRunArtifactStore + { + public string Save(TraceBundle bundle) => throw new IOException("audit volume unwritable"); + public TraceBundle Load(string runId) => throw new NotImplementedException(); + public IReadOnlyList List() => Array.Empty(); + public bool VerifyArtifacts(string runId, byte[]? key = null) => false; + public bool VerifyArtifacts(string runId, IAuditKeyProvider provider) => false; + } + + /// An MCP approval is a SERVER-ISSUED challenge bound to {this call's fingerprint, tenant, + /// expiry} — a raw, replayable node id ("n1") no longer approves, and a challenge for one call can't + /// approve a different call. + [Fact] + public void McpProxy_approvals_must_be_challenge_bound_to_the_call() + { + var root = TempRoot(); + try + { + var key = Encoding.UTF8.GetBytes("mcp-approval-key-0123456789abcd"); + var appr = new ApprovalChallengeService(key); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + approvalService: appr, tenantId: "acme"); + var write = new McpToolCall("write_file", new Dictionary + { ["path"] = Path.Combine(root, "out.txt"), ["content"] = "x" }); + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // A raw node id no longer approves in challenge mode. + Assert.False(proxy.Gate(write, new HashSet { "n1" }).Allowed); + + // A challenge for a DIFFERENT call does not approve this one. + var other = new McpToolCall("write_file", new Dictionary + { ["path"] = Path.Combine(root, "OTHER.txt"), ["content"] = "x" }); + var otherToken = proxy.MintApprovalChallenge(other, now, now + 300, "n2"); + Assert.False(proxy.Gate(write, new HashSet { otherToken }).Allowed); + + // The challenge minted for THIS exact call approves it. + var token = proxy.MintApprovalChallenge(write, now, now + 300, "n1"); + Assert.True(proxy.Gate(write, new HashSet { token }).Allowed); + } + finally { Directory.Delete(root, true); } + } + + /// A forwarded MCP call persists a signed audit bundle BEFORE the external call; if the audit + /// cannot be persisted, the call is NOT forwarded (fail-closed) — no side effect without a record. + [Fact] + public void McpProxy_persists_a_signed_audit_before_forwarding() + { + var root = TempRoot(); + var auditDir = TempRoot(); + try + { + var kp = new FixedKeyProvider("t", Encoding.UTF8.GetBytes("mcp-audit-key-0123456789abcdef")); + var store = new FileRunArtifactStore(auditDir); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + auditStore: store, auditKeyProvider: kp); + var read = new McpToolCall("read_file", new Dictionary { ["path"] = Path.Combine(root, "note.txt") }); + + var fwd = proxy.GateAndForward(read, new FakeMcpClient(() => "read ok")); + + Assert.True(fwd.Gate.Allowed); + Assert.Equal("read ok", fwd.ServerResponse); + Assert.NotEmpty(store.List()); // a signed bundle was persisted before the forward + + // Fail-closed: if the audit can't be written, the call is NOT forwarded. + var throwing = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + auditStore: new ThrowingStore(), auditKeyProvider: kp); + var forwarded = false; + var blocked = throwing.GateAndForward(read, new FakeMcpClient(() => { forwarded = true; return "should not run"; })); + Assert.False(blocked.Gate.Allowed); + Assert.Null(blocked.ServerResponse); + Assert.False(forwarded); // the real transport was never invoked + } + finally { Directory.Delete(root, true); Directory.Delete(auditDir, true); } + } + /// /// A node that ends Halted (approved, but the adapter halted) must NOT be forwarded — the proxy /// forwards only Allowed/Executed/Verified, never an unexpected status. @@ -369,7 +458,7 @@ public void McpProxy_wires_a_real_filesystem_mcp_server_end_to_end() McpStdioClient? client = null; try { - try { client = McpStdioClient.ConnectNpx("@modelcontextprotocol/server-filesystem", root); } + try { client = McpStdioClient.ConnectNpx("@modelcontextprotocol/server-filesystem@2026.1.14", root); } catch { Skip.If(true, "could not launch @modelcontextprotocol/server-filesystem via npx"); return; } var tools = client.ListTools(); if (tools.Count == 0) { Skip.If(true, "filesystem MCP server exposed no tools"); return; } diff --git a/tests/IntentMesh.Tests/WebAuthzTests.cs b/tests/IntentMesh.Tests/WebAuthzTests.cs index 63a3b46..8a2885f 100644 --- a/tests/IntentMesh.Tests/WebAuthzTests.cs +++ b/tests/IntentMesh.Tests/WebAuthzTests.cs @@ -291,7 +291,11 @@ public async Task Per_file_delete_confirmations_are_minted_and_approved_per_file [Fact] public async Task Auth_endpoint_is_rate_limited_per_client() { + // Token mode (so /api/auth/token works) AND behind a trusted proxy presenting the shared secret — + // only then is X-Forwarded-For trusted as the rate-limit client key. var (f, runs) = MakeTokenMode(); + Environment.SetEnvironmentVariable("INTENTMESH_TRUSTED_PROXY", "1"); + Environment.SetEnvironmentVariable("INTENTMESH_PROXY_SECRET", "rl-secret"); try { var codes = new List(); @@ -299,7 +303,8 @@ public async Task Auth_endpoint_is_rate_limited_per_client() { var req = new HttpRequestMessage(HttpMethod.Post, "/api/auth/token") { Content = JsonContent.Create(new { apiKey = "nope" }) }; - req.Headers.Add("X-Forwarded-For", "203.0.113.7"); // a fixed client ip → its own partition + req.Headers.Add("X-Proxy-Secret", "rl-secret"); + req.Headers.Add("X-Forwarded-For", "203.0.113.7"); // trusted (via proxy secret) → its own partition codes.Add((await f.CreateClient().SendAsync(req)).StatusCode); } // The "auth" policy permits 10/min per client; the surplus is rejected with 429.