Skip to content

hyp status reports proxy-mode trust and the launchd env (LLP 0237/0239) - #792

Merged
philcunliffe merged 2 commits into
masterfrom
fix/issue-790
Aug 17, 2026
Merged

hyp status reports proxy-mode trust and the launchd env (LLP 0237/0239)#792
philcunliffe merged 2 commits into
masterfrom
fix/issue-790

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

What this does

Wires the trust and launchd-environment predicates into hyp status, making two Accepted LLPs' claims true:

  • 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."
  • LLP 0239#terminals-predating-attach: "hyp status reports whether the variable is present in the launchd environment (launchctl getenv)."

Neither was implemented. isCaTrusted was called only from attach, and isLaunchdEnvSet was exported and unit-tested with zero production callers. Combined with the silent CA re-mint the issue describes, a user could trust a root that nothing surfaced, with every other status line reading healthy.

The surface

collectHypAwareStatus now builds a proxyTrust section (src/core/daemon/status.js, collectProxyTrust):

fieldsource
caFingerprintreadLocalCaInfo (already documented as "for hyp status", never called by it)
trustedisCaTrusted -> security verify-cert -c <ca> -p ssl
launchdEnvSetisLaunchdEnvSet -> launchctl getenv NODE_USE_SYSTEM_CA

Text (renderStatusText), rendered between the client block and the cache lines:

 proxy trust:
ca fingerprint: A1:B2:...:FF
login keychain: trusted
launchd env: NODE_USE_SYSTEM_CA=1 set

and on the state this exists for:

 login keychain: not trusted - Remote Control inbound will not work, run `hyp attach claude` to retry
launchd env: NODE_USE_SYSTEM_CA not set - run `hyp attach claude` to set it

--json stays a faithful machine copy under proxy_trust: { ca_fingerprint, ca_trusted, launchd_env_set }, or null. ca_trusted / launchd_env_set are tri-state. A probe that could not run reports null, never false: "the dialog was cancelled" and "security did not run" are different answers and only the first is actionable, so rounding one to the other would put a false negative in the machine surface. The two probes are caught independently.

Both probes are injectable through CollectStatusOptions (isCaTrusted, isLaunchdEnvSet), matching the existing isLaunchAgentInstalled / launchAgentStatus seams. That is what makes this testable on Linux, and it also keeps hyp status from tripping LLP 0181's spawn guard in tests.

Degrading off darwin

The section is null (absent from the text surface entirely) when the question does not apply:

  • not darwin - both mechanisms are macOS facts (LLP 0237#darwin-only; LLP 0239 "Non-macOS platforms skip this entirely"). A Linux host is never told about a keychain it does not have, and never shown an "unknown" it could not act on.
  • no CA on disk - proxy mode was never on, so there is nothing to be trusted or untrusted. An ordinary install's text output is unchanged, proxy_trust is null rather than absent (the renderer's null-not-omitted contract), and the probes are not even called (asserted).

LLP 0225 (strip vs escape)

Deliberately not applicable here, and the code says so: the fingerprint is computed locally from the DER and is [0-9A-F:] by construction, the other two fields are tri-state booleans, and probe stderr is deliberately not surfaced. No captured or foreign text reaches this render, so there is nothing to strip or escape. No LLP was edited (both are Accepted and already say what this now does).

Test evidence (the LLP 0002 gate)

New: test/core/status-proxy-trust.test.js, six cases driving collectHypAwareStatus with injected probes over a real minted CA in a temp HYP_HOME, then asserting both the rendered text a user sees and the --json payload.

Before (git checkout -- src/ on this branch, test file only):

1..6
# pass 0
# fail 6

Each failure is + undefined - null / a missing proxy trust block: the collector had no such field and neither renderer printed anything.

After:

1..6
# pass 6
# fail 0

Coverage: trusted+set (fingerprint on both surfaces), untrusted+unset (repair text present, overall still healthy per LLP 0237#attach-anyway-on-refusal), probes throwing (null not false, on both surfaces), non-darwin (no section, no "unknown"), darwin with no CA (text surface unchanged, proxy_trust null), and probes-not-called when there is no CA.

Full battery on this branch:

  • npm test - 4173 pass, 0 fail, 1 skipped (4174 total)
  • npm run typecheck - clean
  • npm run smoke -- status_diagnostics, walkthrough_picker_to_first_query, client_attach_idempotent, cli_bundled_plugins_activated - all ok

F1 and F2 remain open, deliberately

Neither is touched, for the same reason: this host is Linux, and no security(1) invocation can be executed here.

  • F1 (all-policy keychain grant; add -p ssl) is a one-argument change to the exact keychain-merge mechanism the maintainer's live macOS runs A-G proved. Whether Bun's merge honours a policy-scoped trust setting the way it honours an unrestricted one is unverified, and that merge is the whole feature. A blind change risks regressing proven behaviour to harden a same-user-compromise exposure that is not a live hole. It needs a real Mac plus a re-run of the Remote Control acceptance check.
  • F2 (delete-certificate removes one certificate per call, so duplicate-CN roots survive uninstall) depends on security's multi-match semantics, which cannot be exercised here either. Residue, not live risk: re-mint overwrites ca-key.pem in place, so a stranded entry vouches for a certificate whose private key is gone.

F3's remaining two options from the issue (fingerprint-drift detection with a re-trust prompt; narrowing the re-mint triggers) also stay open. Both change lifecycle behaviour and want a decision doc; this PR is observability only.

Scope note on the closing keyword

Fixes #790 below will close the whole issue on merge, including F1 and F2, which this PR does not address. Flagging it explicitly: the recommendation is to re-open a narrower macOS-only issue for F1 + F2 (both need a real Mac and an docs/ACCEPTANCE.md pass), rather than leave #790 closed as if all three were done. Say the word and the keyword can be downgraded to a plain reference instead.

Resolved at triage:Fixes #790 stays. Successor issue #793 now carries F1, F2, F3's remaining lifecycle options, and every finding the #792 review rounds left open (permitted-hosts naming, the post-detach repair prompt, the probe timeout, the launchd-residue doc omission), so nothing is lost when this merges.

Documentation corrections

Round 2 also corrected pre-existing drift in README.md and docs/PRIVACY.md, which still described the pre-proxy CA model after fa701a7 landed: they claimed the system trust store is never modified (LLP 0237 installs a login-keychain trusted root), that detach deletes the CA (LLP 0238 keeps it), that name constraints cover only the intercepted host (LLP 0238 widened them), and that status shows fingerprint and expiry (expiry is deliberately not shown). Bounded to the certificate paragraph in each file. No LLP was touched.

Fixes#790

LLP 0237's Consequences say `hyp status` reports the keychain trust state
alongside the CA fingerprint, and LLP 0239#terminals-predating-attach says it
reports whether NODE_USE_SYSTEM_CA is present in the launchd environment.
Neither was implemented: `isCaTrusted` was called only from attach and
`isLaunchdEnvSet` had zero production callers. Combined with a CA re-mint
silently stranding the keychain trust, a user could hold a trust nothing
surfaced while every other status line read healthy.
The collector now builds a `proxyTrust` section on darwin when a CA is on
disk: the fingerprint, `security verify-cert -p ssl`, and `launchctl getenv`.
Both probes are injectable and caught independently, so a probe that could not
run reports `null` (unknown) rather than a false negative. `--json` carries the
tri-state faithfully under `proxy_trust`; the text surface renders the block
only where the question applies, with the one-command repair on the untrusted
and unset states.
Off darwin, and on a darwin host that never turned proxy mode on, the section
is absent rather than unknown: both mechanisms are macOS-only
(LLP 0237#darwin-only) and no CA means nothing to be trusted or untrusted.
Addresses F3 of #790. F1 (the all-policy `add-trusted-cert` grant) and F2
(single-certificate `delete-certificate`) are untouched: both turn on
`security(1)` semantics no Linux host can exercise, and F1's fix touches the
exact keychain-merge path the maintainer's manual macOS runs proved.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict

Approve with findings. Nothing here is a blocker. The change does exactly
what F3 asked for, the tri-state is real on both surfaces, and no test path
spawns a subprocess. Three findings, all non-blocking; none was fixed, and the
reason for each is given below. Head is unchanged at 2a4d690.

Reviewed manually. codex is not installed here and code-review is not
invocable, so there is no tool-assisted pass behind this.

Does the surface match what the LLPs claim?

Yes, and it stops there.

  • LLP 0237#consequences exists (## Consequences, last bullet) and says
    verbatim: "hyp status should report the trust state alongside the CA
    fingerprint, so 'dialog was cancelled last month' is diagnosable without
    re-running attach." The proxyTrust section delivers fingerprint + trust
    state, together, on both surfaces.
  • LLP 0239#terminals-predating-attach exists (### Terminals predating attach) and says: "hyp status reports whether the variable is present in
    the launchd environment (launchctl getenv)." launchdEnvSet is exactly
    that, sourced from isLaunchdEnvSet which runs launchctl getenv NODE_USE_SYSTEM_CA. The slug convention matches the rest of the corpus
    (#user-domain-trust, #attach-anyway-on-refusal, #launchctl-setenv all
    already resolve the same way), and test/core/llp-ref-hygiene.test.js passes.
  • No over-delivery. readLocalCaInfo also returns notAfter and hosts, and
    neither is surfaced. That is the correct call: the LLPs ask for trust state
    and the env var, not for an expiry line.
  • Not adding a diagnostic (so overall stays healthy) is right and is
    positively asserted by test 2. LLP 0237#attach-anyway-on-refusal makes
    untrusted a degraded-but-working state, so promoting it to a diagnostic
    would contradict the doc it cites.

--json fidelity

Faithful. Verified end to end, not asserted:

  • renderStatusJson (src/core/commands/status.js:289-294) copies the three
    fields by reference with a snake_case rename and no transform. No String(),
    no ??, no coercion.
  • describeCaTrust / describeLaunchdEnv are referenced only from
    renderStatusText (grep over the file: lines 428, 429 and their definitions
    at 562, 576, nothing else). No prose can reach the machine path.
  • The print site is JSON.stringify(payload, null, 2)
    (src/core/commands/status.js:95). Nothing sanitizes between the collector
    and stdout.
  • Tri-state survives: test 3 asserts json.proxy_trust.ca_trusted === null and
    launchd_env_set === null under assert from node:assert/strict, so
    undefined would fail. I re-ran it and it does distinguish the two.

Do the probes spawn during tests?

No, twice over.

  1. The injection seams are honoured: collectHypAwareStatus resolves
    opts.isCaTrusted ?? probeCaTrusted (src/core/daemon/status.js:1112-1117),
    so an injecting test never reaches the real function.
  2. The LLP 0181 guard is the real backstop and it does apply. Both probes go
    through runServiceCommand (src/core/tls/darwin_trust.js:32,
    src/core/daemon/launchd_env.js:38), and serviceManagerSpawnRefusal
    (src/core/daemon/service_ops.js:101-110) gates on underTestRunner()
    ahead of spawn, for any binary, not just launchctl/systemctl. So even
    a future non-injecting status test rejects instead of spawning, and
    collectProxyTrust's per-probe catch turns that into null. That is the
    right failure mode and it is the state test 3 renders.

On a non-darwin host the section short-circuits before any probe
(status.js:1202), so hyp status on Linux cannot spawn or hang here at all.
Confirmed empirically: test/core/status-proxy-trust.test.js runs 6/6 on this
Linux box with a real minted CA and never touches a subprocess.

Failure and edge behaviour

  • Probe throws: caught per-probe, independently, into null. The error
    object is discarded, so no message text reaches the terminal. Verified by
    reading, and exercised by test 3.
  • Probe returns non-zero: isCaTrusted returns exitCode === 0 and
    isLaunchdEnvSet returns exitCode === 0 && stdout.trim() === '1'. Neither
    returns stderr or stdout to the caller, so probe stderr genuinely is
    not surfaced
    . This is the author's claim and it holds.
  • Fingerprint: fingerprint() (src/core/tls/ca.js:127-130) is
    sha256(der).digest('hex').toUpperCase() split into colon-separated pairs.
    [0-9A-F:] by construction, locally computed from bytes the process itself
    parsed. LLP 0225 correctly does not apply: nothing captured or foreign
    reaches this render.
  • CA present but unreadable / malformed: readLocalCaInfo returns
    undefined on ENOENT and on an X509 parse failure, and throws on other read
    errors (EACCES). collectProxyTrust wraps the call, so all three collapse to
    "no section". Slightly lossy for EACCES, but it fails closed and quiet, which
    is the right direction for a status line.
  • Timeout: see finding 3.

Findings

1. The section gates on "a CA exists", but a CA outlives detach, so a deliberately detached Mac gets a standing repair prompt (low, not fixed)

src/core/daemon/status.js:1207 (if (!ca) return null), rationale at
status.js:1183-1184 and repeated in the PR body: "with no CA on disk proxy
mode was never on, so there is nothing to be trusted or untrusted."

The converse does not hold, and the repo says so. LLP 0238#ca-survives-detach
keeps the CA and its keychain trust across hyp detach claude on purpose (so
the one password-dialog grant survives), while LLP 0239 has detach release
the launchd variable. releaseProxyModeLaunchdEnv
(src/core/config/client_detach_disk.js:713-716) implements exactly that, and
client_detach_disk.js:324 annotates the deliberate non-deletion of the CA.

So the steady state after a plain detach on macOS is: CA on disk, trust still
installed, variable unset. hyp status will then print, indefinitely:

 proxy trust:
ca fingerprint: A1:B2:...:FF
login keychain: trusted
launchd env: NODE_USE_SYSTEM_CA not set - run `hyp attach claude` to set it

That is a permanent repair suggestion for a state the user chose. The
login keychain: trusted line is fine and arguably useful there (it is why
LLP 0238 keeps it), but the launchd line reads as a fault.

Not fixed, and I do not think it should be fixed in this PR. The remedy is to
gate on the proxy attach marker (marker.mode === 'proxy') rather than on CA
presence, or to soften the launchd line when no proxy attach is live. Either is
a behaviour choice about what hyp status claims, which wants a decision doc,
and this PR is deliberately observability-only. Preference, and one for the
maintainer to route
, not a blocker: the line is additive, correct about the
machine's actual state, and harms nothing.

2. The --json shape is not "unchanged"; it gains an always-present key (nit, not fixed)

src/core/commands/status.js:283-285: "Null on a host where the question does
not apply (not darwin, or no CA), so the V1 JSON shape is unchanged for an
ordinary install." The PR body repeats this.

proxy_trust is emitted unconditionally, null or not, on every platform.
Test 4 asserts json.proxy_trust === null on Linux, i.e. present-and-null, not
absent. That is the right behaviour and it matches the renderer's own
documented contract two doc comments up ("missing values surface as null
rather than being omitted, so smoke assertions can probe specific fields
directly"). The change is purely additive and breaks no consumer. Only the
comment overstates it. Left as-is rather than pushing a comment-only commit and
churning the head SHA; worth a one-line edit if the branch moves again.

3. The probes have no timeout, so hyp status on darwin now has two new ways to block (low, not fixed, unexercisable here)

runServiceCommand (src/core/daemon/service_ops.js:122-139) spawns with no
timeout and resolves only on close. hyp status is an interactive foreground
command and now shells out twice on every darwin run with a CA present.

In practice this is probably fine: security verify-cert against a locally
minted root with no AIA extension should not reach the network, and launchctl getenv is a table read. The residual risk is a locked login keychain making
security raise a GUI prompt, which I cannot test from Linux. Precedent cuts
in favour of shipping as-is: the daemon status path already spawns launchctl print through the same unguarded helper, so this adds no new class of hazard.
Recording it rather than fixing it, since a timeout belongs on the shared
helper and that is a wider change than F3.

Observation, not a finding: README and PRIVACY still describe the pre-proxy CA model

README.md:305-306 says "hyp status shows its fingerprint and expiry, and
hyp detach claude deletes it", and docs/PRIVACY.md:50-51 says "hyp status
shows its fingerprint, and hyp detach claude deletes it". Both also still
describe NODE_EXTRA_CA_CERTS and "your system trust store is never touched".
Those claims were made false by fa701a7 (LLP 0237 puts the CA in the login
keychain; LLP 0238 makes detach keep it), not by this PR. This PR makes the
fingerprint half of the status claim true for the first time; expiry is still
not shown, which is correct relative to LLP 0237/0239 and simply means the
README asks for more than the LLPs do. Flagging so the maintainer can fold the
doc sweep into whatever picks up F1/F2; deliberately not touched here, both
because it is outside F3 and because the corrections needed span the whole
proxy-mode section.

The six regression tests

Spot-checked by reverting src/core/commands/status.js,
src/core/daemon/status.js and src/core/daemon/types.d.ts to origin/master
on this branch and re-running the test file alone. Reproduced 0 pass / 6
fail
, then restored and reproduced 6 pass / 0 fail. The claimed numbers
are real.

On whether the failures are the ones claimed:

  • Tests 1-3 (trusted+set, untrusted+unset, probes-throw) fail before on the
    substantive assertions: deepStrictEqual of the whole proxyTrust object,
    and strictEqual against false / null. These are genuine.
  • Tests 4-6 (non-darwin, no CA, probes-not-called) fail before only on
    assert.equal(report.proxyTrust, null), because the field was undefined
    and the file imports node:assert/strict. Their behavioural assertions
    (doesNotMatch(text, /proxy trust/), calls === 0) would have passed on
    master. That is inherent to negative tests and is not a criticism; they are
    correctly guarding the absent-section contract going forward. Worth stating
    plainly since "0/6 before" reads stronger than it is: three of the six are
    regression guards, not demonstrations of the bug.

On the user-visible requirement: all six assert what a user sees. Every one
that produces a section renders through the real renderStatusText into a
buffer and matches the literal lines including the exact column padding
(/launchd env: {4}NODE_USE_SYSTEM_CA=1 set\n/), and separately asserts the
renderStatusJson payload. Test 6 is the only call-counting test, and that is
its actual subject (proving no probe runs without a CA), paired with the
user-visible proxyTrust === null. Tests mint a real CA via ensureLocalCa
into a temp HYP_HOME and assert against the real fingerprint, not a fixture
string.

Conventions

Checked mechanically over the full diff and clean:

  • 0 em dashes (U+2014) anywhere in the added lines, including the runtime
    strings. The repair strings use -, per CLAUDE.md.
  • 0 semicolon-terminated added lines.
  • Types are interfaces in src/core/daemon/types.d.ts, no @typedef, no
    inline import('...'). The new LocalCaInfo import is declared at the top of
    status.js as @import { LocalCaInfo } from '../../../src/core/tls/types.js',
    repo-root-anchored with a .js extension, matching the surrounding block.
  • @ref annotations carry relations and non-trivial glosses. The bare
    [tests]: form in the test header matches 71 existing instances, so it is
    house style, not a lapse.

Gates run

  • npm test: 4173 pass, 0 fail, 1 skipped (4174 total).
  • npm run typecheck: clean.
  • npm run smoke -- status_diagnostics, walkthrough_picker_to_first_query,
    client_attach_idempotent, cli_bundled_plugins_activated: all ok.
  • node --test test/core/status-proxy-trust.test.js: 6/6.

All of the above reproduce the PR body's claims exactly.

What I could not exercise

This host is Linux. Everything below was reviewed by reading only:

  • security verify-cert -c <ca> -p ssl actually returning 0 for a
    user-domain-trusted root, and non-zero for a cancelled dialog. The seam is
    proven; the semantics behind it are not.
  • launchctl getenv NODE_USE_SYSTEM_CA printing 1 with a trailing newline,
    which isLaunchdEnvSet relies on via stdout.trim().
  • Whether either probe can block or raise a GUI prompt (finding 3).
  • The rendered block on a real Mac with a real CA: column alignment, and where
    it sits relative to the client and cache lines. The test asserts the exact
    padding, so this is low risk.
  • Finding 1's post-detach state on a real Mac. It is derived from
    client_detach_disk.js and LLP 0238/0239 rather than observed.

Nothing was pushed

No commit. Head remains 2a4d69088080569cfee211c8a2bff07868385f8d. Findings 1
and 2 are preferences; finding 3 is a pre-existing property of a shared helper.
None of the three justified changing a branch whose gates are all green.

F1 and F2 were out of scope by instruction and are not re-litigated here. The
closing-keyword question the PR body raises (Fixes #790 will close an issue
whose F1 and F2 remain open) needs a human: the author's own recommendation,
to re-open a narrower macOS-only issue for F1 + F2, looks right to me, and I
have deliberately not edited the PR to act on it.

The `--json` comment claimed the V1 shape was "unchanged for an ordinary
install". It is not: `proxy_trust` is emitted unconditionally on every
platform and is `null` where the question does not apply, which is what
this renderer's null-not-omitted contract asks for. Additive and no
consumer breaks, but the comment stated the opposite of what the code
does. Say what it does instead.
README.md and docs/PRIVACY.md still described the pre-proxy CA model,
made false by LLP 0237/0238 rather than by this change:
- "your system trust store is never touched" / "no other application on
the machine is affected". False on macOS since LLP 0237: attach adds
the CA to the login keychain as a user-domain trusted root, so any
application running as that user which consults the login keychain
will accept certificates it signs. PRIVACY.md is where a user makes
that decision, so it now says what is installed, why macOS raises the
password dialog, and that declining is supported.
- "`hyp detach claude` deletes it". False since LLP 0238#ca-survives-detach:
detach keeps the CA and the trust so re-attach needs no second dialog.
`--purge` and `hyp daemon uninstall` are the removal paths.
- "restricted so it cannot vouch for any host other than the one being
intercepted". Widened by LLP 0238#full-provider-constraints to the
static provider set; named now, since that is the grant the user gives.
- README's "`hyp status` shows its fingerprint and expiry": the
fingerprint is true as of this PR, expiry is not shown and no LLP asks
for it. The claim now matches the surface: fingerprint plus trust state.
No LLP was edited; all three are Accepted and already say this.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict

Approve. Round 2 of 2, the last review round. I reviewed 2a4d690, pushed
one commit on top, and this verdict covers the resulting head 1f0423c.
npm test (4173 pass / 0 fail / 1 skipped) and npm run typecheck are green on
that tree.

Reviewed manually. codex is not installed here and code-review is not
invocable, so there is no tool-assisted pass behind this either.

Round 1's substantive verifications stand and are not re-litigated: the surface
matches what LLP 0237 and LLP 0239 claim with no over-delivery, --json is a
faithful copy with the tri-state intact, no test path spawns, and LLP 0225
genuinely does not apply.

Fixed this round: 1f0423c

Round 1's finding 2, the JSON-shape comment (nit, fixed)

src/core/commands/status.js:283 claimed the V1 JSON shape was "unchanged for
an ordinary install" while proxy_trust is emitted unconditionally on every
platform. The behaviour is right; the comment stated the opposite of it. It now
says what the code does: additive, always present, null where the question
does not apply, per this renderer's own null-not-omitted contract. The PR body
repeats the same claim in two places and still needs correcting; the exact text
is at the bottom of this comment.
Reviewers do not gh pr edit.

The README / PRIVACY drift (fixed, and I judged it in scope)

Round 1 recorded this as an observation. I disagree that it can wait, and folded
a contained correction into the same commit.

docs/PRIVACY.md is the document a user reads to decide whether to turn proxy
mode on. It said, of the CA: "your system trust store is never modified, and no
other application on the machine is affected"
. Since fa701a7,
LLP 0237#user-domain-trust has attach run security add-trusted-cert -r trustRoot -k <login keychain>, so on macOS any application running as that user
which consults the login keychain will accept certificates this CA signs. The
sentence is not stale phrasing, it is the negation of what the code does, in the
one place the user's consent decision is made. It also said hyp detach claude
deletes the CA, which LLP 0238#ca-survives-detach reversed on purpose, so a user
who detached to remove the residue was told they had.

Scope reasoning: in scope, because this PR is what makes the status half of
those same sentences true for the first time
, and because a false safety claim
is not something to leave standing for a follow-up that may not come. Bounded to
the certificate paragraph in each file. No LLP was touched (all three are
Accepted and already say this), the privacy model itself is unchanged, and I did
not rewrite anything beyond the specific claims:

  • docs/PRIVACY.md:45-51 -> names the login-keychain install, why macOS raises
    the password dialog, what it means for other applications running as you, that
    declining is supported, that the system keychain and other user accounts are
    never modified, and that the file-scoped model still applies off macOS.
  • README.md:303-307 -> same corrections, plus: the CA is name-constrained to
    the provider set (LLP 0238#full-provider-constraints widened it past "the one
    being intercepted"), detach keeps it and --purge / hyp daemon uninstall
    remove it, and hyp status shows the fingerprint and trust state rather
    than "fingerprint and expiry". Expiry is correctly not shown and no LLP asks
    for it, so the doc was asking for more than the design.

Verified in the committed tree, not just the worktree: the false strings are
gone from git show HEAD:README.md and git show HEAD:docs/PRIVACY.md (0
matches for never touched / never modified / deletes it / fingerprint and expiry), and git show HEAD:src/core/commands/status.js:283 carries the new
comment.

Residual drift, deliberately not touched (out of the certificate paragraph,
and an omission rather than a false claim): neither doc mentions that macOS
proxy attach sets NODE_USE_SYSTEM_CA=1 via launchctl setenv and installs a
login LaunchAgent to re-apply it (LLP 0239). That is a real login-item residue a
user might want documented. Follow-up, not a blocker.

Findings left unfixed

1. Post-detach macOS renders a standing repair prompt (low, not fixed, recommendation below)

src/core/daemon/status.js:1207. Round 1 raised this. I traced it
independently and it is real.

Evidence:

  • client_detach_disk.js:324-326 annotates the deliberate non-deletion
    (@ref LLP 0238#ca-survives-detach [implements]: the CA is deliberately NOT deleted here) and, in the same block, calls releaseProxyModeLaunchdEnv.
  • releaseProxyModeLaunchdEnv (client_detach_disk.js:713-716) returns early
    unless marker.mode === 'proxy' and darwin, then calls removeLaunchdEnv,
    which both unsets the variable and removes the login plist.
  • Deletion of the CA and its keychain trust lives only in
    purgeProxyTrustResidue (src/core/commands/clients.js:1407-1425), whose own
    doc comment says it is "Called by hyp detach --purge and by the uninstall
    sweep - the two paths allowed to end the once-per-machine trust grant."
  • LLP 0238#ca-survives-detach states the same in prose.

So a plain hyp detach claude on macOS leaves exactly: CA on disk, keychain
trust installed, launchd variable released. collectProxyTrust gates on darwin
plus CA-on-disk, so that state renders `launchd env: NODE_USE_SYSTEM_CA not set

  • run `hyp attach claude` to set it` indefinitely. Round 1's claim holds in
    full.

Recommendation: follow-up issue, not a blocker, and not this PR. Reasoning:

  • The line is factually correct about the machine. It is the imperative
    ("run hyp attach claude") that reads as a fault, not the fact.
  • overall stays healthy, no diagnostic is added, no exit code changes, and
    the block carries no ! marker. Nothing downstream treats it as broken.
  • The same rendering is right in the neighbouring state that shares the gate:
    a daemon running with proxy_mode: true mints the CA
    (ai-gateway/src/source.js:479) before any attach happens, so "CA present,
    never attached" also hits this branch, and there the prompt is exactly the
    correct instruction. The gate is not wrong, it is ambiguous between two states
    that want different words.
  • The fix is not a one-liner, which is the strongest argument against
    squeezing it in here. ClientAttachReport (src/core/daemon/types.d.ts:201-215)
    carries name, plugin, configured, attached, settingsPath, version,
    port - no mode. Gating on "a proxy attach is live" means plumbing
    mode out of probeClientAttachFromDescriptor and into the report first. That
    is a surface change plus a behaviour decision about what hyp status claims,
    which wants a doc. Round 1 was right to leave it and I did not implement it.

2. hyp status does not name the CA's permitted hosts, which an Accepted LLP requires (medium, not fixed) - round 1 missed this

src/core/daemon/status.js:1204 (collectProxyTrust reads ca and returns only
ca.fingerprint).

LLP 0238 Consequences, second bullet, verbatim: "A user who trusts the CA but
never enables Codex carries a trust grant for OpenAI hosts they do not use. The
dialog and hyp status must name all permitted hosts
, so the grant is
informed."

Half of that is implemented. The attach dialog does it
(hypaware-core/plugins-workspace/claude/src/index.js:230, 537, annotated
@ref LLP 0238#full-provider-constraints [constrained-by]: the dialog context must name all permitted hosts). hyp status does not, and this PR builds the
exact block where it belongs while already holding the data:
readLocalCaInfo returns hosts (src/core/tls/types.d.ts:17) and
collectProxyTrust has the whole LocalCaInfo in hand at
src/core/daemon/status.js:1204.

Round 1 checked LLP 0237 and LLP 0239 because those are what the PR cites, and
concluded that dropping hosts and notAfter was correct non-over-delivery.
For notAfter that is right: no LLP asks for an expiry line, and LLP 0238's
renewal requirement is a re-trust prompt, not a status field. For hosts it is
not right: a third Accepted LLP says "must", about the same surface, and its
rationale is informed consent for a trust grant covering api.openai.com and
chatgpt.com on installs that use neither.

Not fixed, deliberately. It changes the user-visible text surface and adds a
--json key at the last review round, with no round left to review it, and LLP
0238 says the hosts must be named without saying how. That is an author call,
not a reviewer's. It is cheap when someone takes it: one field on
ProxyTrustReport, one text line, one JSON key, one assertion in the existing
test file.

3. No timeout on the probes (low, not fixed, unexercisable here)

src/core/daemon/service_ops.js:122-139. Round 1's finding, unchanged and not
re-litigated: pre-existing property of a shared helper that the daemon status
path already depends on, and a timeout belongs on the helper rather than here.

Blocker vs preference, for triage

#ItemClassification
1Post-detach standing repair promptPreference. Correct about the machine, overall unaffected, ambiguous wording only. Needs mode plumbed into ClientAttachReport plus a behaviour doc. File as follow-up.
2hyp status does not name permitted hosts (LLP 0238 "must")Preference for merging this PR, production-relevant follow-up. It leaves a pre-existing false claim standing rather than creating one, and nothing regresses. But it is the same class of defect as the one this PR exists to fix, on the same surface, and the doc says "must". Should be filed before #790 is closed.
3No probe timeoutPreference. Pre-existing, shared helper, wider than F3.
-README / PRIVACY false CA claimsWas a blocker-class doc defect, now fixed in 1f0423c.
-NODE_USE_SYSTEM_CA login-item residue undocumentedPreference. Omission, not a false claim. Fold into whichever change picks up F1/F2.
-Fixes #790 over-closes (author's own flag)Needs a human, unchanged from the author's note. F1, F2, and finding 2 above all want a narrower macOS-only successor issue.

Nothing here is a production blocker. Merging is safe.

PR-body text that needs correcting

Two places repeat the claim fixed in 1f0423c. Under Degrading off darwin:

  • no CA on disk - proxy mode was never on, so there is nothing to be trusted or untrusted. An ordinary install's text output and JSON shape are unchanged, and the probes are not even called (asserted).

should read

  • no CA on disk - proxy mode was never on, so there is nothing to be trusted or untrusted. An ordinary install's text output is unchanged, proxy_trust is null rather than absent (the renderer's null-not-omitted contract), and the probes are not even called (asserted).

and in the Coverage line, darwin with no CA (V1 surface unchanged) should
read darwin with no CA (text surface unchanged, proxy_trust null).

The body should also gain a line for the doc corrections in 1f0423c, since
"What this does" currently describes a code-only change: "Corrects
README.md and docs/PRIVACY.md, which still described the pre-LLP-0237/0238 CA
model (no trust-store write, detach deletes the CA)."

What I could not exercise

This is a Linux host. Everything about darwin behaviour below is read from code
and the LLP corpus, not run:

  • security verify-cert / security add-trusted-cert / security delete-certificate, so the real trusted probe, the keychain dialog, and a
    locked-keychain GUI prompt (finding 3's residual risk) are all unrun.
  • launchctl getenv / setenv / unsetenv and the login LaunchAgent.
  • The real hyp attach claude proxy flow and the post-detach hyp status
    render that finding 1 describes. That state is traced through
    client_detach_disk.js, clients.js and LLP 0238, not observed.
  • The corrected README / PRIVACY claims are checked against LLP 0237, LLP 0238
    and the code that implements them, not against a live keychain.
  • No codex and no code-review, so this is an unassisted manual read.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage: ship, with successor issue #793

Path B. Both review rounds are recorded, the cap is reached, and every unresolved finding classifies as a preference, not a production defect. Gates re-verified independently on this branch at 1f0423c: npm test 4173 pass / 0 fail / 1 skipped, npm run typecheck clean, node --test test/core/status-proxy-trust.test.js 6/6.

Classification of the unresolved findings

1. hyp status does not name the CA's permitted hosts (round 2, medium): preference. This was the closest call, argued explicitly. LLP 0238's Consequences say verbatim that "the dialog and hyp status must name all permitted hosts, so the grant is informed", and the grant covers api.openai.com and chatgpt.com on installs using neither, so the requirement is real, Accepted, and about exactly the surface this PR builds while collectProxyTrust already holds ca.hosts. It is still not a blocker, for three reasons. First, the consent moment is the attach dialog, and that half is implemented and annotated: the user is informed at grant time, so the missing status half weakens ongoing inspectability rather than the grant's informedness. Second, on master this requirement was equally unmet because the status surface did not exist at all; merging strictly narrows the gap and creates no wrong behaviour, no false claim, and no regression, which is the definition of an incomplete-but-honest increment. Third, adding a user-visible line and a --json key at the final review round, with no round left to review it, would be a worse process outcome than a tracked follow-up for a change round 2 correctly sized at one field, one line, one key, one assertion. It is captured as a named medium item in #793 so the "must" is not lost.

2. Post-detach standing repair prompt (round 1, low): preference. Traced independently: releaseProxyModeLaunchdEnv gates on marker.mode === 'proxy' plus darwin and releases the variable, while the CA and its trust survive detach by design (LLP 0238#ca-survives-detach), so the state is real. But the rendered line is factually correct about the machine, overall stays healthy, no diagnostic or exit code changes, and the identical rendering is exactly right in the sibling state (CA minted by the daemon, never attached) that shares the gate. Round 2 also established the fix needs mode plumbed into ClientAttachReport plus a behaviour decision. Follow-up in #793.

3. No timeout on the probes (round 1, low): preference. Pre-existing property of runServiceCommand, which the daemon status path already uses unguarded; a timeout belongs on the shared helper. Unexercisable on this host. In #793.

4. Docs omit the NODE_USE_SYSTEM_CA launchd var and login LaunchAgent: preference. An omission, not a false claim, outside the certificate paragraph the round 2 correction was bounded to. In #793.

The Fixes #790 decision

Fixes #790 stays, and successor issue #793 was filed before this triage marker. Reasoning: after merge, #790's F3 section would describe as unimplemented the exact thing this PR implements, so keeping #790 open would leave the maintainer a stale issue needing mental subtraction, while a fresh issue states only what actually remains. #793 carries F1, F2, F3's two remaining lifecycle options (fingerprint-drift re-trust prompt, re-mint trigger narrowing), and all four items above, with the macOS verification caveat attached. Nothing is silently lost when this merges. This also matches the author's own recommendation in the PR body and this repo's existing follow-up-issue pattern.

The Linux caveat, weighed

Every darwin path in this PR (security verify-cert, launchctl getenv, the keychain, the LaunchAgent, the rendered block on a real Mac, the post-detach state) was reviewed by reading, not execution, across both rounds and this triage. I ship anyway because the change is read-only observability with a fail-closed shape: the probes are injectable, cannot spawn under the test runner (LLP 0181 guard, verified at service_ops.js), degrade per-probe to a null the surfaces render as "unknown" rather than a false negative, and are never called without a CA on disk (asserted). The worst darwin-only failure mode is a wrong or hanging probe, which is finding 3's pre-existing helper class, not a new one. The residual risk belongs to the acceptance-smoke tier; #793 records that whichever change ships first should carry a real-Mac pass per docs/ACCEPTANCE.md.

The round 2 doc corrections to README.md / docs/PRIVACY.md were re-checked against LLP 0237/0238 and the code: the previous text was the negation of what the code does in the file a user reads to consent, so fixing it here was in scope and correct.

Not marked ready and not merged; that is the maintainer's call.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 16, 2026
@philcunliffe
philcunliffe merged commit d0f7c4a into masterAug 17, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-790 branch August 17, 2026 05:35
philcunliffe pushed a commit that referenced this pull request Aug 17, 2026
One conflict, in src/core/commands/status.js: both sides added an import
line at the same spot. #792 (proxy-mode trust) added `ENV_VAR_NAME` from
`../daemon/launchd_env.js`; this branch added `sanitizeLabel` from
`../util/json_util.js`. Both kept.
The `printable()` collision this PR anticipated did not happen: #681 has
not landed, so `src/core/commands/status.js` still holds exactly one
`printable()` and one `MAX_ERROR_CHARS` (400), and `src/core/commands/
daemon.js` holds its own single pair. Label sites use `sanitizeLabel`'s
120 default, error sites pass 400.
Both intents survive unchanged: this branch's cleaning of every
interpolation it targets, including the collector-assembled
`remote_config_rolled_back` message whose cleaned prose reaches `--json`
while `remote_config.last_rollback` beside it stays byte-exact, and
#792's `proxy trust` text block plus tri-state `proxy_trust` JSON key,
which stays raw and absent off-darwin exactly as it landed.
philcunliffe pushed a commit that referenced this pull request Aug 17, 2026
Master gained #792, which adds a `proxy trust` section to the same status
render and the same collector LLP 0228's maintenance-skip summary lands in.
The two are conceptually independent, so every conflict resolved as a union:
- src/core/commands/status.js: both imports kept
(`describeMaintenanceSkipReasons` and `ENV_VAR_NAME`).
- src/core/daemon/status.js: one `@import` line carrying both
`MaintenanceSkip*` and `ProxyTrustReport`, plus 0228's cache-types import.
- src/core/daemon/status.js: `collectHypAwareStatus` returns both
`maintenance` and `proxyTrust`.
- src/core/daemon/types.d.ts: `HypAwareStatusReport` carries both fields with
their own doc comments.
The render bodies merged without conflict and stay in separate places:
`proxy trust` after `recent clients`, `maintenance` after `first sync`, each
still gated on its own condition, neither swallowing the other's spacing.
#792's tri-state (`null` when a probe could not run) and its darwin-plus-CA
gate are untouched.
LLP 0228 is unique across origin/master, every remote branch, and
llp/tombstones after the merge.
Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 17, 2026
Conflict: the import block in `src/core/commands/status.js`. This branch
added `sanitizeLabel` (the render-time cleaning for the status-file strings
the text surface interpolates); master's #792 added `ENV_VAR_NAME` for the
`proxy trust` block's launchd line. Independent, so both imports stay.
Everything else auto-merged and was checked rather than assumed. The
collector stays raw: `gatewayDroppedUpstreams` still reads
`details.upstreams` and `details.upstreams_dropped_names` through
`stringList` alone, so `attributeDroppedUpstreams` still intersects
unsanitized names with `registered_presets` and its
`names.length === dropped` precondition still counts the file's names.
`printableUpstreamNames` still derives `+N more` from the raw list's
length, and each message's singular/plural still comes off the raw
`silent` / `covered` arrays, so cleaning cannot make a plural set read
singular. #792's `proxy trust` block, its tri-state `null`-when-unprobed
fields, and its absence off darwin or with no CA are untouched.
npm test: 4194 tests, 0 fail (baseline origin/master: 4183, 0 fail).
npm run typecheck: clean on both.
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: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.

Follow-up: deferred review findings from PR #782

1 participant

@philcunliffe