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
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Keep the build context minimal and reproducible: never copy local build outputs, VCS history, secrets,
# persisted runs, or packaged artifacts into the image. Only source needed to restore+publish is sent.
**/bin/
**/obj/
artifacts/
**/runs/
.git/
.github/
.vs/
.vscode/
*.user
*.nupkg
*.snupkg
**/.audit/
**/*.env
*.pfx
*.key
*.pem
secrets*.json
principals*.json
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,6 @@ jobs:
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@<pinned>` — 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

Expand Down Expand Up @@ -97,6 +92,15 @@ jobs:
artifacts/pack/SHA256SUMS
if-no-files-found: error

# The real-filesystem E2E runs LAST and downloads a pinned npm package via npx. It is sequenced
# AFTER pack + upload so network-fetched code can never mutate the release artifacts (they are
# already built and uploaded from a clean workspace). No secret is in this step's env. The test
# FAILS (does not skip) under INTENTMESH_FS_E2E=1, so a green run proves the real path executed.
- name: Real filesystem MCP E2E (pinned npx; runs after packing; NO secrets in env)
env:
INTENTMESH_FS_E2E: '1' # runs `npx -y @modelcontextprotocol/server-filesystem@<pinned>` — 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"

# Build-provenance attestation runs in a SEPARATE job that holds the only id-token/attestations write
# scopes — so the build/test/pack job above has no write tokens. Push events only (fork PRs can't sign).
attest:
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md)
for the production-ready / experimental / future breakdown.

## v1.14.0 — Audit fidelity, verification & supply-chain hardening (seventh review pass)

Closes a seventh external review (7 High + 3 Medium). **249 passing + 3 env-gated skipped.**

High:
- **Approved MCP bundles record their approvals.** `GateAndForward` now persists the *applied* approvals
(the verified challenge-attested node ids), not an empty list — an approved side-effect bundle carries
its own signed approval header.
- **Filesystem forwards strip unknown args.** Only recognized keys (`path/source/destination/paths/content`)
reach the server; an extra/unknown arg the policy never checked is dropped before forwarding.
- **CI requires real FS-MCP behavior.** The real-filesystem E2E now *fails* (not skips) under
`INTENTMESH_FS_E2E=1` on a missing node / launch failure / empty tool list — green CI proves it ran.
- **Network npm runs after packing.** The npx FS-E2E step is sequenced *after* pack + upload, so
network-fetched code can't mutate release artifacts (built/uploaded from a clean workspace first).
- **Production-grade Docker.** Base images are **digest-pinned**; a `.dockerignore` keeps build outputs /
secrets / runs out of the context; `/data/runs` is created + `chown`ed for the non-root UID and exposed
as a `VOLUME`; `HEALTHCHECK` now hits `/readyz` (write-probe), not `/healthz`.
- **Granular side-effect verification.** New `pc-send-matches-approval` and `pc-block-matches-approval`
prove *every* sent email / committed block maps to an approved executed node — not merely that "some
approved node ran" (matches the per-file delete check).

Medium:
- **Proxy-mode dedicated auth key.** In Production, trusted-proxy mode (like token mode) now requires a
dedicated `INTENTMESH_AUTH_KEY` so approval challenges don't share the audit key.
- **`/api/explain` no longer honors caller approvals** — consistent with `/api/run` and `/api/export`; it
projects approving every gated node (kernel-computed), not caller-supplied node ids.
- **NuGet package signing** remains a documented residual (needs a code-signing certificate).

## 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.**
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.13.0</Version>
<Version>1.14.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.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>
<PackageReleaseNotes>v1.14.0 — seventh review pass: MCP bundles record applied approvals + strip unknown forward args; granular email/calendar verification; CI requires real FS-MCP + packs before npx; digest-pinned Docker with .dockerignore + writable runs volume; proxy-mode dedicated auth key; explain no longer honors caller approvals. 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 # 246 passing (+3 env-gated skipped)
dotnet test IntentMesh.slnx # 249 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). **246 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.**
operator workflow, audit operations). **249 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 246 passing (+3 env-gated skipped).** Five demo
7/7 round-trip verify; typed action contracts across four domains. **xUnit 249 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` — **246 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so.
true (`dotnet test IntentMesh.slnx` — **249 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
22 changes: 22 additions & 0 deletions src/IntentMesh.Core/PostconditionVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ void Add(string id, string expected, string actual, bool pass, string evidence)
ws.SentEmails.Count == 0 ? "none sent" : (sentApproved ? "sent after approval" : "sent WITHOUT approval!"),
noUnapprovedSend, $"drafts={ws.Drafts.Count}, sent={ws.SentEmails.Count}, approved-send-node={sentApproved}");

// Granular: EVERY sent email must map to an executed (approved) send node addressed to that
// recipient — not merely that "some send node ran". An adapter regression that sent an extra
// message would pass the coarse check above but fail here.
var approvedSendRecipients = graph.Nodes
.Where(n => n.Type == Kinds.SendEmail && n.Status == NodeStatus.Executed && n.Action is SendEmailAction)
.Select(n => ((SendEmailAction)n.Action).Recipient)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unapprovedSends = ws.SentEmails.Where(rcpt => !approvedSendRecipients.Contains(rcpt)).ToList();
Add("pc-send-matches-approval", "every sent email maps to an approved executed send",
unapprovedSends.Count == 0 ? "all sends approved" : "sent to UNAPPROVED recipient(s)!", unapprovedSends.Count == 0,
$"sent -> {string.Join(", ", ws.SentEmails)}; approved send recipients -> {string.Join(", ", approvedSendRecipients)}");

bool rmatch = ws.Drafts.All(d => userRecipients.Contains(d.Recipient, StringComparer.OrdinalIgnoreCase));
Add("pc-recipient-matches-request", "every draft recipient was named by the user",
rmatch ? "match" : "mismatch", rmatch,
Expand Down Expand Up @@ -89,6 +101,16 @@ void Add(string id, string expected, string actual, bool pass, string evidence)
Add("pc-block-committed-only-with-approval", "no block committed without approval",
!anyCommitted ? "proposal only" : (blockCommitApproved ? "committed after approval" : "committed WITHOUT approval!"),
ok, $"proposed={ws.ProposedBlocks.Count}, committed={ws.ProposedBlocks.Count(b => b.Committed)}, approved-node={blockCommitApproved}");

// Granular: EVERY committed block must map to an executed (approved) block node with that title.
var approvedBlockTitles = graph.Nodes
.Where(n => n.Type == Kinds.CreateCalendarBlock && n.Status == NodeStatus.Executed && n.Action is CreateCalendarBlockAction)
.Select(n => ((CreateCalendarBlockAction)n.Action).Title)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unapprovedBlocks = ws.ProposedBlocks.Where(b => b.Committed && !approvedBlockTitles.Contains(b.Title)).Select(b => b.Title).ToList();
Add("pc-block-matches-approval", "every committed block maps to an approved executed block",
unapprovedBlocks.Count == 0 ? "all commits approved" : "committed UNAPPROVED block(s)!", unapprovedBlocks.Count == 0,
$"committed -> {string.Join(", ", ws.ProposedBlocks.Where(b => b.Committed).Select(b => b.Title))}; approved block titles -> {string.Join(", ", approvedBlockTitles)}");
}

// Files: deleted only with approval.
Expand Down
17 changes: 14 additions & 3 deletions src/IntentMesh.Integrations/McpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ public sealed record McpToolCall(
/// The full IntentMesh pipeline result (nodes, policy, audit) for inspection,
/// logging, or rendering in a control-room UI.
/// </param>
public sealed record McpGateResult(bool Allowed, string Reason, RunResult RunResult);
public sealed record McpGateResult(bool Allowed, string Reason, RunResult RunResult,
// The approvals (verified challenge-attested node ids) actually applied to this run — recorded in the
// signed audit so an approved forward's bundle carries its own approval header.
IReadOnlyList<string>? AppliedApprovals = null);

/// <summary>The outcome of <see cref="McpProxy.GateAndForward"/>: the gate decision, plus the real
/// MCP server's raw JSON response when (and only when) the call was approved and forwarded.</summary>
Expand Down Expand Up @@ -256,7 +259,7 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet<string>? approvals = nu
string reason = policyView is not null ? $"{policyView.Decision}: {policyView.Reason}" : "No policy decision recorded.";

if (status is "Allowed" or "Executed" or "Verified")
return new McpGateResult(Allowed: true, Reason: reason, RunResult: result);
return new McpGateResult(Allowed: true, Reason: reason, RunResult: result, AppliedApprovals: effectiveApprovals.ToList());

var detail = status == "NeedsConfirmation"
? $"Gated (NeedsConfirmation): {reason} — operator approval required before forwarding."
Expand Down Expand Up @@ -296,7 +299,7 @@ public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client,
// forwarded, so a real MCP side effect can never occur without a record.
try
{
var bundle = TraceBundleBuilder.From(gate.RunResult, new List<string>(), _auditKeyProvider);
var bundle = TraceBundleBuilder.From(gate.RunResult, (gate.AppliedApprovals ?? Array.Empty<string>()).ToList(), _auditKeyProvider);
var runId = _auditStore.Save(bundle);
if (_auditStore is FileRunArtifactStore fs)
fs.RecordOwner(runId, new RunOwner(_tenantId, _tenantId, DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
Expand Down Expand Up @@ -360,6 +363,14 @@ private McpToolCall NormalizeForForward(McpToolCall call)
}
catch { /* not a JSON array — it was validated as a single path; leave as-is */ }
}

// Forward ONLY the recognized filesystem arguments — strip any extra/unknown key so an argument the
// typed action never represented (and the path policy never checked) cannot be honored by the
// server. The forwarded payload is therefore exactly the policy-checked fields.
var allowed = new HashSet<string>(new[] { "path", "source", "destination", "paths", "content" }, StringComparer.Ordinal);
foreach (var key in args.Keys.Where(k => !allowed.Contains(k)).ToList())
args.Remove(key);

return call with { Args = args };

static string Resolve(string p, string root)
Expand Down
30 changes: 21 additions & 9 deletions src/IntentMesh.Web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
#
# The TLM bundle is embedded in IntentMesh.Core, so the image is self-contained (no dataset/ needed).
# See docs/DEPLOYMENT.md for the TLS/reverse-proxy contract, required env vars, and the security model.
# Base images are DIGEST-pinned (not floating :10.0 tags) for reproducible, tamper-evident inputs;
# a .dockerignore keeps local build outputs/secrets/runs out of the context (which uses COPY . .).

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# mcr.microsoft.com/dotnet/sdk:10.0
FROM mcr.microsoft.com/dotnet/sdk@sha256:548d93f8a18a1acbe6cc127bc4f47281430d34a9e35c18afa80a8d6741c2adc3 AS build
WORKDIR /src
COPY . .
# Locked restore (honors packages.lock.json + the repo NuGet.config source mapping) so the image is
Expand All @@ -13,20 +16,29 @@ COPY . .
RUN dotnet restore IntentMesh.slnx --locked-mode \
&& dotnet publish src/IntentMesh.Web/IntentMesh.Web.csproj -c Release --no-restore -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
# mcr.microsoft.com/dotnet/aspnet:10.0
FROM mcr.microsoft.com/dotnet/aspnet@sha256:ddcf70ad1ab963a4fcd41fbd722a6b660e404e87567cfbd46fd2809c21b02088 AS runtime
WORKDIR /app
# Run as a non-root user.
RUN useradd -u 10001 -m app
# Run as a non-root user, and pre-create the runs volume mount point owned by that user so persistence
# works with a named volume (the app writes signed bundles + a /readyz write-probe there).
RUN useradd -u 10001 -m app \
&& mkdir -p /data/runs \
&& chown -R 10001:10001 /data/runs
COPY --from=build /app .
USER app

ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_ENVIRONMENT=Production \
INTENTMESH_RUNS_DIR=/data/runs
EXPOSE 8080
VOLUME ["/data/runs"]

# Liveness probe. In Production the app REFUSES to start with the demo audit key — supply a real one:
# docker run -e INTENTMESH_AUDIT_KEY=<base64 >=16 bytes> -e INTENTMESH_WEB_TOKEN=<token> \
# -e "AllowedHosts=mesh.example.com" -p 8080:8080 intentmesh-controlroom
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://localhost:8080/healthz || exit 1
# Readiness probe: /readyz WRITE-PROBES the runs dir (write + atomic move + delete), so an unwritable or
# unmounted volume is reported unhealthy — not a bare liveness ping. In Production the app REFUSES to
# start with the demo audit key or without an auth boundary — supply real values:
# docker run -e INTENTMESH_AUDIT_KEY=<base64 >=16 bytes> -e INTENTMESH_AUTH_KEY=<base64 >=16 bytes> \
# -e INTENTMESH_PRINCIPALS=/run/secrets/principals.json -e "AllowedHosts=mesh.example.com" \
# -v intentmesh-runs:/data/runs -p 8080:8080 intentmesh-controlroom
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://localhost:8080/readyz || exit 1

ENTRYPOINT ["dotnet", "IntentMesh.Web.dll"]
16 changes: 10 additions & 6 deletions src/IntentMesh.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,15 @@
return;
}

// Production safety #3: token mode must use a DEDICATED auth key, not the audit-key fallback (purpose
// separation between audit signing and session/approval signing).
if (app.Environment.IsProduction() && tokenMode && authKeyRaw is null
// Production safety #3: a real auth boundary (token OR trusted-proxy) must use a DEDICATED auth key, not
// the audit-key fallback — session tokens AND approval challenges are signed with it, so it must be
// separate from the audit signing key in BOTH modes.
if (app.Environment.IsProduction() && realAuthConfigured && authKeyRaw is null
&& Environment.GetEnvironmentVariable("INTENTMESH_ALLOW_INSECURE_AUTH") != "1")
{
Console.Error.WriteLine(
"FATAL: token auth in Production requires a dedicated INTENTMESH_AUTH_KEY (>=128-bit), separate from the audit key.");
"FATAL: a Production auth boundary (token or trusted-proxy) requires a dedicated INTENTMESH_AUTH_KEY (>=128-bit), " +
"separate from the audit key — it signs session tokens and approval challenges.");
return;
}

Expand Down Expand Up @@ -490,8 +492,10 @@
if (!Principal(http).Has(Roles.Operator)) return Forbidden(Roles.Operator);
var prompt = (req.Prompt ?? "").Trim();
if (string.IsNullOrEmpty(prompt)) return Results.BadRequest(new { error = "empty prompt" });
var approvals = (req.Approvals ?? Array.Empty<string>()).ToHashSet(StringComparer.OrdinalIgnoreCase);
return Results.Json(RunExplain.Explain(runtime, prompt, Workspace.CreateDemo, approvals));
// Caller-asserted approvals are NOT honored (consistent with /api/run and /api/export). Explain is a
// pure simulation: it projects what approving EVERY gated node would do, computed by the kernel — it
// does not take caller-supplied node ids.
return Results.Json(RunExplain.Explain(runtime, prompt, Workspace.CreateDemo));
});

// Export the run as the canonical, deterministic audit artifact (replayable; no timestamps).
Expand Down
Loading
Loading