diff --git a/.changeset/execution-principals.md b/.changeset/execution-principals.md new file mode 100644 index 0000000..172425f --- /dev/null +++ b/.changeset/execution-principals.md @@ -0,0 +1,33 @@ +--- +'@proofoftech/breakwater': minor +'@proofoftech/flowsafe': minor +'anchorage-agent-starter': patch +--- + +Add first-class execution principals so automated work stops impersonating people. + +Every automated path previously fabricated a human to satisfy the one identity the platform had: the schedule tick, cron SLA maintenance, signal-provider delivery, and the suspension-reconcile bridge all minted `role: 'operator'`. That lost provenance and gave autonomous execution an operator's authority. + +Breakwater's `Actor` gains an optional `kind` (`human` | `service` | `agent` | `system`, absent meaning human), and both `RBACMiddleware` and `createGuardedAgent` gain `allowedPrincipalKinds`, defaulting to `['human']`. The gate checks kind before role and does not consult the role allowlist for a non-human kind, because an automated principal carries a role only to satisfy the required field — consulting it would either admit whatever role the host projected, or force hosts to allow that role and thereby admit real humans holding it. Both the processor gate and the direct-call gate enforce it. **An existing agent therefore denies every automated principal without a config change.** + +Flowsafe adds `ExecutionPrincipal`, with `purpose` required on every automated kind, and persists it in agent-run state and approval resume targets. `AgentMeta.allowedAutomation` declares which principal kinds may enter on which entry paths; absent or empty denies all automated entry, and an optional host authorizer can only narrow it further. `ApprovalActor` is unchanged and still means an authenticated human at the HTTP boundary or a reviewer deciding an approval — a human approval never transfers the decider's authority into the resumed run. + +The `@proofoftech/flowsafe/agent-host` entry point exports its automation policy types, including `AgentAutomationRule`, `AutomationCheck`, `AutomatedEntryRequest`, and `AutomatedEntryAuthorizer`, so public catalog and host signatures never require deep imports. + +`ApprovalService` gains `createAsPrincipal` and `supersedeStaleAsPrincipal` for trusted platform bridges. They replace the human role gate with a kind-and-tenant check rather than widening it. There is deliberately no principal-taking `decide`, `claim`, or `delegate`. + +`trustAutomationPrincipal()` returns a branded, frozen canonical clone rather than the caller's own object. Validating a principal and handing the same reference back left the vouch time-of-check/time-of-use: the caller kept a mutable alias and could rewrite a vouched `system` principal into `{kind:'human', role:'admin'}` before the service read `kind`. The trusted entries now recheck the own brand, the automated shape, the kind, and that every field is a plain data property — an accessor survives `Object.freeze` and would reopen the same hole — instead of trusting a parameter type that does not exist at runtime. `ExecutionPrincipal` fields are `readonly`. + +`AutomatedExecutionPrincipal` is added for duties that want provenance but derive no authority from the principal, so the trust brand is demanded only where it is read. `sweepSLA` and `SlaSweepMaintenanceOptions` take it, and `sweepSLA` refuses a human or malformed principal outright: it writes across every tenant, and a human there would stamp `principalKind: 'human'` onto cron escalations. `TRUSTED_AUTOMATION` is not on the package barrel — `trustAutomationPrincipal` is the sanctioned constructor. + +Audit correlation now carries `principalKind`, `principalId`, `purpose`, and `delegatedBy` alongside the existing tenant, run, thread, and entry-path fields. + +`x-flowsafe-actor` and `x-flowsafe-role` are retired from the wire. The principal is now the sole identity channel: a thread Durable Object projects `scope.actor` from it, so a host's separate `TenantContext.actor` can no longer disagree with what executes. Both header constants are removed from `@proofoftech/flowsafe/do-runner`; the topology strips the names on send and forward, and `createTenantResolver` still refuses them on inbound requests so a mixed-version client fails loudly. + +`queueApprovalForSuspension`, `reconcileApprovalsForSummary`, and `resumeRunWithRequeue` take a `systemActorId` string instead of a principal, and mint their own bookkeeping identity against the service's tenant binding. Hosts no longer perform a trust assertion for the platform's own bookkeeping. `ApprovalService` exposes its `tenantId` for that. + +The principal travels to a Durable Object in a trusted `x-flowsafe-principal` header that `createThreadTopology` stamps on every send and forward. A thread DO refuses a request that carries none rather than treating the caller as a human, and `createTenantResolver` refuses the header on inbound requests exactly as it does the tenant, actor, and role headers. + +BREAKING for in-flight state, deliberately and without an upgrade path: `AgentRunRecord` is version 2 and `agent-thread` resume targets now store an `ExecutionPrincipal`. Records written by the previous release fail closed, so a suspended agent run started before this upgrade cannot resume. A version-1 record cannot be upgraded honestly — a `schedule.fire` run stored `role: 'operator'`, so reading it back as a human would launder exactly the authority this change removes. Flowsafe's breakwater peer floor moves to `>=0.7.0`. `rejectReservedAgentContext` is removed from `@proofoftech/flowsafe/agent-host`; it was exported but never called on any path, and every real caller uses `sanitizeStoredAgentContext`. + +A thread Durable Object now requires the principal header on every request, so a deployment whose Worker and Durable Object resolve different `@proofoftech/flowsafe` versions returns 403 until both sides ship this release. Cloudflare's single-bundle model makes that skew unlikely, but there is no negotiation. diff --git a/docs/durable-agents.md b/docs/durable-agents.md index 0b6f690..33f5794 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -40,14 +40,34 @@ interface AgentModule { title: string; description: string; allowedRoles?: readonly ApprovalRole[]; + allowedAutomation?: readonly { + kind: 'service' | 'agent' | 'system'; + entryPaths: readonly AgentEntryPath[]; + }[]; }; agent: GuardedAgentHandle; } ``` +`allowedRoles` governs authenticated humans. `allowedAutomation` governs everything else, and **an omitted or empty list denies every automated entry.** A schedule tick, a signal-provider delivery, a notification dispatch, or a delegating agent reaches an agent only if that agent names the principal kind together with the exact entry path. Naming the path and not just the kind is what stops an agent that may fire on a schedule from also accepting webhook-delivered signals. + +`approval.resume` is never declared: resuming is implied by the kind that started the run. Requiring hosts to list it would mean an automated run that suspends for approval is stranded the moment a human approves it. A kind removed from the declaration entirely can still no longer resume. + +The guarded handle must agree. `createGuardedAgent({ allowedPrincipalKinds })` decides which kinds may execute at all, and catalog construction refuses a module whose declared automation kinds differ from it — so a host cannot advertise automation Breakwater will refuse, or register an automation-capable agent its catalog will never route to. + +```typescript +// An agent driven by a schedule and by provider deliveries. +allowedAutomation: [ + { kind: 'system', entryPaths: ['schedule.fire', 'notification.dispatch'] }, + { kind: 'service', entryPaths: ['signal.notification'] }, +], +// and on the guarded agent: +allowedPrincipalKinds: ['human', 'system', 'service'], +``` + The Worker receives metadata only. The thread Durable Object constructs the complete module because its model, storage, runtime, pub/sub, connector, and database objects belong to that instance. -Catalog construction rejects path-unsafe or duplicate ids, empty descriptions, invalid role lists, metadata/handle id mismatches, and metadata roles that differ from the guarded handle. An omitted role list uses `RUN_START_ROLES`. +Catalog construction rejects path-unsafe or duplicate ids, empty descriptions, invalid role lists, metadata/handle id mismatches, metadata roles that differ from the guarded handle, and automation declarations that name a human kind, an unknown entry path, `approval.resume`, a repeated kind, or a kind set differing from the guarded handle. An omitted role list uses `RUN_START_ROLES`; an omitted automation list denies all automated entry. Mount `createAgentRouter()` through `createFlowsafeWorker({ buildAgentRouter })`. It exposes: @@ -64,7 +84,7 @@ The start body accepts only `{"prompt":"..."}`. The router caps the raw UTF-8 bo Each stream line contains the next reconnect cursor and one event. Replay depends on the configured Mastra cache and is not process-restart durable. When the durable run exists but its replay cache does not, the stream route returns 409 and the client must use the status route. -Approval records store an `agent-thread` target with the agent, thread, resource, and original authorized principal. `createAgentApprovalResumer()` rechecks the current catalog roles, reconstructs the guarded module after eviction, and resumes as that original principal. Before resume, the wrapper rebuilds Mastra's local and global run registries from fresh trusted context. It invokes only Breakwater's reserved RBAC `processInput` hook during rehydration, then installs the complete input, LLM-request, and output processor lists for resumed loop execution. It does not replay application or policy `processInput` hooks. An authorization denial stops before registry installation, observation, or tool execution. The reviewer identity remains attached to the approval decision. +Approval records store an `agent-thread` target with the agent, thread, resource, and original authorized principal. `createAgentApprovalResumer()` re-authorizes that stored principal against the current catalog — a human against the agent's roles, an automated principal against its `allowedAutomation` declaration on the `approval.resume` entry path — reconstructs the guarded module after eviction, and resumes as that original principal. Before resume, the wrapper rebuilds Mastra's local and global run registries from fresh trusted context. It invokes only Breakwater's reserved RBAC `processInput` hook during rehydration, then installs the complete input, LLM-request, and output processor lists for resumed loop execution. It does not replay application or policy `processInput` hooks. An authorization denial stops before registry installation, observation, or tool execution. The reviewer identity remains attached to the approval decision. ## Use the lower-level durable wrapper @@ -116,8 +136,8 @@ Host rules: 2. Resolve the authenticated `TenantContext`. 3. Return 404 for a foreign stored id with `requireOwnedMemoryId()`. 4. Address the thread Durable Object through `createThreadTopology()`. -5. Let the topology overwrite `x-flowsafe-tenant`, `x-flowsafe-actor`, and `x-flowsafe-role` from the resolved context. -6. Have `ThreadDurableObject` reconstruct the actor and verify the stamped tenant against its own `id.name` prefix. +5. Let the topology stamp `x-flowsafe-tenant` and `x-flowsafe-principal` from the resolved context. The principal is the sole identity channel: the retired `x-flowsafe-actor` and `x-flowsafe-role` headers are stripped on send and forward, and `createTenantResolver` refuses an inbound request that carries either. The Durable Object refuses a request that carries no principal header rather than treating the caller as a human. +6. Have `ThreadDurableObject` project the actor from the stamped principal and verify the stamped tenant against its own `id.name` prefix. The D1 recall-path tests use one database and the same business key for two tenants. They prove isolated `recall`, `listThreads`, and working memory behavior through Mastra's own memory implementation. @@ -241,6 +261,8 @@ Only connectors whose permission manifest is read-only may opt into model-reques ## Add signal providers +Provider deliveries arrive as a `service` principal on the `signal.notification` entry path. The target agent must declare that pair in `allowedAutomation`, or delivery is refused. + Core signal providers deliver through an in-process agent registry, which is not durable or tenant-aware enough for this topology. Flowsafe preserves the provider contract while routing delivery through the thread topology. Wire: @@ -281,4 +303,6 @@ Keep independent duties in separate failure boundaries. CPU termination is not a | Notification dispatch tick | When delayed notifications are enabled | | Provider polling alarm | Per tenant when a pollable subscription exists | +Each duty that reaches an agent carries an automated principal: the schedule tick fires as `system` on `schedule.fire`, the notification dispatch tick as `system` on `notification.dispatch`, and provider delivery as `service` on `signal.notification`. Enabling a duty is not enough — the target agent must declare that kind and entry path in `allowedAutomation`, or the run is refused at the host. + The advanced starter makes these responsibilities visible in one host. The [Deployment reference](deployment-reference.md) lists bindings and configuration, and the [Operations runbook](operations-runbook.md) covers recovery and offboarding. diff --git a/docs/flowsafe-architecture.md b/docs/flowsafe-architecture.md index cd8f4e0..0b9bd59 100644 --- a/docs/flowsafe-architecture.md +++ b/docs/flowsafe-architecture.md @@ -140,7 +140,7 @@ D1-backed memory recall uses the salted ids through Mastra's own memory implemen Public starts mint the thread, resource, and run ids after authentication. Status and NDJSON observation recheck the stored agent/thread/run binding and return 404 for foreign or mismatched ids. Stream replay lasts only as long as Mastra's configured cache; authoritative status remains available after replay eviction. -An agent has no public raw-resume route. Approval records persist the original authorized principal, and an approval decision resumes as that principal after rechecking the current catalog roles. The reviewer remains the actor on the approval decision event. +An agent has no public raw-resume route. Approval records persist the original authorized principal, and an approval decision resumes as that principal after re-authorizing it against the current catalog: a human principal against the agent's roles, an automated principal against its `allowedAutomation` declaration on the `approval.resume` entry path. The reviewer remains the actor on the approval decision event. After Durable Object eviction, the in-process tool registry is gone while D1 state remains. The agent host validates the memory binding, reconstructs the guarded module, and derives fresh trusted resume context. It then rehydrates Mastra's registries by invoking only Breakwater's reserved RBAC `processInput` hook. Before installation, it restores the complete input, LLM-request, application output, and mandatory output-processor lists for resumed loop execution. Initial application and policy `processInput` hooks do not run again. The host then starts observation and resumes through `RunnerRuntime`. diff --git a/docs/proposals/breakwater-improvement-roadmap.md b/docs/proposals/breakwater-improvement-roadmap.md index 3ef8122..e5a1978 100644 --- a/docs/proposals/breakwater-improvement-roadmap.md +++ b/docs/proposals/breakwater-improvement-roadmap.md @@ -305,42 +305,22 @@ serialization and redaction behavior are defined. calls in one resumed leg unless that is an explicit, audited run-scoped policy. -### 6. Define Human, Service, and Agent Principals +### 6. Define execution principals (shipped) -#### Current state +#### Shipped implementation -`RBACMiddleware` assumes an actor with a human-style role. Scheduled work, -signals, background tasks, service-to-service execution, and agent-to-agent -delegation do not naturally have a logged-in human actor. Treating all of them -as a fabricated `operator` loses provenance and may accidentally grant human -permissions to autonomous execution. +Flowsafe defines `ExecutionPrincipal` for human, service, agent, and system execution. Human principals carry a role. Every automated principal requires `purpose`, and agent principals may also carry `delegatedBy`. -#### Improvement +Breakwater accepts the projected principal kind but does not resolve identity. Its `allowedPrincipalKinds` gate runs before role authorization and never consults human roles for an automated principal. Flowsafe's agent catalog uses `allowedAutomation` to constrain each automated kind to declared entry paths; human starts continue to use `allowedRoles`. -Before enabling automated agent entry points, define a host-level principal -model: +`trustAutomationPrincipal()` canonicalizes and freezes principals used by trusted platform entries. Audit events preserve the tenant, principal kind, principal ID, purpose, and delegation provenance. Approval decisions remain attributed to the human decider. -```ts -type Principal = - | { kind: 'human'; id: string; role: ApprovalRole; tenantId: string } - | { kind: 'service'; id: string; permissions: readonly Permission[]; tenantId: string } - | { kind: 'agent'; id: string; delegatedBy?: string; permissions: readonly Permission[]; tenantId: string } - | { kind: 'system'; id: string; purpose: string; tenantId: string }; -``` - -Breakwater does not need to become the source of this identity. Flowsafe should -resolve the principal and project the minimum actor/permission context needed -by each gate. - -#### Acceptance criteria +#### Shipped guarantees - Scheduled or service execution never masquerades as an arbitrary human. -- Every autonomous call has tenant, principal kind, principal ID, and - delegation provenance in audit events. -- Service/agent permissions are narrower than administrative human roles by - default. -- Human approval remains attributable to the human decider even when the - requester is a service or agent. +- Agent entry and approval-maintenance audit events implemented in this phase carry tenant, principal kind, principal ID, and delegation provenance when applicable. +- Automated principals cannot derive authority from administrative human roles. +- Human approval remains attributable to the human decider even when the requester is a service or agent. ### 7. Add an End-to-End Enforcement Matrix @@ -791,7 +771,7 @@ The shipped host deliberately omits a public raw-resume route. It accepts connec ### Phase B: Approval capability and principal hardening -1. Define human, service, agent, and system principals beyond the Phase A human-role snapshot. +1. ~~Define human, service, agent, and system principals beyond the Phase A human-role snapshot.~~ Shipped. `ExecutionPrincipal` carries a kind, a required `purpose` on every automated kind, and optional delegation. Breakwater's `Actor` gained `kind`, and `RBACMiddleware`/`createGuardedAgent` gate on `allowedPrincipalKinds` before roles. The agent host routes automated entry through each agent's `allowedAutomation` declaration. 2. Choose connector/leg/tool-call/input/nonce grant scope for structured grants. 3. Prove scheduled, signal, background, and nested execution cannot inherit a stale or broader grant. 4. Add dynamic principal re-resolution only when a concrete identity-provider contract exists. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 6e33466..b85a5e1 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -154,7 +154,7 @@ The trusted suspension bridge records: `approvalGrantProvider()` reads only approved records and requires an exact match on the current leg. The runtime-owned resume count distinguishes repeated same-step suspensions even when timestamps collide. -An agent resume target contains the agent, thread, resource, and original authorized principal. A reviewer decision resumes execution as that principal after current catalog-role validation; the reviewer cannot replace it. Legacy agent approvals without this principal fail closed. +An agent resume target contains the agent, thread, resource, and original authorized principal. A reviewer decision resumes execution as that principal after re-authorizing it against the current catalog: a human principal against the agent's roles, an automated principal against its `allowedAutomation` declaration. The reviewer cannot replace it. Legacy agent approvals without this principal fail closed. An explicit trusted `runScoped: true` record is a standing grant. A step-less record without that flag grants nothing. @@ -187,7 +187,10 @@ A host with only one human reviewer must consciously choose availability or sepa | Foreign or mismatched agent binding reached | Server catalog plus persisted thread/run/agent binding checks before mutation authorization | Raw namespace access outside the agent topology is unsupported | | Client smuggles memory id | Recursive body rejection and server minters | Application-specific aliases must not bypass the minter | | Schedule row plants grant/context | Reserved-key rejection and runtime-last merge | Direct database writers remain part of the trusted computing base | -| Reviewer becomes agent execution principal | Persist original requester in the approval target and recheck current catalog roles on resume | The stored principal is an authorization snapshot, not a dynamic identity-provider lookup | +| Reviewer becomes agent execution principal | Persist the original execution principal in the approval target and re-authorize it on resume: roles for a human, `allowedAutomation` for an automated principal | The stored principal is an authorization snapshot, not a dynamic identity-provider lookup | +| Automation acquires a human's authority | Automated work carries a non-human `ExecutionPrincipal` with a required `purpose`; Breakwater gates on `allowedPrincipalKinds` before roles and never consults the role allowlist for a non-human kind; the agent host requires an `allowedAutomation` declaration naming the kind and the exact entry path | Host code inside the trusted computing base still vouches for its own automated principals | +| Automated principal mutated after it is vouched | `trustAutomationPrincipal()` returns a branded, frozen canonical clone; the trusted service entries recheck the own brand, the shape, the kind, and that every field is a plain data property, rather than trusting the erased parameter type | In-process code can recover the brand by reflection from any vouched principal and stamp a frozen object of its own, the same deliberate residual the tenant-binding brand accepts | +| Vouched principal answers differently on a later read | Accessor properties are refused outright: a getter survives `Object.freeze`, and the trusted entries read a principal several times per call | A caller inside the trusted computing base can still pass a plain object built to its own liking | | Webhook claims victim tenant | Verify raw bytes first; tenant from subscription row | Provider secret compromise can forge provider events | | Provider alarm lost after subscription | Post-commit reconcile callback and retryable mutation-applied response | Hosts that omit reconciliation must arm polling themselves | | Duplicate connector side effect | Atomic idempotency lease and shared store | Poor business keys or too-short pending TTL can still duplicate | diff --git a/packages/agent-starter/README.md b/packages/agent-starter/README.md index 02c3b59..c2b133b 100644 --- a/packages/agent-starter/README.md +++ b/packages/agent-starter/README.md @@ -407,9 +407,12 @@ remain deliberately narrow. - Mint run, thread, and resource IDs after authentication. Never accept them in a create body. -- Reach thread Durable Objects only through `createThreadTopology()`. It - overwrites the internal tenant/actor headers and checks ownership before - addressing the namespace. +- Reach thread Durable Objects only through `createThreadTopology()`. It stamps + the internal tenant and execution-principal headers and checks ownership + before addressing the namespace. +- Declare every automated entry the agent should accept in + `allowedAutomation`. Enabling a duty is not enough, and an omitted list + denies all automated entry. - Keep approval grants server-derived through `approvalGrantProvider()`. Do not copy grants from a decision body, schedule row, model output, or signal. - Keep agent resume behind `ApprovalService.decide()`. A public raw resume path @@ -469,4 +472,17 @@ with real `MODEL_ID`/`MODEL_API_KEY` values. The starter uses only the public `@proofoftech/flowsafe/agent-host` subpath. The Worker router receives `STARTER_AGENT_META`, while each `StarterThread` constructs its complete module from instance-scoped model, storage, runtime, pub/sub, connector, and database objects. -`createThreadAgentHost()` owns the internal start/status/observe/resume topology. Application code does not author private `/agent/*` Durable Object URLs or keep mutable current-request scope. `createAgentApprovalResumer()` restores the original requester principal from the persisted approval target and delegates generic workflow records to the existing run topology. +`createThreadAgentHost()` owns the internal start/status/observe/resume topology. Application code does not author private `/agent/*` Durable Object URLs or keep mutable current-request scope. `createAgentApprovalResumer()` restores the original execution principal from the persisted approval target and delegates generic workflow records to the existing run topology. + +### Declare which automation may run the agent + +Unattended work arrives as a non-human execution principal, and an agent accepts none of it by default. `STARTER_AGENT_META` names each admitted kind together with the exact entry paths it may arrive on: + +```typescript +allowedAutomation: [ + { kind: 'system', entryPaths: ['schedule.fire', 'notification.dispatch'] }, + { kind: 'service', entryPaths: ['signal.notification'] }, +], +``` + +That covers the starter's three automated paths: the schedule tick, the notification dispatch tick, and signal-provider delivery. The guarded agent declares the matching `allowedPrincipalKinds: ['human', 'system', 'service']`, and catalog construction refuses the module if the two ever drift. Removing a kind from either half stops that automation at the host, with no other change needed. diff --git a/packages/agent-starter/src/agent.ts b/packages/agent-starter/src/agent.ts index f199286..0d19fb7 100644 --- a/packages/agent-starter/src/agent.ts +++ b/packages/agent-starter/src/agent.ts @@ -20,6 +20,22 @@ export const STARTER_AGENT_META = { description: 'Records one approval-gated operation in the tenant-isolated starter ledger.', allowedRoles: ['admin', 'operator', 'builder'], + // Every automated entry this starter actually wires, and nothing else. + // Naming entry paths rather than just kinds is what stops a scheduler from + // also arriving through a signal. 'approval.resume' is deliberately absent: + // resuming is implied by the kind that started the run, so an automated run + // that suspends for approval is not stranded once a human approves it. + // + // worker.ts wires all three: scheduleTick.startAgent (system/schedule.fire), + // createNotificationDispatchTick (system/notification.dispatch), and the + // signal-provider host whose deliveries arrive as a service principal. + allowedAutomation: [ + { + kind: 'system', + entryPaths: ['schedule.fire', 'notification.dispatch'], + }, + { kind: 'service', entryPaths: ['signal.notification'] }, + ], } as const satisfies AgentMeta; const actionInput = z.object({ @@ -114,6 +130,9 @@ export function createStarterAgentModule(options: { [RECORD_ACTION_CONNECTOR_ID]: recordAction, }, allowedRoles: STARTER_AGENT_META.allowedRoles, + // Must mirror STARTER_AGENT_META.allowedAutomation's kinds; the catalog + // refuses the module at construction if the two ever drift. + allowedPrincipalKinds: ['human', 'system', 'service'], policies: [], audit: options.audit, maxSteps: 1, diff --git a/packages/agent-starter/src/durable-objects.ts b/packages/agent-starter/src/durable-objects.ts index bd827b9..a270771 100644 --- a/packages/agent-starter/src/durable-objects.ts +++ b/packages/agent-starter/src/durable-objects.ts @@ -12,6 +12,7 @@ import { import { ApprovalService, approvalGrantProvider, + principalActor, } from '@proofoftech/flowsafe/approval-api'; import { BackgroundTaskHost, @@ -173,9 +174,10 @@ export class StarterThread extends ThreadDurableObject { startIdleRun: async (input) => { const scope: ThreadScope = { threadId: input.threadId, - tenantId: input.actor.tenantId, - actor: input.actor, - requestedBy: input.actor.id, + tenantId: input.principal.tenantId, + actor: principalActor(input.principal), + principal: input.principal, + requestedBy: input.principal.id, init: this.#initResult(), }; const result = await this.#host().start(scope, { diff --git a/packages/agent-starter/src/system-tenant.ts b/packages/agent-starter/src/system-tenant.ts index b60abfb..23148de 100644 --- a/packages/agent-starter/src/system-tenant.ts +++ b/packages/agent-starter/src/system-tenant.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 import { - type ApprovalActor, ApprovalService, + type ExecutionPrincipal, + principalActor, type TenantContext, } from '@proofoftech/flowsafe/approval-api'; import { @@ -16,19 +17,34 @@ import { approvalStoreFactoryFor } from '@proofoftech/flowsafe/host-kit'; import { SYSTEM_ACTOR_ID } from './config.js'; -export function systemTenant(env: Env, tenantId: string): TenantContext { - return tenantForActor(env, { +/** + * The unattended scheduler identity. It is SYSTEM automation, not a synthetic + * human operator: an agent it fires must have declared `system` on the + * `schedule.fire` entry path, or the host denies the start. + */ +export function systemTenant( + env: Env, + tenantId: string, + purpose = 'scheduled-agent-execution', +): TenantContext { + return tenantForPrincipal(env, { + kind: 'system', id: SYSTEM_ACTOR_ID, - role: 'operator' as const, tenantId, + purpose, }); } -export function tenantForActor(env: Env, actor: ApprovalActor): TenantContext { - const { tenantId } = actor; +export function tenantForPrincipal( + env: Env, + principal: ExecutionPrincipal, +): TenantContext { + const { tenantId } = principal; + const actor = principalActor(principal); let service: ApprovalService | undefined; return { actor, + principal, tenantId, service: () => { service ??= new ApprovalService({ diff --git a/packages/agent-starter/src/worker.ts b/packages/agent-starter/src/worker.ts index 3ed633e..8b6f1e1 100644 --- a/packages/agent-starter/src/worker.ts +++ b/packages/agent-starter/src/worker.ts @@ -58,7 +58,7 @@ import { subscriptionStoreFactory, threadStateStore, } from './storage.js'; -import { systemTenant, tenantForActor } from './system-tenant.js'; +import { systemTenant, tenantForPrincipal } from './system-tenant.js'; import { WORKFLOWS } from './workflows.js'; export { @@ -258,7 +258,7 @@ const worker = createFlowsafeWorker({ fallback, agents: [STARTER_AGENT_META], topology: createAgentThreadTopology(env.THREAD), - tenantForActor: (actor) => tenantForActor(env, actor), + tenantForPrincipal: (principal) => tenantForPrincipal(env, principal), }), buildSignalRouter: (resolve, env) => createSignalRouter({ @@ -322,7 +322,8 @@ const worker = createFlowsafeWorker({ const notifications = createNotificationDispatchTick({ storage: notificationsStore(env.DB), topology: threadTopology, - resolveTenant: (tenantId) => systemTenant(env, tenantId), + resolveTenant: (tenantId) => + systemTenant(env, tenantId, 'notification-dispatch'), limit: 100, }); return async () => ({ diff --git a/packages/breakwater/README.md b/packages/breakwater/README.md index 7e25efe..5dc2dca 100644 --- a/packages/breakwater/README.md +++ b/packages/breakwater/README.md @@ -117,6 +117,24 @@ The handle exposes only unstructured `generate()` and `stream()`. Each call requ `allowedRoles` is an exact allowlist with no role hierarchy. Application input processors may implement only `processInput`. Application output processors must implement both `processOutputStream` and `processOutputResult`. +### Admit automated callers + +`Actor` carries an optional `kind` — `human`, `service`, `agent`, or `system`. An absent `kind` means `human`, so existing hosts are unaffected. + +`createGuardedAgent()` and `RBACMiddleware` both accept `allowedPrincipalKinds`, which **defaults to `['human']`**. An agent written before this option denies every automated caller until you widen it: + +```typescript +const agent = createGuardedAgent({ + // ... + allowedRoles: ['operator', 'admin'], + allowedPrincipalKinds: ['human', 'system', 'service'], +}); +``` + +The gate checks `kind` before `role`, and it does **not** consult `allowedRoles` for a non-human kind. An automated caller carries a role only because `Actor.role` is required; consulting it would either admit whatever role the host projected, or force you to allow that role and thereby admit real humans holding it. Both the processor gate and the direct-call gate enforce this. + +Flowsafe's agent host declares the matching half with `allowedAutomation`, which names each admitted kind together with the exact entry paths it may arrive on. See [Durable agents](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md). + The narrow handle prevents accidental use of raw Mastra execution methods. It is not a sandbox against hostile code in the same JavaScript process. Use the authenticated Flowsafe agent host when callers cross an HTTP or tenant boundary. ## Choose agent policies @@ -228,7 +246,7 @@ The connector wrapper reads these keys: | Constant | Runtime key | Value | Who should set it | | --- | --- | --- | --- | -| `ACTOR_CONTEXT_KEY` | `breakwater.actor` | `{ id, role }` | Authenticated host or `getActor` | +| `ACTOR_CONTEXT_KEY` | `breakwater.actor` | `{ id, role, kind? }` | Authenticated host or `getActor` | | `APPROVED_CONNECTORS_CONTEXT_KEY` | `breakwater.approvedConnectors` | Connector ID array | Trusted approval service only | | `DRY_RUN_CONTEXT_KEY` | `breakwater.dryRun` | `true` | Caller requesting simulation | | `IDEMPOTENCY_KEY_CONTEXT_KEY` | `breakwater.idempotencyKey` | Non-empty string | Host-derived operation identity | @@ -413,8 +431,8 @@ Type exports: `PolicyEngineOptions`, `PolicyEvaluator`, `PolicyContext`, | `RBACMiddleware`, `ROLES`, `ACTOR_CONTEXT_KEY`, `actorFromRequestContext` | Actor authorization and lookup | | `AuditLogger`, `combineAuditSinks`, `metricsAuditSink` | Buffered audit, sink fan-out, and metrics adaptation | -Type exports: `Actor`, `Role`, `RBACMiddlewareOptions`, `AuditEvent`, -`AuditSink`, `AuditLoggerOptions`, and `MetricsRecorder`. +Type exports: `Actor`, `Role`, `PrincipalKind`, `RBACMiddlewareOptions`, +`AuditEvent`, `AuditSink`, `AuditLoggerOptions`, and `MetricsRecorder`. The `rbac` subpath also re-exports `AuditLogger` and its original audit types for compatibility. diff --git a/packages/breakwater/src/agent/agent.test.ts b/packages/breakwater/src/agent/agent.test.ts index 8e2e06d..6fcf061 100644 --- a/packages/breakwater/src/agent/agent.test.ts +++ b/packages/breakwater/src/agent/agent.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AGENT_AUDIT_CONTEXT_KEY, AuditLogger } from '../audit/index.js'; import { denyPatterns, type PolicyEvaluator } from '../policy-engine/index.js'; -import { ACTOR_CONTEXT_KEY } from '../rbac/index.js'; +import { ACTOR_CONTEXT_KEY, type PrincipalKind } from '../rbac/index.js'; import { createGuardedAgent, type GuardedAgentConfig, @@ -629,6 +629,132 @@ describe('guarded audit behavior', () => { }); }); +describe('createGuardedAgent principal kinds', () => { + function automatedContext( + kind: 'service' | 'agent' | 'system', + role: 'admin' | 'operator' | 'viewer' = 'operator', + ): RequestContext { + const context = new RequestContext(); + context.set(ACTOR_CONTEXT_KEY, { id: 'scheduler-1', role, kind }); + context.set(AGENT_AUDIT_CONTEXT_KEY, { + agentId: 'writer', + entryPath: 'schedule.fire', + principalKind: kind, + principalId: 'scheduler-1', + purpose: 'scheduled-agent-execution', + }); + return context; + } + + it('defaults to humans only, so an existing agent denies automation', async () => { + // #given — `guarded()` names allowedRoles and nothing else, exactly as + // every agent written before principal kinds existed. + const modelCall = vi.fn(); + const agent = guarded({ model: testModel('generated', modelCall) }); + + // #when / #then — 'operator' IS an allowed role; only the kind stops it. + await expect( + agent.generate('hello', { requestContext: automatedContext('system') }), + ).rejects.toThrow( + /principal kind 'system' is not in allowed kinds \[human\]/, + ); + expect(modelCall).not.toHaveBeenCalled(); + expect(agent.allowedPrincipalKinds).toEqual(['human']); + }); + + it.each([ + 'generate', + 'stream', + ] as const)('denies an unnamed kind on the direct %s path, not just in the processor chain', async (method) => { + // #given — the direct entries pre-authorize OUTSIDE the processor chain, + // so a kind gate wired only into RBACMiddleware would leave them open. + const modelCall = vi.fn(); + const agent = guarded({ + model: testModel('generated', modelCall), + allowedPrincipalKinds: ['human', 'service'], + }); + + // #when / #then + await expect( + agent[method]('hello', { requestContext: automatedContext('agent') }), + ).rejects.toThrow(/principal kind 'agent' is not in allowed kinds/); + expect(modelCall).not.toHaveBeenCalled(); + }); + + it('runs an automated principal whose kind is named, ignoring its role', async () => { + // #given — 'viewer' is deliberately outside allowedRoles: an automated + // principal must not need a human role to be admitted, because needing one + // would also admit the humans who hold it. + const modelCall = vi.fn(); + const agent = guarded({ + model: testModel('generated', modelCall), + allowedRoles: ['admin'], + allowedPrincipalKinds: ['system'], + }); + + // #when + const result = await agent.generate('hello', { + requestContext: automatedContext('system', 'viewer'), + }); + + // #then + expect(result.text).toBe('generated'); + expect(modelCall).toHaveBeenCalledTimes(1); + }); + + it('keeps the human role gate intact once automation is enabled', async () => { + // #given + const modelCall = vi.fn(); + const agent = guarded({ + model: testModel('generated', modelCall), + allowedRoles: ['admin'], + allowedPrincipalKinds: ['human', 'system'], + }); + + // #when / #then — a real human operator is still refused. + await expect( + agent.generate('hello', { requestContext: actorContext('operator') }), + ).rejects.toThrow(/role 'operator' is not in allowed roles \[admin\]/); + expect(modelCall).not.toHaveBeenCalled(); + }); + + it('carries principal correlation into the authorization audit event', async () => { + // #given + const audit = new AuditLogger(); + const agent = guarded({ + audit, + allowedPrincipalKinds: ['system'], + }); + + // #when + await agent.generate('hello', { + requestContext: automatedContext('system'), + }); + + // #then — provenance a fabricated operator could never have carried. + expect(audit.events()[0]).toMatchObject({ + decision: 'allowed', + actor: { id: 'scheduler-1', kind: 'system' }, + detail: { + entryPath: 'schedule.fire', + principalKind: 'system', + principalId: 'scheduler-1', + purpose: 'scheduled-agent-execution', + }, + }); + }); + + it('rejects an invalid kind allowlist at construction', () => { + // #when / #then + expect(() => guarded({ allowedPrincipalKinds: [] })).toThrowError( + /allowedPrincipalKinds must be a non-empty array/, + ); + expect(() => + guarded({ allowedPrincipalKinds: ['root' as PrincipalKind] }), + ).toThrowError(/unknown principal kind 'root'/); + }); +}); + describe('Mastra Agent execution-entry inventory', () => { it('requires every own prototype property to remain classified', () => { const wrapped = ['generate', 'stream']; diff --git a/packages/breakwater/src/agent/index.ts b/packages/breakwater/src/agent/index.ts index 41f247e..e7d8c45 100644 --- a/packages/breakwater/src/agent/index.ts +++ b/packages/breakwater/src/agent/index.ts @@ -25,6 +25,7 @@ import { ROLES, type Role, } from '../rbac/index.js'; +import { assertPrincipalKinds, type PrincipalKind } from '../rbac/principal.js'; const RESERVED_PROCESSOR_IDS = new Set([ 'breakwater-rbac', @@ -171,6 +172,12 @@ export type GuardedAgentConfig< > & { /** Exact actor roles authorized for direct and durable execution. */ allowedRoles: readonly Role[]; + /** + * Exact principal kinds authorized for direct and durable execution. + * Defaults to `['human']`: an agent that does not name its automation denies + * every scheduled, signal, service, and agent-delegated entry. + */ + allowedPrincipalKinds?: readonly PrincipalKind[]; /** Mandatory input and output policies, evaluated in array order. */ policies: readonly PolicyEvaluator[]; /** Required failure-isolated audit logger for every mandatory gate. */ @@ -209,6 +216,8 @@ export interface GuardedAgentHandle { readonly id: string; /** Exact role allowlist enforced at every guarded entry. */ readonly allowedRoles: readonly Role[]; + /** Exact principal-kind allowlist enforced at every guarded entry. */ + readonly allowedPrincipalKinds: readonly PrincipalKind[]; /** Fixed maximum execution steps. */ readonly maxSteps: number; @@ -428,6 +437,7 @@ class GuardedAgent< TRequestContext extends Record | unknown, > extends Agent { readonly allowedRoles: readonly Role[]; + readonly allowedPrincipalKinds: readonly PrincipalKind[]; readonly maxSteps: number; readonly #audit: AuditLogger; readonly #applicationInputProcessors: readonly GuardedInputProcessor[]; @@ -450,6 +460,10 @@ class GuardedAgent< throw new TypeError('createGuardedAgent: policies must be an array'); } const allowedRoles = assertRoles(options.allowedRoles); + const allowedPrincipalKinds = assertPrincipalKinds( + options.allowedPrincipalKinds, + 'createGuardedAgent', + ); const maxSteps = options.maxSteps; const toolChoice = assertToolChoice(options.toolChoice); const applicationInputProcessors = validateInputProcessors( @@ -460,6 +474,7 @@ class GuardedAgent< ); const { allowedRoles: _allowedRoles, + allowedPrincipalKinds: _allowedPrincipalKinds, policies, audit, maxSteps: _maxSteps, @@ -476,6 +491,7 @@ class GuardedAgent< }); const rbac = new RBACMiddleware({ allowedRoles, + allowedPrincipalKinds, audit, resource: `agent:${options.id}`, }); @@ -494,6 +510,7 @@ class GuardedAgent< }, } as AgentConfig); this.allowedRoles = allowedRoles; + this.allowedPrincipalKinds = allowedPrincipalKinds; this.maxSteps = maxSteps; this.#audit = audit; this.#applicationInputProcessors = applicationInputProcessors; @@ -539,6 +556,9 @@ class GuardedAgent< #preauthorize(requestContext: RequestContext): void { authorizeActor({ allowedRoles: this.allowedRoles, + // Direct calls bypass the processor chain entirely, so this gate must + // carry the same kind allowlist or it is a hole around the middleware. + allowedPrincipalKinds: this.allowedPrincipalKinds, audit: this.#audit, resource: `agent:${this.id}`, requestContext, diff --git a/packages/breakwater/src/audit/index.ts b/packages/breakwater/src/audit/index.ts index 04457f5..41c3f36 100644 --- a/packages/breakwater/src/audit/index.ts +++ b/packages/breakwater/src/audit/index.ts @@ -33,6 +33,19 @@ export interface AgentAuditContext { resourceId?: string; /** Trusted invocation path, such as an HTTP start or approval resume. */ entryPath: string; + /** Which kind of principal is executing: human, service, agent, or system. */ + principalKind?: string; + /** + * Stable principal identifier. Distinct from the audit event's `actor.id`, + * which is the identity the gate evaluated: on an approval resume the actor + * is the restored original principal while the decision belongs to a human + * reviewer, and a correlated trail needs to name both. + */ + principalId?: string; + /** Why an automated principal exists, as declared by the host. */ + purpose?: string; + /** The principal that delegated this execution, for agent-to-agent work. */ + delegatedBy?: string; } const AGENT_AUDIT_OPTIONAL_FIELDS = [ @@ -40,6 +53,10 @@ const AGENT_AUDIT_OPTIONAL_FIELDS = [ 'runId', 'threadId', 'resourceId', + 'principalKind', + 'principalId', + 'purpose', + 'delegatedBy', ] as const; /** diff --git a/packages/breakwater/src/index.ts b/packages/breakwater/src/index.ts index 0abf6ae..5659ad4 100644 --- a/packages/breakwater/src/index.ts +++ b/packages/breakwater/src/index.ts @@ -131,12 +131,16 @@ export { } from './policy-engine/index.js'; export type { Actor, + PrincipalKind, RBACMiddlewareOptions, Role, } from './rbac/index.js'; export { ACTOR_CONTEXT_KEY, actorFromRequestContext, + DEFAULT_ALLOWED_PRINCIPAL_KINDS, + PRINCIPAL_KINDS, + principalKindOf, RBACMiddleware, ROLES, } from './rbac/index.js'; diff --git a/packages/breakwater/src/rbac/CLAUDE.md b/packages/breakwater/src/rbac/CLAUDE.md index 2ea3ac0..a8ee0ac 100644 --- a/packages/breakwater/src/rbac/CLAUDE.md +++ b/packages/breakwater/src/rbac/CLAUDE.md @@ -1,6 +1,8 @@ # RBAC navigation - `index.ts`: roles, actor request-context lookup, and `RBACMiddleware` +- `principal.ts`: principal kinds and the shared kind-allowlist validator +- `authorize.ts`: the one gate both the processor and direct calls run through - `rbac.test.ts`: authorization and audit coverage Authentication remains a host responsibility. See [`../../../../docs/breakwater-purpose-and-boundaries.md`](../../../../docs/breakwater-purpose-and-boundaries.md). diff --git a/packages/breakwater/src/rbac/authorize.ts b/packages/breakwater/src/rbac/authorize.ts index 1c3a497..87a5851 100644 --- a/packages/breakwater/src/rbac/authorize.ts +++ b/packages/breakwater/src/rbac/authorize.ts @@ -4,9 +4,16 @@ import type { RequestContext } from '@mastra/core/request-context'; import { type AuditLogger, agentAuditDetail } from '../audit/index.js'; import type { Actor, Role } from './index.js'; +import { type PrincipalKind, principalKindOf } from './principal.js'; export interface ActorAuthorizationOptions { allowedRoles: readonly Role[]; + /** + * Required, because both call sites normalize it through + * `assertPrincipalKinds` first. A default here would be a second place the + * human-only policy could drift from that one. + */ + allowedPrincipalKinds: readonly PrincipalKind[]; audit?: AuditLogger; resource: string; requestContext?: RequestContext; @@ -51,7 +58,30 @@ export function authorizeActor(options: ActorAuthorizationOptions): Actor { }); options.deny(reason); } - if (!options.allowedRoles.includes(actor.role)) { + // Kind before role, and fail closed on an UNDECLARED kind: a host that has not + // thought about automation must not have its human role allowlist quietly + // answer a question about a scheduled job. + const allowedKinds = options.allowedPrincipalKinds; + const kind = principalKindOf(actor); + if (!allowedKinds.includes(kind)) { + const reason = `principal kind '${kind}' is not in allowed kinds [${allowedKinds.join(', ')}]`; + options.audit?.record({ + actor, + action: 'agent.input.authorize', + resource: options.resource, + decision: 'denied', + reason, + detail: agentAuditDetail(options.requestContext), + }); + options.deny(reason); + } + // Roles describe human authority, so they are authoritative only for humans. + // An automated principal carries a role solely because `Actor.role` is + // required; checking it here would mean either admitting whichever human role + // the host projected, or forcing hosts to add that role to `allowedRoles` and + // thereby admitting real humans holding it. The kind allowlist above is the + // whole gate for automation, and it is opt-in. + if (kind === 'human' && !options.allowedRoles.includes(actor.role)) { const reason = `role '${actor.role}' is not in allowed roles [${options.allowedRoles.join(', ')}]`; options.audit?.record({ actor, diff --git a/packages/breakwater/src/rbac/index.ts b/packages/breakwater/src/rbac/index.ts index 9a9859b..442e845 100644 --- a/packages/breakwater/src/rbac/index.ts +++ b/packages/breakwater/src/rbac/index.ts @@ -15,6 +15,11 @@ import type { RequestContext } from '@mastra/core/request-context'; import type { AuditLogger } from '../audit/index.js'; import { authorizeActor } from './authorize.js'; +import { + assertPrincipalKinds, + PRINCIPAL_KINDS, + type PrincipalKind, +} from './principal.js'; /** Role labels accepted by the built-in actor contract. */ export type Role = 'admin' | 'builder' | 'operator' | 'reviewer' | 'viewer'; @@ -28,12 +33,27 @@ export const ROLES: readonly Role[] = [ 'viewer', ]; +// Re-exported for hosts; `assertPrincipalKinds` stays internal to the package. +export type { PrincipalKind } from './principal.js'; +export { + DEFAULT_ALLOWED_PRINCIPAL_KINDS, + PRINCIPAL_KINDS, + principalKindOf, +} from './principal.js'; + /** Authenticated identity evaluated by RBAC and attached to audit events. */ export interface Actor { /** Stable actor identifier from the host authentication system. */ id: string; - /** Role used by the middleware's exact allowlist. */ + /** + * Role used by the middleware's exact allowlist. Meaningful only for the + * 'human' kind; for automated kinds the role allowlist is not consulted at + * all and hosts should project the least-privileged label. See + * `authorizeActor`. + */ role: Role; + /** Absent means 'human', so an existing host keeps its exact behavior. */ + kind?: PrincipalKind; } export type { @@ -64,15 +84,34 @@ export function actorFromRequestContext( ) { return undefined; } + // An unrecognized kind resolves to no actor rather than to a human: a value + // this build does not understand must never fall through to the default that + // every guarded agent already admits. + if ( + candidate.kind !== undefined && + !(PRINCIPAL_KINDS as readonly unknown[]).includes(candidate.kind) + ) { + return undefined; + } return (ROLES as readonly string[]).includes(candidate.role) - ? { id: candidate.id, role: candidate.role } + ? { + id: candidate.id, + role: candidate.role, + ...(candidate.kind !== undefined ? { kind: candidate.kind } : {}), + } : undefined; } /** Configuration for `RBACMiddleware`. */ export interface RBACMiddlewareOptions { - /** Exact roles authorized to call the agent. */ + /** Exact roles authorized to call the agent. Consulted for humans only. */ allowedRoles: readonly Role[]; + /** + * Exact principal kinds authorized to call the agent. Defaults to + * `['human']`, so an existing configuration denies every automated principal + * without changing a line — the caller must name the automation it wants. + */ + allowedPrincipalKinds?: readonly PrincipalKind[]; /** Optional audit logger for authorization decisions and lookup failures. */ audit?: AuditLogger; /** Audit resource. Defaults to the stable processor identifier. */ @@ -86,6 +125,7 @@ export class RBACMiddleware implements Processor<'breakwater-rbac'> { /** Stable Mastra processor identifier. */ readonly id = 'breakwater-rbac' as const; readonly #allowedRoles: readonly Role[]; + readonly #allowedPrincipalKinds: readonly PrincipalKind[]; readonly #audit?: AuditLogger; readonly #getActor: (args: ProcessInputArgs) => Actor | undefined; readonly #resource: string; @@ -95,6 +135,10 @@ export class RBACMiddleware implements Processor<'breakwater-rbac'> { throw new Error('RBACMiddleware: allowedRoles must not be empty'); } this.#allowedRoles = options.allowedRoles; + this.#allowedPrincipalKinds = assertPrincipalKinds( + options.allowedPrincipalKinds, + 'RBACMiddleware', + ); this.#audit = options.audit; this.#resource = options.resource ?? this.id; this.#getActor = @@ -105,6 +149,7 @@ export class RBACMiddleware implements Processor<'breakwater-rbac'> { processInput(args: ProcessInputArgs): ProcessInputResult { authorizeActor({ allowedRoles: this.#allowedRoles, + allowedPrincipalKinds: this.#allowedPrincipalKinds, audit: this.#audit, resource: this.#resource, requestContext: args.requestContext, diff --git a/packages/breakwater/src/rbac/principal.ts b/packages/breakwater/src/rbac/principal.ts new file mode 100644 index 0000000..ad5bb46 --- /dev/null +++ b/packages/breakwater/src/rbac/principal.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Principal kinds — the human/automated distinction the RBAC gates evaluate. +// +// A leaf rather than part of rbac/index.ts so `assertPrincipalKinds`, which is +// shared plumbing between the middleware and the guarded-agent factory, does +// not become public API through the `@proofoftech/breakwater/rbac` subpath. +// The four symbols hosts genuinely need are re-exported from that barrel; this +// one is internal. +// +// @internal + +/** + * What KIND of principal an actor is. Roles are a human vocabulary — a + * scheduled job, a service call, and an agent delegating to another agent have + * no logged-in person behind them, and giving them a human role to satisfy an + * authorization gate is how automated execution silently inherits human + * authority. + * + * Breakwater does not resolve principals; the host does (see + * `breakwater-purpose-and-boundaries.md`). It only needs to tell a person from + * a process so a guarded agent can refuse the latter by default. + */ +export type PrincipalKind = 'human' | 'service' | 'agent' | 'system'; + +export const PRINCIPAL_KINDS: readonly PrincipalKind[] = [ + 'human', + 'service', + 'agent', + 'system', +]; + +/** The only kind authorized when a caller does not opt in to automation. */ +export const DEFAULT_ALLOWED_PRINCIPAL_KINDS: readonly PrincipalKind[] = [ + 'human', +]; + +/** + * An actor's effective kind, treating the absent field as its default. + * + * Structurally typed rather than taking `Actor` so this leaf does not depend on + * the barrel that re-exports it. + */ +export function principalKindOf(actor: { + kind?: PrincipalKind; +}): PrincipalKind { + return actor.kind ?? 'human'; +} + +/** + * Validate a caller-supplied kind allowlist, defaulting to humans only. Shared + * by `RBACMiddleware` and `createGuardedAgent` so the processor gate and the + * direct-call gate cannot be configured differently. + * + * @internal + */ +export function assertPrincipalKinds( + kinds: readonly PrincipalKind[] | undefined, + label: string, +): readonly PrincipalKind[] { + if (kinds === undefined) return DEFAULT_ALLOWED_PRINCIPAL_KINDS; + if (!Array.isArray(kinds) || kinds.length === 0) { + throw new TypeError( + `${label}: allowedPrincipalKinds must be a non-empty array`, + ); + } + const seen = new Set(); + for (const kind of kinds) { + if (!(PRINCIPAL_KINDS as readonly unknown[]).includes(kind)) { + throw new TypeError(`${label}: unknown principal kind '${String(kind)}'`); + } + if (seen.has(kind)) { + throw new TypeError(`${label}: duplicate principal kind '${kind}'`); + } + seen.add(kind); + } + return Object.freeze([...kinds]); +} diff --git a/packages/breakwater/src/rbac/rbac.test.ts b/packages/breakwater/src/rbac/rbac.test.ts index 300dcda..665831b 100644 --- a/packages/breakwater/src/rbac/rbac.test.ts +++ b/packages/breakwater/src/rbac/rbac.test.ts @@ -6,7 +6,12 @@ import { RequestContext } from '@mastra/core/request-context'; import { describe, expect, it } from 'vitest'; import { AGENT_AUDIT_CONTEXT_KEY, AuditLogger } from '../audit/index.js'; -import { ACTOR_CONTEXT_KEY, type Actor, RBACMiddleware } from './index.js'; +import { + ACTOR_CONTEXT_KEY, + type Actor, + type PrincipalKind, + RBACMiddleware, +} from './index.js'; class Tripwire extends Error {} @@ -219,4 +224,136 @@ describe('RBACMiddleware', () => { }); }); +describe('RBACMiddleware principal kinds', () => { + const SCHEDULER: Actor = { + id: 'flowsafe-system', + role: 'operator', + kind: 'system', + }; + + it('denies an automated principal when the caller never opted in', () => { + // #given — the pre-existing configuration shape: roles only. + const audit = new AuditLogger(); + const rbac = new RBACMiddleware({ + allowedRoles: ['operator', 'admin'], + audit, + }); + + // #when / #then — 'operator' is an allowed ROLE, so before principal kinds + // this scheduled principal executed with human authority. + expect(() => + rbac.processInput(makeInputArgs({ contextValue: SCHEDULER })), + ).toThrowError(Tripwire); + expect(audit.events()[0]).toMatchObject({ + decision: 'denied', + reason: "principal kind 'system' is not in allowed kinds [human]", + }); + }); + + it('admits an automated principal only when its kind is named', () => { + // #given + const audit = new AuditLogger(); + const rbac = new RBACMiddleware({ + allowedRoles: ['operator'], + allowedPrincipalKinds: ['system'], + audit, + }); + const args = makeInputArgs({ contextValue: SCHEDULER }); + + // #when + const result = rbac.processInput(args); + + // #then + expect(result).toBe(args.messages); + expect(audit.events()[0]).toMatchObject({ decision: 'allowed' }); + }); + + it('ignores the role allowlist for an automated principal', () => { + // #given — an automated principal carries a role only because Actor.role is + // required. Whatever the host projected must not decide the outcome. + const audit = new AuditLogger(); + const rbac = new RBACMiddleware({ + allowedRoles: ['admin'], + allowedPrincipalKinds: ['service'], + audit, + }); + const args = makeInputArgs({ + contextValue: { id: 'delivery', role: 'viewer', kind: 'service' }, + }); + + // #when / #then — 'viewer' is not in allowedRoles, yet the kind is allowed. + expect(rbac.processInput(args)).toBe(args.messages); + expect(audit.events()[0]).toMatchObject({ decision: 'allowed' }); + }); + + it('still enforces the role allowlist for humans when automation is enabled', () => { + // #given — opting in to automation must not widen the human path. + const audit = new AuditLogger(); + const rbac = new RBACMiddleware({ + allowedRoles: ['admin'], + allowedPrincipalKinds: ['human', 'system'], + audit, + }); + + // #when / #then + expect(() => + rbac.processInput(makeInputArgs({ contextValue: OPERATOR })), + ).toThrowError(Tripwire); + expect(audit.events()[0]).toMatchObject({ + decision: 'denied', + reason: "role 'operator' is not in allowed roles [admin]", + }); + }); + + it('denies an unrecognized kind rather than defaulting it to human', () => { + // #given — a kind this build does not know must not fall through to the + // default every guarded agent already admits. + const audit = new AuditLogger(); + const rbac = new RBACMiddleware({ + allowedRoles: ['operator'], + allowedPrincipalKinds: ['human', 'system'], + audit, + }); + + // #when / #then — actorFromRequestContext resolves no actor at all. + expect(() => + rbac.processInput( + makeInputArgs({ + contextValue: { id: 'u', role: 'operator', kind: 'superuser' }, + }), + ), + ).toThrowError(Tripwire); + expect(audit.events()[0]).toMatchObject({ + decision: 'denied', + actor: null, + reason: "no actor in request context (key 'breakwater.actor')", + }); + }); + + it('rejects an unknown or empty kind allowlist at construction', () => { + // #when / #then + expect( + () => + new RBACMiddleware({ + allowedRoles: ['admin'], + allowedPrincipalKinds: [], + }), + ).toThrowError(/must be a non-empty array/); + expect( + () => + new RBACMiddleware({ + allowedRoles: ['admin'], + allowedPrincipalKinds: ['root' as PrincipalKind], + }), + ).toThrowError(/unknown principal kind 'root'/); + expect( + () => + new RBACMiddleware({ + allowedRoles: ['admin'], + allowedPrincipalKinds: ['system', 'system'], + }), + ).toThrowError(/duplicate principal kind 'system'/); + }); +}); + // AuditLogger's own tests live in ../audit/audit.test.ts. diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 0b0994b..7d0c7c7 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -27,7 +27,7 @@ Compatibility: - TypeScript `moduleResolution: "NodeNext"`, `"Node16"`, or `"Bundler"` - `@mastra/core` in the declared `^1.50.0` peer range - `react` and `react-dom` `>=18 <20` (React 18 or 19) for the optional approval UI -- `@proofoftech/breakwater` `>=0.6.0 <1.0.0` when used +- `@proofoftech/breakwater` `>=0.7.0 <1.0.0` when used ## Choose an export @@ -256,11 +256,11 @@ The following surfaces are supported and opt-in: they are tested and covered by ### Durable agents -Use `@proofoftech/flowsafe/agent-host` for a public protected surface. `createAgentRouter()` lists server-owned metadata and exposes authenticated start, status, and newline-delimited JSON observation routes. The router mints every id, rejects trusted context and execution overrides, and authorizes mutations against both the global start roles and the selected agent's roles. +Use `@proofoftech/flowsafe/agent-host` for a public protected surface. `createAgentRouter()` lists server-owned metadata and exposes authenticated start, status, and newline-delimited JSON observation routes. The router mints every ID and rejects trusted context and execution overrides. Authenticated human starts must satisfy both the global start roles and the selected agent's `allowedRoles`. Automated entry uses trusted host paths instead: it never consults human roles and requires a matching principal kind and entry path in the selected agent's `allowedAutomation`. `createThreadAgentHost()` validates Breakwater's guarded-handle brand before it registers the agent with Mastra. It persists the thread/agent binding and original run principal, so eviction recovery and approval resume cannot switch agents or actors. -Agent resume is approval-only. `createAgentApprovalResumer()` rejects legacy agent targets without the original principal, rechecks current catalog roles, and delegates non-agent workflow records to the existing resume function. +Agent resume is approval-only. `createAgentApprovalResumer()` rejects legacy agent targets without the original principal, then rechecks that principal against the current catalog. A human must still satisfy the selected agent's `allowedRoles`. An automated principal's kind must still appear in `allowedAutomation`; `approval.resume` is implied for a declared kind. The resumer delegates non-agent workflow records to the existing resume function. `createFlowsafeDurableAgent()` remains the lower-level compatibility API. It routes Mastra's durable-agent workflow through `RunnerRuntime`, but it does not guard an arbitrary raw agent. Use `agent-host` when an HTTP surface must enforce catalog and Breakwater invariants. diff --git a/packages/flowsafe/package.json b/packages/flowsafe/package.json index 14669c7..aae2e3b 100644 --- a/packages/flowsafe/package.json +++ b/packages/flowsafe/package.json @@ -65,7 +65,7 @@ }, "peerDependencies": { "@mastra/core": "^1.50.0", - "@proofoftech/breakwater": ">=0.6.0 <1.0.0", + "@proofoftech/breakwater": ">=0.7.0 <1.0.0", "react": ">=18 <20", "react-dom": ">=18 <20" }, diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 2660542..e1476f0 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -44,9 +44,20 @@ try { readFileSync(join(packageDirectory, 'package.json'), 'utf8'), ); assert.equal(manifest.exports['./agent-host'], './dist/agent-host/index.js'); + // Compared against the SOURCE manifest, not a copy of its value: this script + // is a CI-only step, so a hardcoded range silently goes stale the moment the + // peer floor moves and only fails after the change is pushed. + const sourceManifest = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8'), + ); assert.equal( manifest.peerDependencies['@proofoftech/breakwater'], - '>=0.6.0 <1.0.0', + sourceManifest.peerDependencies['@proofoftech/breakwater'], + ); + assert.match( + manifest.peerDependencies['@proofoftech/breakwater'], + /^>=\d+\.\d+\.\d+ <1\.0\.0$/, + 'the packed peer range must stay a bounded 0.x floor', ); readFileSync(join(packageDirectory, 'dist/agent-host/index.js'), 'utf8'); readFileSync(join(packageDirectory, 'dist/agent-host/index.d.ts'), 'utf8'); @@ -83,21 +94,36 @@ try { createAgentThreadTopology, createThreadAgentHost, createAgentApprovalResumer, + type AgentAutomationRule, type AgentMeta, type AgentRunEnvelope, + type AutomatedEntryAuthorizer, + type AutomatedEntryRequest, + type AutomationCheck, } from '@proofoftech/flowsafe/agent-host'; +const automation: AgentAutomationRule = { + kind: 'system', + entryPaths: ['schedule.fire'], +}; const meta: AgentMeta = { id: 'writer', title: 'Writer', description: 'Writes an approved record', + allowedAutomation: [automation], }; +const automationCheck: AutomationCheck = () => true; +const authorizeAutomatedEntry: AutomatedEntryAuthorizer = ( + request: AutomatedEntryRequest, +) => request.agentId === meta.id; const envelope = null as AgentRunEnvelope | null; void createAgentCatalog([meta]); void createAgentRouter; void createAgentThreadTopology; void createThreadAgentHost; void createAgentApprovalResumer; +void automationCheck; +void authorizeAutomatedEntry; void envelope; `, ); diff --git a/packages/flowsafe/scripts/spike-verify.mjs b/packages/flowsafe/scripts/spike-verify.mjs index 0a11e73..ccda136 100644 --- a/packages/flowsafe/scripts/spike-verify.mjs +++ b/packages/flowsafe/scripts/spike-verify.mjs @@ -1705,6 +1705,59 @@ async function main() { }, ); + await step( + 'O2 scheduled agent principal (D-S3): an unattended SYSTEM principal reaches ' + + 'the guarded agent through the real Worker->DO hop, and arrives as automation', + async () => { + const { status, body } = await http('POST', '/sched/agent'); + assert(status === 200, `sched agent probe -> ${status}`, body); + // The composition this proves: systemTenant mints kind:'system', the + // topology stamps x-flowsafe-principal, the DO rebuilds it as SYSTEM (not + // as a human), and the agent host admits it because SPIKE_AGENT_META + // declares system+schedule.fire. Before the principal header was stamped + // this failed 403 with the whole feature unreachable, and no unit test + // noticed because they all build the ThreadScope in-process. + assert( + body.result?.fired === 1, + 'the agent target fired under an automated principal', + body, + ); + assert(body.result?.failed === 0, 'no schedule fire was refused', body); + assert( + typeof body.runId === 'string' && body.runId.startsWith('spike_'), + 'the fired agent runId is INV-1 (_)', + body, + ); + }, + ); + + await step( + 'O3 automated entry DENIED (D-S4): the same SYSTEM principal on an entry ' + + 'path the agent never declared is refused at the host, through the real hop', + async () => { + // The deny direction. Without this the gate is only ever proven to admit, + // which is how an unreachable gate looked healthy for a whole branch. + const { status, body } = await http( + 'POST', + '/sched/agent?entryPath=signal.wake', + ); + assert(status === 200, `sched agent deny probe -> ${status}`, body); + assert( + body.result?.fired === 0 && body.result?.failed === 1, + 'the undeclared entry path did NOT fire', + body, + ); + // Generic to the caller on purpose: the entry path and principal kind go + // to the audit sink, not into a response that a client can probe policy + // with. + assert( + body.error === 'forbidden', + 'the refusal is a generic 403, leaking no policy detail', + body, + ); + }, + ); + await step( 'O schedule barrier + INV-1 (D-S2): a workflow schedule fires through ' + 'RunnerRuntime with a fresh INV-1 runId and the stored-context barrier holds', diff --git a/packages/flowsafe/spike/worker.ts b/packages/flowsafe/spike/worker.ts index 2a44eb5..4b126dc 100644 --- a/packages/flowsafe/spike/worker.ts +++ b/packages/flowsafe/spike/worker.ts @@ -95,6 +95,8 @@ import { createTenantResolver, D1ApprovalStoreFactory, defaultResumeData, + type ExecutionPrincipal, + principalActor, type TenantBoundApprovalStore, type TenantContext, } from '../src/approval-api/index.js'; @@ -203,6 +205,12 @@ const SPIKE_AGENT_META = { description: 'Calls one approval-gated write connector through the catalog-driven durable host.', allowedRoles: ['admin', 'operator'], + // The automated entries the spike drives. 'approval.resume' is implied by the + // kind, so a scheduled run that suspends for approval still resumes. + allowedAutomation: [ + { kind: 'system', entryPaths: ['schedule.fire', 'notification.dispatch'] }, + { kind: 'service', entryPaths: ['signal.notification'] }, + ], } as const satisfies AgentMeta; const modelUsage = { @@ -417,6 +425,7 @@ function createSpikeAgentModule(env: Env, audit: AuditLogger): AgentModule { model: agentModel(env), tools: { [SPIKE_WRITE_CONNECTOR_ID]: write }, allowedRoles: SPIKE_AGENT_META.allowedRoles, + allowedPrincipalKinds: ['human', 'system', 'service'], policies: [], audit, maxSteps: 1, @@ -979,7 +988,8 @@ export class DemoThread extends ThreadDurableObject { const scope = { threadId: input.threadId, tenantId: this.tenantId, - actor: input.actor, + actor: principalActor(input.principal), + principal: input.principal, requestedBy: input.requestedBy, init: this.#initResult(), }; @@ -1314,10 +1324,15 @@ async function handleLiveAgentRoute( }); } -function tenantContextForActor(actor: ApprovalActor, env: Env): TenantContext { +function tenantContextForPrincipal( + principal: ExecutionPrincipal, + env: Env, +): TenantContext { + const actor = principalActor(principal); let service: ApprovalService | undefined; return { actor, + principal, tenantId: actor.tenantId, service: () => { service ??= new ApprovalService({ @@ -1365,7 +1380,8 @@ function buildApprovalService( fallback, agents: [SPIKE_AGENT_META], topology: createAgentThreadTopology(env.THREAD), - tenantForActor: (actor) => tenantContextForActor(actor, env), + tenantForPrincipal: (principal) => + tenantContextForPrincipal(principal, env), }); return new ApprovalService({ store, @@ -1528,6 +1544,12 @@ async function handleSignalProbe( ({ tenantId, actor: { id: 'signal-probe', role: 'operator', tenantId }, + principal: { + kind: 'human', + id: 'signal-probe', + tenantId, + role: 'operator', + }, ownsMemoryId: (id: string) => tenantOwnsMemoryId(tenantId, id), }) as unknown as TenantContext; @@ -1621,6 +1643,7 @@ async function handleGoalProbe( ({ tenantId, actor: { id: 'goal-probe', role, tenantId }, + principal: { kind: 'human', id: 'goal-probe', tenantId, role }, ownsMemoryId: (id: string) => tenantOwnsMemoryId(tenantId, id), }) as unknown as TenantContext; @@ -1801,9 +1824,34 @@ async function handleScheduleProbe( } if (request.method === 'POST' && path === '/sched/agent') { + // `?entryPath=` drives the NEGATIVE half: the same SYSTEM principal on an + // entry path SPIKE_AGENT_META never declared must be refused at the host. + const requestedEntry = new URL(request.url).searchParams.get('entryPath'); const id = `schedule_${crypto.randomUUID()}`; const threadId = mintThreadId('spike'); const resourceId = mintResourceId('spike', threadId); + // A threaded schedule fires only through an EXISTING binding, so bind the + // thread with a human start first. That is also what makes the probe + // meaningful: the thread belongs to a person, and the later unattended fire + // must still arrive as SYSTEM rather than inheriting that person's role. + await createAgentThreadTopology(env.THREAD).start( + tenantContextForPrincipal( + { + kind: 'human', + id: 'sched-owner', + tenantId: 'spike', + role: 'operator', + }, + env, + ), + { + agentId: SPIKE_AGENT_ID, + prompt: 'Bind this thread.', + entryPath: 'http.start', + threadId, + resourceId, + }, + ); await store.createSchedule({ id, target: { @@ -1820,12 +1868,15 @@ async function handleScheduleProbe( updatedAt: now, metadata: { tenantId: 'spike' }, }); - const actor: ApprovalActor = { + // The schedule tick fires as SYSTEM automation, not as a synthetic human + // operator — the agent it targets must have declared this entry. + const principal: ExecutionPrincipal = { + kind: 'system', id: SYSTEM_ACTOR_ID, - role: 'operator', tenantId: 'spike', + purpose: 'scheduled-agent-execution', }; - const tenant = tenantContextForActor(actor, env); + const tenant = tenantContextForPrincipal(principal, env); const topology = createAgentThreadTopology(env.THREAD); const tick = createScheduleTick({ store, @@ -1843,7 +1894,7 @@ async function handleScheduleProbe( agentId: target.agentId, runId, prompt: target.prompt, - entryPath, + entryPath: (requestedEntry ?? entryPath) as typeof entryPath, threaded, requestContext, streamRequestContext, @@ -1865,6 +1916,7 @@ async function handleScheduleProbe( threadId, resourceId, runId: trigger?.runId ?? null, + error: trigger?.error ?? null, }); } diff --git a/packages/flowsafe/src/agent-host/approval-resumer.test.ts b/packages/flowsafe/src/agent-host/approval-resumer.test.ts index 412af7b..602a509 100644 --- a/packages/flowsafe/src/agent-host/approval-resumer.test.ts +++ b/packages/flowsafe/src/agent-host/approval-resumer.test.ts @@ -1,19 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; -import type { - ApprovalActor, - ApprovalRecord, - TenantContext, -} from '../approval-api/index.js'; +import { + type ApprovalRecord, + type ExecutionPrincipal, + principalActor, + type TenantContext, +} from '../approval-api/index.js'; import { createAgentApprovalResumer } from './approval-resumer.js'; import type { AgentThreadTopology } from './thread-topology.js'; -const principal: ApprovalActor = { +const principal: ExecutionPrincipal = { + kind: 'human', id: 'operator-1', - role: 'operator', tenantId: 'acme', + role: 'operator', }; function record(overrides: Partial = {}): ApprovalRecord { @@ -39,10 +41,12 @@ function record(overrides: Partial = {}): ApprovalRecord { }; } -function tenantFor(actor: ApprovalActor): TenantContext { +function tenantFor(principal: ExecutionPrincipal): TenantContext { + const actor = principalActor(principal); return { actor, - tenantId: actor.tenantId, + principal, + tenantId: principal.tenantId, service: () => { throw new Error('unused'); }, @@ -82,7 +86,7 @@ const agents = [ describe('createAgentApprovalResumer', () => { it('restores the original principal, not the reviewer', async () => { const agentTopology = topology(); - const tenantForActor = vi.fn(async (actor: ApprovalActor) => + const tenantForPrincipal = vi.fn(async (actor: ExecutionPrincipal) => tenantFor(actor), ); const fallback = vi.fn(); @@ -90,14 +94,17 @@ describe('createAgentApprovalResumer', () => { fallback, agents, topology: agentTopology, - tenantForActor, + tenantForPrincipal, }); await expect(resume(record(), 'approve')).resolves.toMatchObject({ status: 'success', }); - expect(tenantForActor).toHaveBeenCalledWith(principal, expect.anything()); + expect(tenantForPrincipal).toHaveBeenCalledWith( + principal, + expect.anything(), + ); expect(agentTopology.resume).toHaveBeenCalledWith( - expect.objectContaining({ actor: principal }), + expect.objectContaining({ principal }), expect.objectContaining({ runId: 'acme_run' }), 'approve', ); @@ -113,7 +120,7 @@ describe('createAgentApprovalResumer', () => { fallback, agents, topology: topology(), - tenantForActor: async (actor) => tenantFor(actor), + tenantForPrincipal: async (principal) => tenantFor(principal), }); await expect( resume( @@ -133,7 +140,7 @@ describe('createAgentApprovalResumer', () => { fallback: vi.fn(), agents, topology: topology(), - tenantForActor: async (actor) => tenantFor(actor), + tenantForPrincipal: async (principal) => tenantFor(principal), }); await expect( legacy( @@ -155,7 +162,7 @@ describe('createAgentApprovalResumer', () => { }, ], topology: topology(), - tenantForActor: async (actor) => tenantFor(actor), + tenantForPrincipal: async (principal) => tenantFor(principal), }); await expect(restricted(record(), 'approve')).rejects.toThrow( 'may no longer resume', @@ -167,7 +174,7 @@ describe('createAgentApprovalResumer', () => { fallback: vi.fn(), agents, topology: topology(), - tenantForActor: async () => + tenantForPrincipal: async () => tenantFor({ ...principal, id: 'reviewer-1', role: 'reviewer' }), }); await expect(resume(record(), 'approve')).rejects.toThrow( diff --git a/packages/flowsafe/src/agent-host/approval-resumer.ts b/packages/flowsafe/src/agent-host/approval-resumer.ts index cca1d6a..71ba476 100644 --- a/packages/flowsafe/src/agent-host/approval-resumer.ts +++ b/packages/flowsafe/src/agent-host/approval-resumer.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { DURABLE_AGENTIC_LOOP_WORKFLOW_ID } from '../agent-runner/index.js'; -import type { - ApprovalActor, - ApprovalRecord, - TenantContext, +import { + type ApprovalRecord, + type ExecutionPrincipal, + samePrincipal, + type TenantContext, } from '../approval-api/index.js'; import type { ResumeRunFn } from '../host-kit/index.js'; import { createAgentCatalog } from './catalog.js'; @@ -15,8 +16,13 @@ export interface AgentApprovalResumerOptions { fallback: ResumeRunFn; agents: readonly AgentMeta[]; topology: AgentThreadTopology; - tenantForActor: ( - actor: ApprovalActor, + /** + * Builds the tenant context the resume runs under, from the STORED principal. + * It must return that principal unchanged; createAgentApprovalResumer + * enforces that with an exact comparison after calling this. + */ + tenantForPrincipal: ( + principal: ExecutionPrincipal, record: ApprovalRecord, ) => TenantContext | Promise; } @@ -44,30 +50,42 @@ export function createAgentApprovalResumer( ); } const meta = catalog.get(target.agentId); - const roles = catalog.allowedRoles(target.agentId); - if (!meta || !roles?.includes(target.principal.role)) { + const principal = target.principal; + // Re-authorize the STORED principal against the CURRENT catalog: a decision + // taken yesterday must not resume an agent whose policy has since narrowed. + // Split by kind for the same reason the entry gate is: a human resumes + // under the role rules, automation under its declared entry paths, and + // neither may answer for the other. Note this authorizes the principal that + // STARTED the run, never the reviewer who approved it — a human decision + // does not transfer that human's authority into the resumed leg. + const authorized = + meta !== undefined && + (principal.kind === 'human' + ? catalog.allowedRoles(target.agentId)?.includes(principal.role) === + true + : catalog.automationAllowed( + target.agentId, + principal, + 'approval.resume', + )); + if (!authorized) { throw new Error( - `principal role '${target.principal.role}' may no longer resume agent '${target.agentId}'`, + `principal kind '${principal.kind}' may no longer resume agent '${target.agentId}'`, ); } - if ( - target.principal.tenantId !== record.tenantId || - target.principal.id.trim() === '' - ) { + if (principal.tenantId !== record.tenantId || principal.id.trim() === '') { throw new Error( 'agent approval principal does not match the record tenant', ); } - const tenant = await options.tenantForActor(target.principal, record); + const tenant = await options.tenantForPrincipal(principal, record); if ( tenant.tenantId !== record.tenantId || - tenant.actor.id !== target.principal.id || - tenant.actor.role !== target.principal.role || - tenant.actor.tenantId !== target.principal.tenantId + !samePrincipal(tenant.principal, principal) ) { throw new Error( - 'tenantForActor must preserve the stored agent execution principal exactly', + 'tenantForPrincipal must preserve the stored agent execution principal exactly', ); } const envelope = await options.topology.resume(tenant, record, decision); diff --git a/packages/flowsafe/src/agent-host/catalog.test.ts b/packages/flowsafe/src/agent-host/catalog.test.ts index 328c6fb..cefc563 100644 --- a/packages/flowsafe/src/agent-host/catalog.test.ts +++ b/packages/flowsafe/src/agent-host/catalog.test.ts @@ -12,16 +12,19 @@ vi.mock('@proofoftech/breakwater/agent', () => ({ (value as { guarded?: unknown }).guarded === true, })); +import { AGENT_ENTRY_PATHS } from '../agent-runner/index.js'; import { createAgentCatalog, createAgentModuleCatalog } from './catalog.js'; function handle( id = 'writer', allowedRoles: readonly ApprovalRole[] = ['admin', 'operator'], + allowedPrincipalKinds: readonly string[] = ['human'], ): GuardedAgentHandle { return { guarded: true, id, allowedRoles, + allowedPrincipalKinds, maxSteps: 1, } as unknown as GuardedAgentHandle; } @@ -95,3 +98,112 @@ describe('agent catalog', () => { ).toThrow('metadata roles must exactly match guarded agent roles'); }); }); + +describe('agent automation declaration', () => { + const automated = { + ...meta, + allowedAutomation: [ + { kind: 'system' as const, entryPaths: ['schedule.fire' as const] }, + ], + }; + + it('denies every automated entry when nothing is declared', () => { + // #given — the shape every agent written before principals had. + const catalog = createAgentCatalog([meta]); + + // #when / #then + for (const kind of ['system', 'service', 'agent'] as const) { + for (const entryPath of AGENT_ENTRY_PATHS) { + expect( + catalog.automationAllowed( + meta.id, + { kind, id: 'robot', tenantId: 'acme', purpose: 'p' }, + entryPath, + ), + ).toBe(false); + } + } + }); + + it('admits only the declared kind on the declared entry path', () => { + // #given + const catalog = createAgentCatalog([automated]); + const system = { + kind: 'system' as const, + id: 'sched', + tenantId: 'acme', + purpose: 'scheduled-agent-execution', + }; + + // #when / #then + expect(catalog.automationAllowed(meta.id, system, 'schedule.fire')).toBe( + true, + ); + // Same principal, a path it was not declared for. + expect(catalog.automationAllowed(meta.id, system, 'signal.wake')).toBe( + false, + ); + // Declared path, a kind that was not declared. + expect( + catalog.automationAllowed( + meta.id, + { kind: 'service', id: 'svc', tenantId: 'acme', purpose: 'p' }, + 'schedule.fire', + ), + ).toBe(false); + // An agent that does not exist. + expect(catalog.automationAllowed('ghost', system, 'schedule.fire')).toBe( + false, + ); + }); + + it('never answers for a human, who is authorized by role instead', () => { + // #given + const catalog = createAgentCatalog([automated]); + + // #when / #then + expect( + catalog.automationAllowed( + meta.id, + { kind: 'human', id: 'op', tenantId: 'acme', role: 'operator' }, + 'schedule.fire', + ), + ).toBe(false); + }); + + it.each([ + [[{ kind: 'human', entryPaths: ['schedule.fire'] }], 'is not an automated'], + [[{ kind: 'system', entryPaths: [] }], 'must name at least one entry path'], + [[{ kind: 'system', entryPaths: ['nope'] }], 'unknown entry path'], + [ + [ + { kind: 'system', entryPaths: ['schedule.fire'] }, + { kind: 'system', entryPaths: ['signal.wake'] }, + ], + 'repeats kind', + ], + ])('rejects a malformed declaration (%#)', (allowedAutomation, message) => { + // #when / #then + expect(() => + createAgentCatalog([ + { ...meta, allowedAutomation } as unknown as AgentMeta, + ]), + ).toThrow(message as string); + }); + + it('refuses a module whose declaration disagrees with its guarded agent', () => { + // #given — the two halves of one decision must not drift: flowsafe routes + // the entry, breakwater decides whether the kind may execute at all. + expect(() => + createAgentModuleCatalog([{ meta: automated, agent: handle() }]), + ).toThrow(/allowedAutomation kinds \[system\] must exactly match/); + expect(() => + createAgentModuleCatalog([ + { + meta, + agent: handle('writer', ['admin', 'operator'], ['human', 'system']), + }, + ]), + ).toThrow(/allowedAutomation kinds \[\] must exactly match/); + }); +}); diff --git a/packages/flowsafe/src/agent-host/catalog.ts b/packages/flowsafe/src/agent-host/catalog.ts index c79ae3b..8af4760 100644 --- a/packages/flowsafe/src/agent-host/catalog.ts +++ b/packages/flowsafe/src/agent-host/catalog.ts @@ -2,13 +2,22 @@ import { isGuardedAgentHandle } from '@proofoftech/breakwater/agent'; -import { type ApprovalRole, RUN_START_ROLES } from '../approval-api/index.js'; +import { AGENT_ENTRY_PATHS } from '../agent-runner/index.js'; +import { + type ApprovalRole, + AUTOMATED_PRINCIPAL_KINDS, + type AutomatedPrincipalKind, + RUN_START_ROLES, +} from '../approval-api/index.js'; import { PATH_SAFE_ID_PATTERN } from '../do-runner/index.js'; import type { + AgentAutomationRule, AgentCatalog, + AgentEntryPath, AgentMeta, AgentModule, AgentModuleCatalog, + AutomationCheck, } from './types.js'; function fail(message: string): never { @@ -36,6 +45,96 @@ function normalizedRoles( return Object.freeze([...effective]); } +/** + * Normalize the automation declaration. An absent field stays absent rather + * than becoming a default set: there is no safe default for "which robots may + * drive this agent", and materializing one would make the deny-by-default read + * as an oversight instead of the contract. + */ +function normalizedAutomation( + rules: readonly AgentAutomationRule[] | undefined, + agentId: string, +): readonly AgentAutomationRule[] | undefined { + if (rules === undefined) return undefined; + if (!Array.isArray(rules)) { + fail(`agent '${agentId}' allowedAutomation must be an array`); + } + const seenKinds = new Set(); + const normalized = rules.map((rule) => { + if (rule === null || typeof rule !== 'object') { + fail(`agent '${agentId}' allowedAutomation entries must be objects`); + } + if (!AUTOMATED_PRINCIPAL_KINDS.includes(rule.kind)) { + fail( + `agent '${agentId}' allowedAutomation kind '${String(rule.kind)}' is not an automated principal kind`, + ); + } + if (seenKinds.has(rule.kind)) { + fail( + `agent '${agentId}' allowedAutomation repeats kind '${rule.kind}'; list its entry paths once`, + ); + } + seenKinds.add(rule.kind); + if (!Array.isArray(rule.entryPaths) || rule.entryPaths.length === 0) { + fail( + `agent '${agentId}' allowedAutomation kind '${rule.kind}' must name at least one entry path`, + ); + } + const seenPaths = new Set(); + for (const entryPath of rule.entryPaths) { + if (entryPath === 'approval.resume') { + fail( + `agent '${agentId}' must not declare 'approval.resume'; resuming is implied by the kind that started the run`, + ); + } + if (!(AGENT_ENTRY_PATHS as readonly string[]).includes(entryPath)) { + fail( + `agent '${agentId}' allowedAutomation names unknown entry path '${String(entryPath)}'`, + ); + } + if (seenPaths.has(entryPath)) { + fail( + `agent '${agentId}' allowedAutomation repeats entry path '${entryPath}'`, + ); + } + seenPaths.add(entryPath); + } + return Object.freeze({ + kind: rule.kind, + entryPaths: Object.freeze([...rule.entryPaths]), + }); + }); + return Object.freeze(normalized); +} + +function automationCheckFor( + automationById: ReadonlyMap, + known: (agentId: string) => boolean, +): AutomationCheck { + return (agentId, principal, entryPath) => { + if (!known(agentId)) return false; + // Humans are authorized by role, elsewhere. Saying "not allowed" here is + // the honest answer for a gate that only speaks about automation. + if (principal.kind === 'human') return false; + const rules = automationById.get(agentId); + if (!rules) return false; + // Resuming is CONTINUING a run this kind was already admitted to start, so + // it asks a different question: may this kind still drive this agent at + // all? Demanding that hosts also list 'approval.resume' would mean any + // automated agent that suspends for approval loses the run the moment a + // human approves it — a decided approval and a stranded run. The narrowing + // that matters is still enforced: a kind removed from the declaration + // entirely can no longer resume. + if (entryPath === 'approval.resume') { + return rules.some((rule) => rule.kind === principal.kind); + } + return rules.some( + (rule) => + rule.kind === principal.kind && rule.entryPaths.includes(entryPath), + ); + }; +} + export function validateAgentMeta(meta: AgentMeta): AgentMeta { if (typeof meta.id !== 'string' || !PATH_SAFE_ID_PATTERN.test(meta.id)) { fail('id must be URL-path-safe'); @@ -47,11 +146,13 @@ export function validateAgentMeta(meta: AgentMeta): AgentMeta { fail(`agent '${meta.id}' description must not be empty`); } const roles = normalizedRoles(meta.allowedRoles, meta.id); + const automation = normalizedAutomation(meta.allowedAutomation, meta.id); return Object.freeze({ id: meta.id, title: meta.title, description: meta.description, ...(meta.allowedRoles !== undefined ? { allowedRoles: roles } : {}), + ...(automation !== undefined ? { allowedAutomation: automation } : {}), }); } @@ -61,18 +162,25 @@ export function createAgentCatalog( const agents: AgentMeta[] = []; const byId = new Map(); const rolesById = new Map(); + const automationById = new Map(); for (const candidate of metadata) { const meta = validateAgentMeta(candidate); if (byId.has(meta.id)) fail(`duplicate agent id '${meta.id}'`); agents.push(meta); byId.set(meta.id, meta); rolesById.set(meta.id, normalizedRoles(meta.allowedRoles, meta.id)); + if (meta.allowedAutomation !== undefined) { + automationById.set(meta.id, meta.allowedAutomation); + } } const frozen = Object.freeze(agents); return Object.freeze({ agents: frozen, get: (agentId: string) => byId.get(agentId), allowedRoles: (agentId: string) => rolesById.get(agentId), + automationAllowed: automationCheckFor(automationById, (agentId) => + byId.has(agentId), + ), }); } @@ -99,6 +207,32 @@ export function validateAgentModule(module: AgentModule): AgentModule { `agent '${meta.id}' metadata roles must exactly match guarded agent roles`, ); } + // Two halves of one decision: flowsafe's catalog decides WHICH automated + // entry paths reach the agent, breakwater's handle decides WHICH kinds may + // execute at all. If they disagree, the host either advertises automation + // breakwater will refuse, or declares an agent automation-capable that its + // catalog will never route to. Both are wiring bugs, so fail at construction. + // A handle from a breakwater older than the principal-kinds release has no + // such field. Say so, rather than throwing on `.filter` of undefined. + if (!Array.isArray(module.agent.allowedPrincipalKinds)) { + fail( + `agent '${meta.id}' was built by a @proofoftech/breakwater without principal kinds; >=0.7.0 is required`, + ); + } + const metaKinds = new Set( + (meta.allowedAutomation ?? []).map((rule) => rule.kind), + ); + const handleKinds = new Set( + module.agent.allowedPrincipalKinds.filter((kind) => kind !== 'human'), + ); + if ( + metaKinds.size !== handleKinds.size || + [...metaKinds].some((kind) => !handleKinds.has(kind)) + ) { + fail( + `agent '${meta.id}' allowedAutomation kinds [${[...metaKinds].sort().join(', ')}] must exactly match guarded agent allowedPrincipalKinds [${[...handleKinds].sort().join(', ')}]`, + ); + } return Object.freeze({ meta, agent: module.agent }); } @@ -122,5 +256,6 @@ export function createAgentModuleCatalog( modules: frozen, get: (agentId: string) => byId.get(agentId), allowedRoles: catalog.allowedRoles, + automationAllowed: catalog.automationAllowed, }); } diff --git a/packages/flowsafe/src/agent-host/import-isolation.test.ts b/packages/flowsafe/src/agent-host/import-isolation.test.ts index 0d3336d..d778cff 100644 --- a/packages/flowsafe/src/agent-host/import-isolation.test.ts +++ b/packages/flowsafe/src/agent-host/import-isolation.test.ts @@ -100,3 +100,30 @@ describe('agent-host subpath isolation', () => { expect(result.bare).toContain('@proofoftech/breakwater/agent'); }); }); + +describe('do-runner -> approval-api edge', () => { + // contract.ts documents approval-api -> do-runner as the intended direction. + // thread-do.ts now reaches BACK for the execution-principal validator, because + // reconstructing a principal at the DO trust boundary must use the same + // validator every other consumer does. That one edge is accepted; pin it so a + // future import cannot widen the direction silently. + const ALLOWED = new Set([ + 'approval-api/principal.ts', // the validator thread-do.ts reaches for + 'approval-api/contract.ts', // principal.ts's role vocabulary + // Reached today only by contract.ts's `import type { ApprovalRecord }`, + // which erases. NOTE: this pin is file-level — the walker does not + // distinguish `import type` from a runtime import, so it catches a NEW file + // being reached, not this one being reached a new way. + 'approval-api/types.ts', + ]); + + it('reaches approval-api only through the principal and contract leaves', () => { + const result = graph(path.resolve(here, '..', 'do-runner', 'index.ts')); + expect(result.unresolved).toEqual([]); + const reached = [...result.visited] + .filter((file) => file.includes('/approval-api/')) + .map((file) => file.slice(file.indexOf('approval-api/'))) + .sort(); + expect(reached.filter((entry) => !ALLOWED.has(entry))).toEqual([]); + }); +}); diff --git a/packages/flowsafe/src/agent-host/index.ts b/packages/flowsafe/src/agent-host/index.ts index 396dcc9..edc44e0 100644 --- a/packages/flowsafe/src/agent-host/index.ts +++ b/packages/flowsafe/src/agent-host/index.ts @@ -29,6 +29,8 @@ export { export { type AgentThreadInstanceScope, type AgentThreadStateStorage, + type AutomatedEntryAuthorizer, + type AutomatedEntryRequest, type BoundThreadAgent, createThreadAgentHost, type ThreadAgentHost, @@ -47,15 +49,16 @@ export { AGENT_AUDIT_CONTEXT_KEY, createTrustedAgentRequestContext, deriveTrustedAgentContext, - rejectReservedAgentContext, sanitizeStoredAgentContext, } from './trusted-context.js'; export type { + AgentAutomationRule, AgentCatalog, AgentEntryPath, AgentMeta, AgentModule, AgentModuleCatalog, AgentRunEnvelope, + AutomationCheck, TrustedAgentExecution, } from './types.js'; diff --git a/packages/flowsafe/src/agent-host/router.test.ts b/packages/flowsafe/src/agent-host/router.test.ts index 412f0d5..e255e51 100644 --- a/packages/flowsafe/src/agent-host/router.test.ts +++ b/packages/flowsafe/src/agent-host/router.test.ts @@ -8,11 +8,11 @@ import type { } from '../approval-api/index.js'; import { createTenantResolver, + humanPrincipal, InMemoryApprovalStoreFactory, TenantResolutionError, } from '../approval-api/index.js'; import { RunRouteError } from '../host-kit/index.js'; - import { createAgentRouter } from './router.js'; import type { AgentThreadTopology } from './thread-topology.js'; import type { AgentRunEnvelope } from './types.js'; @@ -30,6 +30,7 @@ function tenant(role: ApprovalRole = 'operator'): TenantContext { const actor: ApprovalActor = { id: `${role}-1`, role, tenantId: 'acme' }; return { actor, + principal: humanPrincipal(actor), tenantId: actor.tenantId, service: () => { throw new Error('unused'); diff --git a/packages/flowsafe/src/agent-host/thread-host.test.ts b/packages/flowsafe/src/agent-host/thread-host.test.ts index 1d28f62..c74b903 100644 --- a/packages/flowsafe/src/agent-host/thread-host.test.ts +++ b/packages/flowsafe/src/agent-host/thread-host.test.ts @@ -3,6 +3,7 @@ import type { MastraCompositeStore } from '@mastra/core/storage'; import type { GuardedAgentHandle } from '@proofoftech/breakwater/agent'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ExecutionPrincipal } from '../approval-api/index.js'; import type { InitResult, RequestContextProvider, @@ -13,8 +14,10 @@ import { mintResourceId } from '../do-runner/index.js'; import { type AgentThreadInstanceScope, type AgentThreadStateStorage, + type AutomatedEntryAuthorizer, createThreadAgentHost, } from './thread-host.js'; +import type { AgentAutomationRule } from './types.js'; const mocked = vi.hoisted(() => ({ stream: vi.fn(), @@ -93,16 +96,27 @@ interface Harness { }): void; } -function guarded(id = 'writer'): GuardedAgentHandle { +function guarded( + id = 'writer', + automationKinds: readonly string[] = [], +): GuardedAgentHandle { return { guarded: true, id, allowedRoles: ['operator'], + allowedPrincipalKinds: ['human', ...automationKinds], maxSteps: 1, } as unknown as GuardedAgentHandle; } -function harness(agentIds: readonly string[] = ['writer']): Harness { +function harness( + agentIds: readonly string[] = ['writer'], + options: { + principal?: ExecutionPrincipal; + allowedAutomation?: readonly AgentAutomationRule[]; + authorizeAutomatedEntry?: AutomatedEntryAuthorizer; + } = {}, +): Harness { const state = new Map(); const stateStorage: AgentThreadStateStorage = { get: async (key: string) => state.get(key) as T | undefined, @@ -163,6 +177,12 @@ function harness(agentIds: readonly string[] = ['writer']): Harness { threadId: 'acme_thread', tenantId: 'acme', actor: { id: 'operator-1', role: 'operator', tenantId: 'acme' }, + principal: options.principal ?? { + kind: 'human', + id: 'operator-1', + tenantId: 'acme', + role: 'operator', + }, requestedBy: 'operator-1', init: { runtime, @@ -173,6 +193,9 @@ function harness(agentIds: readonly string[] = ['writer']): Harness { const storageScopes: AgentThreadInstanceScope[] = []; const approvalScopes: AgentThreadInstanceScope[] = []; const host = createThreadAgentHost({ + ...(options.authorizeAutomatedEntry + ? { authorizeAutomatedEntry: options.authorizeAutomatedEntry } + : {}), buildModules: (instanceScope) => { moduleScopes.push(instanceScope); return agentIds.map((agentId) => ({ @@ -181,8 +204,14 @@ function harness(agentIds: readonly string[] = ['writer']): Harness { title: agentId, description: 'Writes an approved record', allowedRoles: ['operator'], + ...(options.allowedAutomation + ? { allowedAutomation: options.allowedAutomation } + : {}), }, - agent: guarded(agentId), + agent: guarded( + agentId, + (options.allowedAutomation ?? []).map((rule) => rule.kind), + ), })); }, storage: (instanceScope) => { @@ -193,8 +222,10 @@ function harness(agentIds: readonly string[] = ['writer']): Harness { approvalService: (instanceScope) => { approvalScopes.push(instanceScope); return { + // The bridge mints its bookkeeping principal against this binding. + tenantId: 'acme', list: async () => [], - create: async () => { + createAsPrincipal: async () => { throw new Error('unexpected approval creation'); }, } as unknown as import('../approval-api/index.js').ApprovalService; @@ -301,6 +332,7 @@ describe('createThreadAgentHost', () => { expect(streamOptions.requestContext.get('breakwater.actor')).toEqual({ id: 'operator-1', role: 'operator', + kind: 'human', }); }); @@ -435,9 +467,9 @@ describe('createThreadAgentHost', () => { resourceId: RESOURCE_ID, }); state.set('flowsafe:agent-run:v1:acme_run', { - version: 1, + version: 2, agentId: 'writer', - principal: scope.actor, + principal: scope.principal, originEntryPath: 'http.start', }); await host.route( @@ -458,9 +490,9 @@ describe('createThreadAgentHost', () => { resourceId: RESOURCE_ID, }); state.set('flowsafe:agent-run:v1:acme_run', { - version: 1, + version: 2, agentId: 'writer', - principal: scope.actor, + principal: scope.principal, originEntryPath: 'http.start', }); await host.route( @@ -598,9 +630,9 @@ describe('createThreadAgentHost', () => { resourceId: RESOURCE_ID, }); state.set('flowsafe:agent-run:v1:acme_run', { - version: 1, + version: 2, agentId: 'writer', - principal: scope.actor, + principal: scope.principal, originEntryPath: 'http.start', }); const provider = host.requestContextForRun(async () => ({ @@ -659,9 +691,9 @@ describe('createThreadAgentHost', () => { setSummary({ runId: 'acme_run', status: 'suspended' }); setSnapshot({ memory: false }); state.set('flowsafe:agent-run:v1:acme_run', { - version: 1, + version: 2, agentId: 'writer', - principal: scope.actor, + principal: scope.principal, originEntryPath: 'schedule.fire', }); mocked.resumeViaRuntime.mockResolvedValue({ @@ -821,3 +853,158 @@ describe('createThreadAgentHost', () => { expect(mocked.observe).not.toHaveBeenCalled(); }); }); + +describe('createThreadAgentHost automated entry', () => { + const SCHEDULER: ExecutionPrincipal = { + kind: 'system', + id: 'flowsafe-scheduler', + tenantId: 'acme', + purpose: 'scheduled-agent-execution', + }; + const DECLARED: readonly AgentAutomationRule[] = [ + { kind: 'system', entryPaths: ['schedule.fire'] }, + ]; + + function scheduledStart() { + return { + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_scheduled', + prompt: 'scheduled', + entryPath: 'schedule.fire' as const, + }; + } + + function bind(state: Map) { + state.set('flowsafe:agent-thread-binding:v1', { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + } + + it('denies a scheduled start when the agent declares no automation', async () => { + // #given — the agent's roles still include 'operator', which is exactly the + // role the schedule path used to fabricate to get in. + const { host, scope, state } = harness(['writer'], { + principal: SCHEDULER, + }); + bind(state); + + // #when / #then + await expect(host.start(scope, scheduledStart())).rejects.toMatchObject({ + status: 403, + }); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it('denies a declared automated kind arriving on an undeclared entry path', async () => { + // #given + const { host, scope, state } = harness(['writer'], { + principal: SCHEDULER, + allowedAutomation: DECLARED, + }); + bind(state); + + // #when / #then + await expect( + host.start(scope, { ...scheduledStart(), entryPath: 'signal.wake' }), + ).rejects.toMatchObject({ status: 403 }); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it('runs a declared scheduled start and persists the SYSTEM principal', async () => { + // #given + const { host, scope, state } = harness(['writer'], { + principal: SCHEDULER, + allowedAutomation: DECLARED, + }); + bind(state); + // Read the record DURING the run: this harness's runs complete terminally, + // and a terminal run deletes its own metadata on the way out. + let persisted: unknown; + mocked.stream.mockImplementation(async () => { + persisted = state.get('flowsafe:agent-run:v1:acme_scheduled'); + return {}; + }); + + // #when + await host.start(scope, scheduledStart()); + + // #then — the run is attributable to the scheduler, not to a human. + expect(mocked.stream).toHaveBeenCalledOnce(); + expect(persisted).toMatchObject({ + version: 2, + principal: SCHEDULER, + originEntryPath: 'schedule.fire', + }); + }); + + it('projects the automated principal into breakwater as a non-human actor', async () => { + // #given + const { host, scope, state } = harness(['writer'], { + principal: SCHEDULER, + allowedAutomation: DECLARED, + }); + bind(state); + + // #when + await host.start(scope, scheduledStart()); + + // #then — kind is what breakwater's mandatory gate authorizes on, and the + // projected role is the least-privileged one, never 'operator'. + const options = mocked.stream.mock.calls[0]?.[1]; + expect(options?.requestContext.get('breakwater.actor')).toEqual({ + id: 'flowsafe-scheduler', + role: 'viewer', + kind: 'system', + }); + expect( + options?.requestContext.get('breakwater.auditContext'), + ).toMatchObject({ + entryPath: 'schedule.fire', + principalKind: 'system', + principalId: 'flowsafe-scheduler', + purpose: 'scheduled-agent-execution', + }); + }); + + it('lets the host authorizer narrow, never widen, the declaration', async () => { + // #given — the authorizer says yes to everything. + const permissive = vi.fn(async () => true); + const undeclared = harness(['writer'], { + principal: SCHEDULER, + authorizeAutomatedEntry: permissive as AutomatedEntryAuthorizer, + }); + bind(undeclared.state); + + // #when / #then — still denied: the agent declared nothing. + await expect( + undeclared.host.start(undeclared.scope, scheduledStart()), + ).rejects.toMatchObject({ status: 403 }); + expect(permissive).not.toHaveBeenCalled(); + + // #given — declared, but the host refuses this one. + const denying = vi.fn(async () => false); + const declared = harness(['writer'], { + principal: SCHEDULER, + allowedAutomation: DECLARED, + authorizeAutomatedEntry: denying as AutomatedEntryAuthorizer, + }); + bind(declared.state); + + // #when / #then + await expect( + declared.host.start(declared.scope, scheduledStart()), + ).rejects.toMatchObject({ status: 403 }); + expect(denying).toHaveBeenCalledWith( + expect.objectContaining({ + principal: SCHEDULER, + agentId: 'writer', + entryPath: 'schedule.fire', + tenantId: 'acme', + }), + ); + }); +}); diff --git a/packages/flowsafe/src/agent-host/thread-host.ts b/packages/flowsafe/src/agent-host/thread-host.ts index 245de70..c1f2a7d 100644 --- a/packages/flowsafe/src/agent-host/thread-host.ts +++ b/packages/flowsafe/src/agent-host/thread-host.ts @@ -20,11 +20,14 @@ import { writeAgentRunRecord, } from '../agent-runner/index.js'; import { - type ApprovalActor, type ApprovalAuditSink, type ApprovalRecord, type ApprovalService, + type ExecutionPrincipal, + principalActor, + principalAuditFields, RUN_START_ROLES, + samePrincipal, } from '../approval-api/index.js'; import { DoStatusError, @@ -62,7 +65,31 @@ export interface AgentThreadInstanceScope { readonly init: ThreadScope['init']; } +/** What the host is asked to authorize. Never a human — those go by role. */ +export interface AutomatedEntryRequest { + principal: Extract< + ExecutionPrincipal, + { kind: 'service' | 'agent' | 'system' } + >; + agentId: string; + entryPath: AgentEntryPath; + tenantId: string; + threadId: string; +} + +/** + * Optional host policy over automated entry, AND-composed with the agent's own + * `allowedAutomation`. It can only DENY: returning true where the agent did not + * declare the entry changes nothing, so a host cannot widen automation from its + * wiring. Absent means "no additional restriction", not "allow". + */ +export type AutomatedEntryAuthorizer = ( + request: AutomatedEntryRequest, +) => boolean | Promise; + export interface ThreadAgentHostOptions { + /** Narrows automated entry beyond what each agent's metadata declares. */ + authorizeAutomatedEntry?: AutomatedEntryAuthorizer; buildModules: | ((scope: AgentThreadInstanceScope) => readonly AgentModule[]) | ((scope: AgentThreadInstanceScope) => Promise); @@ -155,14 +182,6 @@ async function objectBody(request: Request): Promise> { return value as Record; } -function sameActor(left: ApprovalActor, right: ApprovalActor): boolean { - return ( - left.id === right.id && - left.role === right.role && - left.tenantId === right.tenantId - ); -} - const TERMINAL_RUN_STATUSES: readonly RunSummary['status'][] = [ 'success', 'failed', @@ -383,31 +402,63 @@ export function createThreadAgentHost( return runtime; }; + /** + * The one entry gate, split by principal kind because the two kinds are + * authorized by different things and must not fall through to each other. + * + * A human passes the route-level start roles intersected with the agent's own + * allowedRoles, exactly as before. An automated principal never consults + * roles at all: it must be declared in the agent's `allowedAutomation` for + * this precise entry path, AND survive the host's optional authorizer. Absent + * declaration denies — which is why a scheduled start of an agent that has + * not opted in fails here rather than executing as a synthetic operator. + */ const authorize = async ( scope: ThreadScope, agentId: string, - entry: string, + entry: AgentEntryPath, + principal: ExecutionPrincipal, ) => { const current = await runtimeFor(scope); const module = current.catalog.get(agentId); - const allowed = current.catalog.allowedRoles(agentId); - const granted = - module !== undefined && - RUN_START_ROLES.includes(scope.actor.role) && - allowed?.includes(scope.actor.role) === true; + let reason: string | undefined; + let granted = false; + if (module === undefined) { + reason = 'agent is not registered'; + } else if (principal.kind === 'human') { + const allowed = current.catalog.allowedRoles(agentId); + granted = + RUN_START_ROLES.includes(principal.role) && + allowed?.includes(principal.role) === true; + if (!granted) reason = 'role is not allowed to mutate this agent'; + } else if (!current.catalog.automationAllowed(agentId, principal, entry)) { + reason = `agent does not accept '${principal.kind}' principals on entry path '${entry}'`; + } else { + // AND-composed: the injected authorizer can only narrow what the agent + // already declared. A host cannot widen automation from wiring. + const hostAllows = + (await options.authorizeAutomatedEntry?.({ + principal, + agentId, + entryPath: entry, + tenantId: scope.tenantId, + threadId: scope.threadId, + })) ?? true; + granted = hostAllows; + if (!granted) reason = 'host denied this automated entry'; + } audit(options.audit, { - actor: scope.actor, + actor: principalActor(principal), action: 'agent.entry.authorize', resource: `agent:${agentId}`, decision: granted ? 'allowed' : 'denied', - ...(!granted - ? { reason: 'role is not allowed to mutate this agent' } - : {}), + ...(reason !== undefined ? { reason } : {}), detail: { agentId, tenantId: scope.tenantId, threadId: scope.threadId, entryPath: entry, + ...principalAuditFields(principal), }, }); if (!module) throw new AgentHostRequestError(404, 'agent not found'); @@ -439,16 +490,32 @@ export function createThreadAgentHost( } }; - const systemActor = (scope: ThreadScope): ApprovalActor => ({ - id: options.systemActorId ?? 'flowsafe-system', - role: 'operator', + // Reconciling approvals is trusted platform work with no person behind it. + // It used to mint role:'operator', which is why an approval bridge looked + // indistinguishable from a human operator in the audit trail. The bridge + // mints its own principal from this id against the service's tenant. + const systemActorId = options.systemActorId ?? 'flowsafe-system'; + // Deliberately NOT vouched. Its only consumer projects it to an ApprovalActor + // for a role-gated READ, which grants nothing an automated principal does not + // already have — so calling the trust assertion here would assert trust that + // nothing consumes, and `trustAutomationPrincipal` has to stay greppable as + // "this is where authority is conferred" to be worth anything. + // + // `purpose` is likewise inert here: principalActor drops it, and a successful + // list() emits no audit event, so this string reaches nothing. It is not + // shared with the bridge's RECONCILE_PURPOSE for that reason — there is no + // provenance here to drift. + const systemPrincipal = (scope: ThreadScope): ExecutionPrincipal => ({ + kind: 'system', + id: systemActorId, tenantId: scope.tenantId, + purpose: 'approval-suspension-reconcile', }); const currentApprovals = async ( scope: ThreadScope, summary: RunSummary, - principal: ApprovalActor, + principal: ExecutionPrincipal, agentId: string, resourceId: string, ): Promise => { @@ -458,7 +525,7 @@ export function createThreadAgentHost( service, DURABLE_AGENTIC_LOOP_WORKFLOW_ID, summary, - systemActor(scope), + systemActorId, { kind: 'agent-thread', agentId, @@ -473,7 +540,7 @@ export function createThreadAgentHost( workflowId: DURABLE_AGENTIC_LOOP_WORKFLOW_ID, runId: summary.runId, }, - systemActor(scope), + principalActor(systemPrincipal(scope)), ); const keys = new Set( (summary.suspended ?? []).map((path) => path.join('.')), @@ -496,7 +563,7 @@ export function createThreadAgentHost( resourceId: string; runId: string; }, - principal: ApprovalActor, + principal: ExecutionPrincipal, summary: RunSummary, ): Promise => { const base: AgentRunEnvelope = { @@ -615,7 +682,12 @@ export function createThreadAgentHost( 'suspended agent run has no recoverable execution principal', ); } - const principal = stored?.principal ?? scope.actor; + // Inert today — a suspended run without a stored record already threw 409 + // above, and only a suspended run consults this. Kept as the scope's + // PRINCIPAL rather than its actor so that if those two conditions are ever + // decoupled, the fallback still cannot relabel an automated run as whoever + // happened to poll its status. + const principal = stored?.principal ?? scope.principal; const result = await envelopeFor(scope, ref, principal, summary); if (isTerminalRunStatus(summary.status) && stored) { await deleteAgentRunRecord(options.stateStorage(), ref.runId); @@ -656,13 +728,18 @@ export function createThreadAgentHost( throw new AgentHostRequestError(400, 'agent input is required'); } const entry = entryPath(input.entryPath); - const { current, module } = await authorize(scope, ref.agentId, entry); + const { current, module } = await authorize( + scope, + ref.agentId, + entry, + scope.principal, + ); if (ref.resourceId !== mintResourceId(scope.tenantId, scope.threadId)) { throw new AgentHostRequestError(404, 'run not found'); } const execution: TrustedAgentExecution = { agentId: ref.agentId, - actor: scope.actor, + principal: scope.principal, threadId: scope.threadId, resourceId: ref.resourceId, runId: ref.runId, @@ -711,9 +788,9 @@ export function createThreadAgentHost( }); } const stored: AgentRunRecord = { - version: 1, + version: 2, agentId: ref.agentId, - principal: scope.actor, + principal: scope.principal, originEntryPath: entry, }; await writeAgentRunRecord(options.stateStorage(), ref.runId, stored); @@ -737,7 +814,12 @@ export function createThreadAgentHost( ref.runId, ); if (!summary) throw new Error('agent run did not persist a summary'); - const result = await envelopeFor(scope, ref, scope.actor, summary); + const result = await envelopeFor( + scope, + ref, + scope.principal, + summary, + ); if (isTerminalRunStatus(result.summary.status)) { await deleteAgentRunRecord(options.stateStorage(), ref.runId); } @@ -774,6 +856,7 @@ export function createThreadAgentHost( scope, binding.agentId, entryPath(input.entryPath), + scope.principal, ); const durableAgent = current.agents.get(binding.agentId); if (!durableAgent) throw new Error('guarded agent was not registered'); @@ -819,7 +902,7 @@ export function createThreadAgentHost( if ( !stored || stored.agentId !== ref.agentId || - !sameActor(stored.principal, scope.actor) + !samePrincipal(stored.principal, scope.principal) ) { throw new AgentHostRequestError(404, 'run not found'); } @@ -827,6 +910,7 @@ export function createThreadAgentHost( scope, ref.agentId, entryPath(body.entryPath), + stored.principal, ); const durable = current.agents.get(module.meta.id); if (!durable) throw new Error('guarded agent was not registered'); @@ -838,7 +922,7 @@ export function createThreadAgentHost( : undefined; const execution: TrustedAgentExecution = { agentId: ref.agentId, - actor: stored.principal, + principal: stored.principal, threadId: scope.threadId, resourceId: ref.resourceId, runId: ref.runId, diff --git a/packages/flowsafe/src/agent-host/thread-topology.test.ts b/packages/flowsafe/src/agent-host/thread-topology.test.ts index 2ab0275..b0a2a57 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.test.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.test.ts @@ -70,6 +70,12 @@ function tenant() { let threadMints = 0; const value: TenantContext = { actor: { id: 'operator-1', role: 'operator', tenantId: 'acme' }, + principal: { + kind: 'human', + id: 'operator-1', + tenantId: 'acme', + role: 'operator', + }, tenantId: 'acme', service: () => { throw new Error('unused'); @@ -89,7 +95,7 @@ function tenant() { } describe('createAgentThreadTopology', () => { - it('mints each HTTP start identity exactly once and stamps the full actor', async () => { + it('mints each HTTP start identity exactly once and stamps the principal', async () => { const { topology, hits } = harness(); const scoped = tenant(); const result = await topology.start(scoped.value, { @@ -101,9 +107,11 @@ describe('createAgentThreadTopology', () => { expect(scoped.runMints()).toBe(1); expect(scoped.threadMints()).toBe(1); expect(hits[0]?.init?.headers).toMatchObject({ - 'x-flowsafe-actor': 'operator-1', - 'x-flowsafe-role': 'operator', 'x-flowsafe-tenant': 'acme', + // The principal is the sole identity channel; the DO projects the actor + // from it rather than trusting a second header. + 'x-flowsafe-principal': + '{"kind":"human","id":"operator-1","role":"operator"}', }); }); diff --git a/packages/flowsafe/src/agent-host/trusted-context.test.ts b/packages/flowsafe/src/agent-host/trusted-context.test.ts index 3fba9a5..26a9acc 100644 --- a/packages/flowsafe/src/agent-host/trusted-context.test.ts +++ b/packages/flowsafe/src/agent-host/trusted-context.test.ts @@ -5,14 +5,18 @@ import { describe, expect, it } from 'vitest'; import { createTrustedAgentRequestContext, deriveTrustedAgentContext, - rejectReservedAgentContext, sanitizeStoredAgentContext, } from './trusted-context.js'; import type { TrustedAgentExecution } from './types.js'; const execution: TrustedAgentExecution = { agentId: 'writer', - actor: { id: 'operator-1', role: 'operator', tenantId: 'acme' }, + principal: { + kind: 'human', + id: 'operator-1', + tenantId: 'acme', + role: 'operator', + }, threadId: 'acme_thread', resourceId: 'acme_resource', runId: 'acme_run', @@ -25,15 +29,6 @@ const execution: TrustedAgentExecution = { }; describe('trusted agent context boundary', () => { - it('rejects reserved external keys, including the complete breakwater namespace', () => { - expect(() => - rejectReservedAgentContext({ 'breakwater.futureCapability': true }), - ).toThrow("reserved key 'breakwater.futureCapability'"); - expect(() => rejectReservedAgentContext({ runId: 'forged' })).toThrow( - "reserved key 'runId'", - ); - }); - it('strips reserved persisted values without mutating the source', () => { const source = { safe: 'preserved', @@ -76,6 +71,7 @@ describe('trusted agent context boundary', () => { expect(context.get('breakwater.actor')).toEqual({ id: 'operator-1', role: 'operator', + kind: 'human', }); expect(context.get('breakwater.auditContext')).toMatchObject({ agentId: 'writer', diff --git a/packages/flowsafe/src/agent-host/trusted-context.ts b/packages/flowsafe/src/agent-host/trusted-context.ts index 2216c34..aae6864 100644 --- a/packages/flowsafe/src/agent-host/trusted-context.ts +++ b/packages/flowsafe/src/agent-host/trusted-context.ts @@ -3,23 +3,16 @@ import { RequestContext } from '@mastra/core/request-context'; import { AGENT_AUDIT_CONTEXT_KEY } from '@proofoftech/breakwater/audit'; -import { BREAKWATER_ACTOR_KEY } from '../approval-api/index.js'; import { - assertNoReservedExecutionContext, - stripReservedExecutionContext, -} from '../do-runner/index.js'; + BREAKWATER_ACTOR_KEY, + breakwaterActorFor, + principalAuditFields, +} from '../approval-api/index.js'; +import { stripReservedExecutionContext } from '../do-runner/index.js'; import type { TrustedAgentExecution } from './types.js'; export { AGENT_AUDIT_CONTEXT_KEY }; -export function rejectReservedAgentContext( - context: Record, - label = 'agent request context', -): Record { - assertNoReservedExecutionContext(context, label); - return { ...context }; -} - export function sanitizeStoredAgentContext( context: Record | undefined, ): Record { @@ -30,6 +23,7 @@ export function deriveTrustedAgentContext( execution: TrustedAgentExecution, context?: Record, ): Record { + const { principal } = execution; return { ...stripReservedExecutionContext({ ...execution.safeContext, @@ -38,17 +32,18 @@ export function deriveTrustedAgentContext( runId: execution.runId, threadId: execution.threadId, resourceId: execution.resourceId, - [BREAKWATER_ACTOR_KEY]: { - id: execution.actor.id, - role: execution.actor.role, - }, + // `kind` is what breakwater's mandatory gate authorizes on; the projection + // rule itself lives in breakwaterActorFor so this and the approval-facing + // actor cannot drift apart. + [BREAKWATER_ACTOR_KEY]: breakwaterActorFor(principal), [AGENT_AUDIT_CONTEXT_KEY]: { agentId: execution.agentId, - tenantId: execution.actor.tenantId, + tenantId: principal.tenantId, runId: execution.runId, threadId: execution.threadId, resourceId: execution.resourceId, entryPath: execution.entryPath, + ...principalAuditFields(principal), }, }; } diff --git a/packages/flowsafe/src/agent-host/types.ts b/packages/flowsafe/src/agent-host/types.ts index 289c480..9dbfa7f 100644 --- a/packages/flowsafe/src/agent-host/types.ts +++ b/packages/flowsafe/src/agent-host/types.ts @@ -3,19 +3,42 @@ import type { GuardedAgentHandle } from '@proofoftech/breakwater/agent'; import type { AgentEntryPath } from '../agent-runner/index.js'; import type { - ApprovalActor, ApprovalRecord, ApprovalRole, + AutomatedPrincipalKind, + ExecutionPrincipal, } from '../approval-api/index.js'; import type { RunSummary } from '../do-runner/index.js'; export type { AgentEntryPath } from '../agent-runner/index.js'; +/** + * One automated entry an agent accepts: a principal kind paired with the exact + * entry paths it may arrive on. Kind alone is too coarse — a schedule-driven + * agent should not thereby accept webhook-delivered signals. + */ +export interface AgentAutomationRule { + kind: AutomatedPrincipalKind; + entryPaths: readonly AgentEntryPath[]; +} + export interface AgentMeta { id: string; title: string; description: string; + /** Human roles permitted to start the agent over authenticated HTTP. */ allowedRoles?: readonly ApprovalRole[]; + /** + * Automated entries the agent accepts. ABSENT OR EMPTY DENIES EVERY + * automated start and resume — a schedule, signal, provider, or delegating + * agent must be named here to reach this agent at all. + * + * Declared on the metadata rather than injected at wiring time so the edge + * router and the thread host enforce it from the same catalog, and so an + * agent cannot become reachable by automation through a host that simply + * forgot to pass a policy. + */ + allowedAutomation?: readonly AgentAutomationRule[]; } export interface AgentModule { @@ -23,10 +46,24 @@ export interface AgentModule { agent: GuardedAgentHandle; } +/** + * Whether an automated principal may enter this agent on this path. + * + * Returns false for an unknown agent and for every human principal — humans go + * through the role gate instead, and answering "true" here for a human would + * make two gates look interchangeable when they are not. + */ +export type AutomationCheck = ( + agentId: string, + principal: ExecutionPrincipal, + entryPath: AgentEntryPath, +) => boolean; + export interface AgentCatalog { readonly agents: readonly AgentMeta[]; get(agentId: string): AgentMeta | undefined; allowedRoles(agentId: string): readonly ApprovalRole[] | undefined; + automationAllowed: AutomationCheck; } export interface AgentModuleCatalog { @@ -34,6 +71,7 @@ export interface AgentModuleCatalog { readonly modules: readonly AgentModule[]; get(agentId: string): AgentModule | undefined; allowedRoles(agentId: string): readonly ApprovalRole[] | undefined; + automationAllowed: AutomationCheck; } export interface AgentRunEnvelope { @@ -48,7 +86,12 @@ export interface AgentRunEnvelope { export interface TrustedAgentExecution { agentId: string; - actor: ApprovalActor; + /** + * WHO is executing. On an approval resume this is the restored original + * principal, never the reviewer who decided — so a human approval does not + * transfer that human's authority into the resumed run. + */ + principal: ExecutionPrincipal; threadId: string; resourceId: string; runId: string; diff --git a/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts b/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts index 6fbc908..e357a29 100644 --- a/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts +++ b/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts @@ -31,7 +31,7 @@ import { InMemoryApprovalStore } from '../approval-api/store.js'; import { init } from '../do-runner/init.js'; import { queueApprovalForSuspension } from '../host-kit/approval-bridge.js'; -const SYSTEM: ApprovalActor = { id: 'sys', role: 'operator', tenantId: 'acme' }; +const SYSTEM = 'sys'; const REVIEWER: ApprovalActor = { id: 'rev', role: 'reviewer', diff --git a/packages/flowsafe/src/agent-runner/agent-run-state.test.ts b/packages/flowsafe/src/agent-runner/agent-run-state.test.ts index 687e763..b869c04 100644 --- a/packages/flowsafe/src/agent-runner/agent-run-state.test.ts +++ b/packages/flowsafe/src/agent-runner/agent-run-state.test.ts @@ -46,12 +46,13 @@ describe('durable agent thread/run metadata', () => { it('preserves the original principal until terminal cleanup', async () => { const storage = memoryStorage(); const record = { - version: 1 as const, + version: 2 as const, agentId: 'writer', principal: { + kind: 'human' as const, id: 'starter', - role: 'operator' as const, tenantId: 'acme', + role: 'operator' as const, }, originEntryPath: 'http.start' as const, }; @@ -75,7 +76,7 @@ describe('durable agent thread/run metadata', () => { it('fails closed on malformed persisted state and foreign run principals', async () => { const storage = memoryStorage(); storage.values.set('flowsafe:agent-thread-binding:v1', { - version: 1, + version: 2, agentId: '../writer', resourceId: 'acme_resource', }); @@ -84,27 +85,81 @@ describe('durable agent thread/run metadata', () => { ); await expect( writeAgentRunRecord(storage, 'acme_run-1', { - version: 1, + version: 2, agentId: 'writer', principal: { + kind: 'human', id: 'starter', - role: 'operator', tenantId: 'globex', + role: 'operator', }, originEntryPath: 'http.start', }), ).rejects.toBeInstanceOf(AgentRunStateError); await expect( writeAgentRunRecord(storage, 'acme_run-1', { - version: 1, + version: 2, agentId: 'writer', principal: { + kind: 'human', id: ' ', - role: 'operator', tenantId: 'acme', + role: 'operator', }, originEntryPath: 'http.start', }), ).rejects.toBeInstanceOf(AgentRunStateError); }); }); + +describe('agent run metadata migration', () => { + it('rejects a version-1 record rather than upgrading it to a human', async () => { + // #given — exactly what the previous release wrote for a schedule.fire run: + // an ApprovalActor whose fabricated role was 'operator'. + const storage = memoryStorage(); + await storage.put('flowsafe:agent-run:v1:acme_run-1', { + version: 1, + agentId: 'writer', + principal: { id: 'flowsafe-system', role: 'operator', tenantId: 'acme' }, + originEntryPath: 'schedule.fire', + }); + + // #when / #then — reading it back as a human would hand a scheduled job + // the authority of a human operator, so it fails closed instead. + await expect( + readAgentRunRecord(storage, 'acme_run-1'), + ).rejects.toBeInstanceOf(AgentRunStateError); + }); + + it('rejects a version-2 record whose principal is still an ApprovalActor', async () => { + // #given — the shape change, not just the version number. + const storage = memoryStorage(); + await storage.put('flowsafe:agent-run:v1:acme_run-2', { + version: 2, + agentId: 'writer', + principal: { id: 'starter', role: 'operator', tenantId: 'acme' }, + originEntryPath: 'http.start', + }); + + // #when / #then + await expect( + readAgentRunRecord(storage, 'acme_run-2'), + ).rejects.toBeInstanceOf(AgentRunStateError); + }); + + it('rejects an automated principal that carries no purpose', async () => { + // #given — purpose is the provenance the whole model restores. + const storage = memoryStorage(); + await storage.put('flowsafe:agent-run:v1:acme_run-3', { + version: 2, + agentId: 'writer', + principal: { kind: 'system', id: 'sched', tenantId: 'acme' }, + originEntryPath: 'schedule.fire', + }); + + // #when / #then + await expect( + readAgentRunRecord(storage, 'acme_run-3'), + ).rejects.toBeInstanceOf(AgentRunStateError); + }); +}); diff --git a/packages/flowsafe/src/agent-runner/agent-run-state.ts b/packages/flowsafe/src/agent-runner/agent-run-state.ts index 4da2be6..7007432 100644 --- a/packages/flowsafe/src/agent-runner/agent-run-state.ts +++ b/packages/flowsafe/src/agent-runner/agent-run-state.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -import type { ApprovalActor, ApprovalRole } from '../approval-api/contract.js'; +import { + type ExecutionPrincipal, + isExecutionPrincipal, + samePrincipal, +} from '../approval-api/principal.js'; import { PATH_SAFE_ID_PATTERN, tenantOwnsSaltedId, @@ -32,21 +36,21 @@ export interface AgentThreadBinding { resourceId: string; } +/** + * Version 2 carries an ExecutionPrincipal where version 1 carried an + * ApprovalActor. There is deliberately no v1 upgrade path: a run started from + * `schedule.fire` stored the fabricated `role: 'operator'` that this work + * exists to remove, so reading a v1 record back as a human principal would + * launder that authority through a migration. `validRunRecord` rejects v1 and + * the run fails closed — a suspended pre-upgrade agent run cannot resume. + */ export interface AgentRunRecord { - version: 1; + version: 2; agentId: string; - principal: ApprovalActor; + principal: ExecutionPrincipal; originEntryPath: AgentEntryPath; } -const APPROVAL_ROLES: readonly ApprovalRole[] = [ - 'admin', - 'builder', - 'operator', - 'reviewer', - 'viewer', -]; - export class AgentRunStateError extends Error { constructor(message: string) { super(message); @@ -79,19 +83,14 @@ function validRunRecord( ): value is AgentRunRecord { if (value === null || typeof value !== 'object') return false; const candidate = value as Partial; - const principal = candidate.principal; return ( - candidate.version === 1 && + candidate.version === 2 && typeof candidate.agentId === 'string' && PATH_SAFE_ID_PATTERN.test(candidate.agentId) && - principal !== null && - typeof principal === 'object' && - typeof principal.id === 'string' && - principal.id.trim() !== '' && - typeof principal.role === 'string' && - (APPROVAL_ROLES as readonly string[]).includes(principal.role) && - typeof principal.tenantId === 'string' && - tenantOwnsSaltedId(principal.tenantId, runId) && + isExecutionPrincipal(candidate.principal) && + // The stored principal must own the run it is stored under, so a record + // moved or forged into another tenant's key space fails closed. + tenantOwnsSaltedId(candidate.principal.tenantId, runId) && typeof candidate.originEntryPath === 'string' && (AGENT_ENTRY_PATHS as readonly string[]).includes(candidate.originEntryPath) ); @@ -164,12 +163,13 @@ export async function writeAgentRunRecord( } const current = await readAgentRunRecord(storage, runId); if (current) { + // Structural comparison across every kind-specific field: comparing only + // id/role/tenant would let a run rebind from one automated purpose to + // another, or from an agent's delegation chain to a different one. if ( current.agentId !== record.agentId || current.originEntryPath !== record.originEntryPath || - current.principal.id !== record.principal.id || - current.principal.role !== record.principal.role || - current.principal.tenantId !== record.principal.tenantId + !samePrincipal(current.principal, record.principal) ) { throw new AgentRunStateConflictError( `run '${runId}' is already bound to a different agent principal`, diff --git a/packages/flowsafe/src/approval-api/contract.ts b/packages/flowsafe/src/approval-api/contract.ts index 63243e0..80981e2 100644 --- a/packages/flowsafe/src/approval-api/contract.ts +++ b/packages/flowsafe/src/approval-api/contract.ts @@ -12,10 +12,18 @@ // production. // The requestContext key literals live in do-runner/breakwater-keys.ts — -// the runtime mints the workflow-scope key itself, and homing the literals -// in a do-runner leaf keeps approval-api -> do-runner as the only -// cross-directory dependency direction. Re-exported here because this -// module is the approval-api's contract surface. +// the runtime mints the workflow-scope key itself, and homing the literals in a +// do-runner leaf kept approval-api -> do-runner as the only cross-directory +// dependency direction. Re-exported here because this module is the +// approval-api's contract surface. +// +// That is no longer the ONLY direction: do-runner/thread-do.ts imports +// approval-api/principal.js, because reconstructing an ExecutionPrincipal at +// the DO trust boundary needs the same validator every other consumer uses, and +// the principal's role vocabulary lives here. No runtime cycle exists +// (principal.ts -> contract.ts -> breakwater-keys.ts, a pure-const leaf), but +// the graph is bidirectional at the directory level. Homing the principal in a +// do-runner leaf instead would require moving the role vocabulary with it. import type { ApprovalRecord } from './types.js'; export { diff --git a/packages/flowsafe/src/approval-api/d1-store.ts b/packages/flowsafe/src/approval-api/d1-store.ts index 57fcbf0..f6c50dd 100644 --- a/packages/flowsafe/src/approval-api/d1-store.ts +++ b/packages/flowsafe/src/approval-api/d1-store.ts @@ -20,7 +20,7 @@ import { TENANT_ID_PATTERN, tenantOwnsSaltedId, } from '../do-runner/path-safe-id.js'; -import { APPROVAL_ROLES } from './contract.js'; +import { isExecutionPrincipal } from './principal.js'; import { type ApprovalPatch, type ApprovalStore, @@ -377,21 +377,19 @@ function rowToRecord(row: ApprovalRow): ApprovalRecord { object?.kind === 'thread' && ownsPathSafeId(object.threadId) && (object.resourceId === undefined || ownsPathSafeId(object.resourceId)); - const principal = - object?.principal !== null && typeof object?.principal === 'object' - ? (object.principal as Record) - : undefined; + // Rows written before execution principals stored an ApprovalActor here. + // isExecutionPrincipal rejects that shape, which is the intended migration: + // a run started by the schedule tick stored `role: 'operator'`, so reading + // it back as a human principal would hand a scheduled job the authority of + // a human operator. Such a row fails closed and its run cannot resume. const validAgentTarget = object?.kind === 'agent-thread' && typeof object.agentId === 'string' && PATH_SAFE_ID_PATTERN.test(object.agentId) && ownsPathSafeId(object.threadId) && ownsPathSafeId(object.resourceId) && - typeof principal?.id === 'string' && - principal.id.trim() !== '' && - typeof principal.role === 'string' && - (APPROVAL_ROLES as readonly string[]).includes(principal.role) && - principal.tenantId === row.tenant_id; + isExecutionPrincipal(object.principal) && + object.principal.tenantId === row.tenant_id; if (!validThreadTarget && !validAgentTarget) { throw new Error( `approval '${row.id}' has an invalid or foreign resume_target`, diff --git a/packages/flowsafe/src/approval-api/end-to-end.test.ts b/packages/flowsafe/src/approval-api/end-to-end.test.ts index c9d3c2d..e27f0d8 100644 --- a/packages/flowsafe/src/approval-api/end-to-end.test.ts +++ b/packages/flowsafe/src/approval-api/end-to-end.test.ts @@ -21,6 +21,7 @@ import { AuditLogger, createConnector, ISOLATION_SCOPE_CONTEXT_KEY, + PRINCIPAL_KINDS, ROLES, WORKFLOW_SCOPE_CONTEXT_KEY, } from '@proofoftech/breakwater'; @@ -40,6 +41,7 @@ import { BREAKWATER_ACTOR_KEY, BREAKWATER_APPROVED_CONNECTORS_KEY, BREAKWATER_WORKFLOW_SCOPE_KEY, + DECIDER_ROLES, RUN_START_ROLES, } from './contract.js'; import { @@ -47,6 +49,11 @@ import { approvedConnectorsForLeg, resumeViaRuntime, } from './grants.js'; +import { + AUTOMATED_PRINCIPAL_KINDS, + AUTOMATED_PROJECTED_ROLE, + EXECUTION_PRINCIPAL_KINDS, +} from './principal.js'; import { createApprovalRouter } from './router.js'; import { ApprovalService } from './service.js'; import { InMemoryApprovalStore } from './store.js'; @@ -156,6 +163,25 @@ describe('breakwater contract tripwires', () => { void tenantless; }); + it('mirrors breakwater PrincipalKind by value', () => { + // #given / #when / #then — flowsafe does not import breakwater at runtime, + // so this union is mirrored. Drift makes the catalog reject a kind + // breakwater accepts, at host construction, with no test going red. + expect([...EXECUTION_PRINCIPAL_KINDS]).toEqual([...PRINCIPAL_KINDS]); + expect([...AUTOMATED_PRINCIPAL_KINDS]).toEqual( + PRINCIPAL_KINDS.filter((kind) => kind !== 'human'), + ); + }); + + it('keeps the automated projected role out of every start gate', () => { + // #given / #when / #then — the load-bearing invariant behind projecting an + // inert role: nothing that gates on a HUMAN role may admit it. If this ever + // fails, an automated principal reaching a role check is admitted without + // its agent ever declaring allowedAutomation. + expect(RUN_START_ROLES).not.toContain(AUTOMATED_PROJECTED_ROLE); + expect(DECIDER_ROLES).not.toContain(AUTOMATED_PROJECTED_ROLE); + }); + it('pins RUN_START_ROLES to the start-capable subset', () => { // The coarse start-role gate: the three start-capable roles, excluding the // review-only roles (reviewer/viewer). A host-level concept that mirrors no diff --git a/packages/flowsafe/src/approval-api/index.ts b/packages/flowsafe/src/approval-api/index.ts index 6f5db95..b1f1473 100644 --- a/packages/flowsafe/src/approval-api/index.ts +++ b/packages/flowsafe/src/approval-api/index.ts @@ -39,6 +39,33 @@ export { defaultResumeData, resumeViaRuntime, } from './grants.js'; +export type { + AutomatedExecutionPrincipal, + AutomatedPrincipalKind, + ExecutionPrincipal, + ExecutionPrincipalKind, + TrustedAutomationPrincipal, +} from './principal.js'; +export { + AUTOMATED_PRINCIPAL_KINDS, + AUTOMATED_PROJECTED_ROLE, + assertExecutionPrincipal, + breakwaterActorFor, + decodeExecutionPrincipal, + EXECUTION_PRINCIPAL_KINDS, + encodeExecutionPrincipal, + humanPrincipal, + isExecutionPrincipal, + principalActor, + principalAuditFields, + samePrincipal, + // TRUSTED_AUTOMATION is deliberately NOT re-exported: `trustAutomationPrincipal` + // is the sanctioned constructor and covers every legitimate case, so naming the + // raw symbol here would only widen the public surface with plumbing. This is + // API hygiene, not a capability boundary — the brand stays recoverable by + // reflection from any vouched principal, and the threat model says so. + trustAutomationPrincipal, +} from './principal.js'; export type { PurgeExpiredApprovalsOptions } from './retention.js'; export { purgeExpiredApprovals } from './retention.js'; export type { ApprovalRouter, ApprovalRouterOptions } from './router.js'; diff --git a/packages/flowsafe/src/approval-api/principal.test.ts b/packages/flowsafe/src/approval-api/principal.test.ts new file mode 100644 index 0000000..746cb1a --- /dev/null +++ b/packages/flowsafe/src/approval-api/principal.test.ts @@ -0,0 +1,554 @@ +// SPDX-License-Identifier: Apache-2.0 +// The trust assertion's own tests. `trustAutomationPrincipal` is the ONLY way +// into `createAsPrincipal` and `supersedeStaleAsPrincipal`, both of which +// replace the human role gate with a kind check — so if a vouched principal can +// change kind after the vouch, automation gains create authority a human +// `viewer` does not have. +// +// The assertions that carry the invariant — kind after mutation, every forged +// shape, and the provenance on subordinate events — drive the real +// ApprovalService, because the predicate agreeing with itself proves nothing +// about the entry that consumes it. The clone, freeze, and field-pick tests +// assert the vouch's own output directly, since that output IS their subject. +import { describe, expect, it } from 'vitest'; + +import type { ApprovalAuditEvent } from './contract.js'; +import { + AUTOMATED_PROJECTED_ROLE, + assertExecutionPrincipal, + type ExecutionPrincipal, + isTrustedAutomationPrincipal, + principalActor, + TRUSTED_AUTOMATION, + type TrustedAutomationPrincipal, + trustAutomationPrincipal, +} from './principal.js'; +import { ApprovalAuthzError, ApprovalService } from './service.js'; +import { InMemoryApprovalStoreFactory } from './tenant-store.js'; +import type { CreateApprovalInput } from './types.js'; + +const CREATE: CreateApprovalInput = { + workflowId: 'wf', + runId: 'acme_run-1', + title: 'publish launch post', +}; + +function harness(): { service: ApprovalService; events: ApprovalAuditEvent[] } { + const backend = new InMemoryApprovalStoreFactory(); + const events: ApprovalAuditEvent[] = []; + return { + service: new ApprovalService({ + store: backend.forTenant('acme'), + audit: (event) => events.push(event), + }), + events, + }; +} + +function systemSource(): Record { + return { + kind: 'system', + id: 'flowsafe-system', + tenantId: 'acme', + purpose: 'approval-suspension-reconcile', + }; +} + +/** Vouch a hand-built object, so a test can hold the pre-vouch reference. */ +function vouchRaw(source: Record): TrustedAutomationPrincipal { + return trustAutomationPrincipal(source as unknown as ExecutionPrincipal); +} + +describe('trustAutomationPrincipal', () => { + it('refuses a human and a malformed principal', () => { + // #given / #when / #then — these must use the role-authorized entries. + expect(() => + trustAutomationPrincipal({ + kind: 'human', + id: 'ada', + tenantId: 'acme', + role: 'admin', + }), + ).toThrow(/only a valid automated principal/); + expect(() => + trustAutomationPrincipal({ + kind: 'system', + id: 'sys', + tenantId: 'acme', + // Empty purpose — provenance is the point, so it is not optional. + purpose: ' ', + }), + ).toThrow(/only a valid automated principal/); + }); + + it("returns a canonical clone, not the caller's reference", () => { + // #given + const source = systemSource(); + + // #when + const vouched = vouchRaw(source); + + // #then + expect(vouched).not.toBe(source); + expect(Object.isFrozen(vouched)).toBe(true); + expect( + (vouched as unknown as Record)[TRUSTED_AUTOMATION], + ).toBe(true); + }); + + it('drops properties the automated shape does not declare', () => { + // #given — an attacker-supplied extra must not ride into the TCB, the same + // reason decodeExecutionPrincipal picks fields explicitly. + const source = { ...systemSource(), role: 'admin', injected: 'x' }; + + // #when + const vouched = vouchRaw(source); + + // #then + expect(Object.keys(vouched).sort()).toEqual([ + 'id', + 'kind', + 'purpose', + 'tenantId', + ]); + }); + + it("keeps an agent's delegatedBy and adds it to no other kind", () => { + // #given / #when + const agent = trustAutomationPrincipal({ + kind: 'agent', + id: 'planner', + tenantId: 'acme', + purpose: 'delegated-subtask', + delegatedBy: 'supervisor', + }); + const system = trustAutomationPrincipal({ + kind: 'system', + id: 'sys', + tenantId: 'acme', + purpose: 'bookkeeping', + }); + + // #then + expect(agent).toMatchObject({ delegatedBy: 'supervisor' }); + expect('delegatedBy' in system).toBe(false); + }); + + it('survives mutation of the object it was vouched from', async () => { + // #given — the exact defect: validate once, hand the caller's own object + // back, and the caller rewrites it into a human admin before the service + // reads `kind`. + const source = systemSource(); + const vouched = vouchRaw(source); + + // #when + source.kind = 'human'; + source.role = 'admin'; + source.purpose = undefined; + + // #then — the vouched principal is unmoved, and the service still sees the + // system principal it authorized. + expect(vouched.kind).toBe('system'); + expect(isTrustedAutomationPrincipal(vouched)).toBe(true); + + const { service, events } = harness(); + const { record } = await service.createAsPrincipal(CREATE, vouched); + expect(record.status).toBe('pending'); + expect( + events.find((event) => event.action === 'approval.create')?.detail, + ).toMatchObject({ + principalKind: 'system', + principalId: 'flowsafe-system', + purpose: 'approval-suspension-reconcile', + }); + }); + + it('cannot be mutated in place after vouching', () => { + // #given + const vouched = vouchRaw(systemSource()); + + // #when — frozen, and this module is ESM (always strict), so the write + // throws rather than silently no-op'ing. + expect(() => { + (vouched as unknown as Record).kind = 'human'; + }).toThrow(TypeError); + + // #then + expect(vouched.kind).toBe('system'); + }); + + // A two-trap Proxy: `getOwnPropertyDescriptor` — the channel the validator + // reads — reports one principal, while `get`, the channel any re-read uses, + // reports another. Over an extensible target neither trap is constrained, so + // no amount of checking makes a SECOND read trustworthy. Only returning the + // values that were actually validated closes it. + const VALIDATED = { + kind: 'agent', + id: 'planner', + tenantId: 'acme', + purpose: 'delegated-subtask', + }; + const twoFaced = (lie: Record): ExecutionPrincipal => + new Proxy({} as Record, { + getOwnPropertyDescriptor: (_target, key) => + typeof key === 'string' && key in VALIDATED + ? { + value: VALIDATED[key as keyof typeof VALIDATED], + writable: true, + enumerable: true, + configurable: true, + } + : undefined, + get: (_target, key) => (typeof key === 'string' ? lie[key] : undefined), + ownKeys: () => Object.keys(VALIDATED), + }) as unknown as ExecutionPrincipal; + + it('mints from the values it validated, not from a second read', () => { + // #given + const principal = twoFaced({ + kind: 'system', + id: 'ghost-admin', + tenantId: 'globex', + purpose: 'x'.repeat(5000), + }); + + // #when + const minted = trustAutomationPrincipal(principal); + + // #then — the vouched principal is the one that passed the checks: the + // tenant it was validated against, and a purpose inside the bound. + expect(minted).toMatchObject(VALIDATED); + expect(minted.tenantId).not.toBe('globex'); + expect(minted.purpose).toHaveLength('delegated-subtask'.length); + }); + + it('binds the tenant it compared, and returns that same snapshot', () => { + // #given — the wire channel claims a human admin. If assert returned its + // argument, the caller would encode and project THAT, not what it checked. + const principal = twoFaced({ + kind: 'human', + id: 'ghost-admin', + tenantId: 'acme', + role: 'admin', + }); + + // #when + const asserted = assertExecutionPrincipal(principal, 'acme', 'probe'); + + // #then + expect(asserted).toEqual(VALIDATED); + expect(principalActor(asserted)).toEqual({ + id: 'planner', + role: AUTOMATED_PROJECTED_ROLE, + tenantId: 'acme', + }); + }); + + it.each([ + ['a literal empty purpose', ''], + ['a control character in the purpose', 'reconcile\u0007drop'], + ['a purpose one over the bound', 'p'.repeat(201)], + ])('refuses %s', (_label, purpose) => { + // #given / #when / #then — the bounds are what keep an audit row bounded + // and a header assembly total; refusing here beats a TypeError deep in the + // topology. + expect(() => vouchRaw({ ...systemSource(), purpose })).toThrow( + /only a valid automated principal/, + ); + }); + + it('accepts a purpose exactly at the bound', () => { + // #given / #when — 200 is the documented maximum, so it must be inclusive. + const vouched = vouchRaw({ ...systemSource(), purpose: 'p'.repeat(200) }); + + // #then + expect(vouched.purpose).toHaveLength(200); + }); +}); + +describe('automated service entries reject an unvouched principal', () => { + // Every case type-asserts its way past the signature, which is exactly what + // the runtime check exists to catch: the parameter type is erased, so an `as` + // cast or a value rebuilt from storage arrives typed correctly. + // + // All but the last are FROZEN on purpose. An unfrozen fixture is denied by the + // freeze clause alone, which would leave every other clause — kind, own-brand, + // strict-true — passing untested and deletable while the suite stayed green. + // Freezing isolates each fixture on the one clause it names. + const forge = (value: object): TrustedAutomationPrincipal => + Object.freeze(value) as TrustedAutomationPrincipal; + + const cases: Array<[string, TrustedAutomationPrincipal]> = [ + ['an unbranded object literal', forge(systemSource())], + [ + 'a forged brand on a human principal', + forge({ + kind: 'human', + id: 'ada', + tenantId: 'acme', + role: 'admin', + [TRUSTED_AUTOMATION]: true, + }), + ], + [ + 'a forged brand on a principal with no purpose', + forge({ + kind: 'system', + id: 'sys', + tenantId: 'acme', + [TRUSTED_AUTOMATION]: true, + }), + ], + [ + 'a truthy-but-not-true brand', + forge({ ...systemSource(), [TRUSTED_AUTOMATION]: 1 }), + ], + [ + // Plain property access walks the prototype chain, so this would pass a + // naive brand check without anyone stamping the object itself. + 'a brand inherited from a prototype', + forge( + Object.assign( + Object.create({ [TRUSTED_AUTOMATION]: true }), + systemSource(), + ), + ), + ], + [ + // Frozen, branded, and every getter currently answers CORRECTLY — and it + // is still refused. Freeze pins a data property's value but does nothing + // to an accessor, so a getter may answer differently on the next read; + // the trusted entries read a principal four times in one call (shape, + // tenant compare, principalActor, principalAuditFields). Nothing can + // prove a getter that tells the truth now will tell it then, so the shape + // is refused outright rather than sampled. + 'a frozen object whose fields are own getters', + forge({ + [TRUSTED_AUTOMATION]: true, + id: 'sys', + tenantId: 'acme', + get kind() { + return 'system'; + }, + get purpose() { + return 'p'; + }, + }), + ], + [ + // Every OWN property is honest data; `kind` and `purpose` come from the + // prototype. A shape check over Reflect.ownKeys never sees them, and + // Object.freeze does not constrain them — so only reading fields as own + // data properties refuses this. + 'a frozen object whose fields are inherited getters', + forge( + Object.assign( + Object.create({ + get kind() { + return 'system'; + }, + get purpose() { + return 'p'; + }, + }), + { [TRUSTED_AUTOMATION]: true, id: 'sneaky-bot', tenantId: 'acme' }, + ), + ), + ], + [ + // Same gap, delivered by a Proxy: the target is genuinely frozen and + // genuinely owns only data properties, while `kind`/`purpose` are served + // by a `get` trap for keys the target does not own — which carries no + // specification invariant at all. + 'a proxy serving fields the frozen target does not own', + new Proxy( + Object.freeze({ + [TRUSTED_AUTOMATION]: true, + id: 'sneaky-bot', + tenantId: 'acme', + }), + { + get: (target, key, receiver) => + key === 'kind' + ? 'system' + : key === 'purpose' + ? 'p' + : Reflect.get(target, key, receiver), + }, + ) as unknown as TrustedAutomationPrincipal, + ], + [ + // The shape a rushed fix produces: stamp the brand, skip the minter, and + // keep holding a live mutable reference. The one case whose subject IS + // the freeze clause, so it is deliberately left unfrozen. + 'a branded but unfrozen object', + { + ...systemSource(), + [TRUSTED_AUTOMATION]: true, + } as unknown as TrustedAutomationPrincipal, + ], + ]; + + it.each(cases)('denies create with %s', async (_label, principal) => { + // #given + const { service, events } = harness(); + + // #when / #then + await expect(service.createAsPrincipal(CREATE, principal)).rejects.toThrow( + ApprovalAuthzError, + ); + expect(events).toMatchObject([ + { + action: 'approval.create', + decision: 'denied', + actor: null, + reason: expect.stringContaining('not a vouched automated principal'), + }, + ]); + }); + + it.each(cases)('denies supersede with %s', async (_label, principal) => { + // #given + const { service } = harness(); + + // #when / #then + await expect( + service.supersedeStaleAsPrincipal('any-id', principal, 'stale'), + ).rejects.toThrow(ApprovalAuthzError); + }); + + it('checks the brand before the tenant, so an unvouched principal never reaches the tenant branch', async () => { + // #given — both checks would deny, so only the reason distinguishes which + // ran. Ordering matters: the tenant branch calls principalActor(), which + // reads the very fields an unvouched value has not earned trust for. + const { service, events } = harness(); + const unvouchedAndForeign = { + ...systemSource(), + tenantId: 'globex', + } as unknown as TrustedAutomationPrincipal; + + // #when / #then + await expect( + service.createAsPrincipal(CREATE, unvouchedAndForeign), + ).rejects.toThrow(ApprovalAuthzError); + expect(events).toMatchObject([ + { + decision: 'denied', + actor: null, + reason: expect.stringContaining('not a vouched automated principal'), + }, + ]); + expect(events[0]?.reason).not.toContain('does not match the store binding'); + }); + + it('denies a vouched principal bound to another tenant, with provenance', async () => { + // #given — a real vouch, wrong tenant: the wiring-bug case that must fail + // closed rather than act cross-tenant. + const { service, events } = harness(); + const foreign = trustAutomationPrincipal({ + kind: 'system', + id: 'flowsafe-system', + tenantId: 'globex', + purpose: 'approval-suspension-reconcile', + }); + + // #when / #then + await expect(service.createAsPrincipal(CREATE, foreign)).rejects.toThrow( + /tenant does not match/, + ); + expect(events).toMatchObject([ + { + action: 'approval.create', + decision: 'denied', + // A denial is an automated event too — it carries the same provenance + // the allowed path carries. + detail: { + principalKind: 'system', + principalId: 'flowsafe-system', + purpose: 'approval-suspension-reconcile', + }, + }, + ]); + }); +}); + +describe('automated provenance reaches subordinate audit events', () => { + it('carries the principal onto a notification failure', async () => { + // #given — the notify sink throws, so the only row describing this failure + // is the subordinate one. + const backend = new InMemoryApprovalStoreFactory(); + const events: ApprovalAuditEvent[] = []; + const service = new ApprovalService({ + store: backend.forTenant('acme'), + audit: (event) => events.push(event), + notify: () => { + throw new Error('sink down'); + }, + }); + const principal = trustAutomationPrincipal({ + kind: 'agent', + id: 'planner', + tenantId: 'acme', + purpose: 'delegated-subtask', + delegatedBy: 'supervisor', + }); + + // #when + await service.createAsPrincipal(CREATE, principal); + + // #then + expect( + events.find((event) => event.action === 'approval.notify'), + ).toMatchObject({ + decision: 'error', + detail: { + tenantId: 'acme', + principalKind: 'agent', + principalId: 'planner', + purpose: 'delegated-subtask', + delegatedBy: 'supervisor', + }, + }); + }); + + it('carries the principal onto a supersede stream failure', async () => { + // #given + const backend = new InMemoryApprovalStoreFactory(); + const events: ApprovalAuditEvent[] = []; + const service = new ApprovalService({ + store: backend.forTenant('acme'), + audit: (event) => events.push(event), + stream: () => { + throw new Error('hub down'); + }, + }); + const principal = trustAutomationPrincipal({ + kind: 'system', + id: 'flowsafe-system', + tenantId: 'acme', + purpose: 'approval-suspension-reconcile', + }); + const { record } = await service.createAsPrincipal(CREATE, principal); + + // #when + await service.supersedeStaleAsPrincipal( + record.id, + principal, + 'fingerprint moved', + ); + + // #then + const streamFailures = events.filter( + (event) => event.action === 'approval.stream', + ); + expect(streamFailures.at(-1)).toMatchObject({ + decision: 'error', + detail: { + tenantId: 'acme', + principalKind: 'system', + principalId: 'flowsafe-system', + purpose: 'approval-suspension-reconcile', + }, + }); + }); +}); diff --git a/packages/flowsafe/src/approval-api/principal.ts b/packages/flowsafe/src/approval-api/principal.ts new file mode 100644 index 0000000..2a98da6 --- /dev/null +++ b/packages/flowsafe/src/approval-api/principal.ts @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: Apache-2.0 +// ExecutionPrincipal — WHO is executing, as opposed to who approved. +// +// Before this existed the platform had one identity, ApprovalActor, whose only +// authority vocabulary is a human ApprovalRole. Every automated path therefore +// fabricated a human: the schedule tick, cron maintenance, signal-provider +// delivery, and the suspension-reconcile bridge all minted `role: 'operator'`. +// That loses provenance (nothing records WHY the call exists) and, worse, hands +// autonomous execution whatever an operator may do. +// +// ApprovalActor is deliberately NOT replaced. It stays exactly what it always +// was: the identity of an authenticated human at the HTTP boundary and of a +// reviewer deciding an approval. A human approving an agent's request is still +// attributed to that human — `decidedBy` is unaffected by anything here. +// +// This lives in approval-api rather than contract.ts because contract.ts is the +// breakwater wire contract, mirrored by value and pinned by the cross-package +// tests. A principal is a host concept: breakwater learns only the KIND (its +// `PrincipalKind`), never the tenant or the purpose. + +import { + APPROVAL_ROLES, + type ApprovalActor, + type ApprovalRole, +} from './contract.js'; + +/** + * Mirrors breakwater's `PrincipalKind` by value, for the same reason + * contract.ts mirrors the request-context keys: flowsafe does not import + * breakwater at runtime. The cross-package contract test pins the equality. + */ +export type ExecutionPrincipalKind = 'human' | 'service' | 'agent' | 'system'; + +export const EXECUTION_PRINCIPAL_KINDS: readonly ExecutionPrincipalKind[] = [ + 'human', + 'service', + 'agent', + 'system', +]; + +/** Automated kinds — everything that is not a logged-in person. */ +export const AUTOMATED_PRINCIPAL_KINDS: readonly ExecutionPrincipalKind[] = [ + 'service', + 'agent', + 'system', +]; + +export type AutomatedPrincipalKind = Exclude; + +/** Upper bound on the free-text provenance fields, so audit rows stay bounded. */ +const MAX_PURPOSE_LENGTH = 200; +const MAX_PRINCIPAL_ID_LENGTH = 200; + +/** + * `purpose` is REQUIRED on every automated kind, not optional as the roadmap + * sketch had it. The failure being fixed is that fabricated operators "lose + * provenance"; an optional field would let each new automated path skip the one + * thing that restores it. A human needs no purpose — the person is the reason. + * + * Fields are `readonly` because a principal is an authorization snapshot, not a + * mutable record: every consumer re-reads `kind` to decide what the holder may + * do, so a principal that can change between two reads has no meaning. The + * modifier is the compile-time half; `trustAutomationPrincipal` freezes the + * runtime half. `readonly` is not checked in assignability, so producers may + * still build one from an ordinary object literal. + */ +export type ExecutionPrincipal = + | { + readonly kind: 'human'; + readonly id: string; + readonly tenantId: string; + readonly role: ApprovalRole; + } + | { + readonly kind: 'service'; + readonly id: string; + readonly tenantId: string; + readonly purpose: string; + /** Only an agent delegates; `never` makes a wrong shape a type error. */ + readonly delegatedBy?: never; + } + | { + readonly kind: 'agent'; + readonly id: string; + readonly tenantId: string; + readonly purpose: string; + /** The principal that delegated this run, for agent-to-agent work. */ + readonly delegatedBy?: string; + } + | { + readonly kind: 'system'; + readonly id: string; + readonly tenantId: string; + readonly purpose: string; + readonly delegatedBy?: never; + }; + +/** + * The trusted-automation brand, following the TENANT_BOUND idiom for the same + * reason: TypeScript is structural, so a parameter typed plain + * `ExecutionPrincipal` is satisfied by any object literal. Without this, the + * service's automated entries authorize on the CALLER'S OWN ASSERTION that it + * is a robot — which would give automation strictly more create authority than + * a human `viewer`. + * + * The symbol is exported because declaration emit cannot reference a + * module-private name from an exported type. It is deliberately absent from the + * package barrel, but that is API-surface hygiene, NOT a capability boundary: + * the brand is recoverable by reflection from any vouched principal + * (`Object.getOwnPropertySymbols(maintenancePrincipal('x'))`), so in-process + * code that means to forge one still can. That residual is the same one + * TENANT_BOUND accepts, and it is deliberate — a TCB bypass on par with an `as` + * cast. What the brand eliminates is accidental satisfaction and the rushed fix + * that hands a request-derived principal to a trusted entry. + */ +export const TRUSTED_AUTOMATION: unique symbol = Symbol( + 'flowsafe.trustedAutomation', +); + +/** + * Any non-human principal — the shape a duty needs when it wants provenance but + * derives no authority from the principal (the cron SLA sweep is the case). + * Separate from `TrustedAutomationPrincipal` so the brand is demanded only + * where it is actually read, which is `ApprovalService`'s two trusted entries. + */ +export type AutomatedExecutionPrincipal = Extract< + ExecutionPrincipal, + { kind: AutomatedPrincipalKind } +>; + +/** + * An automated principal that trusted platform code vouched for. The only kind + * accepted by `ApprovalService.createAsPrincipal` and + * `supersedeStaleAsPrincipal`. + */ +export type TrustedAutomationPrincipal = AutomatedExecutionPrincipal & { + readonly [TRUSTED_AUTOMATION]: true; +}; + +/** + * Vouch for an automated principal. Calling this IS the trust assertion, which + * is why it is a named function rather than a cast: it should be greppable, and + * it should never appear on a path that took the principal from a request. + * + * Throws on a human or a malformed principal — those must use the + * role-authorized entries. + * + * Returns a CANONICAL CLONE, branded and frozen. Validating the caller's object + * and handing the same reference back made the vouch time-of-check/time-of-use: + * the caller kept a mutable alias, so a validated `system` principal could be + * rewritten into `{kind:'human', role:'admin'}` after the check and before the + * service read it — and `#authorizeAutomated` re-reads `kind`. The clone comes + * from `canonicalPrincipal`, so it holds the exact values that were validated + * and no extra property rides into the trusted computing base; the brand lets + * consumers prove the value came from here, and the freeze keeps both true for + * the object's whole life. + */ +export function trustAutomationPrincipal( + principal: ExecutionPrincipal, +): TrustedAutomationPrincipal { + // The brand is stamped onto the CANONICAL SNAPSHOT, never onto the argument. + // Validating one object and minting from another is how a value that passed + // the automated check came back out as a human admin; taking both from the + // same read makes that impossible rather than merely difficult. + const canonical = canonicalPrincipal(principal); + if (canonical === undefined || canonical.kind === 'human') { + throw new Error( + 'execution principal: only a valid automated principal can be trusted for platform work', + ); + } + return Object.freeze({ + ...canonical, + [TRUSTED_AUTOMATION]: true as const, + }); +} + +/** + * Read one field as an OWN DATA property, or `undefined`. + * + * Every principal field is read through this rather than by `value.kind`, + * because a plain read is not a stable observation of a value someone else + * built: it walks the prototype chain, so an inherited getter answers it, and + * an own accessor's getter can answer differently each time. `Object.freeze` + * is no substitute — it constrains a value's own data properties and says + * nothing about accessors, inherited fields, or virtual ones. + * + * This does NOT by itself prove the field cannot lie: over a Proxy with an + * extensible target, `Object.getOwnPropertyDescriptor` is a trap as + * unconstrained as `get`. What closes the class is that the single read is + * captured — see `canonicalPrincipal`, which returns a snapshot built from + * these reads, so nothing downstream re-reads the caller's object at all. + * + * The minter emits plain own data properties, so this refuses nothing real. + */ +function ownField(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor + ? descriptor.value + : undefined; +} + +/** + * Does this value carry the brand AND still hold a valid automated shape? + * + * The parameter type alone proves nothing at runtime — TypeScript is erased, + * and the ways in that remain (an `as` cast, a value rebuilt from storage or a + * structured clone across a boundary) all produce something the type accepts. + * This is the check that makes the brand load-bearing rather than decorative, + * so the trusted service entries call it instead of trusting their signature. + * + * Deliberately NOT on the package barrel: it is the enforcement half of an + * internal invariant, and exporting it would put a runtime shape check into the + * public API that consumers would have to keep working across minor versions. + */ +export function isTrustedAutomationPrincipal( + value: unknown, +): value is TrustedAutomationPrincipal { + if (value === null || typeof value !== 'object') return false; + return ( + canonicalAutomatedPrincipal(value) !== undefined && + // The brand is read as an OWN DATA property for the same reason every other + // field is: a plain read would accept one inherited from a prototype, which + // nobody stamped. + ownField(value, TRUSTED_AUTOMATION) === true && + // A branded-but-UNFROZEN principal is by definition one somebody stamped + // onto a live mutable object rather than minting. It is also what forces a + // Proxy's descriptor and `get` channels to agree, since the spec invariants + // only bind over a non-extensible target. + Object.isFrozen(value) + ); +} + +/** + * Validate and return a canonical principal that is not a person. + * + * The returned snapshot contains the exact values that passed validation, so + * callers never need to re-read a mutable or adversarial input. Kept off the + * package barrel: this is an internal enforcement helper, not public API. + */ +export function canonicalAutomatedPrincipal( + value: unknown, +): AutomatedExecutionPrincipal | undefined { + const principal = canonicalPrincipal(value); + return principal !== undefined && principal.kind !== 'human' + ? principal + : undefined; +} + +/** + * Bounded, non-empty, and free of control characters. + * + * Not an injection barrier for the wire — the principal travels through + * `JSON.stringify`, which escapes U+0000–U+001F. It matters because these + * fields do not stop at the wire: `id` becomes `requestedBy`/`decidedBy` in D1 + * and the actor on every audit row, and `purpose` rides into the SIEM export. + * The bounds keep an audit row bounded; the control-character refusal keeps + * those strings clean at the boundary rather than downstream. Mirrors the + * `containsHeaderControl` check `createTenantResolver` applies to an actor id. + */ +function boundedText(value: unknown, max: number): value is string { + if (typeof value !== 'string' || value.trim() === '' || value.length > max) { + return false; + } + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +/** + * Validate AND canonicalize in one pass: every field is read exactly once, and + * the result is a fresh plain object built from THOSE reads. + * + * Validating a caller's object and then reading it again is the mistake this + * module keeps re-learning. The first version handed back the caller's own + * mutable reference; the second cloned but re-read plainly, which an accessor + * or a prototype getter could answer differently; reading through descriptors + * narrows the channel but does not close it, because + * `Object.getOwnPropertyDescriptor` is itself a Proxy trap and, over an + * extensible target, is as unconstrained as `get`. + * + * Returning the snapshot ends the class rather than narrowing it: "validated" + * and "used" become the same values, so no consumer can be handed something + * other than what was checked, whatever the input was built from. + * + * Fields are picked EXPLICITLY. tsc catches a new kind (the returns stop being + * exhaustive) and a new REQUIRED field (the literal stops matching), but NOT a + * new optional one — that compiles clean and is silently dropped, so add it to + * the pick by hand (`delegatedBy` is the existing example). + */ +function canonicalPrincipal(value: unknown): ExecutionPrincipal | undefined { + if (value === null || typeof value !== 'object') return undefined; + const id = ownField(value, 'id'); + const tenantId = ownField(value, 'tenantId'); + const kind = ownField(value, 'kind'); + if ( + !boundedText(id, MAX_PRINCIPAL_ID_LENGTH) || + typeof tenantId !== 'string' || + tenantId === '' + ) { + return undefined; + } + if (kind === 'human') { + const role = ownField(value, 'role'); + return typeof role === 'string' && + (APPROVAL_ROLES as readonly string[]).includes(role) + ? { kind: 'human', id, tenantId, role: role as ApprovalRole } + : undefined; + } + if (kind !== 'service' && kind !== 'agent' && kind !== 'system') { + return undefined; + } + const purpose = ownField(value, 'purpose'); + if (!boundedText(purpose, MAX_PURPOSE_LENGTH)) return undefined; + const delegatedBy = ownField(value, 'delegatedBy'); + if (delegatedBy !== undefined) { + return kind === 'agent' && boundedText(delegatedBy, MAX_PRINCIPAL_ID_LENGTH) + ? { kind: 'agent', id, tenantId, purpose, delegatedBy } + : undefined; + } + if (kind === 'agent') return { kind: 'agent', id, tenantId, purpose }; + if (kind === 'service') return { kind: 'service', id, tenantId, purpose }; + return { kind: 'system', id, tenantId, purpose }; +} + +/** + * Structural validation only. The TENANT binding is checked separately by + * `assertExecutionPrincipal`, because "is this shaped like a principal" and "is + * this principal allowed here" are different questions and conflating them + * produces call sites that answer neither. + * + * This answers only "was a valid principal readable from this value". It does + * NOT promise a later plain read returns what was validated — nothing can, for + * an object built to lie. Anything that goes on to USE the fields must take + * them from `canonicalPrincipal`'s snapshot (as `trustAutomationPrincipal` and + * `assertExecutionPrincipal` do) rather than from the argument. + */ +export function isExecutionPrincipal( + value: unknown, +): value is ExecutionPrincipal { + return canonicalPrincipal(value) !== undefined; +} + +/** + * Validate a principal AND bind it to the tenant that is about to act on it. + * + * `tenantId` on a decoded principal crosses an authentication boundary exactly + * as `ApprovalActor.tenantId` does — the type says `string`, and the type system + * has no authority over a value read back out of D1 or a DO's storage. + * + * Returns the canonical snapshot, not the argument: the tenant that was + * compared and the tenant the caller goes on to use are then the same string. + */ +export function assertExecutionPrincipal( + value: unknown, + expectedTenantId: string, + label: string, +): ExecutionPrincipal { + const principal = canonicalPrincipal(value); + if (principal === undefined) { + throw new Error(`execution principal: ${label} is malformed`); + } + if (principal.tenantId !== expectedTenantId) { + throw new Error( + `execution principal: ${label} belongs to tenant '${principal.tenantId}', not '${expectedTenantId}'`, + ); + } + return principal; +} + +/** + * Structural equality across every kind-specific field. + * + * Cast-free: the union narrows on `kind`, so a future variant with a new + * discriminating field becomes a compile error here rather than silently + * comparing equal. Three rebinding guards depend on that. + */ +export function samePrincipal( + left: ExecutionPrincipal, + right: ExecutionPrincipal, +): boolean { + if (left.id !== right.id || left.tenantId !== right.tenantId) return false; + if (left.kind === 'human' || right.kind === 'human') { + return ( + left.kind === 'human' && + right.kind === 'human' && + left.role === right.role + ); + } + return ( + left.kind === right.kind && + left.purpose === right.purpose && + left.delegatedBy === right.delegatedBy + ); +} + +/** + * The role an automated principal projects into breakwater's `Actor`. + * + * `Actor.role` is required, so an automated principal must carry SOME label. + * breakwater's gate does not consult the role allowlist for a non-human kind, + * making this value inert there — it is the least-privileged role precisely so + * that any consumer which reads `actor.role` WITHOUT understanding `kind` gets + * the minimum rather than the `operator` these paths used to fabricate. + * + * `viewer` also holds no decider role, so an automated principal projected onto + * `ApprovalService` can never satisfy DECIDER_ROLES and approve anything. + */ +export const AUTOMATED_PROJECTED_ROLE: ApprovalRole = 'viewer'; + +/** + * Project a principal onto the approval-service identity. + * + * Automated principals keep their own id — attribution stays truthful — while + * borrowing the least-privileged role so that the approval service's own role + * gates (CAN_CREATE, DECIDER_ROLES) treat them as read-only. + */ +export function principalActor(principal: ExecutionPrincipal): ApprovalActor { + const { id, role } = breakwaterActorFor(principal); + return { id, role, tenantId: principal.tenantId }; +} + +/** + * The principal as breakwater's `Actor`. The ONE place the projection rule is + * written; `principalActor` and the trusted request context both go through it + * so the breakwater-facing and approval-facing identities cannot disagree. + */ +export function breakwaterActorFor(principal: ExecutionPrincipal): { + id: string; + role: ApprovalRole; + kind: ExecutionPrincipalKind; +} { + return { + id: principal.id, + role: + principal.kind === 'human' ? principal.role : AUTOMATED_PROJECTED_ROLE, + kind: principal.kind, + }; +} + +/** + * Serialize a principal for the trusted thread header. Every field is already + * bounded and header-control-free (`boundedText`), so plain JSON is safe and + * stays readable in a trace. + */ +export function encodeExecutionPrincipal( + principal: ExecutionPrincipal, +): string { + return JSON.stringify( + principal.kind === 'human' + ? { kind: 'human', id: principal.id, role: principal.role } + : { + kind: principal.kind, + id: principal.id, + purpose: principal.purpose, + ...(principal.delegatedBy !== undefined + ? { delegatedBy: principal.delegatedBy } + : {}), + }, + ); +} + +/** + * Rebuild a principal from the trusted header, binding it to the tenant the DO + * already authenticated. Fields are picked EXPLICITLY rather than spread, so an + * attacker-supplied extra property cannot ride into DO storage or D1. + * + * Returns undefined on anything malformed; the caller fails closed. + */ +export function decodeExecutionPrincipal( + header: string, + tenantId: string, +): ExecutionPrincipal | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(header); + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== 'object') return undefined; + const fields = parsed as Record; + const candidate = + fields.kind === 'human' + ? { kind: 'human', id: fields.id, tenantId, role: fields.role } + : { + kind: fields.kind, + id: fields.id, + tenantId, + purpose: fields.purpose, + ...(fields.delegatedBy !== undefined + ? { delegatedBy: fields.delegatedBy } + : {}), + }; + return isExecutionPrincipal(candidate) ? candidate : undefined; +} + +/** An authenticated human at the HTTP boundary, as an execution principal. */ +export function humanPrincipal(actor: ApprovalActor): ExecutionPrincipal { + return { + kind: 'human', + id: actor.id, + tenantId: actor.tenantId, + role: actor.role, + }; +} + +/** Correlation fields carried into every audit event for this principal. */ +export function principalAuditFields(principal: ExecutionPrincipal): { + principalKind: ExecutionPrincipalKind; + principalId: string; + purpose?: string; + delegatedBy?: string; +} { + if (principal.kind === 'human') { + return { principalKind: 'human', principalId: principal.id }; + } + return { + principalKind: principal.kind, + principalId: principal.id, + purpose: principal.purpose, + ...(principal.kind === 'agent' && principal.delegatedBy !== undefined + ? { delegatedBy: principal.delegatedBy } + : {}), + }; +} diff --git a/packages/flowsafe/src/approval-api/service.test.ts b/packages/flowsafe/src/approval-api/service.test.ts index b4c6ec0..4e6ca5b 100644 --- a/packages/flowsafe/src/approval-api/service.test.ts +++ b/packages/flowsafe/src/approval-api/service.test.ts @@ -9,6 +9,7 @@ import type { ApprovalStreamEvent, ApprovalStreamSink, } from './contract.js'; +import type { AutomatedExecutionPrincipal } from './principal.js'; import { ApprovalAuthzError, ApprovalConflictError, @@ -19,6 +20,7 @@ import { UnknownApprovalError, } from './service.js'; import type { InMemoryApprovalStore } from './store.js'; +import type { SystemApprovalStore } from './tenant-brand.js'; import { InMemoryApprovalStoreFactory } from './tenant-store.js'; import { type ApprovalRecord, @@ -28,6 +30,13 @@ import { } from './types.js'; const ADMIN: ApprovalActor = { id: 'ada', role: 'admin', tenantId: 'acme' }; +const SWEEP_PRINCIPAL: AutomatedExecutionPrincipal = { + kind: 'system', + id: 'sweeper', + tenantId: 'system', + purpose: 'approval-sla-maintenance', +}; + const OPERATOR: ApprovalActor = { id: 'opal', role: 'operator', @@ -107,7 +116,7 @@ function runSweep( } = {}, ): Promise { return sweepSLA(harness.backend.system(), { - systemActor: OPERATOR, + systemPrincipal: SWEEP_PRINCIPAL, audit: (event) => harness.events.push(event), onEscalation: options.onEscalation, notify: options.notify, @@ -149,9 +158,10 @@ describe('ApprovalService.create', () => { threadId: 'acme_thread-1', resourceId: 'acme_resource-1', principal: { + kind: 'human' as const, id: 'starter', - role: 'operator' as const, tenantId: 'acme', + role: 'operator' as const, }, }; @@ -826,6 +836,67 @@ describe('ApprovalService.sweepSLA', () => { ); } }); + + it('keeps canonical audit provenance on every event when the source principal mutates during store I/O', async () => { + // #given — the caller retains a mutable alias and rewrites it while the + // sweep is suspended in its first store read. + const harness = makeHarness(); + await seedPending(harness, { slaSeconds: 60 }); + harness.advance(61_000); + harness.events.length = 0; + const source: Record = { ...SWEEP_PRINCIPAL }; + const backing = harness.backend.system(); + const store: SystemApprovalStore = { + list: async (filter) => { + const records = await backing.list(filter); + source.kind = 'human'; + source.role = 'admin'; + source.purpose = undefined; + return records; + }, + transition: (id, from, patch) => backing.transition(id, from, patch), + purgeExpired: (cutoffIso, limit) => + backing.purgeExpired(cutoffIso, limit), + }; + + // #when + const escalated = await sweepSLA(store, { + systemPrincipal: source as unknown as AutomatedExecutionPrincipal, + audit: (event) => harness.events.push(event), + onEscalation: () => { + throw new Error('pager down'); + }, + notify: () => { + throw new Error('smtp down'); + }, + stream: () => { + throw new Error('hub down'); + }, + now: harness.now, + }); + + // #then — attribution comes from the entry snapshot, not the mutated alias. + expect(escalated).toHaveLength(1); + expect(harness.events).toHaveLength(4); + expect( + harness.events.map((event) => [event.action, event.decision]), + ).toEqual([ + ['approval.escalate', 'allowed'], + ['approval.escalate', 'error'], + ['approval.notify', 'error'], + ['approval.stream', 'error'], + ]); + for (const event of harness.events) { + expect(event).toMatchObject({ + actor: { id: 'sweeper', role: 'viewer', tenantId: 'system' }, + detail: { + principalKind: 'system', + principalId: 'sweeper', + purpose: 'approval-sla-maintenance', + }, + }); + } + }); }); describe('ApprovalService.metrics', () => { @@ -1732,7 +1803,7 @@ describe('ApprovalService audit sink promise containment', () => { await seedPending(harness, { slaSeconds: 60, runId: 'acme_run-2' }); harness.advance(61_000); await sweepSLA(harness.backend.system(), { - systemActor: OPERATOR, + systemPrincipal: SWEEP_PRINCIPAL, audit: () => Promise.reject(new Error('siem down')), now: harness.now, }); @@ -1745,6 +1816,35 @@ describe('ApprovalService audit sink promise containment', () => { proc.off('unhandledRejection', onUnhandled); } }); + + it.each([ + [ + 'a human principal', + { kind: 'human', id: 'ada', tenantId: 'system', role: 'admin' }, + ], + [ + 'a principal with no purpose', + { kind: 'system', id: 'sweeper', tenantId: 'system' }, + ], + ['a non-object', 'sweeper'], + ])('refuses to sweep on behalf of %s', async (_label, principal) => { + // #given — the sweep writes across EVERY tenant, so a bad attribution + // identity makes every escalation it emits unattributable. The type + // excludes a human; this is the erased-type half. + const harness = makeHarness(); + await seedPending(harness, { slaSeconds: 60 }); + harness.advance(61_000); + + // #when / #then — refused before any store write, so nothing escalates. + await expect( + sweepSLA(harness.backend.system(), { + systemPrincipal: principal as unknown as AutomatedExecutionPrincipal, + audit: (event) => harness.events.push(event), + now: harness.now, + }), + ).rejects.toThrow(/must be a valid automated execution principal/); + expect(await harness.store.list({ status: ['escalated'] })).toEqual([]); + }); }); describe('ApprovalService decide audit detail', () => { diff --git a/packages/flowsafe/src/approval-api/service.ts b/packages/flowsafe/src/approval-api/service.ts index ada574b..452304e 100644 --- a/packages/flowsafe/src/approval-api/service.ts +++ b/packages/flowsafe/src/approval-api/service.ts @@ -22,6 +22,15 @@ import type { ApprovalStreamSink, } from './contract.js'; import { APPROVAL_ROLES, DECIDER_ROLES } from './contract.js'; +import { + type AutomatedExecutionPrincipal, + canonicalAutomatedPrincipal, + isExecutionPrincipal, + isTrustedAutomationPrincipal, + principalActor, + principalAuditFields, + type TrustedAutomationPrincipal, +} from './principal.js'; import { type ApprovalPatch, listAllApprovedForRun } from './store.js'; import type { SystemApprovalStore, @@ -185,6 +194,15 @@ export class ApprovalService { readonly #allowSelfDecision?: SelfDecisionPolicy; readonly #now: () => Date; + /** + * The tenant this service is bound to. Exposed so the host-kit bridges can + * mint their own bookkeeping principal against it instead of making every + * host construct one and vouch for it. + */ + get tenantId(): string { + return this.#store.tenantId; + } + constructor(options: ApprovalServiceOptions) { this.#store = options.store; this.#audit = options.audit; @@ -202,6 +220,45 @@ export class ApprovalService { resumeTarget?: ApprovalResumeTarget, ): Promise<{ record: ApprovalRecord; created: boolean }> { this.#authorize(actor, CAN_CREATE, 'approval.create', 'approval'); + return this.#createAuthorized(input, actor, resumeTarget); + } + + /** + * File an approval on behalf of an AUTOMATED principal — the trusted entry + * for platform bridges (suspension reconcile, agent host) that have no person + * behind them. + * + * These callers used to fabricate `role: 'operator'` to satisfy CAN_CREATE. + * They cannot simply project onto a role instead: automated principals + * project to the least-privileged role precisely so they can never decide, + * and `viewer` is not in CAN_CREATE. So the role gate is replaced here — not + * widened — by a kind-and-tenant check. + * + * There is deliberately NO principal-taking claim/decide/delegate. Filing a + * request is trusted platform work; deciding one is a human judgement, and an + * automated principal approving its own request is the separation-of-duties + * hole this whole model exists to close. + */ + async createAsPrincipal( + input: CreateApprovalInput, + principal: TrustedAutomationPrincipal, + resumeTarget?: ApprovalResumeTarget, + ): Promise<{ record: ApprovalRecord; created: boolean }> { + this.#authorizeAutomated(principal, 'approval.create', 'approval'); + return this.#createAuthorized( + input, + principalActor(principal), + resumeTarget, + principalAuditFields(principal), + ); + } + + async #createAuthorized( + input: CreateApprovalInput, + actor: ApprovalActor, + resumeTarget?: ApprovalResumeTarget, + provenance?: Record, + ): Promise<{ record: ApprovalRecord; created: boolean }> { this.#validateCreate(input); const now = this.#now(); const slaSeconds = input.slaSeconds ?? this.#defaultSlaSeconds; @@ -257,6 +314,7 @@ export class ApprovalService { workflowId: result.record.workflowId, runId: result.record.runId, created: result.created, + ...provenance, }, }, ); @@ -264,6 +322,10 @@ export class ApprovalService { // (created: false) returns the EXISTING open record, which already // notified when it entered the queue. if (result.created) { + // `provenance` rides the subordinate events too. An automated operation + // whose primary event carries principalKind/principalId/purpose but whose + // notify and stream failures do not leaves exactly the gaps that make an + // incident unreconstructable: the failures are the rows an operator reads. fireNotification( this.#notify, { type: 'created', record: result.record }, @@ -273,7 +335,10 @@ export class ApprovalService { 'approval.notify', `approval:${result.record.id}`, 'error', - { reason, detail: { tenantId: result.record.tenantId } }, + { + reason, + detail: { tenantId: result.record.tenantId, ...provenance }, + }, ), ); fireStreamEvent( @@ -285,7 +350,10 @@ export class ApprovalService { 'approval.stream', `approval:${result.record.id}`, 'error', - { reason, detail: { tenantId: result.record.tenantId } }, + { + reason, + detail: { tenantId: result.record.tenantId, ...provenance }, + }, ), ); } @@ -568,6 +636,38 @@ export class ApprovalService { reason: string, ): Promise { this.#authorize(actor, CAN_CREATE, 'approval.supersede', `approval:${id}`); + return this.#supersedeAuthorized(id, actor, reason); + } + + /** + * Supersede on behalf of an AUTOMATED principal — the reconcile bridge's + * half of `createAsPrincipal`, and authorized the same way. + * + * Superseding is bookkeeping, not a decision: the record's suspension + * fingerprint no longer matches the run, so it can never be resumed and is + * closed to stop it shadowing the fresh filing. It is deliberately not + * reachable through `decide`, so this does not give automation a decision. + */ + async supersedeStaleAsPrincipal( + id: string, + principal: TrustedAutomationPrincipal, + reason: string, + ): Promise { + this.#authorizeAutomated(principal, 'approval.supersede', `approval:${id}`); + return this.#supersedeAuthorized( + id, + principalActor(principal), + reason, + principalAuditFields(principal), + ); + } + + async #supersedeAuthorized( + id: string, + actor: ApprovalActor, + reason: string, + provenance?: Record, + ): Promise { const now = this.#now().toISOString(); const updated = await this.#store.transition(id, OPEN_STATUSES, { status: 'rejected', @@ -584,6 +684,7 @@ export class ApprovalService { tenantId: updated.tenantId, workflowId: updated.workflowId, runId: updated.runId, + ...provenance, }, }); // `reason` here is the supersede reason (method arg); the reportError arg @@ -594,7 +695,7 @@ export class ApprovalService { (streamError) => this.#record(actor, 'approval.stream', `approval:${id}`, 'error', { reason: streamError, - detail: { tenantId: updated.tenantId }, + detail: { tenantId: updated.tenantId, ...provenance }, }), ); return updated; @@ -749,6 +850,49 @@ export class ApprovalService { ); } + /** + * Authorize an automated caller on kind, brand, and tenant. + * + * The parameter type is NOT the enforcement. `trustAutomationPrincipal` is + * the only minter, but TypeScript is erased at runtime, so a cast or a value + * rebuilt from storage reaches here typed correctly and shaped however the + * caller left it. Re-reading the brand and the shape is what turns "only the + * minter produces this" from a convention into a check — and it is what stops + * a principal validated as `system` from being read back as a human `admin`. + * + * The tenant is checked for the same reason `#authorize` checks it: the + * resolver builds the service from the principal's own tenant, so a mismatch + * can only be a wiring bug — exactly when it must fail closed. + */ + #authorizeAutomated( + principal: TrustedAutomationPrincipal, + action: string, + resource: string, + ): void { + if (!isTrustedAutomationPrincipal(principal)) { + // No actor: an unvouched value has no attribution worth recording, and + // projecting one through principalActor would read the very fields the + // check just refused to trust. + this.#record(null, action, resource, 'denied', { + reason: + 'principal is not a vouched automated principal (missing trust brand or invalid automated shape)', + }); + throw new ApprovalAuthzError( + `${action}: principal is not a vouched automated principal`, + ); + } + if (principal.tenantId === this.#store.tenantId) return; + this.#record(principalActor(principal), action, resource, 'denied', { + reason: `principal tenant '${principal.tenantId}' does not match the store binding '${this.#store.tenantId}'`, + // A denial is an automated event too: without these the only automated + // audit rows carrying provenance would be the ones that succeeded. + detail: principalAuditFields(principal), + }); + throw new ApprovalAuthzError( + `${action}: principal tenant does not match this service's tenant binding`, + ); + } + // Callers pass the final resource string ('approval' for collection-level // actions, 'approval:' for record-level) — no format inference here. #record( @@ -938,21 +1082,19 @@ export class ApprovalService { return; } if (target.kind === 'agent-thread') { - const principal = target.principal; if ( typeof target.agentId !== 'string' || !PATH_SAFE_ID_PATTERN.test(target.agentId) || !ownsPathSafeId(target.threadId) || !ownsPathSafeId(target.resourceId) || - principal === null || - typeof principal !== 'object' || - typeof principal.id !== 'string' || - principal.id.trim() === '' || - !(APPROVAL_ROLES as readonly string[]).includes(principal.role) || - principal.tenantId !== this.#store.tenantId + // Fails closed on the pre-principal `{id, role, tenantId}` form: an + // ApprovalActor is not an ExecutionPrincipal, and coercing one would + // resurrect a fabricated operator as a human. + !isExecutionPrincipal(target.principal) || + target.principal.tenantId !== this.#store.tenantId ) { throw new InvalidApprovalInputError( - 'agent resumeTarget must name path-safe ids and a valid principal owned by the bound tenant', + 'agent resumeTarget must name path-safe ids and a valid execution principal owned by the bound tenant', ); } return; @@ -1037,8 +1179,12 @@ export interface SweepSLAOptions { * Attribution only — the sweep runs inside the trusted computing base * (cron), so there is no role check: the TYPE of the store argument is the * authorization (a SystemApprovalStore is unobtainable from request scope). + * + * Automated kinds only, and REFUSED at runtime as well: a human here would + * stamp `principalKind: 'human'` onto cross-tenant cron escalations, which is + * the synthetic operator this whole model exists to remove. */ - systemActor: ApprovalActor; + systemPrincipal: AutomatedExecutionPrincipal; audit?: ApprovalAuditSink; /** Fired for each record escalated. */ onEscalation?: (record: ApprovalRecord) => void; @@ -1078,6 +1224,18 @@ export async function sweepSLA( store: SystemApprovalStore, options: SweepSLAOptions, ): Promise { + // The principal is attribution, not authorization — the SystemApprovalStore + // type is the authorization. The parameter type already excludes a human; + // this is the erased-type half, because this is the one exported cross-tenant + // function that CARRIES an attribution principal (purgeExpiredApprovals takes + // none, so it has no attribution to corrupt), and a bad principal here makes + // every escalation it emits, for every tenant, unattributable. + const systemPrincipal = canonicalAutomatedPrincipal(options.systemPrincipal); + if (systemPrincipal === undefined) { + throw new Error( + 'sweepSLA: systemPrincipal must be a valid automated execution principal', + ); + } const now = options.now ?? (() => new Date()); const record = ( action: string, @@ -1088,12 +1246,17 @@ export async function sweepSLA( if (!options.audit) return; try { const outcome = options.audit({ - actor: options.systemActor, + actor: principalActor(systemPrincipal), action, resource, decision, reason: extra.reason, - detail: extra.detail, + // Provenance the fabricated maintenance operator never carried: which + // kind of principal swept, under whose id, and why. + detail: { + ...extra.detail, + ...principalAuditFields(systemPrincipal), + }, }); // Same promise containment as ApprovalService's #record: a composed // sink's rejection must never surface as an unhandled rejection. diff --git a/packages/flowsafe/src/approval-api/store.test.ts b/packages/flowsafe/src/approval-api/store.test.ts index 027fe3f..c514f82 100644 --- a/packages/flowsafe/src/approval-api/store.test.ts +++ b/packages/flowsafe/src/approval-api/store.test.ts @@ -181,9 +181,10 @@ function describeStoreContract( threadId: 'acme_thread-agent', resourceId: 'acme_resource-agent', principal: { + kind: 'human', id: 'requester-1', - role: 'operator', tenantId: 'acme', + role: 'operator', }, }, }); @@ -1111,6 +1112,32 @@ describeStoreContract( describe('D1ApprovalStore persisted resume-target validation', () => { it.each([ ['malformed JSON', '{'], + [ + // Exactly what the previous release persisted for an agent run. Reading + // it as a human would resurrect the fabricated operator this removes. + 'pre-principal ApprovalActor shape', + JSON.stringify({ + kind: 'agent-thread', + agentId: 'writer', + threadId: 'acme_thread', + resourceId: 'acme_resource', + principal: { + id: 'flowsafe-system', + role: 'operator', + tenantId: 'acme', + }, + }), + ], + [ + 'automated principal with no purpose', + JSON.stringify({ + kind: 'agent-thread', + agentId: 'writer', + threadId: 'acme_thread', + resourceId: 'acme_resource', + principal: { kind: 'system', id: 'sched', tenantId: 'acme' }, + }), + ], [ 'path-unsafe agent id', JSON.stringify({ diff --git a/packages/flowsafe/src/approval-api/tenant-context.test.ts b/packages/flowsafe/src/approval-api/tenant-context.test.ts index 6368b9a..794fa6d 100644 --- a/packages/flowsafe/src/approval-api/tenant-context.test.ts +++ b/packages/flowsafe/src/approval-api/tenant-context.test.ts @@ -1,10 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; -import { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, - THREAD_TENANT_HEADER, -} from '../do-runner/thread-header.js'; +import { THREAD_TENANT_HEADER } from '../do-runner/thread-header.js'; import type { CreateTenantResolverOptions, TenantContext, @@ -151,8 +147,8 @@ describe('createTenantResolver authenticated actor validation', () => { describe('createTenantResolver server-stamped header boundary', () => { it.each([ THREAD_TENANT_HEADER, - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + 'x-flowsafe-actor', + 'x-flowsafe-role', ])('refuses a mixed-case inbound %s header before authentication', async (header) => { const authenticate = vi.fn(() => ({ id: 'actor-1', diff --git a/packages/flowsafe/src/approval-api/tenant-context.ts b/packages/flowsafe/src/approval-api/tenant-context.ts index 25cccd7..cc1c452 100644 --- a/packages/flowsafe/src/approval-api/tenant-context.ts +++ b/packages/flowsafe/src/approval-api/tenant-context.ts @@ -16,8 +16,7 @@ import { tenantOwnsSaltedId, } from '../do-runner/path-safe-id.js'; import { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, } from '../do-runner/thread-header.js'; import { @@ -26,6 +25,7 @@ import { type ApprovalRole, DECIDER_ROLES, } from './contract.js'; +import { type ExecutionPrincipal, humanPrincipal } from './principal.js'; import { type ApprovalService, type SelfDecisionPolicy, @@ -36,6 +36,12 @@ import type { TenantBoundApprovalStore } from './tenant-brand.js'; export interface TenantContext { /** Already authenticated; tenantId equals actor.tenantId and matches the tenant-ID pattern. */ readonly actor: ApprovalActor; + /** + * WHO is executing. For an authenticated HTTP request this is the human in + * `actor`; trusted internal contexts (schedule ticks, provider delivery, cron + * maintenance) carry an automated principal instead of fabricating a role. + */ + readonly principal: ExecutionPrincipal; readonly tenantId: string; /** The approval service over a store bound to THIS tenant. */ service(): ApprovalService; @@ -142,8 +148,15 @@ export function createTenantResolver( // resolver is the one chokepoint every routed request crosses. if ( request.headers.has(THREAD_TENANT_HEADER) || - request.headers.has(THREAD_ACTOR_HEADER) || - request.headers.has(THREAD_ACTOR_ROLE_HEADER) + // Retired from the wire, still refused: a client forging them means it + // expects an older flowsafe, and a mixed-version deployment should fail + // loudly rather than have the header quietly ignored. + request.headers.has('x-flowsafe-actor') || + request.headers.has('x-flowsafe-role') || + // Without this an external caller could assert its own principal kind, + // and the agent host's automation gate would be answering a question the + // client got to ask. + request.headers.has(THREAD_PRINCIPAL_HEADER) ) { throw new TenantResolutionError( `inbound request carries a server-stamped thread header — refusing to scope it`, @@ -187,6 +200,9 @@ export function createTenantResolver( let service: ApprovalService | undefined; return { actor, + // An authenticated HTTP request is by definition a person; automated + // principals never arrive through this resolver. + principal: humanPrincipal(actor), tenantId, service: () => { service ??= options.buildService( diff --git a/packages/flowsafe/src/approval-api/types.ts b/packages/flowsafe/src/approval-api/types.ts index fe48025..24b9971 100644 --- a/packages/flowsafe/src/approval-api/types.ts +++ b/packages/flowsafe/src/approval-api/types.ts @@ -62,7 +62,17 @@ export type ApprovalResumeTarget = agentId: string; threadId: string; resourceId: string; - principal: import('./contract.js').ApprovalActor; + /** + * The principal to RESTORE on resume — never the reviewer who approved. + * A human decision must not transfer the decider's authority into the + * resumed run, and an automated run must resume as the automation it + * started as. Persisted, so its shape is versioned by validation: + * `#validateResumeTarget` rejects the pre-principal `ApprovalActor` form + * rather than coercing it (a `schedule.fire` run stored `role:'operator'`, + * and reading that back as a human would grant exactly the authority + * this type exists to withhold). + */ + principal: import('./principal.js').ExecutionPrincipal; }; export interface ApprovalRecord { diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index c7df052..8a34f07 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -114,7 +114,6 @@ export { export type { ThreadScope } from './thread-do.js'; export { ThreadDurableObject, ThreadIdentityError } from './thread-do.js'; export { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, } from './thread-header.js'; diff --git a/packages/flowsafe/src/do-runner/thread-do.test.ts b/packages/flowsafe/src/do-runner/thread-do.test.ts index da6a0d0..02c9365 100644 --- a/packages/flowsafe/src/do-runner/thread-do.test.ts +++ b/packages/flowsafe/src/do-runner/thread-do.test.ts @@ -3,12 +3,13 @@ import type { DurableObjectState } from '@cloudflare/workers-types'; import { InMemoryStore } from '@mastra/core/storage'; import { describe, expect, it } from 'vitest'; +import { encodeExecutionPrincipal } from '../approval-api/index.js'; + import { type InitResult, init } from './init.js'; import { mintThreadId } from './memory-id.js'; import { ThreadDurableObject, type ThreadScope } from './thread-do.js'; import { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, } from './thread-header.js'; @@ -47,8 +48,19 @@ function request( ): Request { const headers = new Headers(); if (tenantId !== undefined) headers.set(THREAD_TENANT_HEADER, tenantId); - if (requestedBy !== null) headers.set(THREAD_ACTOR_HEADER, requestedBy); - headers.set(THREAD_ACTOR_ROLE_HEADER, 'operator'); + // The topology stamps this on every send; the DO refuses a request without + // it rather than rebuilding the caller as a human. + if (requestedBy !== null) { + headers.set( + THREAD_PRINCIPAL_HEADER, + encodeExecutionPrincipal({ + kind: 'human', + id: requestedBy, + tenantId: tenantId ?? 'acme', + role: 'operator', + }), + ); + } return new Request('http://thread/messages', { method: 'POST', headers, @@ -105,26 +117,6 @@ describe('ThreadDurableObject tenant assertion', () => { expect(await response.text()).toMatch(/authenticates as ''/); }); - it('refuses a request without the topology-owned actor identity', async () => { - const thread = threadWith(mintThreadId('acme', () => 't1')); - - const response = await thread.fetch(request('acme', null)); - - expect(response.status).toBe(403); - expect(await response.text()).toMatch(/carries no trusted actor/); - }); - - it('refuses a request without a valid topology-owned actor role', async () => { - const thread = threadWith(mintThreadId('acme', () => 't1')); - const forged = request('acme'); - forged.headers.set(THREAD_ACTOR_ROLE_HEADER, 'owner'); - - const response = await thread.fetch(forged); - - expect(response.status).toBe(403); - expect(await response.text()).toMatch(/valid trusted actor role/); - }); - it('is exact at the tenant boundary (the acme vs acmecorp pin)', async () => { // #given — a prefix neighbor: 'acme' must not pass for 'acmecorp's thread const thread = threadWith(mintThreadId('acmecorp', () => 't1')); diff --git a/packages/flowsafe/src/do-runner/thread-do.ts b/packages/flowsafe/src/do-runner/thread-do.ts index 7abec22..b4b7cc9 100644 --- a/packages/flowsafe/src/do-runner/thread-do.ts +++ b/packages/flowsafe/src/do-runner/thread-do.ts @@ -30,26 +30,22 @@ // DurableObject` from 'cloudflare:workers' — so this module and its graph load // in node/vitest, the same posture as DurableObjectRunner and HubDurableObject. -import type { ApprovalActor, ApprovalRole } from '../approval-api/contract.js'; +import type { ApprovalActor } from '../approval-api/contract.js'; +import { + decodeExecutionPrincipal, + type ExecutionPrincipal, + principalActor, +} from '../approval-api/principal.js'; import type { DurableObjectRunnerState } from './cf-types.js'; import { DoStatusError, doErrorResponse } from './do-error-response.js'; import type { InitResult } from './init.js'; import { tenantOfMemoryId } from './memory-id.js'; import { DurableStorageResumeLedger } from './resume-ledger.js'; import { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, } from './thread-header.js'; -const THREAD_ACTOR_ROLES: readonly ApprovalRole[] = [ - 'admin', - 'builder', - 'operator', - 'reviewer', - 'viewer', -]; - /** * A request refused at the thread DO's identity boundary: the DO's name carries * no tenant, or the request's authenticated tenant is not the one the name @@ -76,8 +72,18 @@ export interface ThreadScope { readonly threadId: string; /** The tenant the threadId carries, equal to the request's authenticated one. */ readonly tenantId: string; - /** Complete server-stamped requester principal. */ + /** + * Complete server-stamped requester identity, in human shape. Kept for the + * approval service and every existing thread route; for automated principals + * it is the least-privileged projection of `principal`. + */ readonly actor: ApprovalActor; + /** + * WHO is executing — the authority the agent host gates on. A human here is + * the same identity as `actor`; anything else is automation that must have + * been declared by the target agent. + */ + readonly principal: ExecutionPrincipal; /** Compatibility alias for actor.id. */ readonly requestedBy: string; /** This DO's storage/runtime/pubsub wiring, built once per instance. */ @@ -191,27 +197,41 @@ export abstract class ThreadDurableObject { `thread identity mismatch: instance '${threadId}' belongs to tenant '${tenantId}' but the request authenticates as '${claimed ?? ''}' — refusing`, ); } - const actorId = request.headers.get(THREAD_ACTOR_HEADER); - if (!actorId) { + const principal = this.#principalFrom(request, tenantId); + return { + threadId, + tenantId, + actor: principalActor(principal), + principal, + requestedBy: principal.id, + }; + } + + /** + * Reconstruct the execution principal from the trusted header. + * + * ABSENT IS A REFUSAL, not a human default. `createThreadTopology` stamps + * this on every send and forward — the only sanctioned way to reach a thread + * DO — so a request without it did not come through the topology. Defaulting + * to a human here would let a dropped header turn automation into a person. + * + * This is the SOLE identity channel: `scope.actor` is projected from the + * principal rather than carried alongside it, so the two can never disagree. + */ + #principalFrom(request: Request, tenantId: string): ExecutionPrincipal { + const header = request.headers.get(THREAD_PRINCIPAL_HEADER); + if (header === null) { throw new ThreadIdentityError( - `thread identity mismatch: request for '${threadId}' carries no trusted actor`, + `thread identity mismatch: request for '${this.threadId}' carries no trusted execution principal`, ); } - const role = request.headers.get(THREAD_ACTOR_ROLE_HEADER); - if ( - role === null || - !(THREAD_ACTOR_ROLES as readonly string[]).includes(role) - ) { + const principal = decodeExecutionPrincipal(header, tenantId); + if (!principal) { throw new ThreadIdentityError( - `thread identity mismatch: request for '${threadId}' carries no valid trusted actor role`, + `thread identity mismatch: request for '${this.threadId}' carries an invalid execution principal`, ); } - const actor: ApprovalActor = { - id: actorId, - role: role as ApprovalRole, - tenantId, - }; - return { threadId, tenantId, actor, requestedBy: actor.id }; + return principal; } #ensureInit(): InitResult { diff --git a/packages/flowsafe/src/do-runner/thread-header.ts b/packages/flowsafe/src/do-runner/thread-header.ts index f3514fc..a1b0726 100644 --- a/packages/flowsafe/src/do-runner/thread-header.ts +++ b/packages/flowsafe/src/do-runner/thread-header.ts @@ -21,8 +21,20 @@ */ export const THREAD_TENANT_HEADER = 'x-flowsafe-tenant'; -/** The trusted Worker-stamped actor that caused a thread operation. */ -export const THREAD_ACTOR_HEADER = 'x-flowsafe-actor'; - -/** The trusted Worker-stamped role of the actor causing a thread operation. */ -export const THREAD_ACTOR_ROLE_HEADER = 'x-flowsafe-role'; +/** + * The trusted Worker-stamped EXECUTION PRINCIPAL — WHO is executing, which the + * id/role/tenant headers cannot express. + * + * REQUIRED, and the SOLE identity channel: the DO projects `scope.actor` from + * it, so there is no separate actor or role header to disagree with what + * executes. `createThreadTopology` stamps it on every `send` and `forward`, the + * only sanctioned way to reach a thread DO, and `ThreadDurableObject` refuses a + * request without it. There is deliberately no human default — a dropped + * principal header fails the request rather than admitting automation as a + * human whose entry the agent host never authorized against `allowedAutomation`. + * + * `createTenantResolver` refuses any inbound request that carries this header, + * or the retired `x-flowsafe-actor` / `x-flowsafe-role` names, before a store + * binds — so a mixed-version client fails loudly instead of being trusted. + */ +export const THREAD_PRINCIPAL_HEADER = 'x-flowsafe-principal'; diff --git a/packages/flowsafe/src/host-kit/approval-bridge.test.ts b/packages/flowsafe/src/host-kit/approval-bridge.test.ts index e10f9d8..f851e88 100644 --- a/packages/flowsafe/src/host-kit/approval-bridge.test.ts +++ b/packages/flowsafe/src/host-kit/approval-bridge.test.ts @@ -16,6 +16,7 @@ import { ApprovalService, approvedConnectorsForLeg, InMemoryApprovalStore, + trustAutomationPrincipal, } from '../approval-api/index.js'; import type { RunSummary } from '../do-runner/index.js'; // requestedConnectors is module-internal (not on the barrel): it is the @@ -28,7 +29,15 @@ import { resumeRunWithRequeue, } from './index.js'; -const SYSTEM: ApprovalActor = { id: 'sys', role: 'operator', tenantId: 'acme' }; +const SYSTEM = 'sys'; +// The direct-service calls below bypass the bridge, so they must vouch the way +// a host would; the bridge itself now mints from the id. +const SYSTEM_PRINCIPAL = trustAutomationPrincipal({ + kind: 'system', + id: SYSTEM, + tenantId: 'acme', + purpose: 'test-reconcile', +}); const REVIEWER: ApprovalActor = { id: 'ray', role: 'reviewer', @@ -255,7 +264,7 @@ describe('resumeRunWithRequeue', () => { }); // a first-gate approval, requested by someone OTHER than the reviewer - const { record: gate1 } = await service.create( + const { record: gate1 } = await service.createAsPrincipal( { workflowId: 'product-launch', runId: 'acme_run-1', @@ -265,7 +274,7 @@ describe('resumeRunWithRequeue', () => { connectors: ['deploy-conn'], requestedBy: 'starter', }, - SYSTEM, + SYSTEM_PRINCIPAL, { kind: 'thread', threadId: 'acme_thread', @@ -305,7 +314,7 @@ describe('resumeRunWithRequeue', () => { store, resumeRun: resumeRunWithRequeue(base, () => service, SYSTEM), }); - const { record } = await service.create( + const { record } = await service.createAsPrincipal( { workflowId: 'gtm-outbound', runId: 'acme_run-2', @@ -315,7 +324,7 @@ describe('resumeRunWithRequeue', () => { connectors: ['outreach-email'], requestedBy: 'starter', }, - SYSTEM, + SYSTEM_PRINCIPAL, ); // #when — the reviewer approves and the run finishes @@ -333,7 +342,7 @@ describe('resumeRunWithRequeue', () => { store, resumeRun: resumeRunWithRequeue(base, () => service, SYSTEM), }); - const { record } = await service.create( + const { record } = await service.createAsPrincipal( { workflowId: 'durable-agentic-loop', runId: 'acme_run-agent', @@ -343,16 +352,17 @@ describe('resumeRunWithRequeue', () => { connectors: ['connector'], requestedBy: 'starter', }, - SYSTEM, + SYSTEM_PRINCIPAL, { kind: 'agent-thread', agentId: 'writer', threadId: 'acme_thread', resourceId: 'acme_resource', principal: { + kind: 'human', id: 'starter', - role: 'operator', tenantId: 'acme', + role: 'operator', }, }, ); @@ -367,9 +377,10 @@ describe('resumeRunWithRequeue', () => { resumeTarget: { kind: 'agent-thread', principal: { + kind: 'human', id: 'starter', - role: 'operator', tenantId: 'acme', + role: 'operator', }, }, }); @@ -390,6 +401,59 @@ describe('resumeRunWithRequeue', () => { expect(await store.list({ status: 'pending' })).toHaveLength(0); }); + it('still emits the re-queue event, unattributed, when minting its own principal fails', async () => { + // #given — a blank systemActorId: the vouch itself throws, which is the + // SAME input class that makes the re-queue throw. The event names the + // suspended step paths and is the only signal an operator gets, so it must + // not die of the cause it is reporting. There is no identity to derive, so + // it is attributed to nobody rather than to a fabricated actor. + const store = new InMemoryApprovalStore('acme'); + const events: ApprovalAuditEvent[] = []; + const audit = (event: ApprovalAuditEvent) => events.push(event); + const base: ResumeRunFn = async () => + suspendedSummary('acme_run-9', 'gate2', ['deploy-conn'], 3030, 1); + const service: ApprovalService = new ApprovalService({ + store, + audit, + resumeRun: resumeRunWithRequeue(base, () => service, '', audit), + }); + const { record: gate1 } = await service.createAsPrincipal( + { + workflowId: 'product-launch', + runId: 'acme_run-9', + stepPath: ['approveLaunch'], + suspendedAt: 1000, + title: 'Approve launch', + connectors: ['deploy-conn'], + requestedBy: 'starter', + }, + SYSTEM_PRINCIPAL, + ); + + // #when + const decided = await service.decide( + gate1.id, + { decision: 'approve' }, + REVIEWER, + ); + + // #then — the mint error surfaces, not a swallowed one + expect(decided.resume).toMatchObject({ attempted: true, ok: false }); + expect(decided.resume.error).toMatch(/only a valid automated principal/); + + // #then — the event still fires, with no actor and no provenance + const requeue = events.filter( + (event) => event.action === 'approval.requeue', + ); + expect(requeue).toHaveLength(1); + expect(requeue[0]).toMatchObject({ + decision: 'error', + actor: null, + detail: { runId: 'acme_run-9', suspended: [['gate2']] }, + }); + expect(requeue[0]?.detail).not.toHaveProperty('principalKind'); + }); + it('emits an audit event and reports resume.ok=false when the post-resume re-queue throws (D4)', async () => { // #given — deciding gate1 durably resumes to a re-suspended gate2, but // the store rejects gate2's filing (a transient D1 failure) — the base @@ -409,7 +473,7 @@ describe('resumeRunWithRequeue', () => { audit, resumeRun: resumeRunWithRequeue(base, () => service, SYSTEM, audit), }); - const { record: gate1 } = await service.create( + const { record: gate1 } = await service.createAsPrincipal( { workflowId: 'product-launch', runId: 'acme_run-4', @@ -419,7 +483,7 @@ describe('resumeRunWithRequeue', () => { connectors: ['deploy-conn'], requestedBy: 'starter', }, - SYSTEM, + SYSTEM_PRINCIPAL, ); // #when — the reviewer approves gate1; the base resume durably advances @@ -444,10 +508,17 @@ describe('resumeRunWithRequeue', () => { expect(requeueEvents[0]).toMatchObject({ decision: 'error', resource: `approval:${gate1.id}`, + // The event is emitted by automation, so it is attributed to the + // platform's own bookkeeping principal — a derived least-privileged role + // and full provenance, never a hand-shaped viewer actor. + actor: { id: SYSTEM, role: 'viewer', tenantId: 'acme' }, detail: { workflowId: 'product-launch', runId: 'acme_run-4', suspended: [['gate2']], + principalKind: 'system', + principalId: SYSTEM, + purpose: 'approval-suspension-reconcile', }, }); }); @@ -511,9 +582,7 @@ describe('reconcileApprovalsForSummary', () => { // #then — unlike a human-attributed queueApprovalForSuspension call, // reconcile has no reviewer whose decision caused the suspension expect(filed).toHaveLength(2); - expect(filed.every((record) => record.requestedBy === SYSTEM.id)).toBe( - true, - ); + expect(filed.every((record) => record.requestedBy === SYSTEM)).toBe(true); }); it('uses an explicitly recovered agent principal for reconcile attribution', async () => { @@ -537,9 +606,10 @@ describe('reconcileApprovalsForSummary', () => { threadId: 'acme_thread', resourceId: 'acme_resource', principal: { + kind: 'human', id: 'starter', - role: 'operator', tenantId: 'acme', + role: 'operator', }, }, 'starter', @@ -666,7 +736,7 @@ describe('reconcileApprovalsForSummary', () => { stepPath: ['gate1'], suspendedAt: 5000, resumeCount: 1, - requestedBy: SYSTEM.id, + requestedBy: SYSTEM, }); }); @@ -776,7 +846,7 @@ describe('reconcileApprovalsForSummary', () => { const supersededRecord = await store.get(stale?.id ?? ''); expect(supersededRecord).toMatchObject({ status: 'rejected', - decidedBy: SYSTEM.id, + decidedBy: SYSTEM, decision: 'reject', }); expect(supersededRecord?.comment).toMatch(/stale suspension fingerprint/); @@ -925,7 +995,7 @@ describe('reconcileApprovalsForSummary', () => { decidedBy: REVIEWER.id, }); const afterB = await store.get(staleB?.id ?? ''); - expect(afterB).toMatchObject({ status: 'rejected', decidedBy: SYSTEM.id }); + expect(afterB).toMatchObject({ status: 'rejected', decidedBy: SYSTEM }); }); it('excludes a superseded record from grant derivation even queried at its ORIGINAL fingerprint', async () => { diff --git a/packages/flowsafe/src/host-kit/approval-bridge.ts b/packages/flowsafe/src/host-kit/approval-bridge.ts index 491572c..7a0355d 100644 --- a/packages/flowsafe/src/host-kit/approval-bridge.ts +++ b/packages/flowsafe/src/host-kit/approval-bridge.ts @@ -16,8 +16,12 @@ import { approvalCursor, MAX_APPROVAL_LIST_LIMIT, OPEN_STATUSES, + principalActor, + principalAuditFields, stepKeyOf, type TenantContext, + type TrustedAutomationPrincipal, + trustAutomationPrincipal, } from '../approval-api/index.js'; import type { RunSummary } from '../do-runner/index.js'; @@ -64,6 +68,33 @@ export function requestedConnectors(stepPayload: unknown): string[] { return agentGateConnectors(stepPayload); } +/** + * One purpose string for every audit event this module emits — filing a + * suspension's gates, re-filing them after a decision, and reporting a re-queue + * failure are the same duty, so they must not drift into different provenances. + */ +const RECONCILE_PURPOSE = 'approval-suspension-reconcile'; + +/** + * The platform's own bookkeeping identity for one service. Minted here rather + * than taken from the caller: every in-repo user files under a system principal + * with a fixed purpose and the service's own tenant, so requiring hosts to + * construct and vouch for one only spread `trustAutomationPrincipal` into host + * code — and into the sample every consumer copies. + */ +function bookkeepingPrincipal( + service: ApprovalService, + systemActorId: string, + purpose: string, +): TrustedAutomationPrincipal { + return trustAutomationPrincipal({ + kind: 'system', + id: systemActorId, + tenantId: service.tenantId, + purpose, + }); +} + /** * A suspension IS an approval request: queue one record per suspended step * path (idempotently — the store's partial unique open-step index collapses @@ -81,16 +112,23 @@ export function requestedConnectors(stepPayload: unknown): string[] { * next gate. It must NOT be the system actor: the library's self-decision * separation-of-duties check compares `requestedBy` to the deciding actor, so * attributing every request to the system actor would make that check unfireable. - * `systemActor` is only the record's creator (needs a create-capable role). + * `systemActorId` names only the record's creator. The bridge mints its own + * automated principal from it against the service's tenant binding, so a host + * never performs the trust assertion for the platform's own bookkeeping. */ export async function queueApprovalForSuspension( service: ApprovalService, workflowId: string, summary: RunSummary, requestedBy: string, - systemActor: ApprovalActor, + systemActorId: string, resumeTarget?: ApprovalResumeTarget, ): Promise { + const systemPrincipal = bookkeepingPrincipal( + service, + systemActorId, + RECONCILE_PURPOSE, + ); const suspended = summary.suspended ?? []; const records: ApprovalRecord[] = []; const failures: Array<{ stepKey: string; message: string }> = []; @@ -103,7 +141,7 @@ export async function queueApprovalForSuspension( : undefined; const connectors = requestedConnectors(stepPayload); try { - const { record } = await service.create( + const { record } = await service.createAsPrincipal( { workflowId, runId: summary.runId, @@ -116,7 +154,7 @@ export async function queueApprovalForSuspension( connectors: connectors.length > 0 ? connectors : undefined, requestedBy, }, - systemActor, + systemPrincipal, resumeTarget, ); records.push(record); @@ -171,7 +209,7 @@ export async function queueApprovalForSuspension( export function resumeRunWithRequeue( base: ResumeRunFn, getService: () => ApprovalService, - systemActor: ApprovalActor, + systemActorId: string, audit?: ApprovalAuditSink, ): ResumeRunFn { return async (record, decision) => { @@ -186,13 +224,26 @@ export function resumeRunWithRequeue( record.resumeTarget?.kind === 'agent-thread' ? record.resumeTarget.principal.id : record.decidedBy; + // Declared out here, minted INSIDE the try. Vouching validates and can + // throw on the same inputs the re-queue throws on (a blank or over-long + // systemActorId, an empty service tenant), so minting outside any handler + // would lose the audit event entirely for exactly that class — the event + // is the only signal that names the suspended step paths. Minting inside + // and reporting with whatever identity we managed to derive keeps the + // event unconditional, which is what it was before principals existed. + let principal: TrustedAutomationPrincipal | undefined; try { + principal = bookkeepingPrincipal( + getService(), + systemActorId, + RECONCILE_PURPOSE, + ); await queueApprovalForSuspension( getService(), record.workflowId, summary, requestedBy, - systemActor, + systemActorId, record.resumeTarget, ); } catch (error) { @@ -200,8 +251,15 @@ export function resumeRunWithRequeue( // reporting (same "availability over export reliability" posture as // ApprovalService's own #record). try { + // The platform's own principal, not a hand-shaped viewer actor: this + // event is emitted by automation, so it carries the same + // principalKind/principalId/purpose provenance every other automated + // audit event carries, and its role is derived rather than asserted. + // If the mint itself was what failed there is no identity to derive, + // and an unattributed event beats no event — `#authorizeAutomated` + // records a null actor for the same reason. const outcome = audit?.({ - actor: systemActor, + actor: principal ? principalActor(principal) : null, action: 'approval.requeue', resource: `approval:${record.id}`, decision: 'error', @@ -211,6 +269,7 @@ export function resumeRunWithRequeue( workflowId: record.workflowId, runId: record.runId, suspended: summary.suspended, + ...(principal ? principalAuditFields(principal) : {}), }, }); if (outcome instanceof Promise) { @@ -294,7 +353,7 @@ async function listAllApprovals( * Delegates the actual filing to queueApprovalForSuspension against a copy of * `summary` narrowed to only the healed paths, so a step with a live or * in-flight record is never touched. `requestedBy` defaults to the SYSTEM - * actor because generic workflow reconciliation cannot reliably recover the + * principal because generic workflow reconciliation cannot reliably recover the * initiating principal. Agent hosts persist the original requester and pass * that id explicitly so separation of duties survives eviction and filing * retries. @@ -303,16 +362,21 @@ export async function reconcileApprovalsForSummary( service: ApprovalService, workflowId: string, summary: RunSummary, - systemActor: ApprovalActor, + systemActorId: string, resumeTarget?: ApprovalResumeTarget, - requestedBy = systemActor.id, + requestedBy = systemActorId, ): Promise { + const systemPrincipal = bookkeepingPrincipal( + service, + systemActorId, + RECONCILE_PURPOSE, + ); const suspended = summary.suspended ?? []; if (suspended.length === 0) return []; const existing = await listAllApprovals( service, { workflowId, runId: summary.runId }, - systemActor, + principalActor(systemPrincipal), ); const toFile: string[][] = []; for (const stepPath of suspended) { @@ -332,9 +396,9 @@ export async function reconcileApprovalsForSummary( let lostRace = false; for (const record of stepRecords) { if (!OPEN_STATUSES.includes(record.status)) continue; - const superseded = await service.supersedeStale( + const superseded = await service.supersedeStaleAsPrincipal( record.id, - systemActor, + systemPrincipal, 'superseded: stale suspension fingerprint', ); // null = a real decision won the CAS between the list() above and this @@ -351,7 +415,7 @@ export async function reconcileApprovalsForSummary( workflowId, { ...summary, suspended: toFile }, requestedBy, - systemActor, + systemActorId, resumeTarget, ); } @@ -371,10 +435,11 @@ export function reconcileApprovalsOnStatus( summary: RunSummary, ) => Promise { return async (tenant, workflowId, summary) => { - await reconcileApprovalsForSummary(tenant.service(), workflowId, summary, { - id: systemActorId, - role: 'operator', - tenantId: tenant.tenantId, - }); + await reconcileApprovalsForSummary( + tenant.service(), + workflowId, + summary, + systemActorId, + ); }; } diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index fd7b415..8ccc91c 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -56,7 +56,7 @@ import { import { approvalStoreFactoryFor, buildHostApprovalService, - maintenanceActor, + maintenancePrincipal, reconcileApprovalsOnStatusDetached, runApprovalRetentionPurge, runSlaSweepMaintenance, @@ -794,7 +794,7 @@ export function createFlowsafeWorker( const hubTopology = hub ? createHubTopology(hub) : undefined; return runSlaSweepMaintenance({ store: approvalStoreFactoryFor(env.DB).system(), - systemActor: maintenanceActor(config.systemActorId), + systemPrincipal: maintenancePrincipal(config.systemActorId), queue: env.AUDIT_QUEUE, cron: controller.cron, notify: config.notify?.(env), diff --git a/packages/flowsafe/src/host-kit/host-approval-service.ts b/packages/flowsafe/src/host-kit/host-approval-service.ts index 0c6627c..7e6f9be 100644 --- a/packages/flowsafe/src/host-kit/host-approval-service.ts +++ b/packages/flowsafe/src/host-kit/host-approval-service.ts @@ -11,7 +11,6 @@ // span the isolate, not one request). import type { - ApprovalActor, ApprovalAuditEvent, ApprovalAuditSink, ApprovalDatabase, @@ -23,9 +22,12 @@ import type { } from '../approval-api/index.js'; import { ApprovalService, + type AutomatedExecutionPrincipal, D1ApprovalStoreFactory, purgeExpiredApprovals, sweepSLA, + type TrustedAutomationPrincipal, + trustAutomationPrincipal, } from '../approval-api/index.js'; import { type AuditQueue, queueAuditSink } from '../audit-export/index.js'; import { @@ -41,8 +43,15 @@ import { numberVar } from './env-vars.js'; * identity (RESERVED_TENANT_IDS), which no verifier admits and no store * binds to; per-record tenants ride in the audit detail. */ -export function maintenanceActor(systemActorId: string): ApprovalActor { - return { id: systemActorId, role: 'operator', tenantId: 'system' }; +export function maintenancePrincipal( + systemActorId: string, +): TrustedAutomationPrincipal { + return trustAutomationPrincipal({ + kind: 'system', + id: systemActorId, + tenantId: 'system', + purpose: 'approval-sla-maintenance', + }); } // One factory per isolate, not per request: it owns the memoized schema-init @@ -161,11 +170,6 @@ export function buildHostApprovalService( store: TenantBoundApprovalStore, options: HostApprovalServiceOptions, ): ApprovalService { - const systemActor: ApprovalActor = { - id: options.systemActorId, - role: 'operator', - tenantId: store.tenantId, - }; const audit = hostAuditSink({ queue: options.queue, keepAlive: options.waitUntil, @@ -180,7 +184,7 @@ export function buildHostApprovalService( resumeRun: resumeRunWithRequeue( options.resumeRun, () => service, - systemActor, + options.systemActorId, audit, ), }); @@ -190,8 +194,13 @@ export function buildHostApprovalService( export interface SlaSweepMaintenanceOptions { /** factory.system() — the cron-only cross-tenant view. */ store: SystemApprovalStore; - /** maintenanceActor(systemActorId). */ - systemActor: ApprovalActor; + /** + * `maintenancePrincipal(systemActorId)`. Typed as merely automated, not + * vouched: the sweep derives no authority from the principal (the + * `SystemApprovalStore` type is its authorization), so demanding the trust + * brand here would ask for a token nothing on this path reads. + */ + systemPrincipal: AutomatedExecutionPrincipal; /** Optional audit export queue. */ queue?: AuditQueue; /** The firing cron expression — log correlation only. */ @@ -234,7 +243,7 @@ export async function runSlaSweepMaintenance( try { escalated = ( await sweepSLA(options.store, { - systemActor: options.systemActor, + systemPrincipal: options.systemPrincipal, audit: hostAuditSink({ queue: options.queue, keepAlive: (send) => pendingSends.push(send), diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 17343ae..a8047db 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -48,7 +48,7 @@ export { approvalStoreFactoryFor, buildHostApprovalService, type HostApprovalServiceOptions, - maintenanceActor, + maintenancePrincipal, reconcileApprovalsOnStatusDetached, runApprovalRetentionPurge, runSlaSweepMaintenance, diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index 423fb28..4054c57 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -26,7 +26,6 @@ // step re-checks and fails closed. Approve through the queue, not this route. import { - type ApprovalActor, RUN_START_ROLES, type TenantContext, TenantResolutionError, @@ -259,17 +258,12 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { body.inputData, ); if (summary.status !== 'suspended') return json(summary); - const systemActor: ApprovalActor = { - id: systemActorId, - role: 'operator', - tenantId: tenant.tenantId, - }; const approvals = await queueApprovalForSuspension( tenant.service(), body.workflowId, summary, actor.id, - systemActor, + systemActorId, ); // `approval` remains the single-gate response contract (what the SPA // links); `approvals` carries every record a parallel multi-step diff --git a/packages/flowsafe/src/host-kit/stream-router.test.ts b/packages/flowsafe/src/host-kit/stream-router.test.ts index aa47457..579f42a 100644 --- a/packages/flowsafe/src/host-kit/stream-router.test.ts +++ b/packages/flowsafe/src/host-kit/stream-router.test.ts @@ -32,7 +32,7 @@ import { } from './flowsafe-worker.js'; import { buildHostApprovalService, - maintenanceActor, + maintenancePrincipal, runSlaSweepMaintenance, } from './host-approval-service.js'; import { createHubTopology, type HubNamespaceLike } from './hub-topology.js'; @@ -501,7 +501,7 @@ describe('hub fan-out wiring (host-approval-service, tested here — see file he const order: string[] = []; const sweep = runSlaSweepMaintenance({ store: factory.system(), - systemActor: maintenanceActor('sys'), + systemPrincipal: maintenancePrincipal('sys'), cron: '*/15 * * * *', stream: (event) => hubTopology.publish(event), }).then(() => order.push('sweep-resolved')); diff --git a/packages/flowsafe/src/host-kit/thread-topology.test.ts b/packages/flowsafe/src/host-kit/thread-topology.test.ts index 56d5496..22b8b4b 100644 --- a/packages/flowsafe/src/host-kit/thread-topology.test.ts +++ b/packages/flowsafe/src/host-kit/thread-topology.test.ts @@ -1,14 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 import { InMemoryStore } from '@mastra/core/storage'; import { describe, expect, it } from 'vitest'; - import type { TenantContext } from '../approval-api/index.js'; +import { + type ExecutionPrincipal, + principalActor, +} from '../approval-api/index.js'; import { type InitResult, init, mintThreadId, - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, ThreadDurableObject, type ThreadScope, @@ -21,9 +23,19 @@ import { type ThreadStubLike, } from './thread-topology.js'; -function tenantContext(tenantId: string): TenantContext { +function tenantContext( + tenantId: string, + principal?: ExecutionPrincipal, +): TenantContext { + const effective: ExecutionPrincipal = principal ?? { + kind: 'human', + id: 'operator-1', + tenantId, + role: 'operator', + }; return { - actor: { id: 'operator-1', role: 'operator', tenantId }, + actor: principalActor(effective), + principal: effective, tenantId, // The production predicate, not a hand-copy — the exactness pins below ride // the real one (see memory-boundary.test.ts). @@ -69,8 +81,8 @@ function recordingNamespace(): { url: request.url, method: request.method, tenantHeader: request.headers.get(THREAD_TENANT_HEADER), - actorHeader: request.headers.get(THREAD_ACTOR_HEADER), - roleHeader: request.headers.get(THREAD_ACTOR_ROLE_HEADER), + actorHeader: request.headers.get('x-flowsafe-actor'), + roleHeader: request.headers.get('x-flowsafe-role'), otherHeader: request.headers.get('x-other'), body: await request.text(), }); @@ -113,8 +125,8 @@ describe('createThreadTopology', () => { url: 'http://thread/messages', method: 'POST', tenantHeader: 'acme', - actorHeader: 'operator-1', - roleHeader: 'operator', + actorHeader: null, + roleHeader: null, otherHeader: null, body: '{}', }, @@ -161,7 +173,7 @@ describe('createThreadTopology', () => { expect(calls[0]?.tenantHeader).toBe('acme'); }); - it('case-insensitively overwrites forged actor id and role headers', async () => { + it('case-insensitively strips forged retired identity headers', async () => { const { namespace, calls } = recordingNamespace(); const topology = createThreadTopology(namespace); @@ -177,8 +189,9 @@ describe('createThreadTopology', () => { }, ); - expect(calls[0]?.actorHeader).toBe('operator-1'); - expect(calls[0]?.roleHeader).toBe('operator'); + // Retired: send() no longer stamps them, and never echoes a caller's. + expect(calls[0]?.actorHeader).toBeNull(); + expect(calls[0]?.roleHeader).toBeNull(); }); it('OVERWRITES a FORGED client header on a forwarded request (the trap the house idiom sets)', async () => { @@ -396,3 +409,132 @@ describe('createThreadTopology <-> ThreadDurableObject (mint meets verify)', () expect(response.status).toBe(403); }); }); + +describe('execution principal on the wire (mint meets verify)', () => { + // The gap this closes: the principal header was defined, read by the DO, and + // refused inbound — but stamped by nothing. Every automated caller therefore + // arrived as a human and the agent host's automation gate was unreachable, + // while the whole suite stayed green because the agent-host tests build a + // ThreadScope in-process and never cross this boundary. + class ScopeThread extends ThreadDurableObject { + protected build(): InitResult { + return init({ storage: new InMemoryStore() }); + } + protected route(_request: Request, scope: ThreadScope): Promise { + return Promise.resolve( + new Response( + JSON.stringify({ principal: scope.principal, actor: scope.actor }), + ), + ); + } + } + + function scopeNamespace(): ThreadNamespaceLike { + return { + idFromName: (name) => name, + get: (name): ThreadStubLike => { + const thread = new ScopeThread( + { id: { name } } as never, + {}, + ) as unknown as { fetch(request: Request): Promise }; + return { + fetch: (input: Request | string, init?: RequestInit) => + thread.fetch( + typeof input === 'string' ? new Request(input, init) : input, + ), + } as ThreadStubLike; + }, + }; + } + + const SCHEDULER: ExecutionPrincipal = { + kind: 'system', + id: 'flowsafe-scheduler', + tenantId: 'acme', + purpose: 'scheduled-agent-execution', + }; + + it('delivers an automated principal to the DO as automation, not as a human', async () => { + // #given + const topology = createThreadTopology(scopeNamespace()); + const threadId = mintThreadId('acme', () => 't1'); + + // #when + const response = await topology.send( + tenantContext('acme', SCHEDULER), + threadId, + '/x', + ); + + // #then — kind survives the hop; without it the DO would rebuild a human. + await expect(response.json()).resolves.toEqual({ + principal: SCHEDULER, + actor: { id: 'flowsafe-scheduler', role: 'viewer', tenantId: 'acme' }, + }); + }); + + it('delivers a human principal unchanged', async () => { + // #given + const topology = createThreadTopology(scopeNamespace()); + + // #when + const response = await topology.send( + tenantContext('acme'), + mintThreadId('acme', () => 't1'), + '/x', + ); + + // #then + await expect(response.json()).resolves.toEqual({ + principal: { + kind: 'human', + id: 'operator-1', + tenantId: 'acme', + role: 'operator', + }, + actor: { id: 'operator-1', role: 'operator', tenantId: 'acme' }, + }); + }); + + it('refuses a forged client principal, and a request that carries none', async () => { + // #given — the client asserts system automation on its own request. + const topology = createThreadTopology(scopeNamespace()); + const threadId = mintThreadId('acme', () => 't1'); + const forged = new Request('http://host/x', { + headers: { + [THREAD_PRINCIPAL_HEADER]: JSON.stringify({ + kind: 'system', + id: 'operator-1', + purpose: 'privilege-escalation', + }), + }, + }); + + // #when — forwarded under a HUMAN tenant context. + const response = await topology.forward( + tenantContext('acme'), + threadId, + forged, + ); + + // #then — the minter overwrites, so the DO sees the human. + const seen = (await response.json()) as { principal: ExecutionPrincipal }; + expect(seen.principal.kind).toBe('human'); + + // #and — a request that never went through the minter is refused outright, + // rather than defaulting to a human. + const bare = new ScopeThread({ id: { name: threadId } } as never, {}); + const direct = await ( + bare as unknown as { fetch(request: Request): Promise } + ).fetch( + new Request('http://thread/x', { + headers: { + 'x-flowsafe-tenant': 'acme', + 'x-flowsafe-actor': 'operator-1', + 'x-flowsafe-role': 'operator', + }, + }), + ); + expect(direct.status).toBe(403); + }); +}); diff --git a/packages/flowsafe/src/host-kit/thread-topology.ts b/packages/flowsafe/src/host-kit/thread-topology.ts index e93f3b2..e6bfc61 100644 --- a/packages/flowsafe/src/host-kit/thread-topology.ts +++ b/packages/flowsafe/src/host-kit/thread-topology.ts @@ -24,10 +24,13 @@ // keeping host-kit free of @cloudflare/workers-types — same convention as // RunnerNamespaceLike / HubNamespaceLike. -import type { TenantContext } from '../approval-api/index.js'; import { - THREAD_ACTOR_HEADER, - THREAD_ACTOR_ROLE_HEADER, + assertExecutionPrincipal, + encodeExecutionPrincipal, + type TenantContext, +} from '../approval-api/index.js'; +import { + THREAD_PRINCIPAL_HEADER, THREAD_TENANT_HEADER, } from '../do-runner/index.js'; import { requireOwnedMemoryId } from './memory-boundary.js'; @@ -89,6 +92,26 @@ export interface ThreadTopology { ): Promise; } +/** + * The identity this hop carries, validated against the tenant it is being sent + * to. The principal is the ONLY identity on the wire: the DO projects + * `scope.actor` from it, so a host's separate `TenantContext.actor` can never + * disagree with what executes. + * + * The tenant check matters because `encodeExecutionPrincipal` deliberately omits + * `tenantId` and the DO re-binds it from its own authenticated tenant: without + * this, a context whose principal belongs to another tenant would be silently + * re-tenanted, and the audit trail would name the wrong tenant's principal with + * nothing flagging it. + */ +function stampedPrincipal(tenant: TenantContext) { + return assertExecutionPrincipal( + tenant.principal, + tenant.tenantId, + 'thread topology principal', + ); +} + export function createThreadTopology( namespace: ThreadNamespaceLike, ): ThreadTopology { @@ -119,9 +142,13 @@ export function createThreadTopology( // relies on; it overwrites at every spelling. The value is the RESOLVED // tenant context (authenticate -> INV-3 -> bind), never a header or body. const merged = new Headers(init.headers); + const principal = stampedPrincipal(tenant); merged.set(THREAD_TENANT_HEADER, tenant.tenantId); - merged.set(THREAD_ACTOR_HEADER, tenant.actor.id); - merged.set(THREAD_ACTOR_ROLE_HEADER, tenant.actor.role); + merged.set(THREAD_PRINCIPAL_HEADER, encodeExecutionPrincipal(principal)); + // Retired identity headers: nothing reads them, but a caller's value must + // not ride into the DO as if the topology had stamped it. + merged.delete('x-flowsafe-actor'); + merged.delete('x-flowsafe-role'); const headers: Record = {}; merged.forEach((value, key) => { headers[key] = value; @@ -138,9 +165,16 @@ export function createThreadTopology( // is what makes a forged client value vanish rather than ride along as a // second value the DO might read. const forwarded = new Request(request); + const principal = stampedPrincipal(tenant); forwarded.headers.set(THREAD_TENANT_HEADER, tenant.tenantId); - forwarded.headers.set(THREAD_ACTOR_HEADER, tenant.actor.id); - forwarded.headers.set(THREAD_ACTOR_ROLE_HEADER, tenant.actor.role); + // Retired identity headers: nothing reads them, but a client's forged + // value must not ride into the DO as if the topology had stamped it. + forwarded.headers.delete('x-flowsafe-actor'); + forwarded.headers.delete('x-flowsafe-role'); + forwarded.headers.set( + THREAD_PRINCIPAL_HEADER, + encodeExecutionPrincipal(principal), + ); return stub(addressed).fetch(forwarded); }, }; diff --git a/packages/flowsafe/src/signal-providers/delivery.ts b/packages/flowsafe/src/signal-providers/delivery.ts index 36745e8..6bae4e1 100644 --- a/packages/flowsafe/src/signal-providers/delivery.ts +++ b/packages/flowsafe/src/signal-providers/delivery.ts @@ -15,7 +15,11 @@ import type { SendNotificationSignalInput } from '@mastra/core/notifications'; -import type { ApprovalActor, TenantContext } from '../approval-api/index.js'; +import { + type ExecutionPrincipal, + principalActor, + type TenantContext, +} from '../approval-api/index.js'; import { assertMintableTenantId, mintResourceId, @@ -35,13 +39,18 @@ import type { StoredSubscription } from './subscription-d1.js'; */ export function deliveryTenantContext(tenantId: string): TenantContext { assertMintableTenantId(tenantId, 'deliveryTenantContext'); - const actor: ApprovalActor = { + // A provider delivery is service-to-service work with no person behind it. + // It used to mint role:'operator', which handed webhook and poll delivery the + // authority of a human operator. + const principal: ExecutionPrincipal = { + kind: 'service', id: 'signal-provider-delivery', - role: 'operator', tenantId, + purpose: 'signal-provider-delivery', }; return { - actor, + principal, + actor: principalActor(principal), tenantId, service: () => { throw new Error( diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index 6370a0c..7429b9a 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { d1DatabaseLike, openSqlite } from '../../test-support/sqlite.js'; import { RUNTIME_DRIVEN_AGENT } from '../agent-runner/index.js'; import type { ApprovalActor, TenantContext } from '../approval-api/index.js'; +import { humanPrincipal } from '../approval-api/index.js'; import { createD1Storage, type InitResult, @@ -117,6 +118,7 @@ function tenantCtx(): TenantContext { }; return { actor, + principal: humanPrincipal(actor), tenantId: 'acme', ownsMemoryId: (id: string) => id === THREAD_ID, } as unknown as TenantContext; diff --git a/packages/flowsafe/src/signals/thread-do-routes.test.ts b/packages/flowsafe/src/signals/thread-do-routes.test.ts index ab3b45f..b3cbfc3 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.test.ts @@ -79,6 +79,12 @@ function scopeWith(pubsub: unknown): ThreadScope { threadId: 'acme_t1', tenantId: 'acme', actor: { id: 'operator', role: 'operator', tenantId: 'acme' }, + principal: { + kind: 'human', + id: 'operator', + tenantId: 'acme', + role: 'operator', + }, requestedBy: 'operator', init: { pubsub }, } as unknown as ThreadScope; diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index 2641d5e..10e67ea 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -42,7 +42,7 @@ import { type AgentEntryPath, isRuntimeDrivenAgent, } from '../agent-runner/index.js'; -import type { ApprovalActor } from '../approval-api/index.js'; +import type { ExecutionPrincipal } from '../approval-api/index.js'; import { DoStatusError, mintSaltedId, @@ -83,9 +83,15 @@ export interface StartIdleRunInput { runId: string; threadId: string; resourceId?: string; - actor: ApprovalActor; + /** + * WHO is waking the run. Carries the KIND, so a host synthesizing a + * ThreadScope for the idle run cannot downgrade automation to a human — which + * would let it past the agent host's role branch without its agent ever + * declaring the entry. + */ + principal: ExecutionPrincipal; entryPath: AgentEntryPath; - /** Compatibility alias for actor.id. */ + /** Compatibility alias for principal.id. */ requestedBy: string; message?: AgentMessageInput; signal?: AgentSignal; @@ -318,7 +324,7 @@ export function createThreadSignalRoutes( consultRunCap, scope.tenantId, runtimeDriven, - scope.actor, + scope.principal, entryPath, startIdleRun, serializeWake, @@ -338,7 +344,7 @@ export function createThreadSignalRoutes( consultRunCap, scope.tenantId, runtimeDriven, - scope.actor, + scope.principal, entryPath, startIdleRun, serializeWake, @@ -362,7 +368,7 @@ export function createThreadSignalRoutes( threadId, resourceId, tenantId: scope.tenantId, - actor: scope.actor, + principal: scope.principal, entryPath, runtimeDriven, consultRunCap, @@ -407,7 +413,7 @@ async function handleNotificationDispatch(options: { threadId: string; resourceId: string | undefined; tenantId: string; - actor: ApprovalActor; + principal: ExecutionPrincipal; entryPath: AgentEntryPath; runtimeDriven: boolean; consultRunCap?: RunCapConsult; @@ -516,7 +522,7 @@ async function handleNotificationDispatch(options: { tenantId: options.tenantId, threadId: options.threadId, resourceId, - actor: options.actor, + principal: options.principal, entryPath: options.entryPath, runtimeDriven: options.runtimeDriven, consultRunCap: options.consultRunCap, @@ -671,7 +677,7 @@ async function handleWake(options: { tenantId: string; threadId: string; resourceId: string; - actor: ApprovalActor; + principal: ExecutionPrincipal; entryPath: AgentEntryPath; runtimeDriven: boolean; consultRunCap?: RunCapConsult; @@ -734,9 +740,9 @@ async function handleWake(options: { runId, threadId: options.threadId, resourceId: options.resourceId, - actor: options.actor, + principal: options.principal, entryPath: options.entryPath, - requestedBy: options.actor.id, + requestedBy: options.principal.id, ...(options.message !== undefined ? { message: options.message } : {}), ...(options.signal !== undefined ? { signal: options.signal } : {}), }); @@ -757,7 +763,7 @@ async function handleMessage( consultRunCap: RunCapConsult | undefined, tenantId: string, runtimeDriven: boolean, - actor: ApprovalActor, + principal: ExecutionPrincipal, entryPath: AgentEntryPath, startIdleRun: StartIdleRun | undefined, serializeWake: (operation: () => Promise) => Promise, @@ -787,7 +793,7 @@ async function handleMessage( tenantId, threadId, resourceId, - actor, + principal, entryPath, runtimeDriven, consultRunCap, @@ -854,7 +860,7 @@ async function handleSignal( consultRunCap: RunCapConsult | undefined, tenantId: string, runtimeDriven: boolean, - actor: ApprovalActor, + principal: ExecutionPrincipal, entryPath: AgentEntryPath, startIdleRun: StartIdleRun | undefined, serializeWake: (operation: () => Promise) => Promise, @@ -900,7 +906,7 @@ async function handleSignal( tenantId, threadId, resourceId, - actor, + principal, entryPath, runtimeDriven, consultRunCap, diff --git a/packages/showcase/run-api-dev-plugin.ts b/packages/showcase/run-api-dev-plugin.ts index 1deaed4..bf4eb3d 100644 --- a/packages/showcase/run-api-dev-plugin.ts +++ b/packages/showcase/run-api-dev-plugin.ts @@ -17,7 +17,6 @@ import { InMemoryStore } from '@mastra/core/storage'; import type { - ApprovalActor, SelfDecisionPolicy, TenantBoundApprovalStore, } from '@proofoftech/flowsafe/approval-api'; @@ -201,11 +200,6 @@ export function runApiDevPlugin(): Plugin { // later decision) — the same const-with-deferred-ref pattern the worker // uses. resumeRunWithRequeue resumes in-process and re-queues the next gate. function buildService(store: TenantBoundApprovalStore): ApprovalService { - const systemActor: ApprovalActor = { - id: SYSTEM_ACTOR_ID, - role: 'operator', - tenantId: store.tenantId, - }; const service: ApprovalService = new ApprovalService({ store, defaultSlaSeconds: 3600, @@ -219,7 +213,7 @@ export function runApiDevPlugin(): Plugin { resumeRun: resumeRunWithRequeue( resumeViaRuntime(runtime), () => service, - systemActor, + SYSTEM_ACTOR_ID, ), }); return service; diff --git a/packages/showcase/worker/demo-reset.test.ts b/packages/showcase/worker/demo-reset.test.ts index 26f6eb6..0a09b9b 100644 --- a/packages/showcase/worker/demo-reset.test.ts +++ b/packages/showcase/worker/demo-reset.test.ts @@ -33,6 +33,7 @@ function tenantContext( ): TenantContext { return { actor: { id: `demo-${role}`, role, tenantId }, + principal: { kind: 'human', id: `demo-${role}`, tenantId, role }, tenantId, service: () => { throw new Error('service() must not be touched by the reset route'); diff --git a/packages/showcase/worker/workflows.e2e.test.ts b/packages/showcase/worker/workflows.e2e.test.ts index 48a07e6..ff649c1 100644 --- a/packages/showcase/worker/workflows.e2e.test.ts +++ b/packages/showcase/worker/workflows.e2e.test.ts @@ -33,7 +33,7 @@ import { CRM_ASSIGN_CONNECTOR } from '#worker/workflows/lead-generation'; import { DEPLOY_CONNECTOR } from '#worker/workflows/product-launch'; import { WIRE_CONNECTOR } from '#worker/workflows/wire-transfer'; -const SYSTEM: ApprovalActor = { id: 'sys', role: 'operator', tenantId: 'demo' }; +const SYSTEM = 'sys'; const REVIEWER: ApprovalActor = { id: 'ray', role: 'reviewer', @@ -612,7 +612,7 @@ describe('showcase run routes', () => { ), }), }), - systemActorId: SYSTEM.id, + systemActorId: SYSTEM, start: (workflowId, runId, inputData) => harness.runtime.start(workflowId, { runId, inputData }), status: async (workflowId, runId) =>