Skip to content

Machine-auth: caller resolver for workflow-run children (CL-6286) - #35

Merged
TheGreatAxios merged 9 commits into
mainfrom
cl-6286-machine-auth
Aug 19, 2026
Merged

Machine-auth: caller resolver for workflow-run children (CL-6286)#35
TheGreatAxios merged 9 commits into
mainfrom
cl-6286-machine-auth

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Every route registerMemoryRoutes mounts is guarded by requirePrincipal() + grantGuard(), which read c.get("principal") — set by the host's tenant-session middleware for a browser/API caller. A workflow-run child has no browser session, only its own sidecar bearer token and run address, so a host wanting agents (not just humans) to reach memory had to build a second, parallel HTTP surface itself (see packages/memory-hub/src/workflow-routes.ts in the workbench repo — the workaround this closes).

The seam

RouteDeps gains an optional field:

export type ResolvedCaller = { tenantId: string; principalId: string };

export type CallerResolver = (
  c: Context<TenantEnv>,
) => ResolvedCaller | null | Promise<ResolvedCaller | null>;

export type RouteDeps = {
  memory: Memory;
  requireGrant: RequireGrant;
  grants: GrantConfig;
  callerResolver?: CallerResolver; // NEW, optional
};

createMemory({ callerResolver, ... }) threads it through to registerMemoryRoutes.

A new middleware, resolveCaller(deps), is inserted ahead of requirePrincipal()/grantGuard() on every route (add/search/list/feed):

  • Unset (default): no-op passthrough. Every existing host is byte-for-byte unaffected — identity still comes only from c.get("principal"), set by the host's own tenant-session middleware. No existing test changed meaning.
  • Set: calls deps.callerResolver(c). If it returns null, the request gets 401 unauthorized and never reaches requirePrincipal/grantGuard — it never falls through to a browser principal that might coincidentally be on the context. If it resolves, the {tenantId, principalId} is seated as the context principal/tenant (with placeholder fields Interchange's authorize() never reads — it only touches .id), and the request proceeds through the exact same requirePrincipal/grantGuard/caller() code every browser request goes through.

Why this doesn't violate "authenticate nothing"

The package still authenticates nothing. callerResolver is a function the host supplies and the host implements entirely — bearer-token lookup, run-address lookup, whatever the host's transport is. This package never validates a token, a signature, or a header; it only accepts an already-resolved {tenantId, principalId} and seats it where the rest of the request pipeline (grant guard, caller()) already expects to find it. The host still owns 100% of authentication; this ticket only stops the package from assuming how the host arrived at that identity.

Identity cannot be spoofed by the request body

No route body schema (AddRequest, SearchRequest, ListQuery, FeedQuery) has ever had a tenantId/principalId field, and every handler reads scope only from caller(c) (context), never from the parsed body. This ticket adds an explicit test proving it end-to-end for the new seam:

src/routes/routes.test.ts"a request body naming a different tenant/principal is ignored — the resolved caller always wins": a machine caller resolves to tenant-run/run-principal; the POST body additionally claims tenantId: "tenant-evil" and principalId: "attacker"; the plane still records the write under tenant-run/run-principal.

Also covered: grant guarding still applies to a resolved caller (403 with no grant, 403 with a grant for a different principal, 200 with the right grant), and an unresolvable caller gets 401 without ever falling back to a stale/absent browser principal.

Rate limiting and payload caps: left as a host concern

The downstream workaround bolted a per-run sliding-window add-rate-limiter and a 64k-character add-text cap onto its parallel surface. Not ported here:

  • No route in this package has ever had a rate limit or payload cap, browser or machine. Adding one only for machine callers would be an arbitrary asymmetry, not a security property this ticket is about.
  • A meaningful rate limit is inherently host/topology-specific — it depends on process count, replica count, and how the host wants a runaway caller to degrade (the downstream file's own comments note its per-process limiter under-limits across replicas). That's a deployment decision, not something a shared library should hardcode.
  • If a payload cap is wanted, it belongs to add generally (every caller, not just machine callers) and is a separate, uniformly-applied product decision — out of scope for a caller-resolution ticket.

A host that wants either can still enforce them itself in callerResolver (reject before we ever see the request) or with ordinary Hono middleware ahead of registerMemoryRoutes.

What a downstream host deletes

Once mounted with callerResolver, a host no longer needs a second Hono app (createWorkflowMemoryRoutes/createUnavailableWorkflowMemoryRoutes), a second store adapter (createWorkflowMemoryStore), or hand-rolled body validation/response shaping duplicating add/search/list — it authenticates the run, hands back {tenantId, principalId}, and reuses this package's routes and grant enforcement outright.

Tests

401 → 410 passing (bun test ./src); bun run typecheck clean. New tests: 4 unit tests for resolveCaller (src/routes/deps.test.ts), 5 end-to-end route tests for the machine-caller path (src/routes/routes.test.ts), including the identity-spoofing test above.

Refs CL-6286.

Adversarial review follow-ups

A review pass on the first version of this PR confirmed the core authorization
claim (machine and browser callers hit byte-identical
requireGrant("memory", action)authorize() on all four routes, ordering
consistent, null fails closed with 401, a throwing resolver fails closed with
a generic 500 that does not leak the thrown message) but found four gaps,
addressed in the commits since:

  1. BLOCKER — the resolver's return value was never parsed at a trust
    boundary.
    Any truthy object matching the TS shape was seated directly as
    the request principal, including {tenantId: "", principalId: ""} — a
    silent write under a garbage scope from a buggy host resolver.
    resolveCaller now parses the return with arktype (non-empty
    tenantId/principalId) before seating it.

    Status code: null still means "this caller could not be
    authenticated" — a caller problem, 401. A resolved value that fails the
    schema
    means the host's own resolver is broken — a host bug, not a
    caller's, so it is now 500, not 401: the request was never
    "unauthorized," the identity provider misbehaved. Covered by tests in
    src/routes/deps.ts's resolveCaller unit tests and an end-to-end test in
    routes.test.ts that also proves a grant existing for the empty-string
    principal never gets a chance to authorize the write.

  2. Canary contract test for the fabricated rows. principalRowFor/
    tenantRowFor (deps.ts) synthesize PrincipalRow/TenantRow with
    placeholder fields, verified today to be safe against @intx/hub-api@0.2.2
    because authorize() only reads .id. A new test in routes.test.ts
    seats a Proxy-based canary row that throws on any property access beyond
    .id/.tenantId, run through the real requireGrant/authorize and
    caller(). A future Interchange version that starts reading a fabricated
    field (status, kind, parentId, config, ...) now breaks this suite
    instead of silently authorizing on fiction.

  3. Machine-caller tests extended to search, list, and feed. The
    original tests only covered /memory/add; a failure on the read paths
    means reading another tenant's memories, the worst outcome in this ticket.
    New tests cover all four routes, including the :tenantId URL segment
    claiming a different tenant than the resolved caller's and a cross-tenant
    list fixture proving the resolved caller only ever sees its own tenant's
    events.

  4. Adapted the reviewer's edge-case tests (throwing resolver fails closed
    without leaking; malformed resolver output) directly into
    routes.test.ts; the malformed-output test now asserts rejection (500)
    rather than a garbage-scope write, per fix 1.

  5. Docs: IMPLEMENTATION.md now calls out explicitly that a host deleting
    a hand-rolled parallel surface (like the downstream workflow-routes.ts
    this PR obsoletes) must re-home that surface's per-run rate limiter and
    payload cap as its own middleware first — this package still does not
    implement either, and deleting the file silently drops both. A comment at
    registerMemoryRoutes now states that the :tenantId path segment is
    never read by any handler; scope always comes from caller(c).

Tests: 401 → 422 passing; bun run typecheck clean throughout.

Follow-up: whitespace-only identity

"string >= 1" on ResolvedCallerSchema is a LENGTH constraint, not a content
one — " " has length 1 and passed it, so a whitespace-only
tenantId/principalId was still seated as a "valid" scope: the same exploit
in a different costume (same bug class PR #34 fixed in optionalEnv, where
v.length > 0 accepted " "). Fixed by requiring at least one
non-whitespace character:

const NonBlankId = type("string").narrow(
  (s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank (not just whitespace)"),
);

The malformed-output regression test (deps.test.ts and the end-to-end test
in routes.test.ts) now covers a whitespace-only id (" ", "\t\n")
alongside the empty-string case via test.each.

Tests: 422 → 424 passing; bun run typecheck clean throughout.

Cover the new RouteDeps.callerResolver seam: passthrough when unset, context
seating when a caller resolves, 401 without falling through to a browser
principal when it doesn't, grantGuard still applying to the resolved
principal, and a request body naming a different tenant/principal never
overriding the resolved identity.

resolveCaller() is still a no-op passthrough, so the new red tests fail here
by design; the next commit makes them pass.
…6286)

resolveCaller() now calls deps.callerResolver when the host configures one,
seating the resolved {tenantId, principalId} as the request's
principal/tenant before requirePrincipal/grantGuard run. An unresolved
request gets 401 and never falls through to a browser principal. Grant
checks apply through the exact same requireGrant("memory", action) path a
browser caller gets, since it reads context principal/tenant either way.

Identity from the resolver always wins: routes never read tenantId/
principalId from the request body, so a model's tool arguments can never
name a different tenant.
Document RouteDeps.callerResolver / createMemory({ callerResolver }) in
AGENTS.md's "authenticate nothing" invariant, ARCHITECTURE.md's identity
section, and CHANGELOG.md.
…ds (CL-6286)

Adversarial review findings on PR #35:

- The resolver's return value was never parsed at a trust boundary: an
  empty-string tenantId/principalId was seated as a "valid" scope. New tests
  assert malformed resolver output is rejected with 500, not seated.
- The machine-caller tests only covered /add; extend to search/list/feed,
  including the URL's :tenantId never overriding the resolved caller's
  scope and a cross-tenant list read staying scoped to the resolved tenant.
- Add a canary-Proxy contract test: seats a PrincipalRow/TenantRow that
  throws on any property access beyond .id/.tenantId, so a future
  Interchange version reading a fabricated field (status, kind, parentId,
  ...) breaks the suite instead of silently authorizing on fiction.
- Adapt the reviewer's throwing-resolver and malformed-output tests in
  directly (former tmp/critique-tests/resolver-edge-cases.test.ts).

The empty-string/missing-field/non-object rejection tests are red against
the current resolveCaller (it seats anything truthy); the next commit
makes them pass.
…CL-6286)

resolveCaller() now parses deps.callerResolver's return with arktype
(non-empty tenantId/principalId) before seating it as the context
principal/tenant. A malformed return is rejected with 500, not seated as a
garbage scope: null means the caller could not be authenticated (401, a
caller problem), but a resolved value failing the schema means the host's
own resolver is broken (500, a host problem) -- the request itself was
never "unauthorized."

Also documents, at registerMemoryRoutes, that the :tenantId path segment is
never read by any handler -- scope always comes from caller(c), never the
URL.
CHANGELOG.md notes the arktype validation on callerResolver's return.
IMPLEMENTATION.md documents callerResolver next to the mounted-routes
identity section, and calls out that a host migrating off a hand-rolled
parallel surface must re-home any per-run rate limit / payload cap as its
own middleware before deleting that surface -- this package does not
implement either.
Adversarial review: "string >= 1" on ResolvedCallerSchema is a LENGTH
constraint, not a content one -- " " has length 1 and passes it, so a
whitespace-only tenantId/principalId was still seated as a "valid" scope.
Same bug class PR #34 fixed in optionalEnv (v.length > 0 accepted "   ").

Extends the malformed-output regression test (deps.test.ts and the
end-to-end test in routes.test.ts) to cover a whitespace-only id alongside
the empty-string case. Red against the current resolveCaller, which still
seats it; the next commit rejects it.
…6286)

ResolvedCallerSchema used "string >= 1" -- a LENGTH constraint, so " " (length
1) passed it and got seated as a "valid" principal/tenant, just like the
empty-string exploit in a different costume. Require at least one
non-whitespace character (type("string").narrow(s => s.trim().length > 0))
instead, with a ctx.mustBe() message so the rejection reads clearly in logs.
Design review: before this PR, only Interchange's own middleware could seat
a context principal. callerResolver is a second path that unconditionally
overwrites whatever is on context, which is shape conversion (host identity
in, context principal/tenant out), not authorization -- as long as
ResolvedCaller stays exactly { tenantId, principalId } and every resolved
caller still traverses grantGuard.

The risk is scope creep on ResolvedCaller itself: "let it carry roles too"
or "let a trusted caller skip grantGuard" would turn the conversion shim
into the library making an authorization decision, which is the invariant
violation. Documents this explicitly under invariant 1 so the next person
widening the type reads why the shape is deliberately minimal first.
TheGreatAxios added a commit that referenced this pull request Aug 19, 2026
DocumentIdParam/VersionIdParam use "string >= 1" — a length constraint, not
a content one, so " " (length 1) passes and reaches the plane instead of
being rejected as invalid input. #35 already hit and fixed this exact bug
for the resolved-caller trust boundary (NonBlankId, routes/deps.ts); it
regrew here because the fix lived in a comment instead of a shared schema.
Extract NonBlankId to core/schemas/non-blank-id.ts (deps.ts now imports it
too, unchanged behavior) ahead of reusing it for the path params.
TheGreatAxios added a commit that referenced this pull request Aug 19, 2026
…review)

The most likely real-world caller of forget/purge is a workflow-run child
resolved through #35's callerResolver, retiring memory it wrote itself. If a
resolver's principalId ever drifted from created_by_principal_id (different
derivation, casing, run-address vs principal-address), every automated
retention call would 403 in production with nothing catching it first.
Extend stubMachinePlane with the same creator-check fixtures stubPlane uses
and cover: forget/purge/retention-class succeeding for the resolved run's
own document/version, and still refused for one it does not own.
@TheGreatAxios
TheGreatAxios merged commit 7449350 into main Aug 19, 2026
1 check passed
TheGreatAxios added a commit that referenced this pull request Aug 19, 2026
DocumentIdParam/VersionIdParam use "string >= 1" — a length constraint, not
a content one, so " " (length 1) passes and reaches the plane instead of
being rejected as invalid input. #35 already hit and fixed this exact bug
for the resolved-caller trust boundary (NonBlankId, routes/deps.ts); it
regrew here because the fix lived in a comment instead of a shared schema.
Extract NonBlankId to core/schemas/non-blank-id.ts (deps.ts now imports it
too, unchanged behavior) ahead of reusing it for the path params.
TheGreatAxios added a commit that referenced this pull request Aug 19, 2026
…review)

The most likely real-world caller of forget/purge is a workflow-run child
resolved through #35's callerResolver, retiring memory it wrote itself. If a
resolver's principalId ever drifted from created_by_principal_id (different
derivation, casing, run-address vs principal-address), every automated
retention call would 403 in production with nothing catching it first.
Extend stubMachinePlane with the same creator-check fixtures stubPlane uses
and cover: forget/purge/retention-class succeeding for the resolved run's
own document/version, and still refused for one it does not own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant