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
31 changes: 22 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ jobs:
build-test:
runs-on: ubuntu-24.04 # pinned runner image for reproducibility
permissions:
contents: read
id-token: write # for build-provenance attestation
attestations: write
contents: read # build/test/pack run with NO write scopes — provenance signing is a separate job
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

Expand Down Expand Up @@ -83,12 +81,6 @@ jobs:
done
( cd artifacts/pack && sha256sum *.nupkg > SHA256SUMS && cat SHA256SUMS )

- name: Attest build provenance for the packages
if: github.event_name == 'push' # attestation requires write tokens (not available on fork PRs)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: 'artifacts/pack/*.nupkg'

- name: Upload packages + checksums
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
Expand All @@ -97,3 +89,24 @@ jobs:
artifacts/pack/*.nupkg
artifacts/pack/SHA256SUMS
if-no-files-found: error

# 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:
needs: build-test
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download packed artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: intentmesh-packages
path: artifacts/pack
- name: Attest build provenance for the packages
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: 'artifacts/pack/*.nupkg'
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.11.0 — Service & integration hardening (fifth review pass)

Closes a fifth external review (7 High + 4 Medium). **240 passing + 3 env-gated skipped.**

High:
- **Export no longer honors caller approvals** — `/api/export` signed/bundle output was running with
caller-supplied approvals, bypassing server-issued challenges + approver authorization. It now always
runs unapproved; a signed approved bundle comes only from `/api/runs/{id}/approve`.
- **Verify-before-rerun** — `/challenges` and `/approve` now verify the stored bundle signature before
re-running `saved.Prompt` (HTTP 409 on failure), so a tampered artifact can't become input to a newly
signed approved run.
- **Per-file delete approvals over the web** — challenges are minted per approval *unit*: a bare node id,
or `node#fileRef` for a destructive delete, so granular per-file consent works through the API (the
core already required `node#fileRef`).
- **Fail-closed persistence** — `/api/run` and `/approve` return `503` (no leaked exception text) when a
run can't be durably, verifiably stored, instead of `200` with an unsaved result.
- **Rate limiting** — a per-client fixed-window limiter (built-in framework limiter, no new dependency)
on `/api`, with a stricter policy on `POST /api/auth/token` to blunt credential brute force.
- **Side-effecting GET/HEAD is gated** — imported-OpenAPI confirmation now keys on the inferred side
effect, not the HTTP verb, so a `sendReminder`-style GET still requires confirmation.
- **MCP custom-mapper path enforcement** — path policy now checks the normalized typed action path
(`FsRead/FsWrite.Path`), not only fixed raw argument keys, closing a bypass for mappers that use a
non-standard path argument name.

Medium:
- **Reads are role-gated** — every read endpoint requires at least `viewer` (a roleless principal gets 403).
- **Legacy `INTENTMESH_WEB_TOKEN`** reduced to operator+viewer (no approver) and documented dev-only.
- **Security headers** — strict CSP (`script-src 'self'`), `nosniff`, `DENY` framing, `no-referrer`; the
SPA keeps its bearer token in `sessionStorage` (tab-scoped), not `localStorage`.
- **Release hardening** — repo `NuGet.config` with single-source package mapping; Docker restore uses
`--locked-mode`; build-provenance attestation moved to a separate least-privilege job so build/test/pack
hold no write tokens. (Residual, documented: NuGet package signing needs a code-signing cert; base-image
digest pinning needs registry access.)

## v1.10.1 — Authz hardening (traversal-safe ids, body-cap)

A follow-up security pass on the v1.10.0 authz surface. **232 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.10.1</Version>
<Version>1.11.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.10.1authz hardening: reject traversal-shaped tenant/principal ids, tenant-path containment, and a request-body cap that holds for chunked bodies. See CHANGELOG.md.</PackageReleaseNotes>
<PackageReleaseNotes>v1.11.0service hardening: server-mediated approvals on export, verify-before-rerun, per-file web approvals, fail-closed persistence, rate limiting, CSP/security headers, side-effecting-GET gating, MCP typed-path enforcement. See CHANGELOG.md.</PackageReleaseNotes>

<!-- Reproducible restore: lock files are honored in CI via locked-mode restore. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Expand Down
16 changes: 16 additions & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Supply-chain pinning: restore ONLY from nuget.org, and bind every package id to that single source
via packageSourceMapping. This prevents a stray machine-level source (or a dependency-confusion
package published to another feed) from being silently restored. The lock files
(packages.lock.json plus locked-mode restore) pin exact versions and hashes on top of this. -->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
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 # 232 passing (+3 env-gated skipped)
dotnet test IntentMesh.slnx # 240 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). **232 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.**
operator workflow, audit operations). **240 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 232 passing (+3 env-gated skipped).** Five demo
7/7 round-trip verify; typed action contracts across four domains. **xUnit 240 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
23 changes: 19 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,25 @@ headers** so a caller can't spoof identity.
another tenant returns `404`. Caller-asserted approvals are ignored — approve a gated node by fetching
`POST /api/runs/{id}/challenges` and posting the returned challenge(s) to `POST /api/runs/{id}/approve`.

### Legacy single-token (dev/simple)

`INTENTMESH_WEB_TOKEN` still works as a single shared bearer mapped to a `default`-tenant principal
(operator+approver+viewer). Prefer Mode A/B for anything multi-user.
### Legacy single-token (dev/local only — NOT for production)

`INTENTMESH_WEB_TOKEN` is a single shared bearer mapped to a `default`-tenant principal with
**operator + viewer only** (deliberately *not* approver — a shared token must not be able to
self-approve gated nodes). It exists for local/dev convenience; **do not use it in production** — use
Mode A (token) or Mode B (proxy), which give per-principal identity and real approver separation.

## Hardening notes

- **Rate limiting** — a per-client fixed-window limiter (keyed on `X-Forwarded-For` from the proxy, else
the socket IP) caps `/api` traffic; `POST /api/auth/token` has a stricter limit to blunt credential
brute force. Behind a proxy, ensure it sets a truthful `X-Forwarded-For`.
- **Security headers** — every response carries a strict `Content-Security-Policy` (`script-src 'self'`),
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`. The SPA
keeps its bearer token in `sessionStorage` (tab-scoped), not `localStorage`.
- **Reads are role-gated** — every read endpoint requires at least the `viewer` role (operator/approver/
admin also satisfy it); a principal with no recognized role cannot read run data.
- **Fail-closed persistence** — if a run (or an approved run) cannot be durably, verifiably stored, the
API returns `503` rather than a `200` with an unsaved result.

## TLS / reverse-proxy contract

Expand Down
14 changes: 9 additions & 5 deletions 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` — **232 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so.
true (`dotnet test IntentMesh.slnx` — **240 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 All @@ -24,7 +24,8 @@ true (`dotnet test IntentMesh.slnx` — **232 passing, 3 env-gated skipped**). N
| MCP proxy gates intent before forwarding (stdio + HTTP/SSE) | IntegrationTests — blocked call never reaches the server |
| OpenAPI import (JSON+YAML, `$ref`, semantic inference) | IntegrationTests |
| Operator workflow: history, approval queue, replay diff, artifact viewer, why-blocked | ExplainTests + Web endpoints, smoke-tested |
| Multi-tenant authz: principal/tenant/role identity, tenant-isolated run store, server-issued approval challenges | AuthTests, WebAuthzTests — cross-tenant run is 404, viewer can't run, only a server-minted challenge approves |
| Multi-tenant authz: principal/tenant/role identity, tenant-isolated run store, server-issued approval challenges (incl. per-file delete `node#fileRef`) | AuthTests, WebAuthzTests — cross-tenant run is 404, roleless principal can't read, viewer can't run, only a server-minted challenge approves (export/explain don't honor caller approvals) |
| Service hardening: per-client rate limiting, CSP/security headers, verify-before-rerun, fail-closed persistence | WebTests, WebAuthzTests — auth endpoint 429s per client, CSP present, tampered run is 409, lost persistence is 503 |
| Stable SDK surface + minimal host template | SdkTests; `templates/IntentMesh.Host.Template` builds **and runs** |

## 🧪 Experimental — works, reference-grade, not hardened
Expand All @@ -48,9 +49,12 @@ true (`dotnet test IntentMesh.slnx` — **232 passing, 3 env-gated skipped**). N
backend is future). Put the runs dir on an encrypted volume meanwhile — see [DEPLOYMENT.md](DEPLOYMENT.md).
- **Declarative policy DSL** — see [POLICY-AUTHORING.md](POLICY-AUTHORING.md) (C# authoritative today).
- **Live RSRM hot-load** of the `im-*` bundle.
- **Identity-provider provisioning** (SSO/SCIM) and **rate limiting / quotas** on the Control Room API.
The authz boundary is built (principal/tenant/role, server-issued approvals — see Proven); what remains
future is bulk provisioning, a managed principal store, and per-tenant rate limits.
- **Identity-provider provisioning** (SSO/SCIM) and a **managed principal store**. The authz boundary,
per-client rate limiting, CSP/security headers, and server-mediated approvals are built (see Proven);
what remains future is bulk/federated provisioning and per-tenant quota policies.
- **Signed NuGet packages + digest-pinned base images.** Build-provenance attestation + SHA256SUMS ship
today (and attestation now runs in an isolated least-privilege job); cryptographic package signing needs
a code-signing certificate, and base-image digest pinning needs registry access — both deployment-owned.
- **Fuzz / mutation testing + enforced coverage thresholds** — the suite is example-based today.
- **Live-LLM CI gate** — the real-Anthropic test runs in CI only when the `ANTHROPIC_API_KEY` secret is
configured (it skips otherwise). The real filesystem-MCP and stdio-MCP E2E paths now DO run in CI.
Expand Down
5 changes: 4 additions & 1 deletion src/IntentMesh.Core/IntentMeshRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,11 @@ private static RunResult Project(string prompt, ProposedPlan resolved, IntentGra
var policy = graph.Nodes.Where(n => n.Policy is not null).Select(n =>
{
var p = n.Policy!;
var approvalRefs = n.Action is DeleteFilesAction del
? del.FileRefs.ToList()
: (IReadOnlyList<string>)Array.Empty<string>();
return new PolicyView(n.Id, n.Label, p.Decision.ToString(), p.Risk, p.Reason, p.TriggeredRules,
p.RequiresConfirmation, p.TrustSource, p.Sensitive, p.ExternalSideEffect, p.Destructive);
p.RequiresConfirmation, p.TrustSource, p.Sensitive, p.ExternalSideEffect, p.Destructive, approvalRefs);
}).ToList();

var exec = graph.Nodes.Where(n => n.Execution is not null).Select(n =>
Expand Down
6 changes: 5 additions & 1 deletion src/IntentMesh.Core/RunResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ public sealed record NodeView(
public sealed record PolicyView(
string NodeId, string Label, string Decision, string Risk, string Reason,
IReadOnlyList<string> TriggeredRules, bool RequiresConfirmation,
string TrustSource, bool Sensitive, bool ExternalSideEffect, bool Destructive);
string TrustSource, bool Sensitive, bool ExternalSideEffect, bool Destructive,
// The granular approval units for this node. Empty for a normal node (approve the bare node id);
// for a per-file destructive delete it is the file refs — each is approved as "{NodeId}#{ref}", so
// a UI / API can mint and consent to one approval challenge per file rather than a node-wide blanket.
IReadOnlyList<string> ApprovalRefs);

public sealed record ExecView(string NodeId, string Label, bool Ran, bool Halted, string Summary, IReadOnlyList<string> Effects);

Expand Down
22 changes: 18 additions & 4 deletions src/IntentMesh.Integrations/McpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,14 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet<string>? approvals = nu
}

// Path-safety policy: a filesystem call whose path escapes the allowed root is blocked here,
// before the pipeline runs and before anything is forwarded to the MCP server. Every
// path-bearing argument is checked — including the multi-path `paths` array (read_multiple_files)
// — so a tool whose primary action maps to "." cannot smuggle escaping paths past the gate.
// before the pipeline runs and before anything is forwarded to the MCP server. We check the
// NORMALIZED typed action path(s) — FsReadAction/FsWriteAction.Path — AS WELL AS the raw
// arguments. Checking the typed path is what closes the custom-mapper gap: a per-server mapper
// that reads a non-standard argument name (e.g. "filepath") still produces a typed action whose
// Path is enforced here, even though a raw-key scan over {path,source,destination,paths} would
// miss it. The raw-arg scan remains as defense-in-depth for the multi-path `paths` array.
if (_allowedRoot is not null && action is FsReadAction or FsWriteAction)
foreach (var p in CandidatePaths(call.Args))
foreach (var p in TypedPaths(action).Concat(CandidatePaths(call.Args)))
if (PathEscapesRoot(p))
return new McpGateResult(
Allowed: false,
Expand Down Expand Up @@ -306,6 +309,17 @@ private static (TypedAction?, string) MapFsWrite(McpToolCall call)
return (new FsWriteAction(path, content ?? ""), $"MCP {call.Tool} → {path}");
}

/// <summary>The path(s) carried by the NORMALIZED typed action — enforced regardless of which raw
/// argument name a (possibly custom) mapper read them from.</summary>
private static IEnumerable<string> TypedPaths(TypedAction action)
{
switch (action)
{
case FsReadAction r when !string.IsNullOrEmpty(r.Path): yield return r.Path; break;
case FsWriteAction w when !string.IsNullOrEmpty(w.Path): yield return w.Path; break;
}
}

/// <summary>Every path-bearing argument for a filesystem tool: the single-path keys plus the
/// <c>paths</c> array (read_multiple_files). A <c>paths</c> value is parsed as a JSON array; if
/// that fails it is treated as a single raw path (fail-closed — it still gets checked).</summary>
Expand Down
8 changes: 6 additions & 2 deletions src/IntentMesh.Integrations/OpenApiImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,12 @@ public static ImportedContract ToContract(ToolSchema schema, bool trusted = fals
// Capability the action requires, inferred from the side effect + operation semantics.
var capability = InferCapability(sideEffect, schema.Name, schema.Summary);

// Confirmation required when: mutating method + non-trivial side effect (after the floor above).
bool requiresConfirmation = mutating && sideEffect != "none";
// Confirmation is required whenever the operation has a real side effect — keyed on the SIDE
// EFFECT, not the HTTP verb. A side-effecting GET/HEAD (e.g. an operation named "sendInvite" or
// "purgeCache" exposed over GET) must still be gated; tying confirmation to mutating verbs let
// such operations bypass it. A genuinely safe read infers side-effect "none" and stays ungated;
// a trusted spec can still mark a read explicitly safe via SideEffectHint="none".
bool requiresConfirmation = sideEffect != "none";

return new ImportedContract(
Kind: kind,
Expand Down
7 changes: 5 additions & 2 deletions src/IntentMesh.Web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore IntentMesh.slnx \
&& dotnet publish src/IntentMesh.Web/IntentMesh.Web.csproj -c Release -o /app
# Locked restore (honors packages.lock.json + the repo NuGet.config source mapping) so the image is
# built from the exact, pinned dependency set — a drifted lock file fails the build rather than
# silently resolving different packages.
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
WORKDIR /app
Expand Down
Loading
Loading