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

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

- **Traversal-safe tenant/principal ids** — `AuthIds.IsValid` now rejects ids that start with `.` or `-`
or contain no alphanumeric, so `.`, `..`, dotfiles, and option-like names are refused even though their
characters are in the allowed set. Closes a path-traversal / tenant-isolation-bypass risk where a
`..`-shaped tenant could resolve outside the per-tenant runs root.
- **Tenant-path containment** — the per-tenant store factory re-validates the tenant id and verifies the
resolved directory stays under the runs root before constructing the store (defense-in-depth).
- **Request-body cap holds for chunked bodies** — the `/api` size guard no longer relies only on a
declared `Content-Length`; it also sets the Kestrel max-request-body limit so a chunked or
missing-length body is rejected while binding (an omitted/spoofed `Content-Length` no longer bypasses
the 256 KB cap).

## v1.10.0 — Real multi-tenant authorization

Builds out the authz boundary that the fourth review flagged as a not-yet-built future seam — it is now
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.0</Version>
<Version>1.10.1</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.0real multi-tenant authorization (principal/tenant/role, tenant-isolated runs, server-issued approval challenges). See CHANGELOG.md.</PackageReleaseNotes>
<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>

<!-- Reproducible restore: lock files are honored in CI via locked-mode restore. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ dotnet run --project src/IntentMesh.Cli -- --trace "plan my Friday and draft Sar
dotnet run --project src/IntentMesh.Web # then open the printed localhost URL

# tests
dotnet test IntentMesh.slnx # 218 passing (+3 env-gated skipped)
dotnet test IntentMesh.slnx # 232 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). **218 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.**
operator workflow, audit operations). **232 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 218 passing (+3 env-gated skipped).** Five demo
7/7 round-trip verify; typed action contracts across four domains. **xUnit 232 passing (+3 env-gated skipped).** Five demo
scenarios. See [docs/MATURITY.md](docs/MATURITY.md) for the proven / experimental / future breakdown.
Delivered beyond v0.1:

Expand Down
2 changes: 1 addition & 1 deletion docs/MATURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The single source of truth for **what is production-ready, what is experimental, and what is future
work.** Every "proven" claim below is backed by a test that would fail if the claim stopped being
true (`dotnet test IntentMesh.slnx` — **218 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so.
true (`dotnet test IntentMesh.slnx` — **232 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
7 changes: 6 additions & 1 deletion src/IntentMesh.Core/Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ public sealed record AuthPrincipal(string PrincipalId, string TenantId, IReadOnl
}

/// <summary>Validation for principal/tenant ids. They become a path segment (per-tenant run root), so
/// they are restricted to a safe, traversal-free alphabet — a tenant id can never escape the runs root.</summary>
/// they are restricted to a safe, traversal-free shape — a tenant id can never escape the runs root.
/// Beyond the alphabet, an id must contain a letter or digit and may not start with '.' or '-', which
/// rejects "." / ".." / dotfiles / option-like names (e.g. "-rf") even though their characters are in
/// the allowed set.</summary>
public static class AuthIds
{
public static bool IsValid(string? id)
=> !string.IsNullOrEmpty(id) && id.Length <= 64
&& id[0] != '.' && id[0] != '-'
&& id.Any(char.IsAsciiLetterOrDigit)
&& id.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-');
}

Expand Down
24 changes: 22 additions & 2 deletions src/IntentMesh.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using IntentMesh.Core;
using Microsoft.AspNetCore.Http.Features;

// IntentMesh Control Room — ASP.NET minimal API over IntentMesh.Core. Serves a dependency-free
// SPA (wwwroot) and runs the pipeline on demand. No CDN, no npm — robust offline.
Expand Down Expand Up @@ -115,10 +116,16 @@

if (context.Request.ContentLength > MaxApiBodyBytes)
{
context.Response.StatusCode = 413; // Payload Too Large
context.Response.StatusCode = 413; // Payload Too Large — fast reject when Content-Length is declared
await context.Response.WriteAsJsonAsync(new { error = $"request body exceeds {MaxApiBodyBytes} bytes" });
return;
}
// A declared Content-Length can be omitted or wrong (e.g. chunked transfer-encoding), so also cap the
// actual bytes read: Kestrel enforces this limit while binding the body and rejects an over-limit
// request regardless of the header. (Null/read-only on some test servers — handled gracefully.)
var sizeLimit = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (sizeLimit is { IsReadOnly: false })
sizeLimit.MaxRequestBodySize = MaxApiBodyBytes;

if (path.StartsWithSegments("/api/auth/token")) { await next(); return; } // login endpoint is open

Expand Down Expand Up @@ -406,7 +413,20 @@ static bool ConstTimeEq(string? a, string b)
&& CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));

// Per-tenant run store: each tenant's runs live under {runsDir}/t/{tenant} — isolation by construction.
FileRunArtifactStore StoreFor(string tenant) => new(Path.Combine(runsDir, "t", tenant));
// 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
// traversal-shaped tenant can never escape (no store is created for an out-of-bounds path).
FileRunArtifactStore StoreFor(string tenant)
{
if (!AuthIds.IsValid(tenant))
throw new ArgumentException($"invalid tenant id '{tenant}'", nameof(tenant));
var rootFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(runsDir));
var dir = Path.GetFullPath(Path.Combine(rootFull, "t", tenant));
var cmp = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (!dir.StartsWith(rootFull + Path.DirectorySeparatorChar, cmp))
throw new ArgumentException($"tenant '{tenant}' resolves outside the runs root", nameof(tenant));
return new FileRunArtifactStore(dir);
}

// Approvals: gated node ids are only ever applied when attested by a server-issued challenge.
record RunRequest(string? Prompt, string[]? Approvals);
Expand Down
20 changes: 20 additions & 0 deletions tests/IntentMesh.Tests/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,25 @@ public void Trusted_proxy_headers_map_to_a_principal_and_reject_invalid_ids()

Assert.False(TrustedProxyAuth.TryFromHeaders("alice/../etc", "acme", "viewer", out _)); // traversal id
Assert.False(TrustedProxyAuth.TryFromHeaders("alice", "", "viewer", out _)); // empty tenant
Assert.False(TrustedProxyAuth.TryFromHeaders("alice", "..", "viewer", out _)); // traversal tenant
}

[Theory]
[InlineData("acme")]
[InlineData("acme-corp")]
[InlineData("acme.us_1")]
[InlineData("a")]
public void AuthIds_accepts_safe_ids(string id) => Assert.True(AuthIds.IsValid(id));

[Theory]
[InlineData(".")] // current directory
[InlineData("..")] // parent directory — the traversal segment
[InlineData("...")] // all dots, no alphanumeric
[InlineData(".hidden")] // leading dot (dotfile)
[InlineData("-rf")] // leading dash (option-like)
[InlineData("a/b")] // path separator
[InlineData("a\\b")] // windows separator
[InlineData("")] // empty
[InlineData("___")] // no letter or digit
public void AuthIds_rejects_traversal_and_unsafe_ids(string id) => Assert.False(AuthIds.IsValid(id));
}
14 changes: 14 additions & 0 deletions tests/IntentMesh.Tests/WebTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ public async Task Health_and_readiness_endpoints_respond()
finally { Cleanup(f, runs); }
}

[Fact]
public async Task An_oversized_api_body_is_rejected()
{
Environment.SetEnvironmentVariable("INTENTMESH_WEB_TOKEN", null);
var (f, runs) = Make();
try
{
var huge = new string('x', 300 * 1024); // > 256 KB cap
var resp = await f.CreateClient().PostAsJsonAsync("/api/run", new { prompt = huge });
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, resp.StatusCode);
}
finally { Cleanup(f, runs); }
}

[Fact]
public async Task A_run_persists_and_appears_in_history()
{
Expand Down
Loading