Conversation
DOES NOT COMPILE. Committed to preserve work; flowsafe is mid-migration and `pnpm typecheck` enumerates the remaining sites. See .notes/plan-execution-principals.md for the full status and edit map. breakwater (complete, 470 tests green): - rbac/principal.ts: PrincipalKind, the shared allowlist validator. A leaf so assertPrincipalKinds does not become public API via the /rbac subpath. - Actor.kind, absent meaning 'human' so existing hosts are unchanged. - allowedPrincipalKinds on RBACMiddlewareOptions and GuardedAgentConfig, defaulting to ['human'] — an agent that names no automation denies every scheduled, signal, service, and delegated entry with zero config change. - authorize.ts checks kind BEFORE role and skips the role allowlist entirely for non-humans. An automated principal carries a role only because Actor.role is required; consulting it would either admit whichever role the host projected, or force hosts to allow that role and thereby admit real humans holding it. - Wired into both the RBACMiddleware processor and the direct-call preauthorize, because direct generate/stream bypass the processor chain. - AgentAuditContext gains principalKind, principalId, purpose, delegatedBy. flowsafe (partial): - approval-api/principal.ts: the ExecutionPrincipal union with purpose REQUIRED on every automated kind, tenant-bound validation, and projection that maps automation to the least-privileged role so it can never satisfy DECIDER_ROLES. - ApprovalResumeTarget.principal and AgentRunRecord (now v2) both retyped, with the service validator and the D1 read guard failing closed on the v1 ApprovalActor shape. There is no upgrade path on purpose: a schedule.fire run stored role:'operator', so reading it back as human would launder exactly the authority this removes. - AgentMeta.allowedAutomation, catalog automationAllowed, and a construction-time cross-check that metadata kinds match the guarded handle's allowedPrincipalKinds. - thread-host authorize() split by kind with an AND-composed host seam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Automated work no longer impersonates a person. The schedule tick, cron SLA maintenance, signal-provider delivery, and the suspension-reconcile bridge all minted `role: 'operator'` to satisfy the one identity the platform had. That lost provenance and handed autonomous execution an operator's authority. breakwater: - Actor.kind (human|service|agent|system), absent meaning human so an existing host is unchanged. An unrecognized kind resolves to NO actor rather than falling through to the default every guarded agent already admits. - allowedPrincipalKinds on RBACMiddleware and createGuardedAgent, defaulting to ['human']. An agent that names no automation now denies every scheduled, signal, service, and delegated entry with no config change. - The gate checks kind BEFORE role, and does not consult the role allowlist for a non-human kind. An automated principal carries a role only because Actor.role is required; checking it would either admit whichever role the host projected, or force hosts to add that role to allowedRoles and thereby admit real humans holding it. - Enforced at the processor gate AND the direct generate/stream gate, which bypasses the processor chain. flowsafe: - ExecutionPrincipal, with `purpose` REQUIRED on every automated kind. The defect being fixed is lost provenance; an optional field would let each new automated path skip the one thing that restores it. - AgentMeta.allowedAutomation names kinds AND entry paths, so an agent that may fire on a schedule does not thereby accept signals. Absent denies everything. An injected host authorizer is AND-composed and can only narrow it. - The catalog refuses a module whose declaration disagrees with its guarded agent's allowedPrincipalKinds, so the two halves cannot drift. - ApprovalService.createAsPrincipal and supersedeStaleAsPrincipal replace the human role gate with a kind-and-tenant check for trusted platform bridges, rather than widening it. No principal-taking decide/claim/delegate: filing is platform work, deciding is human judgement. - The principal crosses the DO boundary in its own trusted header, which createTenantResolver refuses on inbound requests like the others. - statusFor falls back to the scope's PRINCIPAL, not its actor — the previous fallback would relabel an automated run as whoever polled its status. - Audit correlation carries principalKind, principalId, purpose, delegatedBy. Persisted state fails closed with no upgrade path, deliberately: AgentRunRecord is v2 and agent-thread resume targets store an ExecutionPrincipal. A v1 record cannot be upgraded honestly, because a schedule.fire run stored role:'operator' and reading it back as a human would launder exactly the authority this removes. A suspended pre-upgrade agent run cannot resume. 2226 tests pass; the workerd spike verifies restart, approval resume, and the cross-tenant and forgery negatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three review lanes independently found the same blocker: THREAD_PRINCIPAL_HEADER was defined, read by the thread DO, and refused inbound — but stamped by nothing. createThreadTopology.send/forward set only the tenant, actor, and role headers, so every automated caller was rebuilt inside the DO as a human carrying the projected 'viewer' role, which no start gate admits. The whole feature was therefore unreachable, and three paths that worked before this branch — scheduled agent fires, signal-provider delivery, cron notification dispatch — failed 403 for the wrong reason. The suite stayed green because every automation test builds a ThreadScope in-process and never crosses that hop. - Stamp the principal in both send and forward. - The DO now REFUSES a request carrying no principal header instead of defaulting to a human, and cross-checks the decoded principal against the actor and role headers so the two channels cannot disagree. - decodeExecutionPrincipal picks fields explicitly rather than spreading, so an attacker-supplied extra property cannot ride into DO storage or D1. - boundedText rejects header control characters, since these fields now serialize into a header. - StartIdleRunInput carries the principal, not an ApprovalActor. The starter and spike were downgrading it with humanPrincipal(), laundering automation into a person on every idle wake. Also from the lanes: - #authorizeAutomated validated a three-field subset, so createAsPrincipal accepted an automated principal with NO purpose — a hole in the one invariant the module exists to enforce. It now validates the whole principal. - Both trusted service entries now attach principalAuditFields, so the two paths that most need attribution stop auditing as a bare 'viewer'. - samePrincipal is cast-free; delegatedBy?: never on service/system makes a wrong shape a compile error rather than a runtime-only rejection. - breakwaterActorFor is the single source for the role projection, which was written twice with two copies of its rationale. - authorizeActor requires allowedPrincipalKinds; its default was unreachable and was a second place the human-only policy could drift. - Dropped isAutomatedPrincipal, an unused export on a published barrel. Comments that claimed protections the code did not provide are corrected: the principal header's "absent means human is stricter" argument was self- contradicting (the human branch is more permissive for an agent that declares no automation), and contract.ts asserted a one-way layering rule that thread-do.ts no longer honours. New coverage for the gaps the lanes named: - A topology->DO round trip asserting an automated principal arrives as automation, a human arrives unchanged, a forged client principal is overwritten, and a request bypassing the minter is refused. - v1 AgentRunRecord, v2-with-ApprovalActor, and purpose-less principals all rejected; the same two shapes rejected on the D1 resume_target read path. - A cross-package tripwire pinning EXECUTION_PRINCIPAL_KINDS to breakwater's PRINCIPAL_KINDS — the comment claimed one existed and none did. - AUTOMATED_PROJECTED_ROLE pinned out of RUN_START_ROLES and DECIDER_ROLES; that invariant is what makes projecting an inert role safe. - The spike now runs /sched/agent, proving a SYSTEM principal reaches a guarded agent through the real Worker->DO hop on workerd. 2236 tests pass; SPIKE VERIFIED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the four review decisions. The service's automated entries authorized on the caller's own assertion that it was a robot: `kind !== 'human' && tenantId === store.tenantId`, all satisfiable by an object literal. Automation therefore had strictly more create authority than a human `viewer`. TrustedAutomationPrincipal now carries a `unique symbol` brand, minted only by `trustAutomationPrincipal`, which refuses humans and malformed principals. Following the TENANT_BOUND idiom already used for SystemApprovalStore, with the same accepted residual: forging requires importing the symbol and stamping it, a deliberate TCB bypass on par with a cast. What it removes is accidental structural satisfaction, and the rushed fix that hands a request-derived principal to a trusted entry. The bridge REQUIRES the brand, so vouching happens at each host's TCB boundary (maintenancePrincipal, buildHostApprovalService, run-router, thread-host, reconcileApprovalsOnStatus) rather than deep inside shared code. #authorizeAutomated is now a tenant check only — the kind half is carried by the type. Layering: principal.ts stays in approval-api and the corrected contract.ts comment stands, but import-isolation.test.ts now pins which approval-api leaves do-runner may reach, so that one accepted edge cannot widen silently. types.ts appears in the pin because contract.ts reaches it with a type-only import that erases at runtime; the comment says so. Public surface: removed ExecutionPrincipalError (assertExecutionPrincipal throws a plain Error) and rejectReservedAgentContext, which was exported but never called anywhere — every real path uses the silent-strip sanitizeStoredAgentContext. Kept assertExecutionPrincipal and EXECUTION_PRINCIPAL_KINDS: the first is the validating minter, the second backs the cross-package tripwire. 2236 tests pass; SPIKE VERIFIED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The re-review found the inverse of the first blocker. The gate was reachable, but the starter declared only system+schedule.fire while wiring three more automated entries against that same agent — so the reference host, which is what consumers copy, silently denied its own features: - provider delivery service / signal.notification -> 403 - notification dispatch system / notification.dispatch -> 403 - approval resume system / approval.resume -> threw The third was the worst: a scheduled run that suspends on the starter's approval-gated connector — its entire advertised purpose — filed an approval, a human approved it, and the resume threw. Decided approval, stranded run. 'approval.resume' is now IMPLIED by the kind that started the run, and declaring it is rejected. Resuming continues a run this kind was already admitted to start, so it asks whether the kind may still drive the agent at all — not whether a second entry path was remembered. The narrowing that matters still holds: a kind removed from the declaration entirely can no longer resume. Starter and spike now declare every entry they actually wire. Also from the lanes: - The topology derives BOTH wire identities from the principal and asserts the principal's tenant matches the context's. encodeExecutionPrincipal omits tenantId and the DO re-binds it, so a context whose principal belonged to another tenant was silently re-tenanted and the audit named the wrong one. Deriving the actor headers from the principal also removes a class where a host that set actor and principal inconsistently got a hard 403 at the far end of a DO hop, reported as an identity attack rather than the wiring bug it was. This gives assertExecutionPrincipal the call site it lacked. - systemTenant took a purpose: it drives both the schedule and dispatch ticks, so every dispatch audit row claimed to be a scheduled agent execution. purpose is required precisely so provenance stops being fabricated. - Dropped tenantForActor from the starter: unreferenced, and its body was the human-downgrade shape the earlier round removed everywhere else. - Dropped the unread actor param from handleNotificationDispatch. - Deleted a stale docblock that claimed #authorizeAutomated refuses humans at runtime; the sibling block correctly says the type carries that half. - boundedText's comment claimed to prevent header injection. JSON.stringify already escapes control characters; the real reasons are that id also travels raw in x-flowsafe-actor and that refusing early beats a Headers.set TypeError. - The import-isolation pin is file-level and cannot see import KIND; it now says so instead of implying it enforces type-only-ness. Docs: allowedAutomation was the most consequential field in the release and appeared in no guide. durable-agents.md now documents the deny-by-default opt-in, the handle/metadata agreement, why approval.resume is never declared, and names the principal kind and entry path each scheduled duty arrives on. The spike gains the deny direction (D-S4): the same SYSTEM principal on an undeclared entry path is refused through the real hop, with a generic 403 that leaks no policy detail. A gate proven only to admit is how an unreachable gate looked healthy for a whole branch. 2236 tests pass; SPIKE VERIFIED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review recommendations, both accepted. The bridge took a pre-branded principal, so the showcase hand-minted one byte-identical to what buildHostApprovalService built internally — a host performing a TCB trust assertion for a value the library already knows how to construct, in the sample every consumer copies. queueApprovalForSuspension, reconcileApprovalsForSummary, and resumeRunWithRequeue now take a systemActorId and mint their own bookkeeping principal against the service's tenant binding. ApprovalService exposes tenantId for that. trustAutomationPrincipal stays exported for genuinely host-specific automated principals. x-flowsafe-actor and x-flowsafe-role are retired. After the principal landed, the DO no longer built identity from them — it projected scope.actor from the principal and kept the two older headers only to compare back against it, a self-consistency check on values the same topology call stamped from the same object. That redundancy bought nothing and cost a failure class: a host whose TenantContext.actor disagreed with its principal got a hard 403 at the far end of a DO hop, reported as an identity attack rather than the construction bug it was. It also hid the tenant gap fixed in the previous commit, because a single field cannot disagree with itself. Gone with them: the actor/role extraction and its two ThreadIdentityError branches, the cross-check, and THREAD_ACTOR_ROLES — a hand-copy of APPROVAL_ROLES that existed only to validate a header nobody reads now. The topology strips both names on send AND forward, so a caller's forged value cannot ride into the DO as if the topology had stamped it, and createTenantResolver still refuses them inbound: a client sending them expects an older flowsafe, and a mixed-version deployment should fail loudly rather than have the header quietly ignored. Test count moves 2236 -> 2234: two thread-do cases asserted refusal of a missing actor id and an invalid actor role, and their subject no longer exists. The refusals they guarded are covered by the missing- and invalid-principal-header cases. 2234 tests pass; SPIKE VERIFIED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's "Verify packed flowsafe agent-host surface" step failed: the packed-surface test asserted the breakwater peer range against a hardcoded '>=0.6.0 <1.0.0', and this branch moves the floor to 0.7.0 because allowedPrincipalKinds ships in that release. Compare against the source manifest instead of a copy of its value. This script runs only as its own CI step, never under `pnpm test`, so a hardcoded range goes stale the moment the floor moves and fails only after the change is pushed — the third time today a value duplicated outside the default test run bit us. A shape assertion stays, so the range cannot silently become unbounded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three pre-merge gaps in the execution-principals work.
trustAutomationPrincipal validated a principal and returned the caller's
own object, so a vouched `system` principal could be rewritten into
`{kind:'human', role:'admin'}` before #authorizeAutomated re-read `kind`.
Reproduced: sameObject=true, brand=null, frozen=false.
The root cause is validate-then-reread, not the missing clone: any second
read of a caller's object can disagree with the first. An own accessor
survives Object.freeze; a prototype getter and a Proxy `get` trap are
invisible to a Reflect.ownKeys shape check; and getOwnPropertyDescriptor
is itself a trap over an extensible target. canonicalPrincipal now reads
every field exactly once as an own data property and returns a fresh
plain snapshot. trustAutomationPrincipal brands and freezes THAT, and
assertExecutionPrincipal returns it, so validated and used are the same
values and there is no second read left to disagree. The trusted entries
recheck brand, shape, kind, and frozen rather than an erased parameter
type; ExecutionPrincipal fields are readonly.
Automated audit provenance stopped at the primary event. principalAuditFields
now rides the tenant-mismatch denial, the create notify/stream failures, and
the supersede stream failure, and the re-queue bridge mints the platform's
own bookkeeping principal instead of hand-shaping a viewer actor. It mints
inside the try: a blank systemActorId fails the vouch for the same reason it
fails the re-queue, and hoisting the mint lost the only event that names the
suspended step paths. Without a principal the event still fires, unattributed.
Public docs described the retired contract: the actor/role headers as live,
resume as a role-only recheck, and no mention of allowedPrincipalKinds or
allowedAutomation in the breakwater and starter READMEs. Roadmap Phase B
item 1 is marked shipped and the threat model gains the new rows.
Also closes the same class where the report did not look: sweepSLA accepted
a human principal, which would have stamped principalKind:'human' onto every
tenant's cron escalations. It is refused, and AutomatedExecutionPrincipal
narrows the boundary so a human is a compile error. TRUSTED_AUTOMATION leaves
the public barrel, and a trustAutomationPrincipal call in the thread host that
asserted trust nothing consumed is gone, so the greppable invariant still means
something.
Every runtime guard is mutation-tested: removing any one turns tests red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: first-class execution principals
release: version packages
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
anchorage-showcase | f304782 | Jul 29 2026, 06:41 AM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes the fully versioned dev release candidate to main.
After merge, the Release workflow publishes unpublished versions to npm in dependency order and creates tags and GitHub releases.