Skip to content

proxy: let the off-allowlist deny carry a reason, and deliver it on HTTP - #597

Open
v0ropaev wants to merge 1 commit into
anthropics:mainfrom
v0ropaev:fix/proxy-deny-reason-in-band
Open

v0ropaev wants to merge 1 commit into
anthropics:mainfrom
v0ropaev:fix/proxy-deny-reason-in-band

Conversation

@v0ropaev

@v0ropaev v0ropaev commented Sep 22, 2026 •

Copy link
Copy Markdown

Closes #273.

Rewritten. When I opened this in September, HttpProxyServerOptions.filter returned a bare boolean and had nowhere to put a reason, so the branch built that plumbing. Main has it now, through the explain callback and markFor, and in a better shape than mine. So this is down to the two things that are still missing, on top of yours.

The off-allowlist deny has no reason to configure

deniedDomainReasons is keyed by the exact deniedDomains entry, which means it can only speak for a destination somebody listed. The denial the issue is actually about is the other one, a host on no list at all, and there is no key to hang text on. Both sites that raise it, strictAllowlist at sandbox-manager.ts:800 and the no-ask-callback path at :819, say host is not on the allow list and nothing else.

network.allowlistDenyReason is that text. It goes through the same denied() helper, so it lands in the violation line, in the CONNECT status phrase and in the HTTP body with no further wiring. Unset, everything reads as it does today.

This is the part of #273 I think is load-bearing. The reporter's complaint is that the agent reads the refusal as a proxy fault and retries with --no-proxy; what they want to say is "do not retry via alternate DNS / proxy / VPN", and no deny-list entry exists for the host that triggered it.

The plain-HTTP handler drops the reason

The CONNECT handler passes an explain collector and puts the reason in the status phrase. The plain-HTTP handler calls options.filter with four arguments and then answers respondDenied(res, ...allowlistDeny), so the reason is neither collected nor written, and respondDenied takes one as its second parameter.

That is backwards with respect to what clients read. curl prints a 403 body and does not print a CONNECT status phrase unless you ask for -v:

Measured against a proxy built from createHttpProxyServer with a filter that denies and explains, before and after the change:

# before
$ curl -sS http://blocked.test/x --proxy <proxy>
Connection blocked by network allowlist
$ curl -sS https://blocked.test/x --proxy <proxy>
curl: (56) CONNECT tunnel failed, response 403

# after
$ curl -sS http://blocked.test/x --proxy <proxy>
blocked by sandbox policy; ask the user to add the host to allowedDomains
$ curl -sS https://blocked.test/x --proxy <proxy>
curl: (56) CONNECT tunnel failed, response 403

curl 8.7.1, macOS. The https:// line is unchanged either way, which is the point: the phrase is there on the wire and curl does not show it.

The resolved-address refusal already does it this way, and the README describes it that way, "the reason in the body and, for a CONNECT, as the status phrase". The host-allowlist deny was the one path that did not.

Four lines in the handler: collect the reason, and pass it as the body with the generic text as the fallback.

Tests

test/sandbox/allowlist-deny-reason.test.ts, four tests through SandboxManager, checking the configured reason in the CONNECT phrase, in the plain-HTTP body and in the violation line, the generic text when the field is unset, and that the schema rejects an empty string.

Two more in proxy-deny-violations.test.ts beside your CONNECT phrase test, driving createHttpProxyServer directly: the reason filter() explains becomes the plain-HTTP body, and an unexplained deny keeps Connection blocked by network allowlist. The first fails on main, which is the gap above.

bun test test/sandbox gives 13 failures on this branch and the same 13 on a clean main (rg missing from PATH, the tls-terminate keep-alive case, the chunked-body decider cases, the temp-object teardown cases). npm run typecheck, npm run lint:check and prettier are clean.

Not here

Reaching an https:// client in band still needs a TLS-terminated 403 rather than a refusal at the CONNECT, which inverts the ordering of the allowlist filter against termination. That is a design call, not something to slip in here, and I have not tried it.

@v0ropaev
v0ropaev force-pushed the fix/proxy-deny-reason-in-band branch from 1d142a1 to c8b3b6a Compare October 11, 2026 09:04
@v0ropaev v0ropaev changed the title proxy: tell the sandboxed client which policy denied its connection proxy: let the off-allowlist deny carry a reason, and deliver it on HTTP Oct 11, 2026
A destination on no list at all is the common denial and the one
deniedDomainReasons cannot reach, since there is no deniedDomains entry to
key a reason on. network.allowlistDenyReason names it once.

The plain-HTTP handler also passed no explain callback and answered with the
generic body, so a reason that reaches a refused CONNECT as its status phrase
reached a plain-HTTP client not at all, although the body is the one place
a client prints without -v.

Signed-off-by: Dmitry Voropaev <dy.voropaev@gmail.com>
@v0ropaev
v0ropaev force-pushed the fix/proxy-deny-reason-in-band branch from c8b3b6a to 4d4a200 Compare October 28, 2026 08:40
@v0ropaev

Copy link
Copy Markdown
Author

Rebased on main. The only conflict was the README option list after #745, and the deny-reason tests still pass on top of it.

This branch has not been deployed

No deployments
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.

Allow overriding proxy deny response for blocked network requests

1 participant