From 381e50a036fe2d48261c923201fa1f80dd156962 Mon Sep 17 00:00:00 2001 From: Smithers Judge Date: Sat, 8 Aug 2026 03:44:58 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(engine,kernel):=20one=20ca?= =?UTF-8?q?pability-scoped=20flow=20wire=20for=20every=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowWire is the transport-neutral execution contract: one serializable Request in, one serializable Response out, over the same Flow definitions and FlowEngine as every other projection. The engine never interprets authority itself — it exposes the EnvelopeInterpreter seam and fails closed when a request names an envelope no interpreter can read. CapabilityEnvelope is the serializable authority that rides along: versioned, fail-closed on decode, and monotone, because apply is CapabilitySet.attenuate. A request can therefore only narrow what its executing host already allows. Declared capability requirements are patterns, not exact capabilities: parsePattern reads action:glob (and the conservative `*` shorthand), and CapabilitySet.allowsPattern proves an intersected authority contains a whole requirement. resourceSubsumes now accepts `*` the same way matches does, so the two predicates cannot disagree about the same glob. CapabilityEnvelope.interpreter is the single decoder-to-attenuator every placement installs, and patternsOf is the single reading of a declaration's capability strings, so no placement can grow its own dialect of either. --- packages/engine/src/FlowWire.ts | 417 ++++++++++++++++++ packages/engine/src/index.ts | 5 + packages/engine/test/FlowWire.test.ts | 278 ++++++++++++ packages/kernel/README.md | 33 +- packages/kernel/src/Capability.ts | 40 +- packages/kernel/src/CapabilityEnvelope.ts | 178 ++++++++ packages/kernel/src/CapabilitySet.ts | 14 +- packages/kernel/src/index.ts | 8 + packages/kernel/test/Capability.test.ts | 10 + .../kernel/test/CapabilityEnvelope.test.ts | 147 ++++++ packages/kernel/test/CapabilitySet.test.ts | 17 + packages/kernel/test/index.test.ts | 1 + 12 files changed, 1130 insertions(+), 18 deletions(-) create mode 100644 packages/engine/src/FlowWire.ts create mode 100644 packages/engine/test/FlowWire.test.ts create mode 100644 packages/kernel/src/CapabilityEnvelope.ts create mode 100644 packages/kernel/test/CapabilityEnvelope.test.ts diff --git a/packages/engine/src/FlowWire.ts b/packages/engine/src/FlowWire.ts new file mode 100644 index 00000000..974eef0f --- /dev/null +++ b/packages/engine/src/FlowWire.ts @@ -0,0 +1,417 @@ +/** + * The transport-neutral flow execution contract. + * + * `FlowProxy` projects flows onto Effect RPC groups and HTTP APIs. This module + * is the third projection, for runtimes where neither stack is practical — a + * browser Service Worker answering `postMessage`, an edge worker's `fetch` + * handler, a sandbox guest reading JSON off a socket. One serializable + * {@link Request} in, one serializable {@link Response} out, and the same + * `Flow` definitions and `FlowEngine` underneath as every other projection. + * + * The request may carry a capability envelope. The engine does not interpret + * envelopes itself — authority is the permission kernel's business — so hosts + * install an {@link EnvelopeInterpreter} that turns the opaque envelope value + * into ambient authority around the execution. Handling is fail-closed: a + * request that names an envelope is refused, not run wide, when no + * interpreter is installed. + * + * @since 0.1.0 + */ +import * as Cause from "effect/Cause" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import type * as Flow from "./Flow.ts" +import type { FlowEngine } from "./FlowEngine.ts" + +/** + * One serializable flow execution request. + * + * `payload` is the flow payload in its JSON encoding. `envelope` is an opaque + * serialized capability envelope; the engine hands it to the installed + * {@link EnvelopeInterpreter} without reading it. + * + * @category models + * @since 0.1.0 + */ +export class Request extends Schema.Class("@smithers/engine/FlowWire/Request")({ + flow: Schema.String, + payload: Schema.Json, + executionId: Schema.optional(Schema.String), + envelope: Schema.optional(Schema.Json) +}) {} + +/** + * The request named a flow the serving runtime does not register. + * + * @category errors + * @since 0.1.0 + */ +export class FlowNotFound extends Schema.TaggedErrorClass()( + "@smithers/engine/FlowWire/FlowNotFound", + { + flow: Schema.String + } +) {} + +/** + * The request's envelope could not become authority. + * + * `unsupported` — the serving runtime has no {@link EnvelopeInterpreter}. + * `uninterpretable` — the interpreter rejected the envelope value. + * Both refuse execution; an envelope is never silently ignored. + * + * @category errors + * @since 0.1.0 + */ +export class EnvelopeRejected extends Schema.TaggedErrorClass()( + "@smithers/engine/FlowWire/EnvelopeRejected", + { + code: Schema.Literals(["unsupported", "uninterpretable"]), + message: Schema.String + } +) {} + +/** + * The request value itself did not decode. + * + * @category errors + * @since 0.1.0 + */ +export class RequestInvalid extends Schema.TaggedErrorClass()( + "@smithers/engine/FlowWire/RequestInvalid", + { + message: Schema.String + } +) {} + +/** + * A request refused before the flow ran. No durable boundary was opened. + * + * @category models + * @since 0.1.0 + */ +export class Rejected extends Schema.Class("@smithers/engine/FlowWire/Rejected")({ + _tag: Schema.tag("Rejected"), + reason: Schema.Union([RequestInvalid, FlowNotFound, EnvelopeRejected]) +}) {} + +/** + * A request that reached the flow. `exit` is the flow's `Exit` in the JSON + * encoding of the flow's own success and error schemas. + * + * @category models + * @since 0.1.0 + */ +export class Completed extends Schema.Class("@smithers/engine/FlowWire/Completed")({ + _tag: Schema.tag("Completed"), + exit: Schema.Json +}) {} + +/** + * One serializable flow execution response. + * + * @category models + * @since 0.1.0 + */ +export const Response = Schema.Union([Completed, Rejected]) + +/** + * One serializable flow execution response. + * + * @category models + * @since 0.1.0 + */ +export type Response = typeof Response.Type + +/** + * Turns a serialized capability envelope into ambient authority around one + * execution. + * + * The engine defines only the seam. The canonical implementation decodes the + * value with `@smithers/kernel`'s `CapabilityEnvelope` and intersects the + * current `CapabilitySet`, so an envelope can only narrow what the executing + * host already allows. + * + * @category services + * @since 0.1.0 + */ +export class EnvelopeInterpreter extends Context.Service< + EnvelopeInterpreter, + { + readonly apply: ( + envelope: Schema.Json + ) => ( + effect: Effect.Effect + ) => Effect.Effect + } +>()("@smithers/engine/FlowWire/EnvelopeInterpreter") {} + +/** + * Builds an interpreter layer from an envelope decoder. + * + * The decoder validates the untrusted wire value and returns the pure + * attenuation combinator to wrap around the execution. Splitting decoding + * from wrapping is what keeps failures honest: a decoding failure becomes an + * `uninterpretable` refusal, while failures of the wrapped execution pass + * through untouched. + * + * @category layers + * @since 0.1.0 + */ +export const layerInterpreter = ( + decode: ( + envelope: Schema.Json + ) => Effect.Effect< + (effect: Effect.Effect) => Effect.Effect, + { readonly message: string } + > +): Layer.Layer => + Layer.succeed(EnvelopeInterpreter)({ + apply: (envelope) => (effect) => + decode(envelope).pipe( + Effect.mapError((failure) => new EnvelopeRejected({ code: "uninterpretable", message: failure.message })), + Effect.flatMap((attenuate) => attenuate(effect)) + ) + }) + +const exitSchema = (flow: Flow.AnyWithProps) => + Schema.toCodecJson( + Schema.Exit(flow.successSchema, flow.errorSchema, Schema.Defect()) + ) + +const decodeRequest = Schema.decodeUnknownEffect(Request) + +/** + * Builds the one serving function every placement shares. + * + * A Service Worker calls it from a message listener, an edge worker from its + * `fetch` handler, a local process from an HTTP route, a sandbox guest from + * its socket loop — the decoding, envelope handling, execution, and exit + * encoding are identical because they are this function. + * + * Envelope refusals raised by the interpreter come back as a {@link Rejected} + * response rather than an encoded flow failure, so a caller can always tell + * "the request was refused" apart from "the flow ran and failed". + * + * @category constructors + * @since 0.1.0 + */ +export const serve = >( + flows: Flows +): (input: unknown) => Effect.Effect> => { + const byTag = new Map() + for (const flow of flows) { + byTag.set(flow._tag, flow as Flow.AnyWithProps) + } + return (input) => + Effect.gen(function*() { + const decoded = yield* Effect.result(decodeRequest(input)) + if (decoded._tag === "Failure") { + return new Rejected({ + reason: new RequestInvalid({ + message: `The flow wire request did not decode: ${decoded.failure.message}` + }) + }) + } + const request = decoded.success + const flow = byTag.get(request.flow) + if (flow === undefined) { + return new Rejected({ reason: new FlowNotFound({ flow: request.flow }) }) + } + + const payload = yield* Effect.result( + Schema.decodeUnknownEffect(Schema.toCodecJson(flow.payloadSchema))(request.payload) + ) + if (payload._tag === "Failure") { + return new Rejected({ + reason: new RequestInvalid({ + message: `The payload for flow ${request.flow} did not decode: ${payload.failure.message}` + }) + }) + } + + let run: Effect.Effect = flow.execute(payload.success, { + executionId: request.executionId + }) as Effect.Effect + + if (request.envelope !== undefined) { + const interpreter = yield* Effect.serviceOption(EnvelopeInterpreter) + if (Option.isNone(interpreter)) { + return new Rejected({ + reason: new EnvelopeRejected({ + code: "unsupported", + message: "This runtime has no envelope interpreter installed; refusing to run with ambient authority" + }) + }) + } + run = interpreter.value.apply(request.envelope)(run) + } + + const exit = yield* Effect.exit(run) + const refusal = envelopeRefusal(exit) + if (refusal !== undefined) { + return new Rejected({ reason: refusal }) + } + const encoded = yield* Effect.orDie( + Schema.encodeEffect(exitSchema(flow))(exit as Exit.Exit) + ) + return new Completed({ exit: encoded }) + }) as Effect.Effect> +} + +/** + * One serialized HTTP projection of a wire response. + * + * @category models + * @since 0.1.0 + */ +export interface HttpResponse { + readonly status: 200 | 400 | 403 | 404 + readonly body: string +} + +const statusOf = (response: Response): HttpResponse["status"] => + response._tag === "Completed" + ? 200 + : response.reason instanceof FlowNotFound + ? 404 + : response.reason instanceof EnvelopeRejected + ? 403 + : 400 + +/** + * Builds the HTTP body-in, body-out projection of the serving function. + * + * An edge worker's `fetch` handler and a local process's HTTP route differ + * only in how they read the request body and write the response — the JSON + * decoding, envelope handling, execution, and status mapping are this + * function. Statuses: `200` the flow ran (the encoded exit carries success or + * typed failure), `400` invalid request, `403` refused envelope, `404` + * unknown flow. The body is always the encoded {@link Response}, so clients + * decode uniformly regardless of status. + * + * @category constructors + * @since 0.1.0 + */ +export const serveHttp = >( + flows: Flows +): (body: string) => Effect.Effect> => { + const handler = serve(flows) + return (body) => + Effect.gen(function*() { + const parsed = yield* Effect.result(Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: (cause) => new RequestInvalid({ message: `The request body is not JSON: ${String(cause)}` }) + })) + const response = parsed._tag === "Failure" + ? new Rejected({ reason: parsed.failure }) + : yield* handler(parsed.success) + const encoded = yield* Effect.orDie(Schema.encodeEffect(Response)(response)) + return { status: statusOf(response), body: JSON.stringify(encoded) } + }) +} + +const envelopeRefusal = ( + exit: Exit.Exit +): EnvelopeRejected | undefined => { + if (!Exit.isFailure(exit)) { + return undefined + } + for (const reason of exit.cause.reasons) { + if (Cause.isFailReason(reason) && reason.error instanceof EnvelopeRejected) { + return reason.error + } + } + return undefined +} + +const decodeResponse = Schema.decodeUnknownEffect(Response) + +const describeReason = ( + reason: RequestInvalid | FlowNotFound | EnvelopeRejected +): string => + reason instanceof EnvelopeRejected + ? `${reason._tag} (${reason.code}): ${reason.message}` + : reason instanceof FlowNotFound + ? `${reason._tag}: ${reason.flow}` + : `${reason._tag}: ${reason.message}` + +/** + * A failure of the transport carrying a wire request, or a refusal from the + * serving side. + * + * @category errors + * @since 0.1.0 + */ +export class WireError extends Schema.TaggedErrorClass()( + "@smithers/engine/FlowWire/WireError", + { + message: Schema.String, + reason: Schema.optional(Schema.Union([RequestInvalid, FlowNotFound, EnvelopeRejected])), + cause: Schema.optional(Schema.Unknown) + } +) {} + +/** + * Builds a typed client over any request-in, response-out transport. + * + * The transport is one function — post JSON, receive JSON. Placements differ + * only in what that function does: call the serving function in-process, + * `postMessage` to a Service Worker, `fetch` an edge or sandbox endpoint. + * + * @category constructors + * @since 0.1.0 + */ +export const client = ( + post: (request: unknown) => Effect.Effect +) => +< + Tag extends string, + Payload extends Flow.AnyStructSchema, + Success extends Schema.Top, + Error extends Schema.Top +>( + flow: Flow.Flow, + payload: Payload["Type"], + options?: { + readonly executionId?: string | undefined + readonly envelope?: Schema.Json | undefined + } +): Effect.Effect => + Effect.gen(function*() { + const flowProps = flow as unknown as Flow.AnyWithProps + const encodedPayload = yield* Effect.orDie( + Schema.encodeEffect(Schema.toCodecJson(flowProps.payloadSchema))(payload) + ) + const request: Record = { + flow: flow._tag, + payload: encodedPayload + } + if (options?.executionId !== undefined) { + request.executionId = options.executionId + } + if (options?.envelope !== undefined) { + request.envelope = options.envelope + } + const raw = yield* post(request) + const response = yield* Effect.orDie(decodeResponse(raw)) + if (response._tag === "Rejected") { + return yield* Effect.fail( + new WireError({ + message: `The flow wire request was rejected: ${describeReason(response.reason)}`, + reason: response.reason + }) + ) + } + const exit = (yield* Effect.orDie( + Schema.decodeUnknownEffect(exitSchema(flowProps))(response.exit) + )) as Exit.Exit + if (Exit.isSuccess(exit)) { + return exit.value + } + return yield* Effect.failCause(exit.cause) + }) as Effect.Effect diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9e818a36..416f6ad7 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -44,6 +44,11 @@ export * as FlowProxy from "./FlowProxy.ts" */ export * as FlowProxyServer from "./FlowProxyServer.ts" +/** + * @since 0.1.0 + */ +export * as FlowWire from "./FlowWire.ts" + /** * @since 4.0.0 */ diff --git a/packages/engine/test/FlowWire.test.ts b/packages/engine/test/FlowWire.test.ts new file mode 100644 index 00000000..4d2299f8 --- /dev/null +++ b/packages/engine/test/FlowWire.test.ts @@ -0,0 +1,278 @@ +import { Context, Effect, Layer, Schema } from "effect" +import { describe, expect, it } from "vitest" +import { Flow, FlowEngine, FlowWire } from "../src/index.ts" + +const effect = (name: string, body: () => Effect.Effect) => + it(name, () => Effect.runPromise(body() as Effect.Effect)) + +/** + * A stand-in for kernel ambient authority: a fiber `Context.Reference` the + * interpreter narrows, exactly the shape `CapabilitySet.attenuate` has. The + * engine test must not depend on `@smithers/kernel`, so the reference is + * local. + */ +const Authority = Context.Reference>( + "test/FlowWire/Authority", + { defaultValue: () => ["*"] } +) + +const Echo = Flow.make("Wire/Echo", { + payload: { value: Schema.Number }, + success: Schema.Number, + error: Schema.Literal("negative"), + idempotencyKey: ({ value }) => String(value) +}) + +const Observe = Flow.make("Wire/Observe", { + payload: { probe: Schema.String }, + success: Schema.Struct({ granted: Schema.Boolean }), + idempotencyKey: ({ probe }) => probe +}) + +const flows = [Echo, Observe] as const + +const handlers = Layer.mergeAll( + Echo.toLayer(({ value }) => value < 0 ? Effect.fail("negative" as const) : Effect.succeed(value + 1)), + Observe.toLayer(({ probe }) => + Effect.gen(function*() { + const authority = yield* Authority + return { granted: authority.includes("*") || authority.includes(probe) } + }) + ) +).pipe(Layer.provideMerge(FlowEngine.layerMemory)) + +/** + * The test interpreter reads `{ allow: [...] }` envelopes and replaces the + * wildcard authority with the envelope's list — monotone in spirit, minimal + * in mechanics. + */ +const EnvelopeSchema = Schema.Struct({ allow: Schema.Array(Schema.String) }) + +const interpreter = FlowWire.layerInterpreter((envelope) => + Schema.decodeUnknownEffect(EnvelopeSchema)(envelope).pipe( + Effect.mapError((error) => ({ message: error.message })), + Effect.map((decoded) => (wrapped: Effect.Effect): Effect.Effect => + Effect.updateService(wrapped, Authority, (current) => + current.filter((entry) => + entry !== "*" + ).concat(decoded.allow)) + ) + ) +) + +// The wire is JSON: every request/response crosses a stringify/parse +// boundary, exactly like postMessage or fetch. +const overTheWire = ( + handler: (input: unknown) => Effect.Effect +) => +(request: unknown): Effect.Effect => + Effect.gen(function*() { + const serialized = JSON.parse(JSON.stringify(request)) + const response = yield* handler(serialized) + const encoded = yield* Effect.orDie(Schema.encodeEffect(FlowWire.Response)(response)) + return JSON.parse(JSON.stringify(encoded)) + }) as unknown as Effect.Effect + +describe("FlowWire", () => { + effect("serves an execute request end to end over JSON", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve(flows))) + return Effect.gen(function*() { + const result = yield* call(Echo, { value: 41 }, { executionId: "wire-echo" }) + expect(result).toBe(42) + // Without options the execution id derives from the idempotency key. + const optionless = yield* call(Echo, { value: 10 }) + expect(optionless).toBe(11) + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + + effect("returns the flow's typed error through the wire", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve(flows))) + return Effect.gen(function*() { + const exit = yield* Effect.exit(call(Echo, { value: -1 }, { executionId: "wire-negative" })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("negative") + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + + effect("rejects a request for an unregistered flow", () => { + const serveOnlyEcho = FlowWire.serve([Echo] as const) + return Effect.gen(function*() { + const response = yield* serveOnlyEcho({ + flow: "Wire/Observe", + payload: { probe: "net" }, + executionId: "missing" + }) + expect(response._tag).toBe("Rejected") + if (response._tag === "Rejected") { + expect(response.reason._tag).toBe("@smithers/engine/FlowWire/FlowNotFound") + } + }).pipe(Effect.provide(handlers)) + }) + + effect("rejects an undecodable request", () => { + const handler = FlowWire.serve(flows) + return Effect.gen(function*() { + const response = yield* handler({ nonsense: true }) + expect(response._tag).toBe("Rejected") + if (response._tag === "Rejected") { + expect(response.reason._tag).toBe("@smithers/engine/FlowWire/RequestInvalid") + } + }).pipe(Effect.provide(handlers)) + }) + + effect("rejects an undecodable payload", () => { + const handler = FlowWire.serve(flows) + return Effect.gen(function*() { + const response = yield* handler({ + flow: "Wire/Echo", + payload: { value: "not a number" }, + executionId: "bad-payload" + }) + expect(response._tag).toBe("Rejected") + if (response._tag === "Rejected") { + expect(response.reason._tag).toBe("@smithers/engine/FlowWire/RequestInvalid") + } + }).pipe(Effect.provide(handlers)) + }) + + effect("an envelope narrows the authority the handler observes", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve(flows))) + return Effect.gen(function*() { + const granted = yield* call(Observe, { probe: "net" }, { + executionId: "enveloped-allow", + envelope: { allow: ["net"] } + }) + expect(granted).toEqual({ granted: true }) + const denied = yield* call(Observe, { probe: "fs" }, { + executionId: "enveloped-deny", + envelope: { allow: ["net"] } + }) + expect(denied).toEqual({ granted: false }) + // Without an envelope the ambient wildcard authority applies. + const ambient = yield* call(Observe, { probe: "fs" }, { executionId: "ambient" }) + expect(ambient).toEqual({ granted: true }) + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + + effect("fails closed when an envelope arrives and no interpreter is installed", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve(flows))) + return Effect.gen(function*() { + const exit = yield* Effect.exit(call(Observe, { probe: "net" }, { + executionId: "no-interpreter", + envelope: { allow: ["net"] } + })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("unsupported") + }).pipe(Effect.provide(handlers)) + }) + + effect("fails closed when the interpreter cannot read the envelope", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve(flows))) + return Effect.gen(function*() { + const exit = yield* Effect.exit(call(Observe, { probe: "net" }, { + executionId: "bad-envelope", + envelope: { deny: true } + })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("uninterpretable") + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + + effect("a client call for an unregistered flow surfaces the flow name", () => { + const call = FlowWire.client(overTheWire(FlowWire.serve([Echo] as const))) + return Effect.gen(function*() { + const exit = yield* Effect.exit(call(Observe, { probe: "net" }, { executionId: "client-missing" })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("FlowNotFound") + expect(String(exit)).toContain("Wire/Observe") + }).pipe(Effect.provide(handlers)) + }) + + effect("schema drift between client and server is a refusal, not a crash", () => { + // The server's "Wire/Echo" takes a string payload; the client still holds + // the numeric definition. The mismatch must come back as a refusal. + const DriftedEcho = Flow.make("Wire/Echo", { + payload: { value: Schema.String }, + success: Schema.String, + idempotencyKey: ({ value }) => value + }) + const call = FlowWire.client(overTheWire(FlowWire.serve([DriftedEcho] as const))) + return Effect.gen(function*() { + const exit = yield* Effect.exit(call(Echo, { value: 7 }, { executionId: "drift" })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("RequestInvalid") + expect(String(exit)).toContain("did not decode") + }).pipe(Effect.provide(handlers)) + }) + + effect("the HTTP projection maps refusals onto statuses and always encodes a response body", () => { + const handler = FlowWire.serveHttp(flows) + const decodeBody = (body: string) => Effect.orDie(Schema.decodeUnknownEffect(FlowWire.Response)(JSON.parse(body))) + return Effect.gen(function*() { + const completed = yield* handler(JSON.stringify({ + flow: "Wire/Echo", + payload: { value: 41 }, + executionId: "http-echo" + })) + expect(completed.status).toBe(200) + expect((yield* decodeBody(completed.body))._tag).toBe("Completed") + + // A flow that ran and failed is still a 200: the exit carries the error. + const failed = yield* handler(JSON.stringify({ + flow: "Wire/Echo", + payload: { value: -1 }, + executionId: "http-negative" + })) + expect(failed.status).toBe(200) + + const notJson = yield* handler("{not json") + expect(notJson.status).toBe(400) + const notJsonBody = yield* decodeBody(notJson.body) + expect(notJsonBody._tag).toBe("Rejected") + + const badPayload = yield* handler(JSON.stringify({ + flow: "Wire/Echo", + payload: { value: "not a number" }, + executionId: "http-bad" + })) + expect(badPayload.status).toBe(400) + + const missing = yield* handler(JSON.stringify({ + flow: "Wire/Missing", + payload: {}, + executionId: "http-missing" + })) + expect(missing.status).toBe(404) + + const enveloped = yield* handler(JSON.stringify({ + flow: "Wire/Echo", + payload: { value: 1 }, + executionId: "http-enveloped", + envelope: { allow: ["net"] } + })) + expect(enveloped.status).toBe(403) + }).pipe(Effect.provide(handlers)) + }) + + effect("repeated wire requests for one execution id deduplicate", () => { + let calls = 0 + const Counted = Flow.make("Wire/Counted", { + payload: { value: Schema.Number }, + success: Schema.Number, + idempotencyKey: ({ value }) => String(value) + }) + const layer = Counted.toLayer(({ value }) => + Effect.sync(() => { + calls++ + return value * 2 + }) + ).pipe(Layer.provideMerge(FlowEngine.layerMemory)) + const call = FlowWire.client(overTheWire(FlowWire.serve([Counted] as const))) + return Effect.gen(function*() { + const first = yield* call(Counted, { value: 4 }, { executionId: "dedupe" }) + const second = yield* call(Counted, { value: 4 }, { executionId: "dedupe" }) + expect([first, second]).toEqual([8, 8]) + expect(calls).toBe(1) + }).pipe(Effect.provide(layer)) + }) +}) diff --git a/packages/kernel/README.md b/packages/kernel/README.md index d71ac338..f25b8ee0 100644 --- a/packages/kernel/README.md +++ b/packages/kernel/README.md @@ -13,22 +13,23 @@ npm install @smithers/kernel The root exports these namespaces, also available from matching `@smithers/kernel/*` subpaths. -| Namespace | Public exports | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Capability` | `Action`, exact `Capability`, `PatternAction`, and `CapabilityPattern`; `make`, `format`, `formatPattern`, `parse`, `matches`, and `subsumes`; `EffectTier`, `TierOptions`, `tierOf`, and `requiresIdempotencyKey`. | -| `CapabilitySet` | `CapabilitySet`; `fromPatterns`, empty authority `none`, `allows`, `intersect`, `equals`, ambient `current`, and monotone `attenuate`. No widening constructor or unrestricted value is public. | -| `Permission` | `PermissionRequired`, `PermissionDenied`, `GrantStoreErrorCode`, and `GrantStoreError`; policy `RuleEffect`, `Rule`, and `evaluate`; constructors `permissionRequired` and `permissionDenied`. | -| `GrantEvent` | `GrantTier`, `GrantScope`, `OnceGrant`, `RememberedGrant`, `RunGrant`, `DeniedGrant`, `EnvelopeGrant`, `GrantEventSchema`, `GrantEvent`, `decode`, and `encode`. | -| `GrantStore` | `PendingRequest`, `Resolution`, `EnvelopeGrantOptions`, `Persist`, and `MakeOptions`; `Service` / `GrantStore` operations `check`, `reply`, `list`, and `grantEnvelope`; `isValidGrantPattern`, `isValidEnvelopePattern`, `make`, `layer`, allow-all `makeNoop`, and `layerNoop`. | -| `JournalGrantStore` | `JournalGrantStoreOptions`; `make` and `layer` replay and persist grants through `Journal`. | -| `HostServices` | Raw `HostService`, `HostServiceTags`, and `HostServiceIds`; permission-aware `ProtectedHostService` and `ProtectedHostServiceTags`; aggregate decorator `layer`. | -| `FileSystem` | Permission-aware `File` and `FileSystem` interface/tag; `make`, `makeNoop`, `layerNoop`, `canonicalResource`, and decorator `layer`. | -| `HttpClient` | `HttpClientError`, permission-aware `HttpClient` interface/tag with `executeModel`; `make`, `makeNoop`, `layerNoop`, and decorator `layer`. | -| `Shell` | Permission-aware `Shell` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | -| `Pty` | Permission-aware `Pty` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | -| `Jj` | Permission-aware `Jj` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | -| `Path` | Effect `Path` type/tag and explicit pass-through `layer`. | -| `Workspace` | `Service` / `Workspace` root configuration; `make`, `layer`, relative test value `makeNoop`, and `layerNoop`. | +| Namespace | Public exports | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Capability` | `Action`, exact `Capability`, `PatternAction`, and `CapabilityPattern`; `make`, `format`, `formatPattern`, `parse`, `parsePattern`, `matches`, and `subsumes`; `EffectTier`, `TierOptions`, `tierOf`, and `requiresIdempotencyKey`. | +| `CapabilitySet` | `CapabilitySet`; `fromPatterns`, empty authority `none`, `allows`, `allowsPattern`, `intersect`, `equals`, ambient `current`, and monotone `attenuate`. No widening constructor or unrestricted value is public. | +| `CapabilityEnvelope` | Serializable `CapabilityEnvelope` and its `version`; `CapabilityEnvelopeError`; `make`, `patternsOf`, `decode`, `encode`, monotone `apply`, `interpret`, and `interpreter`. Unrestricted authority is expressed by omitting the envelope, never by an envelope value. | +| `Permission` | `PermissionRequired`, `PermissionDenied`, `GrantStoreErrorCode`, and `GrantStoreError`; policy `RuleEffect`, `Rule`, and `evaluate`; constructors `permissionRequired` and `permissionDenied`. | +| `GrantEvent` | `GrantTier`, `GrantScope`, `OnceGrant`, `RememberedGrant`, `RunGrant`, `DeniedGrant`, `EnvelopeGrant`, `GrantEventSchema`, `GrantEvent`, `decode`, and `encode`. | +| `GrantStore` | `PendingRequest`, `Resolution`, `EnvelopeGrantOptions`, `Persist`, and `MakeOptions`; `Service` / `GrantStore` operations `check`, `reply`, `list`, and `grantEnvelope`; `isValidGrantPattern`, `isValidEnvelopePattern`, `make`, `layer`, allow-all `makeNoop`, and `layerNoop`. | +| `JournalGrantStore` | `JournalGrantStoreOptions`; `make` and `layer` replay and persist grants through `Journal`. | +| `HostServices` | Raw `HostService`, `HostServiceTags`, and `HostServiceIds`; permission-aware `ProtectedHostService` and `ProtectedHostServiceTags`; aggregate decorator `layer`. | +| `FileSystem` | Permission-aware `File` and `FileSystem` interface/tag; `make`, `makeNoop`, `layerNoop`, `canonicalResource`, and decorator `layer`. | +| `HttpClient` | `HttpClientError`, permission-aware `HttpClient` interface/tag with `executeModel`; `make`, `makeNoop`, `layerNoop`, and decorator `layer`. | +| `Shell` | Permission-aware `Shell` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | +| `Pty` | Permission-aware `Pty` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | +| `Jj` | Permission-aware `Jj` interface/tag; `make`, `makeNoop`, `layerNoop`, and `layer`. | +| `Path` | Effect `Path` type/tag and explicit pass-through `layer`. | +| `Workspace` | `Service` / `Workspace` root configuration; `make`, `layer`, relative test value `makeNoop`, and `layerNoop`. | The public `@smithers/kernel/test/TestGrantStore` subpath exports `layerAllow`, `layerDeny(reason?)`, and `layerScripted(replies)`. diff --git a/packages/kernel/src/Capability.ts b/packages/kernel/src/Capability.ts index 2d947729..3312c9cb 100644 --- a/packages/kernel/src/Capability.ts +++ b/packages/kernel/src/Capability.ts @@ -148,6 +148,16 @@ const PatternAction = Schema.Literals( ] as const ) +const patternActions: ReadonlySet = new Set([ + ...actions, + "fs:*", + "net:*", + "model:*", + "proc:*", + "jj:*", + "*" +]) + /** * An action and resource glob used to grant or deny a family of capabilities. * Resource globs are slash-normalized and matched against the whole resource. @@ -160,6 +170,34 @@ export class CapabilityPattern extends Schema.Class("@smither resource: Schema.String }) {} +/** + * Parses a declared capability requirement as a pattern. The conservative + * registry shorthand `*` means every action over every resource. + * + * @since 0.1.0 + * @category parsing + */ +export const parsePattern = (input: string): Option.Option => { + if (input === "*") { + return Option.some(new CapabilityPattern({ action: "*", resource: "**" })) + } + const components = input.split(":") + const namespace = components[0] + const operation = components[1] + if (namespace === undefined || operation === undefined || components.length < 3) { + return Option.none() + } + const action = `${namespace}:${operation}` + return patternActions.has(action) + ? Option.some( + new CapabilityPattern({ + action: action as PatternAction, + resource: components.slice(2).join(":") + }) + ) + : Option.none() +} + const normalizeSlashes = (value: string): string => value.replaceAll("\\", "/") const matchesAction = (pattern: PatternAction, action: Action): boolean => @@ -198,7 +236,7 @@ const actionSubsumes = (left: PatternAction, right: PatternAction): boolean => { const resourceSubsumes = (left: string, right: string): boolean => { const normalizedLeft = normalizeSlashes(left) const normalizedRight = normalizeSlashes(right) - if (normalizedLeft === normalizedRight || normalizedLeft === "**") { + if (normalizedLeft === normalizedRight || normalizedLeft === "*" || normalizedLeft === "**") { return true } if (!normalizedLeft.endsWith("/**")) { diff --git a/packages/kernel/src/CapabilityEnvelope.ts b/packages/kernel/src/CapabilityEnvelope.ts new file mode 100644 index 00000000..65f8dce7 --- /dev/null +++ b/packages/kernel/src/CapabilityEnvelope.ts @@ -0,0 +1,178 @@ +/** + * The serializable capability envelope. + * + * One flow execution request names its authority once, as data, and every + * placement — a browser Service Worker, a local Bun process, an edge worker, + * a cloud sandbox — turns that same data into ambient authority the same way: + * by intersecting it with whatever the executing host already allows. An + * envelope can therefore only narrow; a request cannot mint capability its + * host never had, no matter which runtime executes it. + * + * Governing design: + * `docs/specs/Concepts/Permission Kernel.md` and + * `docs/specs/Concepts/Effect Taxonomy.md`. + * + * @since 0.1.0 + */ +import { Effect, Option, Schema } from "effect" +import { CapabilityPattern, parsePattern } from "./Capability.ts" +import * as CapabilitySet from "./CapabilitySet.ts" + +/** + * The wire version this module reads and writes. Interpreters fail closed on + * any other value rather than guessing at future semantics. + * + * @category models + * @since 0.1.0 + */ +export const version = "flows/capability-envelope/v1" as const + +/** + * A versioned, JSON-serializable any-of group of capability patterns. + * + * The empty pattern list is the empty envelope: it denies every capability, + * because an intersected empty any-of group can never match. Unrestricted + * authority is expressed by *omitting* the envelope, never by an envelope + * value. + * + * @category models + * @since 0.1.0 + */ +export class CapabilityEnvelope extends Schema.Class( + "@smithers/kernel/CapabilityEnvelope" +)({ + version: Schema.Literal(version), + patterns: Schema.Array(CapabilityPattern) +}) {} + +/** + * Constructs an envelope from capability patterns. + * + * @category constructors + * @since 0.1.0 + */ +export const make = ( + patterns: ReadonlyArray +): CapabilityEnvelope => new CapabilityEnvelope({ version, patterns }) + +/** + * An envelope that could not be decoded. Interpretation is fail-closed: an + * uninterpretable envelope refuses execution instead of running with the + * host's ambient authority. + * + * @category errors + * @since 0.1.0 + */ +export class CapabilityEnvelopeError extends Schema.TaggedErrorClass()( + "@smithers/kernel/CapabilityEnvelopeError", + { + message: Schema.String + } +) {} + +const decodeEnvelope = Schema.decodeUnknownEffect(CapabilityEnvelope) + +/** + * Decodes an untrusted wire value into an envelope, failing closed. + * + * @category parsing + * @since 0.1.0 + */ +export const decode = ( + input: unknown +): Effect.Effect => + decodeEnvelope(input).pipe( + Effect.mapError((issue) => + new CapabilityEnvelopeError({ + message: `The capability envelope is not a valid ${version} value: ${issue.message}` + }) + ) + ) + +const encodeEnvelope = Schema.encodeEffect(Schema.toCodecJson(CapabilityEnvelope)) + +/** + * Encodes an envelope for the wire. + * + * @category encoding + * @since 0.1.0 + */ +export const encode = ( + envelope: CapabilityEnvelope +): Effect.Effect => Effect.orDie(encodeEnvelope(envelope)) + +/** + * Runs an effect with authority intersected with the envelope's patterns. + * + * This is deliberately the only conversion from envelope to authority, and it + * is monotone: `CapabilitySet.attenuate` intersects, so the executing host's + * own policy always still applies. + * + * @category combinators + * @since 0.1.0 + */ +export const apply = ( + envelope: CapabilityEnvelope +): (effect: Effect.Effect) => Effect.Effect => CapabilitySet.attenuate(envelope.patterns) + +/** + * Decodes an untrusted wire value and applies it in one step — the shape a + * host needs to interpret envelopes arriving with remote execution requests. + * + * @category combinators + * @since 0.1.0 + */ +export const interpret = (input: unknown) => +( + effect: Effect.Effect +): Effect.Effect => + Effect.flatMap(decode(input), (envelope) => apply(envelope)(effect)) + +/** + * The one decoder-to-attenuator every placement installs. + * + * Splitting decoding from attenuation is what keeps a host's refusals honest: + * this function fails only when the envelope value itself is not readable, and + * the combinator it returns is pure, so a failure of the wrapped execution can + * never be mistaken for an envelope refusal. `@smithers/engine`'s + * `FlowWire.layerInterpreter` takes exactly this shape, so a host installs the + * canonical interpreter as `layerInterpreter(CapabilityEnvelope.interpreter)` + * and no placement can grow its own dialect of decoding or attenuation. + * + * @category combinators + * @since 0.1.0 + */ +export const interpreter = ( + input: unknown +): Effect.Effect< + (effect: Effect.Effect) => Effect.Effect, + CapabilityEnvelopeError +> => Effect.map(decode(input), apply) + +/** + * Reads the capability strings a flow declaration carries as the patterns of + * the envelope that declaration's calls travel with. + * + * Declarations format capabilities as `action:resource`, and the resource half + * is a glob (`fs:read:src/**`, `jj:*:repository/**`, the conservative `*`), so + * they parse as patterns and never as exact capabilities. Parsing lives here, + * with the envelope it feeds, so every placement derives the same authority + * from the same declaration. An unparseable entry yields `None` — the caller + * decides whether that is a refusal or a defect. + * + * @category parsing + * @since 0.1.0 + */ +export const patternsOf = ( + capabilities: ReadonlyArray +): Option.Option> => { + const patterns: Array = [] + for (const declared of capabilities) { + const parsed = parsePattern(declared) + if (Option.isNone(parsed)) { + return Option.none() + } + patterns.push(parsed.value) + } + return Option.some(patterns) +} diff --git a/packages/kernel/src/CapabilitySet.ts b/packages/kernel/src/CapabilitySet.ts index 5dbaa8e2..8f069556 100644 --- a/packages/kernel/src/CapabilitySet.ts +++ b/packages/kernel/src/CapabilitySet.ts @@ -9,7 +9,7 @@ * @since 0.1.0 */ import { Context, Effect } from "effect" -import { type Capability, type CapabilityPattern, matches } from "./Capability.ts" +import { type Capability, type CapabilityPattern, matches, subsumes } from "./Capability.ts" const CapabilitySetTypeId: unique symbol = Symbol.for("@smithers/kernel/CapabilitySet") @@ -125,6 +125,18 @@ export const allows = ( capability: Capability ): boolean => set.groups.every((group) => group.some((pattern) => matches(pattern, capability))) +/** + * Tests whether the set provably contains every capability selected by one + * declared requirement pattern. + * + * @category predicates + * @since 0.1.0 + */ +export const allowsPattern = ( + set: CapabilitySet, + required: CapabilityPattern +): boolean => set.groups.every((group) => group.some((pattern) => subsumes(pattern, required))) + /** * Intersects two authorities without synthesizing or simplifying globs. * diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index a0eadac4..a7cb47c4 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -12,6 +12,14 @@ */ export * as Capability from "./Capability.ts" +/** + * The serializable capability envelope carried by flow execution requests. + * + * @category namespace exports + * @since 0.1.0 + */ +export * as CapabilityEnvelope from "./CapabilityEnvelope.ts" + /** * Monotone ambient authority and intersection. * diff --git a/packages/kernel/test/Capability.test.ts b/packages/kernel/test/Capability.test.ts index f7b98b87..acf7c41d 100644 --- a/packages/kernel/test/Capability.test.ts +++ b/packages/kernel/test/Capability.test.ts @@ -18,6 +18,15 @@ describe("Capability", () => { expect(Option.isNone(Capability.parse("fs:read"))).toBe(true) }) + it("parses declared requirement patterns and the conservative wildcard", () => { + expect(Option.getOrNull(Capability.parsePattern("fs:read:src/**"))).toEqual( + pattern("fs:read", "src/**") + ) + expect(Option.getOrNull(Capability.parsePattern("*"))).toEqual(pattern("*", "**")) + expect(Option.isNone(Capability.parsePattern("unknown:action:**"))).toBe(true) + expect(Option.isNone(Capability.parsePattern("fs:read"))).toBe(true) + }) + it("round trips formatted capabilities", () => { const action = FastCheck.constantFrom( "fs:read", @@ -62,6 +71,7 @@ describe("Capability", () => { [pattern("fs:read", "src/a.ts"), pattern("fs:read", "src/a.ts"), true], [pattern("fs:*", "src/**"), pattern("fs:read", "src/nested/a.ts"), true], [pattern("*", "**"), pattern("jj:*", "repository"), true], + [pattern("*", "*"), pattern("fs:read", "/workspace/**"), true], [pattern("jj:*", "repository/**"), pattern("jj:diff", "repository/one"), true], [pattern("fs:read", "src/**"), pattern("fs:write", "src/a.ts"), false], [pattern("fs:read", "src/*"), pattern("fs:read", "src/a.ts"), false], diff --git a/packages/kernel/test/CapabilityEnvelope.test.ts b/packages/kernel/test/CapabilityEnvelope.test.ts new file mode 100644 index 00000000..0c13ba5f --- /dev/null +++ b/packages/kernel/test/CapabilityEnvelope.test.ts @@ -0,0 +1,147 @@ +import { Effect, Option } from "effect" +import { describe, expect, it } from "vitest" +import { Capability, CapabilityPattern } from "../src/Capability.ts" +import * as CapabilityEnvelope from "../src/CapabilityEnvelope.ts" +import * as CapabilitySet from "../src/CapabilitySet.ts" + +const run = (effect: Effect.Effect) => Effect.runPromise(effect) + +const pattern = (action: CapabilityPattern["action"], resource: string) => new CapabilityPattern({ action, resource }) + +const netGet = new Capability({ action: "net:get", resource: "https://example.com/data" }) +const fsWrite = new Capability({ action: "fs:write", resource: "/workspace/out.txt" }) + +describe("CapabilityEnvelope", () => { + it("round-trips through its JSON encoding", () => + run(Effect.gen(function*() { + const envelope = CapabilityEnvelope.make([pattern("net:*", "**"), pattern("fs:read", "/workspace/**")]) + const encoded = yield* CapabilityEnvelope.encode(envelope) + // the wire value is plain JSON: survives stringify/parse untouched + const wire: unknown = JSON.parse(JSON.stringify(encoded)) + const decoded = yield* CapabilityEnvelope.decode(wire) + expect(decoded.version).toBe(CapabilityEnvelope.version) + expect(decoded.patterns).toHaveLength(2) + expect(decoded.patterns[0]?.action).toBe("net:*") + }))) + + it("fails closed on an unknown version", async () => { + const exit = await Effect.runPromiseExit( + CapabilityEnvelope.decode({ version: "flows/capability-envelope/v2", patterns: [] }) + ) + expect(exit._tag).toBe("Failure") + }) + + it("fails closed on a malformed value", async () => { + const exit = await Effect.runPromiseExit(CapabilityEnvelope.decode("not an envelope")) + expect(exit._tag).toBe("Failure") + }) + + it("apply intersects: the envelope narrows ambient authority", () => + run( + Effect.gen(function*() { + const set = yield* CapabilitySet.current + expect(CapabilitySet.allows(set, netGet)).toBe(true) + expect(CapabilitySet.allows(set, fsWrite)).toBe(true) + }).pipe( + CapabilityEnvelope.apply( + CapabilityEnvelope.make([pattern("net:*", "**"), pattern("fs:write", "/workspace/**")]) + ) + ) + )) + + it("apply denies capabilities outside the envelope", () => + run( + Effect.gen(function*() { + const set = yield* CapabilitySet.current + expect(CapabilitySet.allows(set, netGet)).toBe(false) + expect(CapabilitySet.allows(set, fsWrite)).toBe(true) + }).pipe( + CapabilityEnvelope.apply(CapabilityEnvelope.make([pattern("fs:*", "**")])) + ) + )) + + it("the empty envelope denies everything", () => + run( + Effect.gen(function*() { + const set = yield* CapabilitySet.current + expect(CapabilitySet.allows(set, netGet)).toBe(false) + expect(CapabilitySet.allows(set, fsWrite)).toBe(false) + }).pipe(CapabilityEnvelope.apply(CapabilityEnvelope.make([]))) + )) + + it("apply cannot widen an already-attenuated fiber", () => + run( + Effect.gen(function*() { + const set = yield* CapabilitySet.current + // The outer attenuation to fs-only still applies even though the + // envelope would allow the network. + expect(CapabilitySet.allows(set, netGet)).toBe(false) + expect(CapabilitySet.allows(set, fsWrite)).toBe(true) + }).pipe( + CapabilityEnvelope.apply(CapabilityEnvelope.make([pattern("*", "**")])), + CapabilitySet.attenuate([pattern("fs:*", "**")]) + ) + )) + + it("interpret decodes then applies in one step", () => + run(Effect.gen(function*() { + const wire = yield* CapabilityEnvelope.encode( + CapabilityEnvelope.make([pattern("net:get", "https://example.com/**")]) + ) + const set = yield* CapabilityEnvelope.interpret(wire)(CapabilitySet.current) + expect(CapabilitySet.allows(set, netGet)).toBe(true) + expect(CapabilitySet.allows(set, fsWrite)).toBe(false) + }))) + + it("interpret fails with CapabilityEnvelopeError on garbage", async () => { + const exit = await Effect.runPromiseExit( + CapabilityEnvelope.interpret({ nonsense: true })(Effect.void) + ) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain("CapabilityEnvelopeError") + } + }) + + it("interpreter decodes once and returns a pure attenuator", () => + run(Effect.gen(function*() { + const wire = yield* CapabilityEnvelope.encode( + CapabilityEnvelope.make([pattern("net:get", "https://example.com/**")]) + ) + const attenuate = yield* CapabilityEnvelope.interpreter(wire) + const set = yield* attenuate(CapabilitySet.current) + expect(CapabilitySet.allows(set, netGet)).toBe(true) + expect(CapabilitySet.allows(set, fsWrite)).toBe(false) + }))) + + it("interpreter fails before it ever wraps an effect, so a run failure is never a refusal", async () => { + const exit = await Effect.runPromiseExit(CapabilityEnvelope.interpreter({ nonsense: true })) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain("CapabilityEnvelopeError") + } + }) + + it("patternsOf reads declared capability strings as envelope patterns", () => { + expect( + Option.getOrNull(CapabilityEnvelope.patternsOf(["fs:read:src/**", "jj:*:repository/**", "*"])) + ).toEqual([ + pattern("fs:read", "src/**"), + pattern("jj:*", "repository/**"), + pattern("*", "**") + ]) + }) + + it("patternsOf refuses a declaration it cannot read", () => { + expect(Option.isNone(CapabilityEnvelope.patternsOf(["fs:read"]))).toBe(true) + expect(Option.isNone(CapabilityEnvelope.patternsOf(["nonsense:action:**"]))).toBe(true) + }) + + it("an envelope built from a declaration grants exactly that declaration", () => + run(Effect.gen(function*() { + const patterns = Option.getOrThrow(CapabilityEnvelope.patternsOf(["fs:write:/workspace/**"])) + const set = yield* CapabilityEnvelope.apply(CapabilityEnvelope.make(patterns))(CapabilitySet.current) + expect(CapabilitySet.allows(set, fsWrite)).toBe(true) + expect(CapabilitySet.allows(set, netGet)).toBe(false) + }))) +}) diff --git a/packages/kernel/test/CapabilitySet.test.ts b/packages/kernel/test/CapabilitySet.test.ts index a2b46a15..3ee99c02 100644 --- a/packages/kernel/test/CapabilitySet.test.ts +++ b/packages/kernel/test/CapabilitySet.test.ts @@ -123,6 +123,22 @@ describe("CapabilitySet", () => { )).toBe(false) }) + it("proves a declared requirement is contained by every intersected group", () => { + const set = CapabilitySets.intersect( + CapabilitySets.fromPatterns([new CapabilityPattern({ action: "fs:*", resource: "/workspace/**" })]), + CapabilitySets.fromPatterns([new CapabilityPattern({ action: "fs:read", resource: "/workspace/src/**" })]) + ) + + expect(CapabilitySets.allowsPattern( + set, + new CapabilityPattern({ action: "fs:read", resource: "/workspace/src/a.ts" }) + )).toBe(true) + expect(CapabilitySets.allowsPattern( + set, + new CapabilityPattern({ action: "fs:read", resource: "/workspace/**" }) + )).toBe(false) + }) + it("intersect is commutative", () => { check([setArbitrary, setArbitrary], (left, right) => CapabilitySets.equals( @@ -235,6 +251,7 @@ describe("CapabilitySet", () => { it("exports no authority-widening API", () => { expect(Object.keys(CapabilitySets).sort()).toEqual([ "allows", + "allowsPattern", "attenuate", "current", "equals", diff --git a/packages/kernel/test/index.test.ts b/packages/kernel/test/index.test.ts index 8bd426f3..03266701 100644 --- a/packages/kernel/test/index.test.ts +++ b/packages/kernel/test/index.test.ts @@ -5,6 +5,7 @@ describe("kernel package barrel", () => { it("exports every public namespace", () => { expect(Object.keys(Kernel).sort()).toEqual([ "Capability", + "CapabilityEnvelope", "CapabilitySet", "FileSystem", "GrantEvent", From 0a0fbe64573ce35f26a3ccf7bf9aa44129e7064f Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 8 Aug 2026 05:46:03 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat(engine):=20let=20a=20place?= =?UTF-8?q?ment=20require=20a=20stated=20capability=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An omitted envelope means "run with whatever this host holds", which is only safe while every caller of the transport is already trusted with that ceiling. `FlowWire.serve`/`serveHttp` now take `requireEnvelope`: the flows that refuse an unenveloped request, checked before the payload decodes, so a caller cannot learn whether its payload was well-formed without first stating its authority. `EnvelopeRejected` gains the `missing` code alongside `unsupported` and `uninterpretable`, so an absent envelope is legible as an authority refusal — 403 over HTTP — rather than a flow that ran and failed. Flows not named keep the ambient-authority default, which is what a pure flow wants. Co-Authored-By: Claude Opus 5 --- packages/engine/src/FlowWire.ts | 55 ++++++++++++++++++++++--- packages/engine/test/FlowWire.test.ts | 59 +++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/FlowWire.ts b/packages/engine/src/FlowWire.ts index 974eef0f..1b1558f6 100644 --- a/packages/engine/src/FlowWire.ts +++ b/packages/engine/src/FlowWire.ts @@ -15,6 +15,12 @@ * request that names an envelope is refused, not run wide, when no * interpreter is installed. * + * Omitting the envelope means "run with whatever authority this host holds", + * which is only safe while the host's own ceiling is the real bound. A host + * that serves effectful flows to callers it does not already trust with its + * full ceiling names those flows in {@link ServeOptions.requireEnvelope}, and + * an unenveloped request for one is refused as `missing` before the flow runs. + * * @since 0.1.0 */ import * as Cause from "effect/Cause" @@ -62,7 +68,10 @@ export class FlowNotFound extends Schema.TaggedErrorClass()( * * `unsupported` — the serving runtime has no {@link EnvelopeInterpreter}. * `uninterpretable` — the interpreter rejected the envelope value. - * Both refuse execution; an envelope is never silently ignored. + * `missing` — the flow requires an envelope and the request named none, so + * the host refuses rather than lending the caller its own ceiling. + * All three refuse execution; an envelope is never silently ignored, and its + * absence is never silently upgraded to ambient authority. * * @category errors * @since 0.1.0 @@ -70,7 +79,7 @@ export class FlowNotFound extends Schema.TaggedErrorClass()( export class EnvelopeRejected extends Schema.TaggedErrorClass()( "@smithers/engine/FlowWire/EnvelopeRejected", { - code: Schema.Literals(["unsupported", "uninterpretable"]), + code: Schema.Literals(["unsupported", "uninterpretable", "missing"]), message: Schema.String } ) {} @@ -185,6 +194,27 @@ const exitSchema = (flow: Flow.AnyWithProps) => const decodeRequest = Schema.decodeUnknownEffect(Request) +/** + * How a placement serves its flows. + * + * @category models + * @since 0.1.0 + */ +export interface ServeOptions { + /** + * The flows that refuse a request carrying no envelope. + * + * An omitted envelope means "run under this host's own ceiling", which is + * the right default only when every caller of the transport is already + * trusted with that ceiling. Naming an effectful flow here makes the + * envelope load-bearing instead of implicit: the caller must state the + * authority it is exercising, and the host's ceiling then narrows it + * further. Flows not named here keep the ambient-authority default, which is + * what a pure flow wants. + */ + readonly requireEnvelope?: ReadonlyArray | undefined +} + /** * Builds the one serving function every placement shares. * @@ -201,8 +231,10 @@ const decodeRequest = Schema.decodeUnknownEffect(Request) * @since 0.1.0 */ export const serve = >( - flows: Flows + flows: Flows, + options?: ServeOptions ): (input: unknown) => Effect.Effect> => { + const mustCarryEnvelope = new Set(options?.requireEnvelope ?? []) const byTag = new Map() for (const flow of flows) { byTag.set(flow._tag, flow as Flow.AnyWithProps) @@ -223,6 +255,18 @@ export const serve = >( return new Rejected({ reason: new FlowNotFound({ flow: request.flow }) }) } + // Asked before the payload is even decoded: whether the caller stated + // its authority is a property of the request, not of what it carries. + if (request.envelope === undefined && mustCarryEnvelope.has(request.flow)) { + return new Rejected({ + reason: new EnvelopeRejected({ + code: "missing", + message: + `The flow ${request.flow} requires a capability envelope; refusing to run it with this host's ambient authority` + }) + }) + } + const payload = yield* Effect.result( Schema.decodeUnknownEffect(Schema.toCodecJson(flow.payloadSchema))(request.payload) ) @@ -298,9 +342,10 @@ const statusOf = (response: Response): HttpResponse["status"] => * @since 0.1.0 */ export const serveHttp = >( - flows: Flows + flows: Flows, + options?: ServeOptions ): (body: string) => Effect.Effect> => { - const handler = serve(flows) + const handler = serve(flows, options) return (body) => Effect.gen(function*() { const parsed = yield* Effect.result(Effect.try({ diff --git a/packages/engine/test/FlowWire.test.ts b/packages/engine/test/FlowWire.test.ts index 4d2299f8..8f80a7e6 100644 --- a/packages/engine/test/FlowWire.test.ts +++ b/packages/engine/test/FlowWire.test.ts @@ -178,6 +178,65 @@ describe("FlowWire", () => { }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) }) + effect("a flow that requires an envelope refuses an unenveloped request", () => { + const handler = FlowWire.serve(flows, { requireEnvelope: ["Wire/Observe"] }) + return Effect.gen(function*() { + const refused = yield* handler({ + flow: "Wire/Observe", + payload: { probe: "fs" }, + executionId: "must-state-authority" + }) + expect(refused._tag).toBe("Rejected") + if (refused._tag === "Rejected") { + expect(refused.reason._tag).toBe("@smithers/engine/FlowWire/EnvelopeRejected") + expect((refused.reason as FlowWire.EnvelopeRejected).code).toBe("missing") + } + + // The refusal precedes payload decoding: a caller cannot learn whether + // its payload was well-formed without first stating its authority. + const refusedBadPayload = yield* handler({ + flow: "Wire/Observe", + payload: { probe: 42 }, + executionId: "must-state-authority-bad-payload" + }) + expect(refusedBadPayload._tag).toBe("Rejected") + if (refusedBadPayload._tag === "Rejected") { + expect(refusedBadPayload.reason._tag).toBe("@smithers/engine/FlowWire/EnvelopeRejected") + } + + // The same request with an envelope runs, narrowed by it. + const enveloped = yield* handler({ + flow: "Wire/Observe", + payload: { probe: "fs" }, + executionId: "stated-authority", + envelope: { allow: ["net"] } + }) + expect(enveloped._tag).toBe("Completed") + expect(JSON.stringify(enveloped)).toContain("false") + + // A flow the host did not name keeps the ambient-authority default. + const unlisted = yield* handler({ + flow: "Wire/Echo", + payload: { value: 41 }, + executionId: "ambient-still-allowed" + }) + expect(unlisted._tag).toBe("Completed") + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + + effect("the HTTP projection refuses a required-envelope omission as 403", () => { + const handler = FlowWire.serveHttp(flows, { requireEnvelope: ["Wire/Observe"] }) + return Effect.gen(function*() { + const refused = yield* handler(JSON.stringify({ + flow: "Wire/Observe", + payload: { probe: "fs" }, + executionId: "http-must-state-authority" + })) + expect(refused.status).toBe(403) + expect(refused.body).toContain("requires a capability envelope") + }).pipe(Effect.provide(interpreter), Effect.provide(handlers)) + }) + effect("a client call for an unregistered flow surfaces the flow name", () => { const call = FlowWire.client(overTheWire(FlowWire.serve([Echo] as const))) return Effect.gen(function*() {