Repository navigation
fix(proxy): refuse what the proxy cannot read, and lock the approval UI - #109
Open
CharmingGroot wants to merge 3 commits into
Open
CharmingGroot wants to merge 3 commits into
CharmingGroot wants to merge 3 commits into
Conversation
The approval UI took a form post from anything that could reach its port. The guarded agent can: the kernel layer always passes loopback, so one curl from an allowed shell tool approved the agent's own pending Ask, and on the dashboard added an address to the kernel allowlist. A web page the operator had open could do the same cross-site. A token is generated from the OS random source at startup and printed in the login URL. Opening it moves the token into a SameSite=Strict, HttpOnly cookie; every route requires it, and a state change whose Origin names another host is refused. Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
The OpenAI parser read Chat Completions only, and every field of that shape defaulted, so a Responses API body parsed as a completion with no tool calls: function_call, local_shell_call and custom_tool_call items reached the agent under a default-deny policy. The deprecated message.function_call field and Gemini's streamGenerateContent answered as a JSON array were missed the same way. Responses output items are read, streamed and not. Any *_call item the provider does not execute itself is treated as a tool call, so item types newer than this parser are guarded rather than skipped. The SSE reader now frames events as the spec does (CR/LF/CRLF, multi-line data joined) and fails closed: an event that is not JSON, or argument fragments that belong to no named call, make the stream unreadable instead of silently shorter. OpenAI argument strings are re-serialized before the guard sees them, so model-written escapes such as \u002f cannot hide a path from a rule. Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
… read Every path went upstream, and a completion response that did not parse was relayed unchecked, so a body in a shape the parser did not know carried its tool calls straight to the agent, and /v1/files or /v1/embeddings carried a prompt past every inspector. WireFormat::route names each endpoint. A completion's response must parse as the format or it is refused; token counting is screened but not parsed; model listing passes; anything else is refused before it leaves unless the operator names it with --passthrough-path, announced at every start. The default route treats every path as a completion, so an out-of-tree format gets the strict reading. With an inspector on, a prompt that cannot be read is refused, and Responses API prompts are read. Provider errors are relayed with status and headers. The upstream is asked for Accept-Encoding: identity, hop-by-hop headers are dropped, and a completion compressed anyway is refused: what was checked must be what the agent decodes. Request and response bodies are capped (--max-request-bytes 32 MiB, --max-response-bytes 64 MiB); the read timeout is idle-only, so without the cap an endless stream was buffered forever. Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
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.
The proxy treated anything it could not read as "nothing to guard". A Responses API body, a compressed body, an unknown endpoint and a stream event it did not parse were all forwarded unchecked — and a
default: denypolicy let a shell call through. Separately, the approval UI took a form post from anything that could reach its port, including the agent it guards.Each fix below was reproduced as a failing test against the old code first, run with the shipped
deploy/rules.yaml, then mutation-checked: reverting the guard makes its test fail again.The approval UI answers only to whoever started it
/decisionand/egress/allowtook unauthenticated form posts, and approval ids are sequential. The kernel layer always passes loopback, so an allowed shell tool could approve the agent's own pendingAsk, or add an address to the kernel allowlist. Any web page the operator had open could do the same cross-site.Every UI request now needs a token generated from the OS random source at startup. The printed URL carries it once; after that it lives in a
SameSite=Strict,HttpOnlycookie, and a state change whoseOriginnames another host is refused.Tool calls in every shape they arrive in
output[]items, streamed and not. Any*_callitem the provider does not run itself counts as a tool call, including types newer than this code.message.function_call, and GeministreamGenerateContentanswered as a JSON array.data:joined). An event that is not JSON, or argument fragments that belong to no named call, make the stream unreadable instead of silently shorter./cannot hide a path from a rule.Forward only what it can guard
WireFormat::routenames each endpoint:--passthrough-path.routeis the strict reading, so out-of-tree formats are covered.Accept-Encoding: identity, hop-by-hop headers are dropped, and a completion compressed anyway is refused.--max-request-bytes(32 MiB) and--max-response-bytes(64 MiB). The read timeout is idle-only, so an endless stream used to be buffered forever.Behaviour changes
--passthrough-pathto forward them uninspected.pasu_ui::router,serveandserve_alltake anAccess.Verification
cargo test,clippy -D warningsandfmt --checkpass (portable crates)./v1/embeddings: 403.identityalthough the client asked for gzip.🤖 Generated with Claude Code