Skip to content

Let a boundary rule ask what started a run - #526

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
Hotragn:let-a-rule-ask-what-started-the-run
Sep 14, 2026
Merged

davidmckayv merged 2 commits into
CopilotKit:mainfrom
Hotragn:let-a-rule-ask-what-started-the-run

Conversation

@Hotragn

@Hotragn Hotragn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What this changes

A routine's turn goes through exactly the path a person's chat turn does, as the routine's owner:
their grants, their connections, their thread. That is the right design, and it is why actor.id
cannot tell a scheduled run at three in the morning from the same person typing.

The trail already draws that distinction. AuditInitiator is signed into the run assertion and
written onto the row (audit.ts:426):

What caused a row, where actorUserId is only whose authority it borrowed.

The boundary could not ask the same question, so this was unwritable:

deny: initiator.kind == "routine" && intent == "run_command"

PolicyContext now carries initiator — the same kind and id the trail already records. A
deployment happy for a Bot to run a shell while somebody watches, and not happy for it to do so
unattended, can now say so. The id is bound too, so one routine can be named rather than scheduled
runs as a class, and handoff is its own kind for a Bot handing work to another Bot.

Nothing is refused that was not refused before. Every path that does not carry an initiator reads
as a person.

Three decisions worth naming

Required on the type, not optional. cel-js throws on an unbound identifier and a throw fails
closed, so a field that were sometimes absent would turn one rule about routines into a deployment
that refuses every ordinary click — the failure #115 exists to prevent. Flattened to two
always-present strings for the same reason, with id as "" for the kinds that carry none, which is
the neutral mcp.effect already uses.

The computer gateway passes a person, and that is truthful rather than convenient. A Bot's
computer is driven by frontend tools in the browser (app/src/lib/copilot/computer-tools.tsx), so
every action reaching that gateway came from somebody's session — there is no headless path to it
today. #298 is the change that makes that untrue, and the comment at the call site says so, because
inheriting a hardcoded person after that lands would be the trail lying rather than being quiet.

plugins/store.ts is where it is real. A routine's tools run through there carrying the
initiator its run assertion was signed with, already plumbed as far as callTool. This PR is mostly
about binding something already present rather than introducing something new.

The history replay reads the initiator off the row when it is there, and treats a row that predates
the field — or carries a shape this version does not recognise — as a person. Its own narrow reader
rather than the one in callback-token.ts: that one narrows a value this deployment signed and can
trust, and a stored row is neither.

Answers the half of #484 that survived. The rest of that issue was my own misreading — AuditInitiator
already existed and I had not looked — which I have corrected on the issue.

Where it runs

  • New state that outlives a request? None. The field is computed per decision from a value the
    request already carried.
  • What happens on the second replica? Identical. Nothing is held, cached, or derived from
    process state — the initiator arrives on the signed assertion.
  • Anything serialised? No new writes. The audit row already carried this; only the policy context
    is new.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • This is a boundary change: it adds a field a rule can name. It adds no permission — a rule can
    only ever refuse more, never less, and a deployment that writes none is unaffected.
  • Default-deny, deny-first and fail-closed evaluation are untouched.
  • The dry-run replay (Test a boundary rule against history before saving it #241) binds it too, so a rule can be tested against history before it is saved
    rather than discovering the field only in production.
  • No new refusal type and no audit change: the row already recorded the initiator, which is what made
    this a binding rather than a new fact.

Changelog

Added under Unreleased, including that nothing is refused that was not refused before.

Proof

bun test server/tests/computer-policy.test.ts    52 pass, 0 fail, 93 expect()
bun test server/tests/policy-dry-run.test.ts     13 pass, 0 fail, 38 expect()
bun test server/tests/computer-gateway.test.ts   60 pass, 0 fail, 173 expect()

Ten tests are new; the 45 existing policy tests and all 60 gateway tests are unchanged, which is what
says no decision moved. The ones worth reading:

  • a routine is refused by a rule naming it, and a person is not — the identical action, identical
    actor, differing only in initiator. That is the whole point of the field.
  • a rule naming the initiator does not refuse an action that has a person — the Keep a rule about one action surface from refusing another #115 property for
    this field specifically.
  • a shape this version does not recognise reads as a person rather than as itself — four
    malformed payloads, so a replay cannot be thrown by a row from a later version.

server typecheck clean. biome format and biome lint clean on all six changed files.

Worth stating: three pre-existing errors show in my local typecheck for @ag-ui/mastra,
@mastra/client-js and cron-parser. Those are declared in server/package.json and simply absent
from my node_modules — my checkout was stale, not this change.

What is not covered

  • The gateway still cannot report a non-person initiator, because nothing can give it one yet.
    When Give a Bot its computer when nobody is watching #298 lands, that call site needs the run's own initiator rather than the neutral, and it is
    commented accordingly. I have not pre-built that seam because its shape depends on how Give a Bot its computer when nobody is watching #298 drives
    the computer.
  • No screen offers this in the Boundaries editor. The rule is writable and testable through the
    dry-run today; a picker for it is a separate change.
  • initiator.kind is a string a rule compares against. A typo — "routines" — is a rule that
    silently never matches, the same as any other CEL string comparison. The dry-run is the existing
    answer to that, and it now binds this field.

🤖 Generated with Claude Code

A routine's turn goes through exactly the path a person's chat turn does, as
the routine's owner: their grants, their connections, their thread. That is the
right design, and it is why `actor.id` cannot tell a scheduled run at three in
the morning from the same person typing.

The trail already draws that distinction. `AuditInitiator` is signed into the
run assertion and written onto the row, with the docstring "what caused a row,
where `actorUserId` is only whose authority it borrowed". The boundary could not
ask the same question, so this was unwritable:

    deny: initiator.kind == "routine" && intent == "run_command"

`PolicyContext` now carries `initiator`, the kind and id the trail already
records. The id is there too, so a deployment can name one routine rather than
deciding about scheduled runs as a class, and `handoff` is its own kind.

Required on the type rather than optional, and flattened to two always-present
strings. cel-js throws on an unbound identifier and a throw fails closed, so a
field that were sometimes absent would turn one rule about routines into a
deployment that refused every ordinary click — the failure CopilotKit#115 exists to
prevent. `id` is "" for the kinds that carry none, the neutral `mcp.effect`
already uses.

Set at all three places a context is built. `plugins/store.ts` is where it is
real: a routine's tools run through there carrying the initiator its assertion
was signed with. The computer gateway passes a person, and that is truthful
rather than convenient — a Bot's computer is driven by frontend tools in the
browser, so every action reaching that gateway came from somebody's session.
CopilotKit#298 is the change that makes that untrue, and the comment says so. The
history replay reads the initiator off the row when it is there.

Nothing is refused that was not refused before.

Ten tests: a routine refused by a rule naming it while the identical action with
a person is allowed, one routine named without catching another, a rule naming
the initiator leaving an ordinary click alone, and a replayed row with no
initiator or an unrecognised one reading as a person. 52 pass in
computer-policy, 13 in policy-dry-run, and computer-gateway's 60 unchanged.

Answers the half of CopilotKit#484 that survived — the rest of that issue was my own
misreading, corrected there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep-reviewed clean (validation, no secret leak, fail-closed, agrees with existing layers). CI green.

@davidmckayv
davidmckayv merged commit fc64922 into CopilotKit:main Sep 14, 2026
15 checks passed
davidmckayv pushed a commit that referenced this pull request Sep 14, 2026
Both places that list the fields a boundary rule can use — the Boundaries screen and docs/architecture.md — now name `initiator`, the field #526 added, noting that `actor.id` on a scheduled run is the routine's owner and cannot tell an unattended run from a person. Prose only; no behaviour change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants