Take the kind of human input from the path, not the body - #111
Merged
davidmckayv merged 1 commit intoAug 21, 2026
Merged
Conversation
`/:botId/human/:kind` checks `kind` against the four gestures a person's mouse and
keyboard produce, and then built the call as `{ kind, ...body }`. The spread came
second, so a body carrying its own `kind` replaced the value that had just been
checked. `humanInput` puts that value straight into the path it calls on the computer
and the transport concatenates it onto the base address, so a fetch resolves the `..`
away and the request lands on a different endpoint entirely.
`{"kind":"../computers/reset"}` sent to `/bot-1/human/click` reaches `/computers/reset`,
which wipes the profile and every login in it. `{"kind":"../exec","command":"..."}`
reaches the shell. Both carry the deployment's computer token, because the transport
attaches it to everything it sends.
Three defences are not defeated so much as stepped around, since the request never
enters the paths that carry them. This route deliberately skips the policy decision and
the audit row, which is the price of a takeover: a person entering a password is
exactly what must not be recorded. The computer's own `humanMayDrive` check is keyed to
the four `/human/*` paths and never sees a request routed elsewhere. So the one route
that writes nothing down is the one that could be pointed anywhere.
The only gate in front of it is the Bot-access check every route under `/:botId/*` has,
so this is reachable by any signed-in person who may act as the Bot, and a Bot whose
visibility is public means everybody.
Fixed at both layers, because they answer different questions. The route spreads the
body first and puts the checked `kind` last, so the value it verified is the value it
sends. The gateway checks the gesture against the set before interpolating, because
that is where the string becomes a path: `kind` is typed as a union of four, and the
route casts a parsed body to that shape, so the type is erased exactly where it would
have helped.
Reverting either one turns tests red on its own.
The end-to-end test binds a socket and asks the far side what arrived, because the
stubbed-fetch tests either side of it asserted on a URL this process had just built.
The `..` is resolved by URL parsing inside fetch, so reading back the string passed in
describes the argument rather than the request. With both layers reverted, a real
server receives `/computers/reset`, `/exec` and `/health`.
beardthelion
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 21, 2026 20:54
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.
Closes #110.
The route checks
:kindagainst the four gestures and then built the call as{ kind, ...body }. The spread came second, so a body carrying its ownkindreplaced the value that had just been checked, and the gateway puts that value into the path it calls on the computer. A body of{"kind":"../computers/reset"}sent to/human/clickwipes the profile;{"kind":"../exec","command":"..."}runs a command. Both carry the deployment's computer token.What it does
Two layers, because they answer different questions.
The route spreads the body first and puts the checked
kindlast, so the value it verified is the value it sends. That is the whole of the root cause.The gateway checks the gesture against a set before interpolating, because that is where the string becomes a path.
kindis typed as a union of four and the route casts a parsed body to that shape, so the type is erased exactly where it would have helped. This is the layer that holds if another caller arrives later, andhumanInputis worth guarding twice: it is the one acting method that writes no audit row, so a call that went somewhere else leaves nothing behind that says so.Nothing else changes. The four gestures behave as before, and an unknown
:kindin the path is still refused with 400 by the check that was already there.Verification
Twelve tests across three files. Each layer was reverted on its own with the tests live: reverting the route turns the route tests red, reverting the gateway turns the gateway tests red.
Route (
computer-routes.test.ts): a real gesture carries through with its coordinates; a body naming its ownkinddoes not decide where the call goes; the same for the shell; an unknown path kind is still refused before the gateway is asked.Gateway (
computer-gateway.test.ts): four rejected values, asserting that nothing left the process rather than merely that something threw, since a request that goes out and comes back 404 also throws and that is the bug; plus all four gestures reaching their own endpoints.End to end (
human-input-end-to-end.test.ts, new): binds a socket, drives the real router through the real gateway and the real transport with no injected fetch, and asserts on what the far side received. This is the one that settles it. The..is resolved by URL parsing insidefetch, so a test that reads back the URL string it passed in is describing its own argument rather than a request. With both layers reverted, the server receives/computers/reset,/execand/health.It stands in for
agent-computerrather than running it, because that process opens Chromium at import time. What it reproduces is the part that matters: an HTTP API on the far end of the transport with endpoints beyond the four this route may reach.serversuite failure set is identical tomain(integration tests wanting a Postgres).bun run --filter server typecheckandbunx biome checkare clean on the changed files.