Skip to content

v0.4.11: a scope the generator never entered

Choose a tag to compare

@matAtWork matAtWork released this 27 Aug 23:46
· 119 commits to main since this release
67e9ac9

What's Changed

  • #54runAs() re-establishes the principal across deferred work (#53) by @matAtWork
  • A patch release: one security-relevant fix to the ambient principal, at the seam a host uses to invoke a tool itself. No API changed, and nothing in this repo behaved differently.

Full Changelog: v0.4.10...v0.4.11


What it's for

An async generator's body does not begin until the first pull. So an iterator returned out of a runAs
scope carried its whole extent outside it: the identity was established for the construction, and the
work then ran under whatever happened to be ambient where it was pulled.

The tool ABI returns exactly that shape — ToolExecutor.execute() is an AsyncIterable — so a host wiring
up its own tool invocation (an HTTP endpoint, a scheduler) reached for the broken form first:

// WRONG — typechecked, ran with the wrong identity
const events = runAs(principal, () => tool.executor.execute(input, ctx));
for await (const ev of events) { /* body runs HERE, outside the scope */ }

And it failed quietly. A host that entered a boot principal at process entry — which the CLI does — read a
plausible identity rather than an error:

actual:   [ 'matbot-boot' ]
expected: [ 'alice' ]

matbot's own call sites were never affected: each consumes inside the scope, both frontend-web tool routes
included. This is entirely about the seam handed to an embedder, where the identity is a security boundary.

The shape

Nothing to adopt — the form that was wrong is now correct as written:

const events = runAs(principal, () => tool.executor.execute(input, ctx));
await writeSse(events);            // pulls re-enter the scope, wherever they happen

runAs re-establishes the identity around each pull of a returned async iterator, and unwraps a native
promise to find one behind an async () => execute(…). The value crossing back out is still what it was: the
wrapper is a Proxy over the pull points, so a class-based iterator keeps its own members, its prototype,
instanceof, and a private field still resolves.

Nesting needs no rule of its own — runAs(A, () => runAs(B, () => gen())) resolves every pull to B, the
same answer plain nesting already gives with no iterator in sight.

What is deliberately not covered

Each of these is a boundary with a reason, and each is pinned by a test:

  • The caller's own continuations. A catch/finally chained onto the result runs in the caller's flow.
    Scoping those would extend a privilege rather than restore one — await calls then on a thenable, so a
    patched one would leak the identity into the whole remainder of the awaiting function.
  • Exotic thenables. PromiseLike.then need not return a promise, so adopting one would hand the caller
    back a different object — a chainable query builder being the shape that breaks. Left intact, and so not
    rescoped.
  • ReadableStream. One can be proxied (measured: instanceof, pipeTo and a platform
    new Response(stream) all survive), but only [Symbol.asyncIterator] and getReader().read() could be
    re-entered — pipeTo/pipeThrough/tee pull from platform internals no wrapper reaches. A conditional
    guarantee is worse than none here: a host testing with for await and shipping pipeTo would regress
    silently, which is the exact failure mode this release fixes. The exposure is narrow anyway — start() is
    eager and already inside the scope, so only a lazy pull() that reads the principal is uncovered, and
    matbot has none: core is pure native JS, and FileHandle.stream() is AsyncIterable by contract.
  • A returned function is not bound to the principal. An iterator has one thing that can be done to it,
    and pulling it is the deferred half of the operation the scope was opened for — unspellable from outside,
    which is why the repair belongs in the primitive. A function is a new operation the caller starts later
    and N times, and the spelling is theirs: scope the call, not the construction
    (() => runAs(p, () => fn(x))). Nothing distinguishes a deferred body from a factory result either —
    typeof value === 'function' is equally true of a class, an unsubscribe function and a comparator.
  • An iterator nested inside a returned object ({ events }) — no structural check can reach it.
  • machineBusy / withUsageScope. Identical () => T shape, identical footgun, but a hold and a
    roll-up settle when fn does. An identity is a re-entrant label; a resource with a settle edge is not, so
    contextSwitch(p, () => gen()) gets the right identity and still releases the hold early. Their doc
    warnings stand.

Deliberately not built

The issue proposed two alternatives, and neither shipped.

  • A type-level guard — an overload resolving to never for iterable T. Measured: it raises no
    diagnostic at the runAs call, and for the motivating shape — writeSse(runAs(P, () => exec()))tsc
    exits 0, because never is assignable to everything. It errors only on a direct for await, as TS2504
    pointing at the consumer rather than the cause. It would have traded a silent runtime bug for a silent type
    hole.
  • A separate runAsIterable primitive. Fixing runAs itself makes it redundant: no new export, no
    second name to teach, and existing "wrong" code becomes correct without downstream changes.

Cost

~250ns per yield, on the previously-broken shape only. One extra microtask on a promise-returning runAs.
No in-repo behaviour change; 309 tests pass, and every new test was run against the unpatched build first.

Versioning

core, plugin-api, cli and web-bundle move together as a changesets fixed group; only plugin-api
changed, and the rest are bumped anyway because the boot banner reads any difference between the CLI and the
resolved core/plugin-api versions as two physical copies of a host singleton. Rebuilding
apps/web-bundle/dist is part of the release rather than cosmetic: it inlines core, so it is how the browser
bundle gets the fix at all.

Published: core, plugin-api, cli, web-bundle at 0.4.11. No plugin packages changed.