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
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ jobs:
- 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"
run: |
out=$(dotnet test IntentMesh.slnx -c Release --no-build --nologo \
--filter "FullyQualifiedName~McpProxy_wires_a_real_filesystem_mcp_server_end_to_end" 2>&1)
echo "$out"
# Guard against a renamed test silently matching zero cases (dotnet test exits 0 on "no match").
echo "$out" | grep -qE "Total:[[:space:]]+1\b" || { echo "::error::FS-E2E filter matched no test (expected exactly 1)"; exit 1; }

# 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).
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,40 @@
All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md)
for the production-ready / experimental / future breakdown.

## v1.16.0 — Approval integrity, single-use challenges & concurrency safety (ninth review pass)

Closes a ninth external review (5 High + Mediums). **260 passing + 3 env-gated skipped.**

High:
- **Approval can't drift from the reviewed run.** `/api/runs/{id}/approve` now requires the stored run to
still **reproduce** under the current runtime (`RunReplay.Reproduce(...).Reproduced`) before applying
approvals — if code/bundle behavior drifted since review, it returns 409. `/challenges` mints from the
run's **own signed policy decisions** (not a fresh re-run), so the approval queue matches the reviewed graph.
- **Approval challenges are single-use.** A `NonceLedger` consumes each challenge's nonce on success
(web `/approve` and the MCP proxy), so the same challenge can't trigger repeated side effects within its TTL.
- **Private-note drafts are blocked at the gate.** A `DraftEmailAction` whose body sources include a
private note is refused (`pol-draft-private-ref`) **before** the adapter dereferences private content —
not merely flagged as a postcondition after the fact.
- **Concurrent prune can't lose audit history.** `Archive` is serialized and never deletes an existing
archive destination (a content-addressed run already there is the same run); racing prunes drop the
redundant live copy instead. Temp files use unique names so a save racing a prune can't clobber staging.
- **Production startup guards are regression-tested.** A test hosts the app in **Production** and asserts
it refuses to start without a real auth boundary — so the guards can't silently stop firing.

Medium:
- **Rotation-aware sidecar verification** — owner/external-call signatures verify under the *recorded*
key id (resolved via the provider), so key rotation doesn't invalidate older signed sidecars.
- **Docker healthcheck uses `curl`** (installed in the image) — the base aspnet image ships no `wget`/`curl`,
so the prior probe never worked.
- **Live-Anthropic test fails (not passes) on a dead transport** — it now asserts a non-empty bounded
proposal, so a swallowed transport error can't go green.
- **CI guards against a zero-match FS-E2E filter** (a renamed test would otherwise pass vacuously).
- Stale README version snippet replaced with a non-drifting reference.

> Known constraints (documented, not regressions): an `McpStdioClient` wraps one server subprocess and is
> not safe to share across threads (use one client per connection); tenant-wide run visibility is the
> chosen model; NuGet cryptographic signing is wired in CI but needs a certificate.

## v1.15.0 — Container startup, audit binding & production-auth hardening (eighth review pass)

Closes an eighth external review (3 High + 6 Medium). **256 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.15.0</Version>
<Version>1.16.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.15.0 — eighth review pass: embedded-bundle fallback for containers, signed exact MCP payload + signed owner + distinct principal, 128-bit run ids + collision-fail, retention enforcement, untrusted-GET hint can't suppress confirmation, auth-key!=audit-key + proxy-secret strength + XFF-last, opt-in NuGet signing, doc drift. See CHANGELOG.md.</PackageReleaseNotes>
<PackageReleaseNotes>v1.16.0 — ninth review pass: reproduce-before-approve, single-use approval challenges, private-note draft block at the gate, collision-safe archive, production-startup regression test, rotation-aware sidecar verify, unique temp names, curl healthcheck, fail-not-pass live-LLM test, CI zero-match guard. See CHANGELOG.md.</PackageReleaseNotes>

<!-- Reproducible restore: lock files are honored in CI via locked-mode restore. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Expand Down
10 changes: 5 additions & 5 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 # 256 passing (+3 env-gated skipped)
dotnet test IntentMesh.slnx # 260 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). **256 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.**
operator workflow, audit operations). **260 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 @@ -206,8 +206,8 @@ and the [CHANGELOG](CHANGELOG.md).

## Status

Research prototype with a production-shaped core, **v1.15.0**. Symbolic layer: 7 TLMs, ~125 concepts,
7/7 round-trip verify; typed action contracts across four domains. **xUnit 256 passing (+3 env-gated skipped).** Five demo
Research prototype with a production-shaped core, **v1.16.0**. Symbolic layer: 7 TLMs, ~125 concepts,
7/7 round-trip verify; typed action contracts across four domains. **xUnit 260 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 Expand Up @@ -266,7 +266,7 @@ Conventions follow PassGen: .NET 10, nullable + implicit usings, file-scoped nam
**Build & SDK.** Requires the **.NET 10 SDK** (10.0.2xx), pinned via [`global.json`](global.json)
(`rollForward: latestFeature`) for reproducible builds. The three libraries —
`IntentMesh.Tlm`, `IntentMesh.Core`, `IntentMesh.Integrations` — are packable (`dotnet pack -c Release`,
versioned at 1.8.0 with NuGet READMEs); demos, the web host, tools, and tests are not. Publishing to
versioned from `Directory.Build.props` with NuGet READMEs); demos, the web host, tools, and tests are not. Publishing to
nuget.org is a future decision — `dotnet pack` produces valid local packages today.

## License
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` — **256 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so.
true (`dotnet test IntentMesh.slnx` — **260 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
24 changes: 22 additions & 2 deletions src/IntentMesh.Core/Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,11 @@ public string Mint(string runId, string nodeId, string tenantId, long issuedAtUn
}

/// <summary>Verify a presented challenge against the run+tenant the caller is acting on. Returns the
/// attested node id only when the signature, type, run, tenant, and expiry all hold.</summary>
public bool TryVerify(string? token, string expectedRunId, string expectedTenant, long nowUnix, out string nodeId)
/// attested node id only when the signature, type, run, tenant, and expiry all hold. When a
/// <paramref name="ledger"/> is supplied the challenge is SINGLE-USE: its nonce is consumed on success,
/// so the same token can't trigger a repeated side effect within its TTL.</summary>
public bool TryVerify(string? token, string expectedRunId, string expectedTenant, long nowUnix,
out string nodeId, NonceLedger? ledger = null)
{
nodeId = "";
if (!SignedToken.TryDecode(token, _key, out var json)) return false;
Expand All @@ -155,11 +158,28 @@ public bool TryVerify(string? token, string expectedRunId, string expectedTenant
if (p is null || p.typ != "appr" || p.exp <= nowUnix) return false;
if (!string.Equals(p.run, expectedRunId, StringComparison.Ordinal)) return false;
if (!string.Equals(p.ten, expectedTenant, StringComparison.Ordinal)) return false;
// Consume the nonce LAST (after every other check passes) so a single-use challenge is burned only
// when it would actually be honored. A reused token fails here.
if (ledger is not null && !ledger.TryConsume(p.jti, p.exp, nowUnix)) return false;
nodeId = p.node;
return true;
}
}

/// <summary>A thread-safe single-use ledger of challenge nonces (jti). A nonce is accepted at most once;
/// expired entries are evicted opportunistically so memory stays bounded to live (unexpired) challenges.</summary>
public sealed class NonceLedger
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _seen = new();

public bool TryConsume(string jti, long expiresAtUnix, long nowUnix)
{
foreach (var kv in _seen) // evict expired (bounds memory to active challenges)
if (kv.Value <= nowUnix) _seen.TryRemove(kv.Key, out _);
return _seen.TryAdd(jti, expiresAtUnix); // false if this nonce was already consumed
}
}

/// <summary>Maps verified reverse-proxy / OIDC headers to a principal. The TRUST decision (is this
/// request actually from the configured proxy hop?) is made by the host before calling this; here we
/// only validate and shape the asserted identity.</summary>
Expand Down
9 changes: 9 additions & 0 deletions src/IntentMesh.Core/PolicyGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ public PolicyDecision Evaluate(IntentNode node, PolicyContext ctx)

if (node.Type == Kinds.DraftEmail && IsEmail(node.Action, out var to))
{
// Block BEFORE the adapter dereferences private note bodies into the draft: a draft whose body
// sources include a PRIVATE note is refused at the gate (not merely flagged as a postcondition
// failure after the private content was already pulled into the message).
if (node.Action is DraftEmailAction d
&& d.BodySourceRefs.Any(r => ctx.Workspace.Notes.Any(n => n.Id == r && n.Private)))
return new PolicyDecision(node.Id, Decision.Block, risk,
"Draft references a private note — blocked before any private content is dereferenced into the message.",
new[] { "pol-draft-private-ref" }, false, trust, sensitive, false, false);

// Check the recipient AT THE GATE, before the draft is created — not only as a postcondition.
// A draft to a recipient the user never named is gated for confirmation, even from a
// full-authority proposer (which could invent one).
Expand Down
37 changes: 27 additions & 10 deletions src/IntentMesh.Core/RunArtifactStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ public string Save(TraceBundle bundle)
/// signature-failing artifact.</summary>
private static void WriteAtomic(string path, string content)
{
var tmp = path + ".tmp";
// Unique temp name per write so concurrent writers (e.g. a save racing a prune) can't clobber each
// other's staging file — each stages to its own temp, then atomically renames into place.
var tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(tmp, content);
File.Move(tmp, path, overwrite: true);
}
Expand Down Expand Up @@ -188,7 +190,11 @@ public void RecordOwner(string runId, RunOwner owner, IAuditKeyProvider? signer
if (verifier is not null)
{
if (string.IsNullOrEmpty(owner.Signature)) return null;
var expected = AuditSigner.SignString(OwnerCanonical(owner), verifier);
// Verify under the key id the record was SIGNED with (resolved via the rotation-aware provider),
// not the provider's current key — so rotation doesn't invalidate older signed sidecars.
var key = AuditSigner.ResolveKey(owner.KeyId ?? verifier.KeyId, verifier);
if (key is null) return null;
var expected = AuditSigner.SignString(OwnerCanonical(owner), key);
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(owner.Signature)))
return null;
}
Expand Down Expand Up @@ -218,7 +224,10 @@ public void RecordExternalCall(string runId, string canonicalPayload, IAuditKeyP
if (rec is null) return null;
if (verifier is not null)
{
var expected = AuditSigner.SignString(rec.Payload, verifier);
// Verify under the recorded key id (rotation-aware), not the provider's current key.
var key = AuditSigner.ResolveKey(rec.KeyId, verifier);
if (key is null) return null;
var expected = AuditSigner.SignString(rec.Payload, key);
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(rec.Signature)))
return null;
}
Expand Down Expand Up @@ -255,15 +264,23 @@ public IReadOnlyList<RunSummary> ListSummaries()

/// <summary>Move a run's artifacts to a <c>.archive/</c> subdirectory (preserving verifiability),
/// so retention doesn't destroy the audit trail.</summary>
private static readonly object _archiveLock = new();
public void Archive(string runId)
{
var src = RunDir(runId);
if (!Directory.Exists(src)) return;
var archiveRoot = Path.Combine(_root, ".archive");
Directory.CreateDirectory(archiveRoot);
var dest = Path.Combine(archiveRoot, runId);
if (Directory.Exists(dest)) Directory.Delete(dest, true);
Directory.Move(src, dest);
// Serialize archiving (across store instances in the process) and NEVER delete an existing archive
// destination: a run is content-addressed, so a dest with the same id IS the same run. Racing
// prunes therefore drop the redundant live copy rather than risk deleting the only archived copy.
lock (_archiveLock)
{
var src = RunDir(runId);
if (!Directory.Exists(src)) return;
var archiveRoot = Path.Combine(_root, ".archive");
Directory.CreateDirectory(archiveRoot);
var dest = Path.Combine(archiveRoot, runId);
if (Directory.Exists(dest)) { Directory.Delete(src, true); return; } // already archived — drop the live dup
try { Directory.Move(src, dest); }
catch (IOException) when (Directory.Exists(dest)) { if (Directory.Exists(src)) Directory.Delete(src, true); }
}
}

/// <summary>Retention: keep the <paramref name="keepNewest"/> most-recent runs live and archive
Expand Down
3 changes: 2 additions & 1 deletion src/IntentMesh.Integrations/McpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public sealed class McpProxy
private readonly ApprovalChallengeService? _approvalService;
private readonly string _tenantId;
private readonly string _principalId;
private readonly NonceLedger _approvalLedger = new(); // approval challenges are single-use within TTL

/// <param name="runtime">
/// A loaded IntentMeshRuntime. The caller controls which capabilities are
Expand Down Expand Up @@ -249,7 +250,7 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet<string>? approvals = nu
var fingerprint = CallFingerprint(call);
var verified = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var token in effectiveApprovals)
if (_approvalService.TryVerify(token, fingerprint, _tenantId, now, out var approvedNode))
if (_approvalService.TryVerify(token, fingerprint, _tenantId, now, out var approvedNode, _approvalLedger))
verified.Add(approvedNode);
effectiveApprovals = verified;
}
Expand Down
9 changes: 7 additions & 2 deletions src/IntentMesh.Web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ FROM mcr.microsoft.com/dotnet/aspnet@sha256:ddcf70ad1ab963a4fcd41fbd722a6b660e40
WORKDIR /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 \
# Non-root user + the runs volume mount point (owned by that user), and curl for the HEALTHCHECK — the
# base aspnet image ships neither wget nor curl, so the probe tool must be installed explicitly.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -u 10001 -m app \
&& mkdir -p /data/runs \
&& chown -R 10001:10001 /data/runs
COPY --from=build /app .
Expand All @@ -39,6 +44,6 @@ VOLUME ["/data/runs"]
# 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
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/readyz || exit 1

ENTRYPOINT ["dotnet", "IntentMesh.Web.dll"]
Loading
Loading