Skip to content

Refuse a ref this server cannot resolve, instead of acting blind - #183

Merged
davidmckayv merged 1 commit into
CopilotKit:mainfrom
beardthelion:fix/unresolved-ref-forwarded
Aug 22, 2026
Merged

Refuse a ref this server cannot resolve, instead of acting blind#183
davidmckayv merged 1 commit into
CopilotKit:mainfrom
beardthelion:fix/unresolved-ref-forwarded

Conversation

@beardthelion

Copy link
Copy Markdown
Contributor

Closes #182.

What was wrong

A click citing a ref the server cannot resolve was carried out, and every deny rule keyed on the element was unable to match it.

resolve answering undefined binds an all-empty element into the policy context. Empty is the right neutral value for an action that names no element, which is what #68 added it for and what stops a rule about one action surface throwing on another. It is the wrong answer for an action that named a ref and did not get one: contains(element.name, "submit") against empty strings is false, the shipped default allows, and the click lands on whatever that ref points at now. The rule did not decline to match. It was never shown the element.

The computer's own staleness check does not cover it. That check compares the citation against the computer's own counter, so it catches the cases where the two disagree. The case that matters is the one where the computer is content and only this server is out of step, which is what a computer restarting its generation counter under a stored row leaves behind.

Driven against a real computer and a real browser

Real agent-computer process, real Chromium, a page whose button increments a visible counter, a policy of deny: contains(element.name, "submit"):

ROUND 1 snapshot generation: 6
ROUND 1 click: REFUSED by the rule
ROUND 1 clicks landed on the page: 0
   ... agent-computer restarted, as a redeploy does ...
ROUND 2 snapshot generation handed to the model: 2
ROUND 2 what the store now holds: 6
ROUND 2 click: PERFORMED
ROUND 2 clicks landed on the page: 1

The same click, on the same button, under the same policy: refused before the restart, carried out after it.

The fix

govern refuses a citation it cannot resolve rather than forwarding it with the element half of the context empty.

Narrow in three ways, and each condition turns tests red if removed:

  • Only a cited ref. A scroll, a page-level keypress, a shell call and a file read name no element, so they have nothing to have failed to resolve. Dropping this refuses them too: 7 red.
  • Only against a snapshot this server holds. With no stored page there is nothing here to judge the citation against, the computer is the only party that can answer, and refusing locally would take its answer away, including the one that says a person has the wheel. Dropping this breaks the control-refusal tests from Tell a Bot a person has the wheel, not that its refs are stale #164: 3 red.
  • Raised after the decision row is written. An action whose ref resolves to nothing is still an action somebody tried to take, and refusing before the row would be a way to act without appearing on the trail. The failure row beside it keeps the trail from claiming a permitted action was carried out when nothing was sent.

Removing the guard entirely: 4 red.

What this does not change

A reset deletes the row, so after one there is no stored snapshot and the citation is still forwarded for the computer to answer. That case is left as it is, deliberately, and the test that covers it says why.

Tests

Three added, in server/tests/computer-gateway.test.ts: the refusal, the control that proves the rule still fires when it can see the element, and the boundary that proves a ref-less action is untouched. Four existing tests moved from asserting the forward to asserting the refusal; each keeps the claim it was written for and carries a comment saying why the assertion changed.

One of those comments claimed the empty element was "the one a deny rule treats as a match". That was the misconception underneath this, and it is corrected where it sat.

Full server suite 956 passing, typecheck and lint clean.

A click citing a ref the server cannot resolve was carried out, and every
deny rule keyed on the element was silently unable to match it.

`resolve` answering undefined binds an all-empty element into the policy
context. Empty is the right neutral value for an action that names no
element, which is what stops a rule about one action surface throwing on
another, and it is the wrong answer for an action that named a ref and did
not get one. `contains(element.name, "submit")` against empty strings is
false, the shipped default allows, and the click lands on whatever that ref
points at now. The rule did not decline to match. It was never shown the
element.

The computer's own staleness check does not make this safe. It compares the
citation against its own counter, so it catches the cases where the two
disagree. The case that matters is the one where the computer is content
and only this server is out of step, which is what a computer restarting
its generation counter under a stored row leaves behind: the store keeps
the higher generation and refuses every snapshot until the counter climbs
back past it, while the model is handed those refs anyway.

Driven against a real computer and a real browser: the same click on the
same button, refused by the rule before the restart and performed after it.

Narrow on purpose, in three ways, each of which turns a test red if it goes.
Only a cited ref, so a scroll, a page-level keypress, a shell call and a
file read are untouched. Only against a snapshot this server holds, because
with no stored page there is nothing here to judge the citation against, the
computer is the only party that can answer, and refusing locally would take
its answer away, including the one that says a person has the wheel. And
raised after the decision row is written, because an action whose ref
resolves to nothing is still an action somebody tried to take.

A comment in the tests claimed the empty element was "the one a deny rule
treats as a match". That was the misconception underneath this, and it is
corrected where it sat.

@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.

Validated independently rather than read.

Ran your test file against unfixed main: the headline test fails, and it fails for the right reason — the click comes back as a completed action, {action: "click", url: …}, where the refusal was expected. The deny rule was never shown the element, so it could not match and the default permitted. Bug reproduced.

Then checked the guard is as narrow as you said by breaking each condition and counting:

condition dropped you claimed measured
cited ref 7 red 7
stored snapshot 3 red 3
guard removed 4 red 4

Exact, all three. That is what makes this safe to take: scrolls, page-level keypresses, shell calls and file reads are untouched, and a computer this server holds no snapshot for still forwards, so the answer that says a person has the wheel is not taken away.

Also re-verified merged onto current main, which has moved since your CI ran: 1315 passing, typecheck, lint and format clean.

The correction to the old comment that called the empty element "the one a deny rule treats as a match" is the part I would have missed. Fixing the misconception where it sat is worth as much as the guard.

Note for next time: this sat at action_required and had never run CI — fork PRs need a maintainer to release the workflow, so it was showing no checks rather than passing ones. I approved the run.

@davidmckayv
davidmckayv merged commit 0ef5b01 into CopilotKit:main Aug 22, 2026
8 checks passed
davidmckayv added a commit that referenced this pull request Aug 22, 2026
#183 changes what a deployment does: a click citing a ref this server
holds a snapshot for and cannot resolve is refused where it used to be
carried out with the boundary blind to the element.

It landed without a line here. That is the one shape of omission this file
exists to prevent — an operator upgrading meets refusals nothing warned
them about, and the security fix underneath them is invisible.
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.

An action citing a ref the server cannot resolve is carried out with every element rule unable to match

2 participants