Refuse a ref this server cannot resolve, instead of acting blind - #183
Conversation
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
left a comment
There was a problem hiding this comment.
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.
#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.
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.
resolveanswering 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-computerprocess, real Chromium, a page whose button increments a visible counter, a policy ofdeny: contains(element.name, "submit"):The same click, on the same button, under the same policy: refused before the restart, carried out after it.
The fix
governrefuses 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:
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.