Skip to content

Attach Claude Code by proxy so Remote Control keeps working (LLP 0231-0235) - #782

Merged
philcunliffe merged 9 commits into
masterfrom
feat/proxy-mode-capture
Aug 15, 2026
Merged

Attach Claude Code by proxy so Remote Control keeps working (LLP 0231-0235)#782
philcunliffe merged 9 commits into
masterfrom
feat/proxy-mode-capture

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Why

Claude Code disables Remote Control whenever ANTHROPIC_BASE_URL points anywhere other than api.anthropic.com. Attach repoints exactly that key, so attaching a machine costs the user Remote Control. This is a deliberate client-side gate, not a gateway bug, so no amount of improving the gateway reaches it.

Proxy mode routes Claude Code through the gateway with HTTPS_PROXY plus a machine-local CA instead, leaving the base URL alone. Because the endpoint is then genuinely first-party, ENABLE_TOOL_SEARCH and the undocumented _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL become unnecessary rather than merely unset, retiring the standing duty to re-verify an undocumented flag every release.

Off unless proxy_mode = true is configured. Codex is unaffected and stays on base-URL attach, so both mechanisms coexist on one listener and one port.

The actual problem: aperture, not transport

A reverse proxy only receives what a client deliberately sends it. A proxy receives all client egress. Two narrowings keep what is captured identical to today:

  • Only hosts a registered upstream names are decrypted. Everything else is blind-tunnelled and never read.
  • Only paths an adapter's preset claims are recorded. Reusing the routing matcher would have been wrong: it accepts an sk-ant- bearer alone, which under a CONNECT is true of every request to the host. A synthetic POST to /api/eval/sdk-* carrying a messages array was measured projecting 2 stored rows through that hole.

Capture parity was measured at 4 rows / 35 populated columns either way.

Certificates

Minted in-process (hand-rolled DER plus node:crypto). No openssl shell-out (macOS ships LibreSSL, Windows ships neither) and no new dependency. The CA is a ten-year credential name-constrained to the full static provider set (api.anthropic.com, api.openai.com, chatgpt.com — LLP 0238), with IPv4 and IPv6 excluded because RFC 5280 leaves an absent name form unrestricted and a leaked key would otherwise still vouch for an IP. It lives in src/core/tls/ rather than the gateway plugin because uninstall must remove it with the plugin unloaded.

Trust is delivered two ways, and the second was forced by live testing (see below). NODE_EXTRA_CA_CERTS in Claude's own settings file covers the main API client. But Claude Code is a Bun binary with two HTTP clients holding different trust stores (LLP 0236): Remote Control's inbound SSE transport verifies against the system store, which NODE_EXTRA_CA_CERTS never reaches. So on macOS, attach also installs the CA as a user-domain trusted root in the login keychain — no sudo, the native macOS password dialog is the consent step, refusal degrades to a warning with capture still working (LLP 0237) — and delivers NODE_USE_SYSTEM_CA=1 via launchctl setenv plus a login LaunchAgent (LLP 0239). The earlier claim that the system trust store is never touched no longer holds on macOS; that escalation is the price of Remote Control actually working, and it is consented per machine through the OS's own dialog.

The keychain grant is once per machine: detach keeps the CA and its trust so re-attach is silent, and only hyp daemon uninstall or hyp detach --purge removes them (LLP 0238).

Failure modes

A dead proxy breaks all of Claude Code's HTTPS, not just its model calls, so both directions get an explicit answer:

  • Attach refuses unless a CA proves proxy mode is actually running.
  • A listener that cannot intercept but may still have a client pointed at it serves blind tunnels, so egress degrades to unrecorded-but-working rather than dying.

Review found two blockers, both fixed

An independent review pass reproduced both:

  1. Proxy mode recorded nothing on a default install.hyp init writes path_prefix: "/" for the anthropic upstream, and operator config wins over the adapter preset, so reading the routing prefix as the record anchor meant the feature was dead on arrival. The record anchor now comes from the preset (record_prefix); routing stays the operator's. Every test that used a hand-written upstream missed this, so there is now one that asserts against the table mergeUpstreams really compiles.
  2. A mid-tunnel upstream reset injected a plaintext 502 into an established TLS tunnel, because openUpstream could call back twice. Latched, and the regression test was verified to fail without the fix.

Also fixed from that pass: the missing IP name-constraint; a permittedHosts byte-scan that invented phantom hosts (~1 CA in 700) and regenerated the CA every boot for hosts it could not read; no key/cert match check on a stored CA; a base-URL attach on top of a proxy marker destroying the user's own ANTHROPIC_BASE_URL; detach resolving the CA from the ambient home rather than homeDir; and a re-attach swallowing a hand-edited HTTPS_PROXY.

A second review round (see PR comments) fixed a displaced HTTPS_PROXY credential reaching stdout, --json and a log record (redactUrlUserinfo, on every display sink; the on-disk undo copy stays verbatim), and a terminated tunnel resolving its upstream on hostname alone where the trust decision was made on host and port.

Live testing forced the trust-model change, then passed

The PR was briefly closed when a live run found Remote Control only half working: capture succeeded while the SSE inbound stream failed TLS verification forever, because of the split trust stores described above. The keychain + launchd design (LLP 0236-0239, commit dc9f9fe) is the fix; the full flow was then verified end to end on the real binary (run G): one password dialog on first attach, silent re-attach, SSETransport: Connected with phone messages arriving while capture recorded in the same window, detach restoring settings byte-identical.

Post-review hardening: the CONNECT front door answers loopback peers only

Review round 2's finding A, resolved as option 1 (7a3bee2): a CONNECT from any peer that is not the machine itself is refused 403 before the target is parsed, blind tunnels included. The check is on the peer, not the bind, so a non-loopback listen keeps working for its own client (attach always writes http://127.0.0.1:<port>) while no longer being an open relay for its network. Recorded in LLP 0233 ("Loopback peers only").

Design docs

LLP 0231 (RFC) plus narrow decisions 0232-0235, with forward-refs added to 0016, 0044, 0045, 0114, 0116 and 0206. The live-testing pivot added LLP 0236 (research: the split trust stores) and decisions 0237-0239 (keychain trust, the long-lived full-provider CA, NODE_USE_SYSTEM_CA via launchd).

This supersedes settled decisions rather than extending them, and the RFC argues each: LLP 0044 said "the gateway records only traffic a client actually routes to it", and LLP 0016/0116 said the gateway holds no secret-bearing code. LLP 0114's #interception-accepted threat model is revisited by name, and its conclusion holds.

Testing

  • At head 7a3bee2: 4165/4168 pass (2 skipped); npm run typecheck clean; npm run build:types clean.
  • The one failure (a query whose heap growth exceeds the execution budget...) is GC-sensitive under full-suite load and passes in isolation with and without this branch's changes.
  • Smokes green: claude_attach_detach, client_attach_idempotent, client_attach_on_join, gateway_claude_capture, gateway_codex_capture, daemon_foreground_start_stop, status_diagnostics, hypignore_capture_drop. (walkthrough_picker_to_first_query was already red on master.)
  • New coverage: pure-JS X.509 minting incl. a real TLS handshake and a permitted subtree violation negative, the CA lifecycle, the CONNECT front door (terminate, blind tunnel, mid-tunnel reset, shutdown), proxy-mode recording gates, keep-alive across a reused tunnel, and proxy attach/detach incl. mode migration in both directions.

Not done

upstream_proxy corporate-proxy chaining has unit coverage but no real enterprise-proxy test, and it currently accepts an https: proxy URL it would dial in cleartext (review round 2 finding B — follow-up). hyp status trust-state surfacing has helpers but is unwired, and a future CA rotation re-prompts and strands the prior same-name keychain entry (noted in the run G comment).

Decided, deferred to its own PR: proxy mode becomes the default for new Claude Code installs, possibly retiring base-URL attach for Claude entirely. That touches LLP 0044's consent model and the LLP 0100 first-sync privacy review, so it gets its own decision doc after this merges.

🤖 Generated with Claude Code

…-0235)
Claude Code disables Remote Control whenever `ANTHROPIC_BASE_URL` points
anywhere other than api.anthropic.com. Attach repoints exactly that key, so
attaching a machine costs the user Remote Control. It is a deliberate
client-side gate, not a gateway bug, so no amount of improving the gateway
reaches it.
Proxy mode routes Claude Code through the gateway with `HTTPS_PROXY` plus a
machine-local CA instead, leaving the base URL alone. The endpoint is then
genuinely first-party, which also makes `ENABLE_TOOL_SEARCH` and the
undocumented `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` unnecessary rather than
merely unset, retiring the duty to re-verify an undocumented flag every release.
Off unless `proxy_mode = true` is configured. Codex is unaffected and stays on
base-URL attach, so both mechanisms coexist on one listener and one port.
The aperture does not widen. A proxy sees all client egress, so two narrowings
keep what is captured identical to today:
- Only hosts a registered upstream names are decrypted; every other host is
blind-tunnelled and never read.
- Only paths an adapter's preset claims are recorded. Reusing the routing
matcher would have been wrong: it accepts an `sk-ant-` bearer alone, which
under a CONNECT is true of every request to the host, and a synthetic POST to
an unrelated path was measured projecting 2 stored rows through that hole.
Certificates are minted in-process (hand-rolled DER plus node:crypto): macOS
ships LibreSSL rather than OpenSSL, Windows ships neither, and a certificate
library is a large dependency for one CA and one leaf per host. The CA is
name-constrained to the intercepted hosts (IPv4 and IPv6 excluded, or a leaked
key would still vouch for an IP), trusted only by the attached client via its
own settings file, never the system store, and deleted on detach. It lives in
core rather than the gateway plugin because detach and uninstall must remove it
with the plugin unloaded.
Two failure modes get explicit answers, because a dead proxy breaks all of
Claude Code's HTTPS rather than only its capture: attach refuses unless a CA
proves proxy mode is actually running, and a listener that cannot intercept but
may still have a client pointed at it serves blind tunnels so egress degrades to
unrecorded-but-working.
Supersedes rather than extends: LLP 0044 said the gateway records only traffic a
client routes to it, and LLP 0016/0116 said it holds no secret-bearing code.
Both are narrowed and argued in LLP 0231, with forward-refs on the affected docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@philcunliffephilcunliffe added neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) labels Aug 14, 2026
…rash on a mistyped upstream_proxy (#782)
Four defects found reviewing the proxy-mode feature against LLP 0231-0235.
1. An empty routing table stranded a proxy-attached client's entire network.
`launchListener` returned before the front door was ever considered, so a
machine whose upstreams went away stopped binding at all - and a client
attached in proxy mode has `HTTPS_PROXY` pointing at that port for ALL of
its egress. LLP 0233#degrade-to-blind-tunnels already names the rule (a CA
on disk means bind and blind-tunnel); only this route into it was missing.
`startProxy` now accepts an empty routing table when, and only when, it is
tunnel-only.
2. The damaged-marker detach branch reversed `HTTPS_PROXY` but left the CA.
That branch already handles proxy markers, so it was leaving trusted
signing key material behind on exactly the path where a user has least
evidence anything was missed. The removal is now a shared helper both JSON
branches call, with the same homeDir scoping.
3. A mistyped credential in `upstream_proxy` took the gateway down.
`URL` leaves an invalid percent-escape in place and `decodeURIComponent`
throws `URIError` on it, which propagated out of `compileConfig` and
aborted the source start: the one outcome that compiler's contract exists
to prevent. It now compiles to `undefined`, and the source reports an
unusable `upstream_proxy` rather than silently connecting direct.
4. `upstream_proxy` had no test coverage at all, despite LLP 0231 recording
that it has unit coverage. Added: URL compilation (including credentials
and every malformed form), a real CONNECT hop through a corporate proxy
carrying `Proxy-Authorization`, and a refusing proxy surfacing as 502.
Also adds `@ref` annotations to `connect.js`, `tls/ca.js` and `tls/x509.js`,
which carried the rationale as prose but nothing `/ref-check` or `/ref-story`
could follow, and unglues a JSDoc block from the line above it in types.d.ts.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: approve with fixes pushed, one design question left for you

Reviewed manually against LLP 0231-0235 (the code-review skill is not invocable in this environment and codex is not installed, so this is a hand review, not a tool run). I read the five LLPs first and judged the code against them; the docs are unusually good and the implementation matches them closely. Four defects, all now fixed on the branch as 5d3e7eb, plus four things I am leaving to you.

npm test 4068 pass / 0 fail (1 skipped), npm run typecheck clean, in a fresh npm install worktree.

LLP numbering: clear

0231-0235 are free everywhere. Checked origin/master, all 12 remote branches, and llp/tombstones/. Highest allocated elsewhere: 0226 (master), 0228 (fix/issue-742), 0229 (fix/issue-544), 0230 (fix/issue-614). None of the five collide with each other. 0227 is an unused gap, which is harmless.


Fixed (5d3e7eb)

1. HIGH: an empty routing table stranded a proxy-attached client's whole network

hypaware-core/plugins-workspace/ai-gateway/src/source.js:212 (pre-fix)

launchListener returned undefined on upstreams.length === 0beforeprepareInterception ran, so the listener never bound. LLP 0233#degrade-to-blind-tunnels covers exactly this state ("Whenever interception is unavailable but a CA is on disk ... the front door is installed in blind-tunnel-only mode"), and both routes into tunnelOnly were wired up, but this one short-circuited past them.

Failure: a machine attached in proxy mode has HTTPS_PROXY pointing at 18521 for all egress. The routing table can go empty from an ordinary config edit, an adapter that stops registering its preset, or every upstream being dropped at compile for a missing base_url. In any of those the daemon reports itself started-and-idle while Claude Code loses authentication and updates, not just capture. This is the single failure LLP 0231 says the feature is not allowed to have.

Fix: probe for a CA before idling, and bind for blind tunnels when one is present (aigw.idle_serves_tunnels, warn). startProxy now accepts an empty routing table when, and only when, tunnelOnly is set. Covered by an empty routing table with a CA installed still serves blind tunnels, and by a guard test that the no-CA case still idles per LLP 0195.

2. MEDIUM: the damaged-marker detach branch left the CA behind

src/core/config/client_detach_disk.js:550

detachLegacyJsonMarker is reached whenever marker.managed is not a plain object, and this PR deliberately taught it about proxy markers (mode is in POST_LEGACY_MARKER_FIELDS, and reverseLegacyProxyKeys reverses both proxy keys). But deleteLocalCa was only called on the record-driven branch, and the legacy branch was not even given env/homeDir. So a damaged proxy marker removed the trust pointer and left the signing key on disk, silently, with no warning: precisely the residue LLP 0235#detach-removes-the-ca calls the worst this feature can leave, on the path where the user has least evidence anything was missed.

Fix: removeProxyModeCa() shared by both JSON branches, same homeDir scoping. Three tests, including the homeDir-vs-ambient decoy and a negative (a damaged base-URL marker must not delete a CA).

3. MEDIUM: a mistyped upstream_proxy credential took the gateway down

hypaware-core/plugins-workspace/ai-gateway/src/config.js:83

new URL() leaves an invalid percent-escape in username/password untouched, and decodeURIComponent throws URIError on it. compileUpstreamProxy('http://user:p%zz@proxy.corp:8080') therefore threw straight out of compileConfig, aborting the source start, which is the exact outcome the function's own docstring promises it prevents ("A malformed value compiles to undefined rather than throwing").

Fix: guarded decode returns undefined. Also added the other half of that contract, which was missing: the source now logs aigw.upstream_proxy_invalid when upstream_proxy is set but unusable. Without it a customer whose required egress proxy was mistyped got a gateway that silently connected direct and failed every upstream call with no stated cause. The warning names no value, since that field is the one that carries proxy credentials.

4. MEDIUM: upstream_proxy had zero test coverage

LLP 0231 records under Open questions that corporate proxy chaining "has unit coverage and no field evidence". It had none at all: grep -r 'upstream_proxy\|createChainedAgent\|compileUpstreamProxy' test/ matched exactly one line, an assertion on a warning string. Neither the URL compiler nor the CONNECT hop was exercised, which is how #3 survived.

Added to test/plugins/ai-gateway-connect-front-door.test.js: URL compilation (host, defaulted port, percent-decoded credentials, and every malformed form), a real CONNECT hop through a stub corporate proxy asserting the relayed authority and the Proxy-Authorization header, and a refusing proxy surfacing to the client as 502 rather than a hang. The LLP's claim is now true rather than aspirational, so I have not edited the doc.

5. LOW: the two largest new files carried no @ref

connect.js (379 lines, the whole of LLP 0233) and src/core/tls/{ca,x509}.js (893 lines, the whole of LLP 0235) had zero @ref annotations. They carry the rationale as prose, and good prose, but nothing /ref-check or /ref-story can follow, while every other file in the PR is well annotated. Added six, on constructs where the doc says something the code and filename do not.

6. LOW: a JSDoc block glued to the line above it

hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts:81 had match?: (input: AiGatewayRouteInput) => boolean /** on one line. Unglued.


Left for you

A. The CONNECT front door is an unauthenticated open forward proxy (preference, but I would want a decision recorded)

connect.js:104 (onConnect makes no check on the peer), config.js:137 (parseListen accepts any host), proxy.js:83 (the front door is installed regardless of bind address).

On the default 127.0.0.1:18521 this sits inside the boundary LLP 0114#interception-accepted already conceded, and I would not raise it. But listen is operator-configurable to 0.0.0.0:18521, and with proxy_mode = true that turns the daemon into an open internet relay: CONNECT any.host:any.port, unauthenticated, with no allowlist. Before this PR a non-loopback bind only exposed reverse-proxying to registered upstreams; now it exposes arbitrary TCP, including into services that trust 127.0.0.1. None of 0231-0235 discusses the bind address.

I did not fix this because LLP 0114 explicitly says not to add hardening here as a drive-by. Options as I see them:

  1. Refuse CONNECT from a non-loopback peer (clientSocket.remoteAddress). Costs nothing for the documented flow, since attach always writes http://127.0.0.1:<port> regardless of the bind host, so a 0.0.0.0 install keeps working for its local client.
  2. Refuse to install the front door at all when the listen host is not loopback, and say so in status.
  3. Record the risk in a new LLP and leave the behaviour, on the same reasoning 0114 used.

Option 1 is what I would pick, but it is your call and it belongs in a doc either way.

B. upstream_proxy accepts https: but connects in cleartext (blocker-ish for anyone who configures it, otherwise inert)

config.js:76 admits https: and defaults its port to 443; connect.js:281/connect.js:288 reach it with net.connect, i.e. plain TCP. A user who copies https://proxy.corp:443 out of their environment gets an opaque failure. Either reject https: with the new aigw.upstream_proxy_invalid warning, or TLS-wrap the hop. I left it alone because both are behaviour choices rather than repairs, and the second is new functionality.

C. Credential-bearing proxy URLs are echoed to stdout (preference)

claude/src/settings.js:352 and claude/src/index.js:509/535 print the displaced HTTPS_PROXY verbatim, and a corporate proxy URL routinely carries user:pass@. Backing it up into prev_env is right and I would not change it (it restores the user's own value into the file it came from). Echoing it to the terminal and into the JSON prev_value is a small leak against the "do not record credentials" rule. Redacting the userinfo in the printed form only would keep the warning just as useful.

D. Routing after termination ignores the port (preference)

proxy.js:156 keys interceptsHost on host and port, exactly as LLP 0234 requires. proxy.js:191matchUpstreamByHost then resolves on hostname alone. Two upstreams sharing a hostname on different ports would route a terminated tunnel to whichever sorts first. Not reachable with any preset shipping today, so noting it rather than pushing a change.

E. Informational

The branch is 13 commits behind master (it predates 0219-0226) but still MERGEABLE, and none of the LLP numbers collide, so this is only worth a rebase if you want the newer decisions in view while reading it.


Nothing here blocks merge once you have looked at A. The design work in 0231-0235 is the strongest part of this change: the aperture argument in 0234 and the permittedSubtrees/excludedSubtrees encoding note in 0235 both caught real holes before they shipped, and the test suite is honest about the negatives (a bearer token on an unmatched path cannot reopen recording, a side channel starts no exchange). The four defects I found were all in the seams between that design and the states it did not walk: an empty routing table, a damaged marker, and a config value nothing exercised.

…f every report, and a terminated tunnel routes on the port it was trusted for (#782)
Two defects from a second review pass over LLP 0231-0235.
1. A displaced `HTTPS_PROXY` was echoed, serialised and logged verbatim.
The value proxy-mode attach takes over is far more likely to be corporate
egress than a leftover, and such a URL routinely carries `user:pass@`.
Attach printed it to stdout, put it in `--json` as `prev_value`, and pushed
the same string into a `client.attach.malformed_block` log record, which an
operator's own sink may ship off the machine; detach then printed and
serialised it again as `restored_value`. Recording credentials is the one
thing none of those surfaces may do.
The userinfo now comes off every copy a human or a sink reads, via a shared
`redactUrlUserinfo` beside the other display sanitisers in core's util. The
copy on the marker stays verbatim, because it is the only backup the undo
has: a test asserts the report is redacted, the marker is not, and detach
still restores the user's own proxy byte for byte. `***@` rather than a bare
strip, so a reader can still tell the value had credentials at all, and the
host and port survive so the notice still names what was displaced.
2. A terminated tunnel resolved its upstream on the hostname alone.
`interceptsHost` keys the trust decision on host AND port, deliberately, so
that terminating `CONNECT host:8443` cannot end up forwarded to 443 (its own
comment says so). `matchUpstreamByHost`, which decides where the decrypted
request then goes, ignored the port, so the check was decoration: with two
upstreams naming one host on different ports - an ordinary `upstreams`
config, though no shipping preset does it - the request went to whichever
entry sorted first, and was recorded under that entry's name and record
anchor. The CONNECT port is now stamped alongside the host and both halves
are matched, so a tunnel is routed to the entry that authorised it. A miss is
impossible in practice, since this is only reached on a tunnel
`interceptsHost` already matched, but the 502 that reports one now names the
port too.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: approve. Round 1's six fixes are real; two more found and fixed; three left for you, only one of which needs a decision

Round 2 of 2, reviewed 5d3e7eb manually (code-review is not invocable in this environment and codex is not installed, so this is a hand review, not a tool run). Fresh npm install worktree: npm test4072 pass / 0 fail / 1 skipped, npm run typecheck and npm run build:types clean. Smokes green: gateway_claude_capture, gateway_codex_capture, claude_attach_detach, client_attach_idempotent, hypignore_capture_drop, status_diagnostics.

Fixes for this round are pushed as c23e21d.


Round 1's fixes: all six verified in the committed tree, none inert

I walked each one rather than reading the diff, because the "plausible fix that is never reached" failure mode is the one that gets past a second pass.

1 (HIGH, empty routing table stranded a proxy-attached client) - real, and tunnelOnly covers every route into the state.launchListener (ai-gateway/src/source.js:243) now probes for a CA before idling and falls through to the bind when one is present. The question that matters is whether startProxy can then be reached with an empty table and tunnelOnly unset, which throws. It cannot: from the empty-table path, prepareInterception has exactly two outcomes, and both return { tunnelOnly: true }. With proxy_mode off, the readLocalCaInfo probe at source.js:434 finds the same CA the caller just found and returns tunnel-only (source.js:443); with proxy_mode on, the host list is derived from upstreams, which is empty, so hosts.length === 0 returns tunnel-only (source.js:463). startProxy's guard (proxy.js:61) is upstreams.length === 0 && !opts.tunnelOnly, so the throw is unreachable from here. reload() re-enters through the same launchListener, so the reload route is covered too. Verified against the tests that assert both halves (bind with a CA, still idle without one).

2 (MEDIUM, damaged-marker detach left the CA) - removeProxyModeCa() is called on both JSON branches, client_detach_disk.js:290 (record-driven) and :631 (legacy), both with env and homeDir threaded through, and the helper resolves the state root from homeDir rather than the ambient one. The negative (a damaged base-URL marker must not delete a CA) is held by marker.mode !== 'proxy'.

3 (MEDIUM, URIError from a mistyped upstream_proxy) - config.js:83 decodes inside a try and returns undefined, and source.js logs aigw.upstream_proxy_invalid naming no value. Contract and warning now match the docstring.

4 (MEDIUM, upstream_proxy untested) - the CONNECT hop through a stub corporate proxy asserts the relayed authority and Proxy-Authorization, and the refusing-proxy case surfaces as 502. LLP 0231's claim is now true.

5 and 6 (LOW) - the six @refs and the unglued JSDoc are present and the anchors they name exist in 0233/0234/0235.


Fixed this round (c23e21d)

1. MEDIUM: a displaced HTTPS_PROXY credential reached stdout, --json, and a log record

claude/src/settings.js:352 and :428 (pre-fix), src/core/config/client_detach_disk.js:247 (pre-fix)

Round 1 raised this as preference C and scoped it to stdout and prev_value. It is wider than that, and the extra sink is the one that matters. Four places carried the value verbatim:

  1. The attach warning (settings.js:352) - printed as ! ..., echoed into JSON warnings, and pushed into logger.warn('client.attach.malformed_block', { detail: warning }) at claude/src/index.js:224. That is a log record, not a terminal line: an operator with a configured sink ships it off the machine. CLAUDE.md is explicit that telemetry must not record credentials.
  2. result.prevValue -> stdout (previous HTTPS_PROXY was ...) and JSON prev_value (index.js:509, :535).
  3. Detach's restoredValue -> Restored ... from both hyp detach (commands/clients.js:1288) and hyp daemon uninstall (commands/daemon.js:295), plus JSON restored_value.
  4. The marker's prev_env.HTTPS_PROXY. This one is correct as-is and is unchanged: it is the only copy the undo has.

A corporate HTTPS_PROXY routinely carries user:pass@, and this feature's own warning text tells the user to copy that same value into upstream_proxy, so the credential-bearing case is the expected one rather than the exotic one.

Fix: a shared redactUrlUserinfo in src/core/util/json_util.js, beside the other display sanitisers, applied to every copy a human or a sink reads. ***@ rather than a bare strip, so the reader can still tell the value had credentials at all; scheme, host, port and path survive, so the notice still names which proxy was displaced. The regex stops at the authority (@, /, ?, # all excluded), so an @ in a path or query is not mistaken for userinfo.

Reversal verified, which was the thing worth checking: the on-disk write still uses the unredacted value, the marker still stores it verbatim, and the new test asserts all three at once - the report is redacted, prev_env.HTTPS_PROXY is not, and detach restores the user's own proxy byte for byte.

2. LOW: a terminated tunnel resolved its upstream on the hostname alone

ai-gateway/src/proxy.js:191 (pre-fix)

Round 1's finding D, which it left on the grounds that no shipping preset reaches it. That is true of presets and not true of config. mergeUpstreams keys on name, so two operator-written upstreams entries naming one host on different ports both compile:

[[ai_gateway.upstreams]]
name = "anthropic"base_url = "https://api.anthropic.com"
[[ai_gateway.upstreams]]
name = "anthropic-alt"base_url = "https://api.anthropic.com:8443"

interceptsHost keys the trust decision on host and port, deliberately, and its own comment says why: terminating CONNECT api.anthropic.com:8443 and then forwarding to 443 sends the request somewhere the client did not ask for. matchUpstreamByHost then decided exactly that destination on the hostname alone, so the port check was decoration. Reproduced against the real compiler:

old resolve for CONNECT :8443 -> anthropic forwards to port 443

The decrypted request goes to the wrong port, and any row it produces is written under the wrong upstream's name, provider and record_prefix.

Fix: the CONNECT port is stamped alongside the host (a second symbol, deliberately, because CONNECT_HOST doubles as the proxy-mode discriminator and folding a port into it would change what an absent value means), and matchUpstreamByHost matches both halves. A miss is impossible in practice - this is only reached on a tunnel interceptsHost already matched, so an exact host+port entry exists by construction - but the 502 that would report one now names the port too. Regression test asserts the two functions agree on all three ports.


Left for you, with blocker-vs-preference

A. The CONNECT front door is an unauthenticated open forward proxy - not a blocker on the shipped default; a production blocker for one config, and it needs your decision

connect.js:132 (onConnect makes no check on the peer), config.js:146 (parseListen accepts any host), proxy.js:83 (the front door is installed regardless of bind address)

Not fixed, and I did not touch it: LLP 0114 #interception-accepted instructs that hardening here is not to be added as a drive-by, and LLP 0235's Consequences revisit that section by name and conclude it still holds. Restating it for triage as round 1 framed it, since nothing about it changed:

  • On the default 127.0.0.1:18521 this is inside the boundary 0114 already conceded. Preference.
  • With listen set to 0.0.0.0:18521andproxy_mode = true, the daemon becomes an unauthenticated internet relay: CONNECT any.host:any.port, no allowlist, including into services that trust 127.0.0.1. Before this PR a non-loopback bind exposed only reverse-proxying to registered upstreams. Production blocker for that configuration, which is reachable from documented config alone and which none of 0231-0235 discusses.

The cheapest answer remains refusing CONNECT from a non-loopback peer: attach always writes http://127.0.0.1:<port> regardless of the bind host, so a 0.0.0.0 install keeps working for its own client. Refusing to install the front door at all on a non-loopback bind, and saying so in status, is the other. Either way it wants a line in a doc, because the current docs are silent on the interaction.

B. upstream_proxy accepts https: but connects in cleartext - preference; blocker only for a user who configures it that way

config.js:76 admits https: and defaults its port to 443; connect.js:309 and :316 reach the proxy with net.connect, i.e. plain TCP. Unchanged from round 1 and still accurate. A user who copies https://proxy.corp:443 out of their environment gets an opaque failure. Rejecting https: through the new aigw.upstream_proxy_invalid warning is a repair; TLS-wrapping the hop is a feature. I left it because the choice between them is yours. Inert for everyone who does not set the field.

C. hyp daemon uninstall can leave the CA behind - preference; not exploitable as it stands

src/core/tls/ca.js:360, src/core/config/client_detach_disk.js:682

New this round. deleteLocalCa has exactly one caller, removeProxyModeCa, which is gated on marker.mode === 'proxy'. Uninstall reaches it only transitively, through detachAllClientsFromDisk. So the CA is removed only when an attached client is carrying a readable proxy marker.

The gap is the documented ordering of the feature itself. The gateway mints the CA at daemon start whenever proxy_mode = true (source.js:467), independent of any attach - it has to, because attach's preflight reads it. So between enabling proxy mode and running hyp attach claude there is a normal, expected window in which a 0600 signing key exists with no marker anywhere. hyp daemon uninstall in that window leaves ~/.hyp/hypaware/tls/ populated. The same holds if the client's settings file was reset by hand first, and on the narrow path where a marker is damaged badly enough to lose mode while NODE_EXTRA_CA_CERTS is left in place with a warning (reverseLegacyProxyKeys).

Why it is a preference and not a blocker: trust is client-scoped, so an orphan CA with no client pointing NODE_EXTRA_CA_CERTS at it is inert key material, not a live risk. Two things are nonetheless off:

  • deleteLocalCa's own docstring says "Called by detach and by daemon uninstall", which is only true through the client sweep. The comment overstates what the code guarantees.
  • LLP 0235 settles this as "Detach deletes the CA whenever a proxy marker is reversed", so closing it properly is a design change, not a repair. I did not touch it: 0235 is settled, and CLAUDE.md says to extend rather than edit. If you want it closed, the shape is an unconditional deleteLocalCa after the sweep in commands/daemon.js, which is idempotent and safe because uninstall has just detached every client - but it needs a line in a new doc, not a silent addition.

D. Round 1's fix 1 retired an invariant status.js still documents - preference, observability only

src/core/daemon/status.js:160, :171, :196

gatewayDroppedUpstreams derives idle from details.listening === false, and its docstring states the reasoning outright: "Every entry dropped. The routing table is empty, so the source binds no listener at all (listening: false)", and "the two are mutually exclusive by construction". Fix 1 created a third state that comment does not model - bound, with zero routes - so in proxy mode a total loss of every upstream now reports kind: gateway_upstreams_dropped instead of gateway_idle_no_upstreams.

The message stays substantively true (covered is necessarily empty in that state, so the text lands on the "nothing is proxied or captured" branch) and the diagnostic still fires, so nothing is hidden from a human. What changes is the kind a scripted consumer gates on, and the docstring is now stale guidance about a guarantee the code no longer makes. Worth a comment update at least; whether the classifier should grow the third state is your call.

Secondary consequence of the same fix, same severity: in the tunnel-only state state.listen is set, so localEndpoint() returns a URL instead of throwing, and hyp attach codex will now succeed against a gateway whose routing table is empty (every request 404s) where it previously refused. Narrow - it needs a CA on disk and an empty table - but it is a refusal turning into a silent misconfiguration.

E. The branch now conflicts with master, and it is my doing

13 commits behind, unchanged from round 1, but no longer auto-mergeable. git merge-tree reports exactly one conflicted file, test/core/util-json-util.test.js; src/core/util/index.js and src/core/util/json_util.js both auto-merge. Both sides are pure appends plus one import line, so the resolution is "take both".

The collision is worth a glance rather than a blind resolve: master's LLP 0225 added escapeForDisplay to the same module under a "one vocabulary" argument for display sanitisation, and redactUrlUserinfo is the same family. After the merge it is worth deciding whether they should be described together.


Nothing here blocks merge except your answer on A, which is where it was after round 1. The two defects this round were both in the same seam as round 1's: a value that is correct where it is stored and wrong where it is displayed, and two functions that LLP 0234 requires to agree, agreeing on only half the key.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Closing: not ready. Live testing on a real machine found a blocker that the
test suite could not have caught.

What works. Tested against real api.anthropic.com traffic with an
isolated HYP_HOME: CA minting and 0600 key permissions, correctly scoped
interception (only api.anthropic.com is decrypted; other hosts keep their
real certificates through blind tunnels), capture parity (326 rows, SSE
included), side-channel privacy (no rows for the non-/v1/messages paths
Claude Code hits on the same host), clean detach including CA deletion, and
the degradation guard (proxy off with a stale CA keeps egress alive).

What does not. Remote Control - the entire point of this change - is only
half working. Its inbound channel is an SSE stream on the intercepted host:

GET https://api.anthropic.com/v1/code/sessions/<id>/worker/events/stream
POST https://api.anthropic.com/v1/code/sessions/<id>/worker/events

The POST succeeds, so the chat renders on the phone. The SSE stream fails with
unable to verify the first certificate and retries forever, so messages sent
from the phone never arrive. Detaching restores it immediately.

Root cause: certificate trust is split inside one process. In the same run,
with the same environment, /v1/messages succeeded (capture worked) while
SSETransport failed 7x on certificate verification. NODE_EXTRA_CA_CERTS is
honoured by Claude Code's main API client but not by the transport behind
Remote Control. Claude Code 2.1.233 is a Bun 1.4.0 binary and references
NODE_EXTRA_CA_CERTS 55 times, so this is two HTTP clients with different
trust stores, not a missing feature.

This invalidates the premise in LLP 0231: setting NODE_EXTRA_CA_CERTS does
not make Claude Code trust the local CA, it makes part of Claude Code trust
it. Any Bun-native-fetch transport breaks the same silent way; Remote Control
is simply the first one found.

Why the obvious fixes are closed.

  • NO_PROXY cannot help: the SSE endpoint is on the one host we must decrypt
    to capture anything. Verified - exempting claude.ai and
    platform.claude.com failed identically.
  • Path-based exemption is impossible: the failure is at the TLS handshake,
    before a request path exists. CONNECT names only a host.
  • That leaves either getting that transport to trust the CA (not in our
    control) or installing the CA in the system trust store, which is the
    consent escalation LLP 0235 deliberately avoided.

Ruled out by experiment, recorded so nobody re-derives them: WebSocket upgrade
stripping (a raw handshake through the proxy returns a normal 405, same as
control) and the ALPN http/1.1-only downgrade (the failure precedes ALPN).

Branch feat/proxy-mode-capture is kept. The implementation and LLP 0231-0235
stay as the record; reopening needs a new decision doc, since the honest
options are now narrower than when those were written.

@philcunliffephilcunliffe removed neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) labels Aug 15, 2026
…LLP 0236-0239)
PR #782 closed because proxy mode silently broke Remote Control's inbound
channel: Claude Code's SSE transport verifies TLS against Bun's default
store, which NODE_EXTRA_CA_CERTS never reaches (LLP 0236, proven by live
runs A-F). This lands the fix the experiments converged on:
- Attach installs the CA as a user-domain trusted root in the login
keychain - no sudo, macOS's own password dialog is the consent step -
and degrades to a warning if refused (LLP 0237).
- The CA becomes a ten-year credential constrained to the full static
provider set (api.anthropic.com, api.openai.com, chatgpt.com), so one
trust grant covers Codex later; detach keeps the CA and its trust,
uninstall and the new `hyp detach --purge` remove them (LLP 0238).
- NODE_USE_SYSTEM_CA=1 is delivered via `launchctl setenv` plus a
login-time LaunchAgent, because no config file reaches Bun's boot-time
trust store - settings.json delivery was proven too late (LLP 0239).
Status reports intercept_hosts and the wider ca_permitted_hosts
separately, so the trust grant stays inspectable. Also converts the LLP
0232-0235 anchor tags to headings so ref-check resolves the branch's
existing @refs (16 broken refs repaired, none added).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Reopening with the fix for the close reason.

The close comment's root cause (Remote Control's inbound SSE stream verifies TLS against Bun's default store, which NODE_EXTRA_CA_CERTS never reaches) is now addressed by commit dc9f9fe, designed in LLP 0236 (research) + 0237-0239 (decisions):

  • Attach installs the CA as a user-domain trusted root in the login keychain - no sudo, macOS's native password dialog is the consent step, refusal degrades to a warning (capture still works). Idempotent via a read-only security verify-cert probe.
  • The CA is now a ten-year credential constrained to the full provider set (api.anthropic.com, api.openai.com, chatgpt.com), so one trust dialog covers Codex proxy capture later. Detach keeps the CA and trust; hyp daemon uninstall and the new hyp detach --purge remove them.
  • NODE_USE_SYSTEM_CA=1 is delivered via launchctl setenv + a login-time LaunchAgent, because settings.json env was proven (run E) to arrive after Bun fixes its boot-time trust store, and CLAUDE_CODE_CERT_STORE=system was proven (run F) not to govern the SSE path.

The full working configuration was verified live end to end on the real Claude Code binary (runs C and D: SSETransport: Connected, phone messages arriving, capture recording in the same window). The new attach-flow code path itself still needs one manual acceptance run, since agents are classifier-blocked from security add-trusted-cert.

🤖 Generated with Claude Code

philcunliffeand others added 2 commits August 14, 2026 22:57
The two old tests asserted the damaged branch deletes the CA, which
dc9f9fe's lifecycle change (LLP 0238/0239: CA and trust survive detach,
launchd env is released) made red on the pushed head. Replaces them with
the working-tree version that asserts the new lifecycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ow (run G)
The run G acceptance test proved launchctl setenv never reaches new
windows of an already-running terminal app: windows inherit the app
process's pre-setenv environment, and terminal apps are single-process.
The old mid-attach line ("already-open terminals need a new window")
therefore pointed users at a step that cannot work, invisible to the
launchctl getenv check.
Attach now ends with a conditional final notice (proxy mode, launchd env
set, trust not refused): quit the terminal app completely and reopen it.
JSON output gains launchd_env_set for scripted callers. LLP 0239 gets a
provenance-tagged correction; the decision itself is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Run G acceptance result: PASS (2026-08-14, macOS, real binary)

The automated attach flow now delivers end to end what runs C-F proved by hand (isolated HYP_HOME, real api.anthropic.com, Claude Code 2.1.233):

  • Attach: CA minted with ten-year validity and the full 3-host name-constraint set; the native macOS password dialog appeared exactly once (login keychain, user domain, no sudo); launchctl env + LaunchAgent + settings keys all verified.
  • Idempotency: re-attach showed no dialog (the verify-cert probe short-circuits).
  • The live test: Remote Control inbound worked while capture recorded - 0 unable to verify errors, SSETransport: Connected, phone messages arrived, 110+ rows captured in the same window.
  • Detach lifecycle: CA + keychain trust kept, launchd var + LaunchAgent released, settings restored byte-identical; re-attach after detach was silent (no dialog). This is the once-per-machine grant working as designed (LLP 0238/0239).

One real finding, fixed in 4bb6400: launchctl setenv never reaches new windows of an already-running terminal app - terminal apps are single-process, and windows inherit the app's pre-setenv environment. The old message ("already-open terminals need a new window") pointed users at a step that cannot work, and launchctl getenv cannot detect the trap (it reads launchd's table, not the shell env). Attach now ends with a conditional notice to fully quit and reopen the terminal app; LLP 0239 carries a provenance-tagged correction; JSON output gained launchd_env_set. During diagnosis the constrained CA was verified against OpenSSL, Node 22, Bun 1.3.6, and standalone Bun 1.4.0-canary (user-domain trust alone) - the CA design itself is sound.

fe8de94 additionally fixes the two damaged-marker detach tests that were red on dc9f9fe (they asserted the pre-lifecycle delete-the-CA behavior).

Known follow-ups, not blockers: hyp status trust-state surfacing (helpers exist, unwired); on a future CA rotation (host-list widening or the 10-year renewal) attach re-prompts and strands the prior same-name keychain entry, since only detach --purge removes trust.

🤖 Generated with Claude Code

@philcunliffephilcunliffe added the neutral:adopt Foreign PR adopted into neutral's reconcile scope label Aug 15, 2026
Conflict only in test/core/util-json-util.test.js, resolved keep-both:
this branch's redactUrlUserinfo tests plus master's LLP 0225
sanitizeLabel/escapeForDisplay tests. The 15 local test failures after
the merge (parquet pushdown area) reproduce identically on clean
origin/master in this environment and are unrelated to the merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffephilcunliffe added neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human labels Aug 15, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

What neutral was doing. Triage for PR #782 at head 3d64df2: classifying the residual findings from review round 2 (which ran at 5d3e7eb) and deciding whether the PR can carry a triage clearance.

Why this is stuck rather than cleared. Two independent reasons, either one sufficient.

1. A substantial, security-critical body of work arrived after both review rounds and has never been reviewed.

Both neutral review rounds ran at older heads (536886d, 5d3e7eb). Since then the branch gained roughly 1,600 net new lines, dominated by dc9f9fe "Keychain trust + launchd env make Remote Control survive proxy mode (LLP 0236-0239)". That commit is not a touch-up; it changes the trust model the reviewed rounds signed off on:

  • The interception CA becomes a ten-year, per-machine credential installed into the macOS login keychain as a user-domain trusted root via security add-trusted-cert (LLP 0237, src/core/tls/darwin_trust.js). The PR body's "trust is client-scoped via NODE_EXTRA_CA_CERTS ... the system trust store is never touched" no longer describes the shipped macOS behaviour.
  • CA name constraints widen from the configured upstream hosts to the full static provider list (INTERCEPT_PROVIDER_HOSTS union, hypaware-core/plugins-workspace/ai-gateway/src/source.js:475-481, LLP 0238).
  • Detach now keeps the CA and its keychain trust; only uninstall or hyp detach --purge removes them (LLP 0238, src/core/commands/clients.js:1260-1265).
  • Persistent machine state: launchctl setenv plus a login LaunchAgent that re-applies NODE_USE_SYSTEM_CA=1 (LLP 0239, src/core/daemon/launchd_env.js).

Clearing the PR on two stale review rounds would present this work as reviewed when it is not. What would need reviewing: dc9f9fe in full (src/core/tls/darwin_trust.js, src/core/tls/ca.js, src/core/daemon/launchd_env.js, src/core/commands/clients.js, src/core/config/client_detach_disk.js, hypaware-core/plugins-workspace/claude/src/index.js and settings.js, LLP 0236-0239), plus the follow-ups fe8de94 and 4bb6400.

2. Unresolved blocker from round 2: the CONNECT front door is an unauthenticated open forward proxy.

Still true at head; none of the post-review commits touched the bind or auth path.

  • hypaware-core/plugins-workspace/ai-gateway/src/connect.js:132-155: onConnect accepts any CONNECT with no authentication and blind-tunnels every non-intercepted host (tunnel(...) call at line 154, implementation at line 223).
  • hypaware-core/plugins-workspace/ai-gateway/src/config.js:28-29: listen accepts any operator string, so listen = "0.0.0.0:18521" with proxy_mode = true is reachable from documented config with no refusal or warning, and is undiscussed in LLP 0231-0239.

On the loopback default this is a preference. On a non-loopback bind it is an open relay that anyone on the network can tunnel arbitrary TCP through, a production security hole. Neutral deliberately did not harden it drive-by, because LLP 0114 #interception-accepted reserves that threat-model call for a settled decision.

Non-blockers (would ride in a follow-up issue if this were otherwise clearable):

  • upstream_proxy accepts an https: URL (hypaware-core/plugins-workspace/ai-gateway/src/config.js:76) but openUpstream dials it cleartext (connect.js:316, net.connect), so an https corporate proxy fails opaquely.
  • src/core/daemon/status.js (invariant prose around lines 150-180, reported at line 875) still documents "no upstreams means no listener", but proxy mode introduced a bound-with-zero-routes third state, so total upstream loss in proxy mode reports gateway_upstreams_dropped instead of gateway_idle_no_upstreams, and localEndpoint() returns a URL in that state.

Verified healthy at head:

  • Round 2's fixes both survived the later commits and the master merge: credential redaction (redactUrlUserinfo in src/core/util/json_util.js:320, used by hypaware-core/plugins-workspace/claude/src/settings.js and src/core/config/client_detach_disk.js) and the port-blind matchUpstreamByHost (now host+port with CONNECT_PORT, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js).
  • The round-2 preference about uninstall leaving the CA was addressed by dc9f9fe: the uninstall sweep now purges the CA and keychain trust unconditionally (src/core/commands/clients.js:1264).
  • LLP numbering 0231-0239 is collision-free against origin/master (which tops out at 0226), every remote branch (siblings hold 0228, 0229, 0230, 0240) and llp/tombstones/; the four new docs are distinct from each other.
  • CI is green at head (test 22/24, typecheck 22/24, duplicate-numbers).
  • The master merge 3d64df2 has exactly one manual conflict resolution (test/core/util-json-util.test.js), a correct keep-both; no other divergence from the automatic merge.

Decision needed from the maintainer:

  1. Settle the non-loopback + proxy_mode stance for finding 2: refuse to bind non-loopback in proxy mode, require CONNECT authentication, or accept and document the exposure in an LLP. Any of the three resolves the blocker.
  2. For finding 1, either request a fresh neutral review round at the current head covering dc9f9fe..3d64df2, or merge on your own judgement, explicitly accepting that the keychain/launchd trust work carries no neutral review. Both are legitimate calls; a triage clearance built on the stale rounds is not.

How to unstick: make the two calls above, then remove neutral:stuck; triage will re-run at the then-current head.

A CONNECT whose peer is not the machine itself is refused 403 before the
target is parsed, blind tunnels included. The check is on the peer, not
the bind: a non-loopback listen previously exposed only reverse-proxying
to registered upstreams, and the front door made it an unauthenticated
open relay to any host and port. Attach always writes
http://127.0.0.1:<port> whatever the bind host, so a 0.0.0.0 install
keeps working for its own client and loses nothing.
Decision recorded in LLP 0233 "Loopback peers only" (review round 2,
finding A, option 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Both calls made, unsticking.

Finding 2 (open forward proxy): Option 1, fixed in 7a3bee2. The CONNECT front door now refuses any peer that is not loopback (127.0.0.0/8, ::1, and their IPv4-mapped forms) with a 403 and a warn log (aigw.connect_refused_remote_peer), before the target is parsed. Blind tunnels are refused on the same rule as terminated ones — an unrecorded relay is still a relay. The check is on the peer, not the bind, so a 0.0.0.0 install keeps working unchanged for its own client: attach always writes http://127.0.0.1:<port> whatever the bind host says. Decision recorded in LLP 0233 ("Loopback peers only"), with @refs in the code and two tests (a matrix on the loopback classifier, and a LAN-peer CONNECT asserting the 403, the warning, and that nothing reaches the HTTP server).

Verified: front-door tests 13/13, npm run typecheck clean, full suite 4165/4168 (2 skipped; the 1 failure is the GC-sensitive query-sql-budget heap test, which passes in isolation with and without this change and fails only under full-suite load).

Finding 1 (unreviewed keychain/launchd work): fresh neutral review round requested at the current head, covering dc9f9fe..7a3bee2 — the keychain trust + launchd env work (LLP 0236-0239), its follow-ups fe8de94 and 4bb6400, and the loopback fix above.

Separately decided, deliberately out of scope for this PR: proxy mode will become the default for new Claude Code installs, possibly retiring base-URL attach for Claude entirely. That touches LLP 0044's consent model and the LLP 0100 first-sync privacy review, so it gets its own decision doc and PR after this merges.

Removing neutral:stuck per the triage instructions.

🤖 Generated with Claude Code

@philcunliffephilcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 15, 2026
…#782)
The launchd delivery of NODE_USE_SYSTEM_CA=1 was reversed only by the
marker-driven detach branch, which needs a settings file with an intact
`mode: proxy` marker to read. A user who deleted ~/.claude/settings.json
by hand, or whose marker was damaged past its `mode` field, ran
`hyp daemon uninstall` and got the CA and its keychain trust removed while
`NODE_USE_SYSTEM_CA=1` and its login LaunchAgent stayed behind, re-applied
at every login on a machine HypAware was no longer installed on.
The purge sweep that already ends the trust grant now releases the launchd
environment too. Idempotent, so the ordinary path that already released it
via the marker is unchanged, and best-effort like its neighbours: a
launchctl hiccup becomes a line, never a failed uninstall.
LLP 0239's Consequences already say the undo reverses the launchd pieces
"on detach and uninstall"; this makes the code match.
Review finding, neutral review round 3.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: findings, none blocking

Round 3, requested by the maintainer. Scope reviewed as a first review of
dc9f9fe..7a3bee2 (keychain trust + launchd env, LLP 0236-0239; follow-ups
fe8de94 and 4bb6400; the loopback fix 7a3bee2), not as a delta.
One finding is fixed and pushed as 94f687a, which is the head this marker
names; the other three are judgement calls I have deliberately left for a
human, with options.

Proxy mode becoming the default for new installs is out of scope and was not
reviewed. codex is not installed and /code-review is not invocable here,
so this was a manual read plus targeted execution.


1. The trust model

Key protection: good, verified by execution

Exercised on Linux against the real ensureLocalCa:

~/.hyp/hypaware/tls 0700 uid = the invoking user
~/.hyp/hypaware/tls/ca-key.pem 0600
~/.hyp/hypaware/tls/ca-cert.pem 0644

atomicWriteFile passes mode to fs.open(tmp, 'w', mode) before the rename
(src/core/util/fs_atomic.js:88), so the 0600 is on the file from creation,
not applied after a window of exposure. No other local user can read the key;
a same-user process can, which LLP 0235's blast-radius argument already covers
and LLP 0238 restates. The key path is never logged, never in an export, and
nothing in the repo sweeps the state root into a support bundle.

Worth stating plainly because it is the part that actually changed: the
store scope widened, not just the host count. Before, the key was trusted by
one client via a file-scoped NODE_EXTRA_CA_CERTS; now it is trusted by every
Node and Bun process in the login session (keychain root + session-wide
NODE_USE_SYSTEM_CA=1). LLP 0237's Consequences and LLP 0239's
"Session-wide scope accepted" each cover half of that; neither says it as one
sentence. Not a finding, but it is the sentence a future reader will want.

Name constraints: really present, really enforced, and narrower than the docs claim

Present and correct, verified rather than read. The minted CA carries:

X509v3 Name Constraints: critical
Permitted: DNS:api.anthropic.com, DNS:api.openai.com, DNS:chatgpt.com
Excluded: IP:0.0.0.0/0.0.0.0, IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0
X509v3 Basic Constraints: critical CA:TRUE, pathlen:0
X509v3 Key Usage: critical Certificate Sign, CRL Sign

Enforcement, against openssl verify -CAfile:

leafresult
SAN api.anthropic.comOK
SAN evil.compermitted subtree violation
no SAN, CN=evil.compermitted subtree violation

So the legacy-CN case is caught too, and the IPv4/IPv6 exclusion closes the
by-IP hole. As a TLS-server-impersonation boundary the constraints do exactly
what LLP 0235 and LLP 0238 claim. See finding F1 for the one place they do
not.

Lifecycle: no rotation path, and a re-mint fails silently

See finding F3. Ten years is defensible for a root (roots are not subject
to leaf lifetime limits, and the re-mint cost here is a password dialog), and
CA_RENEW_WITHIN_DAYS = 45 still rolls it. There is no revocation path, which
is fine given the removal path is "delete the trust entry" rather than a CRL.
The gap is that LLP 0238 requires renewal to be "surfaced as a re-trust
prompt, not a silent swap" and nothing implements that, and re-mint is
reachable today rather than in 2036.

Second machine: per-machine CA, per-machine grant, nothing shared. Lost key:
next boot regenerates, which lands in F3. Detach keeping the CA is the
documented intent (LLP 0238#ca-survives-detach), it is discoverable via
ca_permitted_hosts in hyp status's source details, and hyp detach --purge removes it, so the "trusts a root they no longer use" case is
answered.

Uninstall: one real residue path, now fixed

hyp daemon uninstall reaches purgeProxyTrustResidue, which removes the CA
files and the keychain entry unconditionally, so the earlier round's
CA-left-behind finding is genuinely closed. But it did not touch the
launchd delivery, which was reversed only by the marker-driven detach branch.
That is finding F4, fixed in 94f687a.

launchctl setenv scope

Session-wide and accepted in LLP 0239 with reasoning I agree with: the
variable only adds the OS keychain to Node's and Bun's default stores, which
is standard in managed environments, and no per-process delivery with this
reliability exists. Reverted on detach (marker branch) and, after 94f687a,
on uninstall and --purge as well. The residual honesty gap is that nothing
reports whether it is set, despite LLP 0239 saying hyp status does: F3.


2. The loopback fix (7a3bee2): correct

hypaware-core/plugins-workspace/ai-gateway/src/connect.js:62-76

  • 127.0.0.0/8, not just 127.0.0.1: the regex is ^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$, so 127.8.9.10 passes. Correct.
  • ::1 and ::ffff:127.0.0.1: both accepted, the mapped prefix stripped at the right offset.
  • Fails closed on everything else, including undefined (destroyed socket), '', uppercase ::FFFF: (Node lowercases), the expanded 0:0:0:0:0:0:0:1, the hex mapped form ::ffff:7f00:1, 127.1 shorthand, and zone-suffixed addresses. I could not find a representation that is both producible by net.Socket#remoteAddress and wrongly classified.
  • Two shapes the regex over-accepts, neither reachable: 127.0.0.999 (invalid octet) and ::ffff:127.0.0.999. remoteAddress cannot produce either, so this is a note, not a finding, and tightening it would add risk for no gain.
  • Refusal precedes the parse: the peer check sits above parseAuthority(req.url) and above track(), so a refused CONNECT never has its target parsed and never enters the tracked-socket set.
  • Blind tunnels take the same path: the check is at the top of onConnect, above the shouldIntercept branch, so tunnel() and terminate() are both behind it. The test asserts rig.seen.length === 0, proving nothing reached the HTTP server.
  • 0.0.0.0 bind still works: confirmed, and not only from the attach output. hypaware-core/plugins-workspace/claude/src/settings.js:373 hardcodes managedEnv.HTTPS_PROXY = `http://127.0.0.1:${port}` with no reference to the configured bind host, so the client's peer address is loopback whatever listen_host says.
  • Warn log: { peer: <address> } only. No credential, no token, no target (the target has not been parsed yet). Clean.

Moving the clientSocket.on('error') listener above the refusal is a strict
improvement: a refused socket now has an error handler too.


3. fe8de94 and 4bb6400: both good

fe8de94 correctly retires the two tests that asserted the old
delete-the-CA-on-detach lifecycle and replaces them with one that asserts the
new one, including the exact launchctl unsetenv call. Deleting the
homeDir-not-ambient decoy test is right: it was testing a deletion that no
longer happens on that branch, and the surviving record-driven test still
pins the homeDir rule.

4bb6400 is a genuine correction, not a copy tweak: "open a new window" was
advice that cannot work, and the run G provenance note on LLP 0239 is the
right way to record it against an Accepted doc (a dated correction to a
factual claim, with the decision itself left intact). Gating the notice on
mode === proxy && launchdEnvSet && trust !== 'refused' is the correct
condition. Adding launchd_env_set to the JSON output is a good call.


4. Conventions and refs

  • No em dashes anywhere in the changed files, code or LLP prose. Checked every file in the range.
  • No semicolons in the new modules.
  • darwin_trust.js and launchd_env.js both use @import with repo-root-anchored .js specifiers (../../../src/core/tls/types.js), no @typedef, no inline import('...') types. Shared types are interfaces in src/core/tls/types.d.ts. Correct.
  • @refs: 638 refs across the changed files validated against the LLP corpus. Every LLP 0236-0239 anchor resolves to a real heading, and dc9f9fe's conversion of the 0232-0235 anchor tags to headings does repair the previously dangling ones. Seven apparent misses were false positives in my slug generator (headings containing & and /); all seven headings exist.
  • @ref honesty: the two refs I checked hardest are honest. ca.js:398@ref LLP 0238#ca-survives-detach [constrained-by]: routine detach must not call this on deleteLocalCa is exactly the kind of ref that earns its place. The one that is now stale in spirit is LLP 0239#launchctl-setenv's claim about hyp status: see F3.
  • Tests for the new behaviour: test/core/tls-darwin-trust.test.js and test/core/launchd-env.test.js pin the exact argv shape of every security and launchctl invocation, the CN round-trip against the minted subject, the cancelled-dialog path, and idempotence. test/plugins/ai-gateway-connect-front-door.test.js covers the classifier table and a real socket whose remoteAddress is overridden to a LAN address. Good coverage for code that cannot be executed in CI.
  • No secrets in logs anywhere in the range.

Findings

F1. The keychain grant is all-policy; the name constraints only bound TLS server identity

Medium. Preference / needs a human decision. Not a blocker.
src/core/tls/darwin_trust.js:77

security add-trusted-cert -r trustRoot -k <login keychain> <cert> is issued
with no -p. macOS's default for -p is every policy, so the resulting
user-domain trust setting covers code signing, S/MIME, client auth and the
rest, not just SSL. The probe right next to it already uses -p ssl
(darwin_trust.js:59), so install and preflight do not describe the same
grant.

The name constraints do not compensate, and I verified this rather than
inferring it. RFC 5280 4.2.1.10 leaves a name form absent from
permittedSubtrees unrestricted, so a leaf carrying neither a dNSName nor an
iPAddress is unconstrained. Against the real minted CA:

subject CN=Totally Legit Software, O=Acme, no SAN
openssl verify -CAfile ca.pem leaf-nonhost.pem -> OK

Consequence: an attacker who reads ca-key.pem gets, for that user's session,
a trusted issuer for arbitrary non-server-auth identities, not only for the
three provider hosts. LLP 0237's Consequences say "the CA's name constraints
bound what the trust can vouch for regardless of store", and that sentence is
measurably wider than what the constraints deliver.

This is not a merge blocker: reaching it requires already being able to read a
0600 file in the user's home, which is the same-user compromise LLP 0235
explicitly accepts, and at that point the client's own API tokens are readable
too. It is a real widening of what that compromise buys.

Options, for a human:

  1. Add -p ssl to the install. One argument, no certificate change, no
    re-mint for existing users, and it makes the grant match the probe.
    Risk: unverified. It is not certain that Bun's keychain merge honours a
    policy-scoped trust setting the same way it honours an unrestricted one,
    and that merge is exactly what runs A-F proved. Needs a macOS re-run of the
    run A-F Remote Control check before it ships.
  2. Add extendedKeyUsage: serverAuth to the CA certificate. Constrains
    via EKU nesting in most verifiers, independent of the trust store. Costs a
    re-mint for every existing user, which under LLP 0238 means a new password
    dialog, so it is strictly worse than option 1 unless option 1 fails.
  3. Accept and record it. Amend LLP 0237's Consequences to say the trust is
    all-policy and that the constraints bound TLS server identity specifically.
    Cheapest, and honest.

I did not push a fix because LLP 0237 records the command shape verbatim in an
Accepted decision, and because a blind change to the one mechanism proven live
is the wrong thing to do from a Linux host that cannot test it.

F2. removeCaTrust removes one certificate per call; duplicates survive uninstall

Low. Preference. Not a blocker.
src/core/tls/darwin_trust.js:101

security delete-certificate -c "HypAware Local CA" -t <keychain> matches by
common name and removes a single certificate per invocation. Every HypAware CA
ever minted on that machine carries the same CN, so a machine that has
re-minted holds two or more identically-named trusted roots and uninstall
clears exactly one. The rest stay trusted forever, invisible unless the user
opens Keychain Access.

Re-mint is not hypothetical: ca.js:236 regenerates on key/cert divergence,
on corruption, on expiry roll, and on any change to the permitted set,
including the config-driven union at source.js:475 (see F3).

Options: loop removeCaTrust until it reports not-found, bounded at a handful
of iterations; or delete by -Z <sha1> per known fingerprint. Both are small.
I did not push either: security's multi-match behaviour is a macOS fact I
cannot check from here, and a loop around a trust-store mutation is not
something to land unverified.

F3. A re-minted CA silently strands the trust, and no command surfaces it

Medium. Preference / needs a human decision. Not a blocker.
src/core/daemon/launchd_env.js:147, hypaware-core/plugins-workspace/ai-gateway/src/source.js:475, src/core/tls/ca.js:236

Three documented behaviours are not implemented:

  • LLP 0238#ten-year-validity: "renewal must be surfaced as a re-trust prompt, not a silent swap." Nothing does. The daemon re-mints and logs ca_created: true at info level; no user-facing surface exists.
  • LLP 0237 Consequences: "hyp status should report the trust state alongside the CA fingerprint, so 'dialog was cancelled last month' is diagnosable without re-running attach." It does not. isCaTrusted is called only from attach.
  • LLP 0239#terminals-predating-attach states as settled fact: "hyp status reports whether the variable is present in the launchd environment (launchctl getenv)." It does not. isLaunchdEnvSet is exported and unit-tested and has zero production callers.

The reason this matters now rather than in 2036: source.js:475 mints against
new Set([...INTERCEPT_PROVIDER_HOSTS, ...hosts]), while ca.js:236 requires
the stored permitted set to equal the requested set exactly. An install that
configures an upstream outside the static provider list therefore mints a
four-host CA; removing that upstream makes the next daemon boot re-mint a
three-host CA. The keychain still trusts the old certificate. Claude Code's
Remote Control inbound channel silently stops working, hyp status shows a
healthy gateway and a valid CA, and nothing anywhere connects the two.

Options:

  1. Wire isCaTrusted and isLaunchdEnvSet into hyp status, which is what
    two Accepted LLPs already say happens. Both functions exist; this is
    plumbing, not design.
  2. Cheaper: when the gateway mints with ca.created === true and a trust entry
    already exists, warn "re-run hyp attach claude to re-grant trust".
  3. Cheapest: record a dated correction on LLP 0239 in the style of 4bb6400,
    saying status does not yet report this, and file the wiring as follow-up.

Left unfixed because option 1 is new user-facing surface on a maintainer
feature, and picking between the three is a product call, not a review call.

F4. Uninstall left NODE_USE_SYSTEM_CA=1 and its LaunchAgent behind. FIXED

Medium. Fixed in 94f687a.
src/core/commands/clients.js:1406

purgeProxyTrustResidue removed the CA files and the keychain entry but not
the launchd delivery. The launchd release lived only in
releaseProxyModeLaunchdEnv, which needs a settings file carrying an intact
mode: "proxy" marker to read (client_detach_disk.js:710). Two reachable
paths skipped it:

  • the user deletes ~/.claude/settings.json by hand, then runs
    hyp daemon uninstall. detachClientFromDisk returns { changed: false }
    before the marker branch, so nothing releases the variable.
  • the marker is damaged past its mode field, so releaseProxyModeLaunchdEnv
    returns at its first guard, on both the record-driven and legacy branches.

In both, uninstall completed, the CA and its trust were removed, and
~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist stayed
behind re-applying NODE_USE_SYSTEM_CA=1 at every login, forever, on a
machine HypAware was no longer installed on. This is the same class as the
CA-left-behind finding from the earlier round, one artifact along.

94f687a adds the release to the purge sweep, inside the existing darwin
block, best-effort and idempotent like its neighbours, so the ordinary
marker-driven path is unchanged. LLP 0239's Consequences already say the undo
reverses the launchd pieces "on detach and uninstall", so this makes the code
match the Accepted doc and needed no doc change.


Blocker vs preference

No blockers. F1, F2 and F3 are all preferences or product calls that I
have deliberately not decided on the maintainer's behalf. F4 was the one
finding with an unambiguous correct answer and it is fixed and pushed.

If any of them should gate the release rather than the merge, F1 option 1 is
the one to run through the codex_desktop_capture style manual gate on a real
Mac before shipping, since it is the only one whose fix could plausibly break
what runs A-F proved.


Tests

Full suite, real npm install, run twice (at 7a3bee2 and again after
94f687a), identical both times:

# tests 4168
# pass 4167
# fail 0
# skipped 1

npm run typecheck: clean.

On the reported characterisation: I could not reproduce it. The maintainer
reported 4165/4168 with 2 skips and a GC-sensitive query-sql-budget heap
failure. On this Linux host the suite is fully green with 0 failures and 1
skip (resolveEncodeSettings falls back to SNAPPY, gated on ZSTD
availability). test/core/query-sql-budget.test.js:67 is genuinely
GC-sensitive by construction, so the reported flake is plausible and
host-specific, but I can only report what I measured: nothing fails here, and
the count difference is environment, not code. Treating the reported failure
as a known flake is reasonable; treating my run as proof it does not exist on
macOS is not.


What I could not exercise on Linux

Stated plainly, because several of the above are reads, not verifications:

  • Nothing in the macOS path ran. No security add-trusted-cert, no verify-cert, no delete-certificate, no launchctl setenv/unsetenv/getenv, no keychain, no LaunchAgent load. Every claim about what those commands do, including F1's all-policy default and F2's single-match behaviour, comes from the documented semantics of security(1) plus the code, not from execution. Both should be confirmed on a Mac before acting on them.
  • My own fix 94f687a is unexecuted. It sits inside if (process.platform === 'darwin'), so it is unreachable on this host and the existing tests neither cover it nor could. It typechecks, the suite stays green, and it is a call to an already-tested idempotent function inside an existing try/catch neighbourhood. It has not been run.
  • Bun's trust-store merge and whether Remote Control's SSE channel actually recovers: entirely unverified here. LLP 0236's live runs A-F are the only evidence, and I have taken them at face value.
  • The macOS password dialog and the refusal path were not observed.

What I did execute: CA generation with real file permissions and ownership,
the DER encoding of the name constraints via OpenSSL, chain validation of
permitted, forbidden, legacy-CN and no-SAN leaves against the real CA, the
full 4168-test suite twice, and the typecheck.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage at 94f687a: clear to merge, three findings deferred to #790

Round 3's residual findings are all classified non-blocking. F4's fix (uninstall releasing NODE_USE_SYSTEM_CA and its LaunchAgent when no proxy marker survives) is verified present in the tree at head, in purgeProxyTrustResidue (src/core/commands/clients.js), calling the already-tested idempotent removeLaunchdEnv inside the existing darwin block. Follow-up issue: #790.

F1, the central call: all-policy keychain grant. Preference, not a blocker.

The mechanism argument, since this is the one worth spelling out. The all-policy grant only matters to an attacker who holds ca-key.pem, and that file is 0600 inside the user's own home. So the precondition is same-user code execution (or root), which is the compromise LLP 0235 already accepts and at which point the user's API tokens, shell profile, and ability to install LaunchAgents are all in hand.

Given that precondition, the delta between all-policy and SSL-only trust is: certificates for non-server-auth policies (S/MIME signatures the user's own Mail would show valid, per-user code-signing trust, client-auth certs) minted under a pre-authorized root, because RFC 5280 leaves absent name forms unrestricted and the name constraints carry only DNS and IP forms. That is a real widening of what a same-user compromise buys, and the reviewer proved it by execution rather than inference. But it crosses no boundary the compromise had not already crossed: the trust is user-domain only, scoped to the already-compromised session, and it grants nothing toward other users, other machines, or persistence the attacker could not get more directly. The one asset the attacker parasitizes rather than mints (the password-dialog-authorized trust anchor) vouches, in the SSL policy where it could reach beyond the machine, for only the three pinned provider hosts.

That is a hardening gap, defense in depth against a compromise that is already game-over locally, not a privilege gain. And the one-argument fix (-p ssl) carries real risk in the wrong direction: it touches the exact mechanism (Bun's keychain merge) that live runs A-G proved working, from a host that cannot test it. Shipping it blind to close a hardening nicety would trade a theoretical narrowing for a plausible regression in the feature's whole point. It belongs in #790 behind a macOS acceptance re-run, which is exactly where it now is.

F2: preference

One security delete-certificate per call means duplicate-CN roots survive uninstall. Residue, not risk: re-mint overwrites the key in place and uninstall deletes it, so a stranded trust entry vouches for a certificate whose private key no longer exists anywhere. Needs macOS multi-match semantics confirmed before a loop or delete-by-fingerprint fix lands.

F3: preference and product call, the closest of the three to the line

Three documented behaviours (re-trust prompt on renewal, trust state in hyp status, launchd env in hyp status) are unimplemented; isLaunchdEnvSet has zero production callers, verified by grep at head. The concrete failure (config churn on a custom upstream re-mints the CA, keychain trusts the old one, Remote Control inbound dies silently with a healthy-looking status) is real, but it requires a non-default upstream configuration under an opt-in feature, degrades Remote Control rather than capture or data, and every fix shape is a product decision among the three options in #790. Wrong behaviour in a corner plus an observability gap does not clear the bar for re-sticking a PR the maintainer just unstuck; it clears the bar for a named follow-up, which it now has.

The unexecuted-on-Linux caveat, weighed

Every security and launchctl claim in rounds 1-3, including F1's all-policy default and F2's single-match behaviour, rests on documented semantics plus code reading, and the reviewer said so plainly. What offsets it: the maintainer executed the real flow end to end on a real Mac (runs A-G, including the automated attach path, the password dialog, idempotent re-attach, live Remote Control inbound plus capture in the same window, and byte-identical detach), and the repo explicitly separates merge confidence from release acceptance (docs/ACCEPTANCE.md). The one piece never executed anywhere is F4's fix itself, unreachable off-darwin; its failure mode is the pre-fix behaviour plus a printed manual fallback line, so it fails toward the old state, not a new one. A manual uninstall and hyp detach --purge pass on a real Mac should be part of the release gate for this adapter, and #790 says so.

Test characterisation

The reviewer's two full runs (4168 tests, 4167 pass, 0 fail, 1 skip) could not reproduce the reported query-sql-budget failure and saw one skip rather than two. The failing test is GC-sensitive by construction and the count delta is environmental; nothing suggests a code problem at head.

No unresolved blocker remains. Findings F1, F2, F3 are tracked in #790.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
@philcunliffe
philcunliffe merged commit fa701a7 into masterAug 15, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the feat/proxy-mode-capture branch August 15, 2026 20:34
philcunliffe pushed a commit that referenced this pull request Aug 15, 2026
Sole conflict: llp/0044's Extended-by header. Master (#782) added a second
Extended-by line for LLP 0234; this branch appended LLP 0229 to the existing
LLP 0086 line. Both kept.
#782 changes no attach_probe manifest, so the probe-less set is unchanged
(claude-desktop only) and LLP 0229's rule is unaffected.
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
RFC 0231 (proxy-mode capture) shipped in PR #782, but its realization is
cited only through the spawned decisions 0232-0239, plus two prose
mentions. The corpus therefore had no machine-readable edge from any
realization back to the request, and neutral's coverage predicate
reported 0231 as an uncovered request needing a design.
The mode field on ClaudeAttachOptions is the one construct whose comment
already cites the RFC itself (the two-transport surface, and why proxy
exists at all), so this turns that prose 'See LLP 0231' into
@ref LLP 0231#decision rather than minting a duplicate design doc or
annotating a construct the spawned decisions already cover.
Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Aug 19, 2026
RFC 0231 (proxy-mode capture) shipped in PR #782, but its realization is
cited only through the spawned decisions 0232-0239, plus two prose
mentions. The corpus therefore had no machine-readable edge from any
realization back to the request, and neutral's coverage predicate
reported 0231 as an uncovered request needing a design.
The mode field on ClaudeAttachOptions is the one construct whose comment
already cites the RFC itself (the two-transport surface, and why proxy
exists at all), so this turns that prose 'See LLP 0231' into
@ref LLP 0231#decision rather than minting a duplicate design doc or
annotating a construct the spawned decisions already cover.
Co-authored-by: test <test@test.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adoptForeign PR adopted into neutral's reconcile scopeneutral:adoptedAdoption completion record: merged while carrying neutral:adopt (LLP 0031)neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@philcunliffe