Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Demos, tools, the web host, the E2E/bench runners, the template, and tests stay non-packable. -->

<PropertyGroup>
<Version>1.12.0</Version>
<Version>1.13.0</Version>
<Authors>Chad Sandor</Authors>
<Company>wyckit</Company>
<Product>IntentMesh</Product>
Expand All @@ -18,7 +18,7 @@
<!-- MIT-licensed (SPDX expression in package metadata). -->
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageReleaseNotes>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.</PackageReleaseNotes>
<PackageReleaseNotes>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.</PackageReleaseNotes>

<!-- Reproducible restore: lock files are honored in CI via locked-mode restore. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/MATURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/IntentMesh.E2E/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 43 additions & 33 deletions src/IntentMesh.Integrations/McpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,17 @@ public sealed class McpProxy
/// <param name="customMapper">Optional per-server mapping: given an inbound call, return the typed
/// action + label to use, or <c>null</c> 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.</param>
/// <param name="auditStore">When set (with <paramref name="auditKeyProvider"/>), every FORWARDED call
/// is persisted as a signed <see cref="TraceBundle"/> 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.</param>
/// <param name="auditKeyProvider">Signs the pre-forward audit bundle.</param>
/// <param name="approvalService">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
/// <see cref="MintApprovalChallenge"/>; pass the returned token(s) as the approvals to
/// <see cref="Gate"/>/<see cref="GateAndForward"/>.</param>
/// <param name="auditStore">REQUIRED to forward: <see cref="GateAndForward"/> persists a signed
/// <see cref="TraceBundle"/> 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 <see cref="Gate"/> (a pure decision) but
/// <see cref="GateAndForward"/> throws.</param>
/// <param name="auditKeyProvider">Signs the pre-forward audit bundle (required alongside
/// <paramref name="auditStore"/>).</param>
/// <param name="approvalService">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 <see cref="MintApprovalChallenge"/> and pass it as the approval. Supplying approvals
/// without this service throws (a gated call simply stays blocked if nothing is approved).</param>
/// <param name="tenantId">Tenant the proxy acts for (binds approval challenges + audit ownership).</param>
public McpProxy(IntentMeshRuntime runtime, Workspace workspace, string? allowedRoot = null,
Func<McpToolCall, (TypedAction? action, string label)?>? customMapper = null,
Expand Down Expand Up @@ -219,13 +221,17 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet<string>? 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<string>)new HashSet<string>();
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<string>(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -270,6 +276,13 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet<string>? approvals = nu
public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client,
IReadOnlySet<string>? approvals = null, IProgress<string>? 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)
Expand All @@ -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<string>(), _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<string>(), _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
Expand Down
16 changes: 13 additions & 3 deletions src/IntentMesh.McpDemo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -56,8 +63,11 @@ void Show(string title, McpForwardResult r)
var writeCall = new McpToolCall("write_file", new Dictionary<string, string> { ["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<string> { "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<string> { approval }));
Console.WriteLine($" out.txt exists on disk: {File.Exists(Path.Combine(root, "out.txt"))}");
}
return 0;
Expand Down
6 changes: 5 additions & 1 deletion tests/IntentMesh.Tests/E2ESliceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading