diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20ca13c..f4cc8be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c509484..c400a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/Directory.Build.props b/Directory.Build.props index c53269c..46659fe 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.14.1 + 1.15.0 Chad Sandor wyckit IntentMesh @@ -18,7 +18,7 @@ MIT false - v1.14.1 — per-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. + 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. true diff --git a/README.md b/README.md index d94042e..7b84c07 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 # 251 passing (+3 env-gated skipped) +dotnet test IntentMesh.slnx # 256 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). **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 @@ -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: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b0e21f3..1bfa8c7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/docs/MATURITY.md b/docs/MATURITY.md index ce091f1..859aa07 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` — **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, diff --git a/src/IntentMesh.Core/RunArtifactStore.cs b/src/IntentMesh.Core/RunArtifactStore.cs index 19c2b4c..faf117e 100644 --- a/src/IntentMesh.Core/RunArtifactStore.cs +++ b/src/IntentMesh.Core/RunArtifactStore.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace IntentMesh.Core; @@ -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. public const string OwnerFile = "owner.json"; + /// Signed record of the exact external call forwarded for this run (e.g. an MCP payload). + public const string ExternalCallFile = "external.call.json"; private static readonly JsonSerializerOptions OwnerJson = new() { PropertyNameCaseInsensitive = true }; private readonly string _root; @@ -46,9 +50,11 @@ public FileRunArtifactStore(string root) Directory.CreateDirectory(_root); } - /// Deterministic run id: the first 16 hex of the bundle signature. + /// Deterministic content-address: the first 32 hex (128-bit) of the bundle signature — long + /// enough that a collision between two DIFFERENT runs is negligible ( still fails + /// closed on the astronomically-unlikely collision rather than overwriting a different run). public static string RunIdOf(TraceBundle bundle) - => bundle.BundleSignature[..Math.Min(16, bundle.BundleSignature.Length)]; + => bundle.BundleSignature[..Math.Min(32, bundle.BundleSignature.Length)]; /// A run id is 1–64 hex characters — exactly the shape produces. By /// construction it has no path separator, drive, or .., so it can never escape the runs root. @@ -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; @@ -149,18 +165,64 @@ private bool VerifyArtifacts(string runId, TraceBundle bundle, bool signatureVal return File.Exists(path) ? File.ReadAllText(path) : null; } - /// Record the principal/tenant that created a run (an access-control sidecar, not part of - /// the signed bundle). Written atomically into the run directory. - public void RecordOwner(string runId, RunOwner owner) - => WriteAtomic(Path.Combine(RunDir(runId), OwnerFile), JsonSerializer.Serialize(owner, OwnerJson)); + /// Record the principal/tenant that created a run. When a is given, + /// the record is HMAC-signed so tampering with the (otherwise plain) sidecar is detectable on read. + 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)); + } - /// Read a run's ownership sidecar, or null if none was recorded. - public RunOwner? ReadOwner(string runId) + /// Read a run's ownership sidecar, or null if none was recorded. When a + /// is given, an unsigned or tamper-detected record returns null (the + /// ownership claim is rejected rather than trusted). + 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(File.ReadAllText(path), OwnerJson); } + RunOwner? owner; + try { owner = JsonSerializer.Deserialize(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; + } + + /// The fields the owner signature covers (everything but the signature itself). + private static string OwnerCanonical(RunOwner o) => $"{o.PrincipalId}\n{o.TenantId}\n{o.CreatedAtUnix}"; + + /// 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. + 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)); + } + + /// Read the signed external-call record, or null if none / signature invalid under the verifier. + 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(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 List() @@ -221,8 +283,14 @@ public IReadOnlyList Prune(int keepNewest) } /// 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. -public sealed record RunOwner(string PrincipalId, string TenantId, long CreatedAtUnix); +/// Used for tenant-scoped access control and audit display. Optionally HMAC-signed ( +/// under ) so a tamper of this otherwise-plain sidecar is detectable on read. +public sealed record RunOwner(string PrincipalId, string TenantId, long CreatedAtUnix, + string? Signature = null, string? KeyId = null); + +/// 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. +public sealed record ExternalCallRecord(string Payload, string Signature, string KeyId); /// 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). diff --git a/src/IntentMesh.Integrations/McpProxy.cs b/src/IntentMesh.Integrations/McpProxy.cs index 727adfa..01486e8 100644 --- a/src/IntentMesh.Integrations/McpProxy.cs +++ b/src/IntentMesh.Integrations/McpProxy.cs @@ -111,6 +111,7 @@ public sealed class McpProxy private readonly IAuditKeyProvider? _auditKeyProvider; private readonly ApprovalChallengeService? _approvalService; private readonly string _tenantId; + private readonly string _principalId; /// /// A loaded IntentMeshRuntime. The caller controls which capabilities are @@ -139,10 +140,12 @@ public sealed class McpProxy /// token with and pass it as the approval. Supplying approvals /// without this service throws (a gated call simply stays blocked if nothing is approved). /// Tenant the proxy acts for (binds approval challenges + audit ownership). + /// The principal recorded as the run owner — distinct from the tenant (the + /// actor identity, e.g. the connected client/service), defaulting to mcp-proxy. public McpProxy(IntentMeshRuntime runtime, Workspace workspace, string? allowedRoot = null, Func? customMapper = null, IRunArtifactStore? auditStore = null, IAuditKeyProvider? auditKeyProvider = null, - ApprovalChallengeService? approvalService = null, string? tenantId = null) + ApprovalChallengeService? approvalService = null, string? tenantId = null, string? principalId = null) { _runtime = runtime; _workspace = workspace; @@ -152,20 +155,27 @@ public McpProxy(IntentMeshRuntime runtime, Workspace workspace, string? allowedR _auditKeyProvider = auditKeyProvider; _approvalService = approvalService; _tenantId = tenantId ?? "default"; + _principalId = principalId ?? "mcp-proxy"; 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) + /// The canonical, key-sorted payload of a call (tool + args) — the exact bytes bound into the + /// signed external-call record (so data fields the typed action doesn't model, e.g. file content or an + /// email body, are still part of the tamper-evident audit). + private static string CanonicalPayload(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(); + return call.Tool + "\n" + JsonSerializer.Serialize(sorted); } + /// 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) + => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(CanonicalPayload(call)))).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. @@ -294,15 +304,24 @@ public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client, return new McpForwardResult(gate, ServerResponse: null); } + // Compute the NORMALIZED forward call FIRST so the EXACT bytes that will be sent (including data + // fields the typed action doesn't model — file content, edits, email body) are bound into the + // signed audit, not just the typed decision. + var forwardCall = NormalizeForForward(call); + // 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. + // approved gate decision, a signed owner record, and the signed exact forwarded payload. 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 complete signed record. try { var bundle = TraceBundleBuilder.From(gate.RunResult, (gate.AppliedApprovals ?? Array.Empty()).ToList(), _auditKeyProvider); var runId = _auditStore.Save(bundle); if (_auditStore is FileRunArtifactStore fs) - fs.RecordOwner(runId, new RunOwner(_tenantId, _tenantId, DateTimeOffset.UtcNow.ToUnixTimeSeconds())); + { + fs.RecordOwner(runId, new RunOwner(_principalId, _tenantId, DateTimeOffset.UtcNow.ToUnixTimeSeconds()), _auditKeyProvider); + fs.RecordExternalCall(runId, CanonicalPayload(forwardCall), _auditKeyProvider); + } progress?.Report($"audited {call.Tool} (run {runId})"); } catch (Exception ex) @@ -313,10 +332,6 @@ public McpForwardResult GateAndForward(McpToolCall call, IMcpClient client, 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). - var forwardCall = NormalizeForForward(call); progress?.Report($"forwarding {call.Tool}"); var response = ForwardToRealMcpServer(forwardCall, client); progress?.Report($"forwarded {call.Tool}"); diff --git a/src/IntentMesh.Integrations/OpenApiImporter.cs b/src/IntentMesh.Integrations/OpenApiImporter.cs index 263ed3b..8068228 100644 --- a/src/IntentMesh.Integrations/OpenApiImporter.cs +++ b/src/IntentMesh.Integrations/OpenApiImporter.cs @@ -205,9 +205,15 @@ public static ImportedContract ToContract(ToolSchema schema, bool trusted = fals ? schema.SideEffectHint.ToLowerInvariant() : InferSideEffect(schema.Name, schema.Summary, schema.Method); - // SSRF-of-confirmation guard: an untrusted spec can't claim a mutating op is side-effect-free. - if (mutating && !trusted && sideEffect == "none") - sideEffect = InferSideEffect(schema.Name, schema.Summary, schema.Method); + // SSRF-of-confirmation guard: an untrusted spec can't claim a SIDE-EFFECTING op is side-effect-free + // — regardless of the HTTP verb. If the hint says "none" but semantics (name/summary) or the verb + // infer a real side effect, the inference wins for an untrusted spec, so a side-effecting GET/HEAD + // can't suppress confirmation via SideEffectHint:"none". A trusted spec keeps full control. + if (!trusted && sideEffect == "none") + { + var inferred = InferSideEffect(schema.Name, schema.Summary, schema.Method); + if (inferred != "none") sideEffect = inferred; + } // Resolve risk: caller hint wins; otherwise infer from method AND the semantic side effect. var risk = !string.IsNullOrWhiteSpace(schema.RiskHint) diff --git a/src/IntentMesh.Web/Program.cs b/src/IntentMesh.Web/Program.cs index 7fbd214..b6ae675 100644 --- a/src/IntentMesh.Web/Program.cs +++ b/src/IntentMesh.Web/Program.cs @@ -51,10 +51,17 @@ IntentMeshRuntime runtime; try { - string compiled; + // Prefer a dataset/compiled dir if one is present (dev), else fall back to the bundle EMBEDDED in + // IntentMesh.Core — so a published/containerized host with no dataset/ on disk still starts (the + // image is self-contained, as the Dockerfile states). Only a genuine load failure is fatal. + string? compiled = null; try { compiled = DatasetLocator.FindCompiledDir(); } - catch { compiled = DatasetLocator.FindCompiledDir(AppContext.BaseDirectory); } - runtime = IntentMeshRuntime.Load(compiled); + catch + { + try { compiled = DatasetLocator.FindCompiledDir(AppContext.BaseDirectory); } + catch { compiled = null; } + } + runtime = IntentMeshRuntime.Load(compiled); // null => embedded bundle } catch (Exception ex) { @@ -69,6 +76,10 @@ ?? Path.Combine(Directory.GetCurrentDirectory(), "runs"); Directory.CreateDirectory(runsDir); +// Retention guard: after each persisted run, keep at most this many LIVE runs PER TENANT (older ones are +// archived) so distributed clients can't grow the live set without bound. 0 disables. Default 1000. +var runsKeep = int.TryParse(Environment.GetEnvironmentVariable("INTENTMESH_RUNS_KEEP"), out var rk) && rk >= 0 ? rk : 1000; + // The audit key provider, rotation-aware: prior keys (INTENTMESH_AUDIT_PRIOR_KEYS) let runs signed // before a key rotation still verify/replay. Sign and verify go through THIS provider so a bundle's // recorded key id always resolves to the key that produced it. @@ -123,14 +134,15 @@ 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) +// Production safety #2b: trusted-proxy mode in Production MUST require a shared proxy secret of real +// strength (>=16 chars) — otherwise asserted X-Auth-* headers would be trusted from any loopback-presenting +// source, or a trivially-guessable secret would. +if (app.Environment.IsProduction() && trustedProxy && (proxySecret is null || proxySecret.Length < 16) && Environment.GetEnvironmentVariable("INTENTMESH_ALLOW_INSECURE_AUTH") != "1") { Console.Error.WriteLine( - "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."); + "FATAL: trusted-proxy mode in Production requires INTENTMESH_PROXY_SECRET (>=16 chars) — the upstream proxy must " + + "present it as X-Proxy-Secret so injected X-Auth-* headers are trusted only from the real proxy hop."); return; } @@ -146,6 +158,18 @@ return; } +// Production safety #3b: the dedicated auth key must DIFFER from the audit key — sharing one key means a +// leaked audit key forges session tokens + approval challenges (and vice versa). Compare the parsed bytes +// (so base64-vs-utf8 spellings of the same key are caught). +if (app.Environment.IsProduction() && realAuthConfigured && authKeyRaw is not null + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(effectiveAuthKey, keyProvider.GetKey()) + && Environment.GetEnvironmentVariable("INTENTMESH_ALLOW_INSECURE_AUTH") != "1") +{ + Console.Error.WriteLine( + "FATAL: INTENTMESH_AUTH_KEY must differ from INTENTMESH_AUDIT_KEY — auth/approval signing must not share the audit key."); + return; +} + var demos = new[] { new { id = 1, title = "Friday planning", prompt = "Plan my Friday, move anything flexible, book an hour for the gym, and draft Sarah the meeting notes." }, @@ -322,13 +346,14 @@ try { runId = store.Save(TraceBundleBuilder.From(result, new List(), keyProvider)); - store.RecordOwner(runId, new RunOwner(p.PrincipalId, p.TenantId, NowUnix())); + store.RecordOwner(runId, new RunOwner(p.PrincipalId, p.TenantId, NowUnix()), keyProvider); // signed owner } catch (Exception ex) { Console.Error.WriteLine($"ERROR: could not persist run: {ex}"); return Results.Json(new { error = "run could not be durably persisted" }, statusCode: 503); } + Retain(store); // enforce the per-tenant live-run cap (best effort; the run is already persisted) http.Response.Headers["X-Run-Id"] = runId; if (req.Approvals is { Length: > 0 }) @@ -475,13 +500,14 @@ try { newId = store.Save(TraceBundleBuilder.From(result, approved.ToList(), keyProvider)); - store.RecordOwner(newId, new RunOwner(p.PrincipalId, p.TenantId, now)); + store.RecordOwner(newId, new RunOwner(p.PrincipalId, p.TenantId, now), keyProvider); // signed owner } catch (Exception ex) { Console.Error.WriteLine($"ERROR: could not persist approved run: {ex}"); return Results.Json(new { error = "approved run could not be durably persisted" }, statusCode: 503); } + Retain(store); // enforce the per-tenant live-run cap http.Response.Headers["X-Run-Id"] = newId; return Results.Json(result); }); @@ -546,8 +572,12 @@ static bool ConstTimeEq(string? a, string b) : remote is null || IPAddress.IsLoopback(remote)); if (proxyTrusted) { + // Use the LAST X-Forwarded-For hop — the value stamped by the trusted proxy (the client as IT saw + // it). The leftmost entries are client-supplied and forgeable, so a direct client can't rotate a + // spoofed prefix to mint fresh rate-limit buckets. (Contract: a single trusted proxy that appends + // or sets XFF to the real client — see docs/DEPLOYMENT.md.) var fwd = c.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); + if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[^1].Trim(); } return remote?.ToString(); } @@ -555,6 +585,15 @@ static bool ConstTimeEq(string? a, string b) static bool CanRead(AuthPrincipal p) => p.Has(Roles.Viewer) || p.Has(Roles.Operator) || p.Has(Roles.Approver); // admin covered by Has +// Retention guard: keep at most runsKeep live runs in this tenant's store (older are archived). Best +// effort — a prune failure must not fail an already-persisted run. +void Retain(FileRunArtifactStore store) +{ + if (runsKeep <= 0) return; + try { store.Prune(runsKeep); } + catch (Exception ex) { Console.Error.WriteLine($"WARN: retention prune failed: {ex.Message}"); } +} + // Per-tenant run store: each tenant's runs live under {runsDir}/t/{tenant} — isolation by construction. // Defense-in-depth: the tenant id is already validated when the principal is resolved, but re-validate // here and confirm the resolved directory stays under the runs root before constructing the store, so a diff --git a/tests/IntentMesh.Tests/IntegrationTests.cs b/tests/IntentMesh.Tests/IntegrationTests.cs index a750fab..a013b36 100644 --- a/tests/IntentMesh.Tests/IntegrationTests.cs +++ b/tests/IntentMesh.Tests/IntegrationTests.cs @@ -688,6 +688,52 @@ public void OpenApiImporter_ToContract_side_effecting_get_still_requires_confirm "a side-effecting GET must still be gated — confirmation keys on side effect, not verb."); } + /// An UNTRUSTED spec cannot suppress confirmation for a side-effecting GET/HEAD via + /// SideEffectHint:"none" — the semantic inference overrides the hint. A TRUSTED spec keeps control. + [Fact] + public void OpenApiImporter_untrusted_none_hint_cannot_suppress_a_side_effecting_get() + { + var schema = new ToolSchema( + Name: "send_reminder", Method: "GET", Summary: "Send a reminder email.", + Parameters: new[] { "id" }, SideEffectHint: "none"); + + var untrusted = OpenApiImporter.ToContract(schema); + Assert.NotEqual("none", untrusted.SideEffect); + Assert.True(untrusted.RequiresConfirmation); + + var trusted = OpenApiImporter.ToContract(schema, trusted: true); + Assert.Equal("none", trusted.SideEffect); + Assert.False(trusted.RequiresConfirmation); + } + + /// The MCP pre-forward audit binds the EXACT forwarded payload (signed), including data fields + /// the typed action doesn't model — e.g. write_file.content — so what was sent is tamper-evident. + [Fact] + public void Mcp_forward_records_the_signed_exact_payload_including_content() + { + var root = TempRoot(); + var auditDir = TempRoot(); + try + { + var store = new FileRunArtifactStore(auditDir); + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + auditStore: store, auditKeyProvider: McpTestKeyProvider, approvalService: NewApprovalService(), tenantId: "acme"); + var write = new McpToolCall("write_file", new Dictionary + { ["path"] = Path.Combine(root, "out.txt"), ["content"] = "TOP-SECRET-DATA" }); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var token = proxy.MintApprovalChallenge(write, now, now + 300, "n"); + + var fwd = proxy.GateAndForward(write, new FakeMcpClient(() => "ok"), new HashSet { token }); + Assert.True(fwd.Gate.Allowed); + + var id = store.List().Single(); + var ext = store.ReadExternalCall(id, McpTestKeyProvider); // verifies the signature under the audit key + Assert.NotNull(ext); + Assert.Contains("TOP-SECRET-DATA", ext!.Payload); // content (absent from the typed action) is bound + signed + } + finally { Directory.Delete(root, true); Directory.Delete(auditDir, true); } + } + /// /// A DELETE without a risk hint infers Risk=high from the method. /// diff --git a/tests/IntentMesh.Tests/PersistenceTests.cs b/tests/IntentMesh.Tests/PersistenceTests.cs index d97592f..08f3e93 100644 --- a/tests/IntentMesh.Tests/PersistenceTests.cs +++ b/tests/IntentMesh.Tests/PersistenceTests.cs @@ -22,6 +22,42 @@ private static string TempRoot() return root; } + [Fact] + public void Run_id_is_a_128_bit_content_address_and_resave_is_idempotent() + { + var root = TempRoot(); + try + { + var store = new FileRunArtifactStore(root); + var bundle = TraceBundleBuilder.From(Runtime().Run(Prompt, Workspace.CreateDemo())); + var id = store.Save(bundle); + Assert.Equal(32, id.Length); // 128-bit content address (was 64-bit/16 hex) + Assert.Equal(id, store.Save(bundle)); // same content → idempotent, no collision throw + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public void A_signed_owner_sidecar_detects_tampering_under_verification() + { + var root = TempRoot(); + try + { + var store = new FileRunArtifactStore(root); + var key = new FixedKeyProvider("test", System.Text.Encoding.UTF8.GetBytes("owner-test-key-0123456789abcdef")); + var id = store.Save(TraceBundleBuilder.From(Runtime().Run(Prompt, Workspace.CreateDemo()))); + store.RecordOwner(id, new RunOwner("alice", "acme", 1700000000), key); + + Assert.NotNull(store.ReadOwner(id, key)); // intact → verifies + Assert.Equal("alice", store.ReadOwner(id, key)!.PrincipalId); + + var ownerPath = Path.Combine(root, id, FileRunArtifactStore.OwnerFile); + File.WriteAllText(ownerPath, File.ReadAllText(ownerPath).Replace("alice", "mallory")); + Assert.Null(store.ReadOwner(id, key)); // tampered → rejected under verification + } + finally { Directory.Delete(root, true); } + } + [Theory] [InlineData("../escape")] [InlineData("..\\escape")] diff --git a/tests/IntentMesh.Tests/WebTests.cs b/tests/IntentMesh.Tests/WebTests.cs index 08d209f..46e04a6 100644 --- a/tests/IntentMesh.Tests/WebTests.cs +++ b/tests/IntentMesh.Tests/WebTests.cs @@ -103,6 +103,25 @@ public async Task An_oversized_api_body_is_rejected() finally { Cleanup(f, runs); } } + [Fact] + public async Task Retention_caps_live_runs_per_tenant() + { + Environment.SetEnvironmentVariable("INTENTMESH_WEB_TOKEN", null); + Environment.SetEnvironmentVariable("INTENTMESH_RUNS_KEEP", "1"); + var (f, runs) = Make(); + try + { + var c = f.CreateClient(); + // Two DISTINCT prompts → two content-addressed runs; with KEEP=1 the older is archived. + (await c.PostAsJsonAsync("/api/run", new { prompt = "Plan my Friday, move anything flexible, book an hour for the gym, and draft Sarah the meeting notes." })).EnsureSuccessStatusCode(); + (await c.PostAsJsonAsync("/api/run", new { prompt = "Clean up my downloads and delete anything that looks like junk." })).EnsureSuccessStatusCode(); + + var history = await c.GetFromJsonAsync("/api/runs"); + Assert.Single(history!); // only the newest live run remains + } + finally { Environment.SetEnvironmentVariable("INTENTMESH_RUNS_KEEP", null); Cleanup(f, runs); } + } + [Fact] public async Task A_run_persists_and_appears_in_history() {