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
27 changes: 25 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ jobs:
runs-on: ubuntu-24.04 # pinned runner image for reproducibility
permissions:
contents: read # build/test/pack run with NO write scopes — provenance signing is a separate job
env:
# Opt-in NuGet signing: true only when a code-signing cert secret is configured (evaluated here so
# the step `if:` doesn't reference secrets directly).
HAS_SIGN_CERT: ${{ secrets.NUGET_SIGN_CERT_BASE64 != '' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

Expand Down Expand Up @@ -74,14 +78,33 @@ jobs:
echo "::error::Vulnerable packages detected"; exit 1
fi

- name: Pack (release libraries) + validate + checksums
- name: Pack (release libraries) + validate
run: |
dotnet pack IntentMesh.slnx -c Release --no-build -o artifacts/pack
ls -1 artifacts/pack/*.nupkg
for pkg in IntentMesh.Tlm IntentMesh.Core IntentMesh.Integrations; do
ls artifacts/pack/$pkg.*.nupkg >/dev/null 2>&1 || { echo "::error::missing package $pkg"; exit 1; }
done
( cd artifacts/pack && sha256sum *.nupkg > SHA256SUMS && cat SHA256SUMS )

# Cryptographic NuGet signing is opt-in: it runs ONLY when a code-signing certificate is provided via
# the NUGET_SIGN_CERT_BASE64 / NUGET_SIGN_CERT_PASSWORD secrets. Without a cert the packages ship
# unsigned (provenance attestation + SHA256SUMS still apply). Signing happens BEFORE checksums so the
# published SHA256SUMS cover the final bytes.
- name: Sign packages (opt-in; requires a code-signing cert secret)
if: ${{ env.HAS_SIGN_CERT == 'true' }}
env:
CERT_B64: ${{ secrets.NUGET_SIGN_CERT_BASE64 }}
CERT_PWD: ${{ secrets.NUGET_SIGN_CERT_PASSWORD }}
run: |
echo "$CERT_B64" | base64 -d > "$RUNNER_TEMP/signcert.pfx"
for pkg in artifacts/pack/*.nupkg; do
dotnet nuget sign "$pkg" --certificate-path "$RUNNER_TEMP/signcert.pfx" \
--certificate-password "$CERT_PWD" --timestamper http://timestamp.digicert.com
done
rm -f "$RUNNER_TEMP/signcert.pfx"

- name: Checksums (over the final, possibly-signed packages)
run: ( cd artifacts/pack && sha256sum *.nupkg > SHA256SUMS && cat SHA256SUMS )

- name: Upload packages + checksums
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md)
for the production-ready / experimental / future breakdown.

## 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.**

High:
- **Container startup uses the embedded bundle.** The web host now falls back to the bundle embedded in
`IntentMesh.Core` when no `dataset/compiled` dir is on disk — a published/containerized host starts
self-contained (as the Dockerfile states) instead of exiting fatally.
- **MCP audit binds the exact forwarded payload.** Alongside the signed bundle, `GateAndForward` persists
a **signed `external.call.json`** recording the precise normalized JSON-RPC payload sent — including data
fields the typed action doesn't model (`write_file.content`, `edit_file.edits`, `search_files.pattern`,
email body). Owner records are now HMAC-signed and use a distinct principal (not tenant==principal).
- **Production auth hardening.** `INTENTMESH_AUTH_KEY` must DIFFER from `INTENTMESH_AUDIT_KEY`;
`INTENTMESH_PROXY_SECRET` must be ≥16 chars in Production; and the rate-limit client key uses the
**last** `X-Forwarded-For` hop (stamped by the trusted proxy), not the spoofable leftmost.

Medium:
- **Untrusted `SideEffectHint:"none"` can't suppress a side-effecting GET/HEAD** — inference overrides the
hint for untrusted specs (trusted specs keep control).
- **128-bit run ids + collision-fail.** Run ids are now 32 hex; `Save` fails closed if a different signed
bundle already exists at an id (idempotent re-save still allowed).
- **Signed, tamper-evident owner sidecar** (see above) + distinct MCP principal.
- **Retention enforced by the web host.** After each persist, the per-tenant live-run set is capped to
`INTENTMESH_RUNS_KEEP` (default 1000; older runs archived) so clients can't grow it without bound.
- **Opt-in NuGet signing wired in CI** — signs the packages when a `NUGET_SIGN_CERT_BASE64` /
`NUGET_SIGN_CERT_PASSWORD` secret is configured (checksums computed after signing); ships unsigned
otherwise (provenance attestation + SHA256SUMS still apply). Cryptographic signing still needs you to
supply a certificate.
- **Doc/version drift fixed** — README status reflects the current version; the stale `INTENTMESH_WEB_TOKEN`
reverse-proxy guidance is replaced with the token/proxy-mode contract.

## v1.14.1 — Per-tool MCP forward-arg allowlist (all built-ins)

Extends (and corrects) the v1.14.0 forward-arg stripping. **251 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.14.1</Version>
<Version>1.15.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.14.1per-tool MCP forward-arg allowlist for ALL built-in tools (send_email/run_command/read_calendar + the full filesystem tool set), replacing the v1.14.0 fs key-set that over-stripped richer tools (edit_file/search_files/etc.). See CHANGELOG.md.</PackageReleaseNotes>
<PackageReleaseNotes>v1.15.0eighth 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>

<!-- Reproducible restore: lock files are honored in CI via locked-mode restore. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Expand Down
8 changes: 4 additions & 4 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 # 251 passing (+3 env-gated skipped)
dotnet test IntentMesh.slnx # 256 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). **251 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.**
operator workflow, audit operations). **256 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.8.0**. Symbolic layer: 7 TLMs, ~125 concepts,
7/7 round-trip verify; typed action contracts across four domains. **xUnit 251 passing (+3 env-gated skipped).** Five demo
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
scenarios. See [docs/MATURITY.md](docs/MATURITY.md) for the proven / experimental / future breakdown.
Delivered beyond v0.1:

Expand Down
6 changes: 4 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ Mode A (token) or Mode B (proxy), which give per-principal identity and real app
- Terminate **TLS at a reverse proxy** (nginx/Caddy/cloud LB) in front of the host; the app speaks
plain HTTP on `:8080` inside the trust boundary.
- The proxy must set `Host` to your configured `AllowedHosts` value and forward to the container.
- Send the `INTENTMESH_WEB_TOKEN` as `X-Api-Token` (or `Authorization: Bearer`) — the app rejects
non-loopback `/api` calls without it.
- Authenticate per the mode you configured above: forward the bearer **session token** (`X-Api-Token` /
`Authorization: Bearer`) for token mode, or the verified `X-Auth-*` headers + `X-Proxy-Secret` for
trusted-proxy mode. The proxy must **strip client-supplied `X-Auth-*` / `X-Proxy-Secret` / `X-Forwarded-For`**
headers. (`INTENTMESH_WEB_TOKEN` is dev-only and is not a production boundary — see Authentication above.)

## Health checks

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` — **251 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so.
true (`dotnet test IntentMesh.slnx` — **256 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
92 changes: 80 additions & 12 deletions src/IntentMesh.Core/RunArtifactStore.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace IntentMesh.Core;
Expand Down Expand Up @@ -37,6 +39,8 @@ public sealed class FileRunArtifactStore : IRunArtifactStore
/// access-control record (who created the run), not part of the tamper-evident audit, so writing it
/// does not change the bundle signature or the audit schema.</summary>
public const string OwnerFile = "owner.json";
/// <summary>Signed record of the exact external call forwarded for this run (e.g. an MCP payload).</summary>
public const string ExternalCallFile = "external.call.json";
private static readonly JsonSerializerOptions OwnerJson = new() { PropertyNameCaseInsensitive = true };
private readonly string _root;

Expand All @@ -46,9 +50,11 @@ public FileRunArtifactStore(string root)
Directory.CreateDirectory(_root);
}

/// <summary>Deterministic run id: the first 16 hex of the bundle signature.</summary>
/// <summary>Deterministic content-address: the first 32 hex (128-bit) of the bundle signature — long
/// enough that a collision between two DIFFERENT runs is negligible (<see cref="Save"/> still fails
/// closed on the astronomically-unlikely collision rather than overwriting a different run).</summary>
public static string RunIdOf(TraceBundle bundle)
=> bundle.BundleSignature[..Math.Min(16, bundle.BundleSignature.Length)];
=> bundle.BundleSignature[..Math.Min(32, bundle.BundleSignature.Length)];

/// <summary>A run id is 1–64 hex characters — exactly the shape <see cref="RunIdOf"/> produces. By
/// construction it has no path separator, drive, or <c>..</c>, so it can never escape the runs root.</summary>
Expand Down Expand Up @@ -81,8 +87,18 @@ public string Save(TraceBundle bundle)
{
var id = RunIdOf(bundle);
var dir = RunDir(id);
var bundlePath = Path.Combine(dir, BundleFile);
if (File.Exists(bundlePath))
{
// Content-addressed: the same id MUST mean the same bundle. Re-saving an identical run is
// idempotent; a different signature under the same id is a collision — fail closed rather than
// overwrite a distinct run's audit record.
var existing = TraceBundleBuilder.FromJson(File.ReadAllText(bundlePath));
if (!string.Equals(existing.BundleSignature, bundle.BundleSignature, StringComparison.Ordinal))
throw new InvalidOperationException($"Run id '{id}' collision: a different signed bundle already exists at this id.");
}
Directory.CreateDirectory(dir);
WriteAtomic(Path.Combine(dir, BundleFile), TraceBundleBuilder.ToJson(bundle));
WriteAtomic(bundlePath, TraceBundleBuilder.ToJson(bundle));
foreach (var (name, json) in TraceBundleBuilder.SplitFiles(bundle)) // derived exports
WriteAtomic(Path.Combine(dir, name), json);
return id;
Expand Down Expand Up @@ -149,18 +165,64 @@ private bool VerifyArtifacts(string runId, TraceBundle bundle, bool signatureVal
return File.Exists(path) ? File.ReadAllText(path) : null;
}

/// <summary>Record the principal/tenant that created a run (an access-control sidecar, not part of
/// the signed bundle). Written atomically into the run directory.</summary>
public void RecordOwner(string runId, RunOwner owner)
=> WriteAtomic(Path.Combine(RunDir(runId), OwnerFile), JsonSerializer.Serialize(owner, OwnerJson));
/// <summary>Record the principal/tenant that created a run. When a <paramref name="signer"/> is given,
/// the record is HMAC-signed so tampering with the (otherwise plain) sidecar is detectable on read.</summary>
public void RecordOwner(string runId, RunOwner owner, IAuditKeyProvider? signer = null)
{
if (signer is not null)
owner = owner with { Signature = AuditSigner.SignString(OwnerCanonical(owner), signer), KeyId = signer.KeyId };
WriteAtomic(Path.Combine(RunDir(runId), OwnerFile), JsonSerializer.Serialize(owner, OwnerJson));
}

/// <summary>Read a run's ownership sidecar, or null if none was recorded.</summary>
public RunOwner? ReadOwner(string runId)
/// <summary>Read a run's ownership sidecar, or null if none was recorded. When a
/// <paramref name="verifier"/> is given, an unsigned or tamper-detected record returns null (the
/// ownership claim is rejected rather than trusted).</summary>
public RunOwner? ReadOwner(string runId, IAuditKeyProvider? verifier = null)
{
var path = Path.Combine(RunDir(runId), OwnerFile);
if (!File.Exists(path)) return null;
try { return JsonSerializer.Deserialize<RunOwner>(File.ReadAllText(path), OwnerJson); }
RunOwner? owner;
try { owner = JsonSerializer.Deserialize<RunOwner>(File.ReadAllText(path), OwnerJson); }
catch (JsonException) { return null; }
if (owner is null) return null;
if (verifier is not null)
{
if (string.IsNullOrEmpty(owner.Signature)) return null;
var expected = AuditSigner.SignString(OwnerCanonical(owner), verifier);
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(owner.Signature)))
return null;
}
return owner;
}

/// <summary>The fields the owner signature covers (everything but the signature itself).</summary>
private static string OwnerCanonical(RunOwner o) => $"{o.PrincipalId}\n{o.TenantId}\n{o.CreatedAtUnix}";

/// <summary>Record the EXACT external call forwarded to a downstream server (e.g. an MCP JSON-RPC
/// payload), HMAC-signed with the audit key — so the precise bytes sent (including data fields the
/// typed action doesn't model, like file content or an email body) are bound to the run, tamper-evident.</summary>
public void RecordExternalCall(string runId, string canonicalPayload, IAuditKeyProvider signer)
{
var rec = new ExternalCallRecord(canonicalPayload, AuditSigner.SignString(canonicalPayload, signer), signer.KeyId);
WriteAtomic(Path.Combine(RunDir(runId), ExternalCallFile), JsonSerializer.Serialize(rec, OwnerJson));
}

/// <summary>Read the signed external-call record, or null if none / signature invalid under the verifier.</summary>
public ExternalCallRecord? ReadExternalCall(string runId, IAuditKeyProvider? verifier = null)
{
var path = Path.Combine(RunDir(runId), ExternalCallFile);
if (!File.Exists(path)) return null;
ExternalCallRecord? rec;
try { rec = JsonSerializer.Deserialize<ExternalCallRecord>(File.ReadAllText(path), OwnerJson); }
catch (JsonException) { return null; }
if (rec is null) return null;
if (verifier is not null)
{
var expected = AuditSigner.SignString(rec.Payload, verifier);
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(rec.Signature)))
return null;
}
return rec;
}

public IReadOnlyList<string> List()
Expand Down Expand Up @@ -221,8 +283,14 @@ public IReadOnlyList<string> Prune(int keepNewest)
}

/// <summary>Ownership record for a persisted run: the principal and tenant that created it, and when.
/// Used for tenant-scoped access control and audit display — not part of the signed bundle.</summary>
public sealed record RunOwner(string PrincipalId, string TenantId, long CreatedAtUnix);
/// Used for tenant-scoped access control and audit display. Optionally HMAC-signed (<see cref="Signature"/>
/// under <see cref="KeyId"/>) so a tamper of this otherwise-plain sidecar is detectable on read.</summary>
public sealed record RunOwner(string PrincipalId, string TenantId, long CreatedAtUnix,
string? Signature = null, string? KeyId = null);

/// <summary>Signed record of the exact external (e.g. MCP JSON-RPC) call forwarded for a run — the
/// canonical payload bytes plus an HMAC over them, so what was actually sent is bound to the run.</summary>
public sealed record ExternalCallRecord(string Payload, string Signature, string KeyId);

/// <summary>A compact, inspectable summary of one persisted run — the row an operator history view
/// renders. Built from the persisted bundle (prompt, decision counts, signing key id, approvals).</summary>
Expand Down
Loading
Loading