Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,25 @@ Such a client is then pointed at the gateway with `HTTPS_PROXY` and
with that inbound channel off. No admin rights are needed and the
machine-wide system keychain is not touched. On other platforms trust
stays file-scoped to the client's own settings. `hyp status` shows the
fingerprint and whether the keychain still trusts it.
`hyp detach <client>` keeps the CA and the trust, so re-attaching does not
ask again; `hyp detach <client> --purge` and `hyp daemon uninstall` remove
both.
fingerprint, every host the CA is permitted to vouch for, and whether the
keychain still trusts it. `hyp detach <client>` keeps the CA and the
trust, so re-attaching does not ask again; `hyp detach <client> --purge`
and `hyp daemon uninstall` remove both.
- **On macOS, a proxy attach also leaves a login-session variable behind.**
Bun picks its trust store before any settings file is read, so a keychain
root only counts if `NODE_USE_SYSTEM_CA=1` is already in the process
environment. The attach that trusted the CA therefore ran `launchctl
setenv NODE_USE_SYSTEM_CA 1` and installed a small LaunchAgent,
`~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist`,
whose only job is to re-run that command at each login. It stays a login
item on your machine until it is removed, and it is a session-wide
variable that other Node programs read too. `launchctl setenv` reaches
processes launched after it, so a terminal app that was already running
must be fully quit and reopened. `hyp detach <client>` unsets the
variable and removes the agent, as do `hyp detach <client> --purge` and
`hyp daemon uninstall`; `hyp attach claude` unwinds it when it migrates a
previously proxied machine; and `hyp status` shows whether the variable is
currently live.
- **Only the hosts a registered upstream names are decrypted.** Every other
host the client talks to is tunnelled through without being decrypted.
- **What gets recorded does not change.** Only the recorded API paths are
Expand Down
19 changes: 16 additions & 3 deletions docs/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,22 @@ rights, and the machine-wide system keychain and other user accounts are
never modified. On other platforms the CA is trusted only by Claude Code,
through that client's own settings.

**Its lifetime.** `hyp status` shows the fingerprint and whether the
keychain still trusts it. `hyp detach claude` deliberately keeps the CA and
the trust in place, so re-attaching later does not ask for your password
**What else macOS attach leaves behind.** The keychain root only takes
effect if `NODE_USE_SYSTEM_CA=1` is in the environment before Claude Code
starts, so attach also runs `launchctl setenv NODE_USE_SYSTEM_CA 1` and
installs a LaunchAgent at
`~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist` that
re-runs that one command at each login. What it runs is `/bin/launchctl`
itself, once, which sets the variable and exits: there is no resident
process, no HypAware code in it, and nothing is sent anywhere. It is still a
login item on your machine, and a session-wide variable that other Node
programs will also read. `hyp detach claude` unsets the variable and removes
the agent, as do `hyp detach claude --purge` and `hyp daemon uninstall`.

**Its lifetime.** `hyp status` shows the fingerprint, every host the CA is
permitted to vouch for, whether the keychain still trusts it, and whether
the launchd variable is live. `hyp detach claude` deliberately keeps the CA
and the trust in place, so re-attaching later does not ask for your password
again; `hyp detach claude --purge` and `hyp daemon uninstall` remove the CA
and its keychain trust.

Expand Down
11 changes: 10 additions & 1 deletion src/core/commands/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,16 @@ async function purgeProxyTrustResidue({ ctx }) {
try {
const trust = await removeCaTrust({ homeDir })
if (trust.removed) lines.push('removed the HypAware Local CA keychain trust')
else if (trust.detail) lines.push(`! keychain trust could not be removed (${trust.detail})`)
// Removal deletes duplicate roots one pass at a time, so "some went" and
// "something is left" are not exclusive: a detail alongside a removal
// has to be its own line or the residue it names goes unreported.
if (trust.detail) {
lines.push(
trust.removed
? `! keychain trust may not be fully removed (${trust.detail})`
: `! keychain trust could not be removed (${trust.detail})`
)
}
} catch (err) {
lines.push(
`! keychain trust could not be removed (${err instanceof Error ? err.message : String(err)})`
Expand Down
30 changes: 30 additions & 0 deletions src/core/commands/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,11 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) {
// `ca_trusted` / `launchd_env_set` are tri-state: `null` means the probe
// could not run, which a consumer must not read as "not trusted".
// @ref LLP 0237#consequences [implements]: --json carries the trust state next to the CA fingerprint
// @ref LLP 0238#consequences [implements]: and the full permitted host set the grant covers
proxy_trust: report.proxyTrust
? {
ca_fingerprint: report.proxyTrust.caFingerprint,
permitted_hosts: report.proxyTrust.hosts,
ca_trusted: report.proxyTrust.trusted,
launchd_env_set: report.proxyTrust.launchdEnvSet,
}
Expand Down Expand Up @@ -539,11 +541,18 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std
// dialog was cancelled last month" and "the CA was re-minted and the
// keychain still trusts the old one", neither of which any other line here
// can be read for.
//
// The permitted hosts are named here and not only in the attach dialog: the
// grant covers every provider host the product can intercept, so on an
// install that captures Claude alone it is wider than anything the config
// shows, and after attach this is the only place it can be re-read.
// @ref LLP 0237#consequences [implements]: the trust state is reported next to the CA fingerprint, so a cancelled dialog is diagnosable without re-running attach
// @ref LLP 0238#consequences [implements]: hyp status names all permitted hosts, so the standing grant stays informed and not just the moment it was asked for
// @ref LLP 0239#terminals-predating-attach [implements]: and next to it, whether the launchd environment carries the variable
if (report.proxyTrust) {
stdout.write(' proxy trust:\n')
stdout.write(` ca fingerprint: ${report.proxyTrust.caFingerprint}\n`)
stdout.write(` permitted: ${describePermittedHosts(report.proxyTrust.hosts)}\n`)
stdout.write(` login keychain: ${describeCaTrust(report.proxyTrust.trusted)}\n`)
stdout.write(` launchd env: ${describeLaunchdEnv(report.proxyTrust.launchdEnvSet)}\n`)
}
Expand Down Expand Up @@ -691,6 +700,27 @@ function describeCaTrust(trusted) {
return 'unknown - the keychain probe could not run'
}

/**
* The host set the trust grant covers. Printed as the certificate's own
* permitted subtrees, in the order the DER carries them, so the line is the
* grant rather than a restatement of the configured providers.
*
* An empty set is not "no hosts": a CA carrying no `dNSName` constraint at
* all can vouch for anything, which is the one reading the user most needs,
* so it is named rather than rendered as a blank line. HypAware's own mint
* never produces one (LLP 0238#full-provider-constraints), so this arm only
* fires for a foreign or damaged certificate at the CA path. That same
* certificate is why `collectProxyTrust` sanitizes the entries before they
* reach here: they are bytes off disk, not strings we wrote.
*
* @param {string[]} hosts
* @returns {string}
*/
function describePermittedHosts(hosts) {
if (hosts.length === 0) return 'no dNSName constraints found - this CA is not host-limited'
return hosts.join(', ')
}

/**
* The launchd half of the `proxy trust` block. Same tri-state, same reason.
*
Expand Down
10 changes: 7 additions & 3 deletions src/core/daemon/launchd_env.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,17 @@ export async function removeLaunchdEnv({ homeDir, run = defaultRunner } = {}) {

/**
* Whether the variable is present in the launchd user environment, for
* `hyp status` style reporting.
* `hyp status` style reporting. `timeoutMs` bounds the spawn, for the same
* reason `isCaTrusted` takes one: a status run has nobody waiting on it who
* could decide to give up.
*
* @param {object} args
* @param {TrustCommandRunner} [args.run]
* @param {number} [args.timeoutMs]
* @returns {Promise<boolean>}
*/
export async function isLaunchdEnvSet({ run = defaultRunner } = {}) {
const result = await run('launchctl', ['getenv', ENV_VAR_NAME])
export async function isLaunchdEnvSet({ run, timeoutMs } = {}) {
const runner = run ?? ((cmd, args) => runServiceCommand(cmd, args, { timeoutMs }))
const result = await runner('launchctl', ['getenv', ENV_VAR_NAME])
return result.exitCode === 0 && result.stdout.trim() === ENV_VAR_VALUE
}
56 changes: 54 additions & 2 deletions src/core/daemon/service_ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ export class ServiceManagerSandboxError extends ServiceOpError {
}
}

/**
* Error raised when a service-manager command outlived the timeout its
* caller set. A rejection rather than a non-zero result on purpose: a
* command that was killed never answered, and a probe that maps exit codes
* to a boolean would otherwise read "killed" as "no" - a false negative
* dressed as a measurement. Callers that already treat "the probe could not
* run" as unknown get that answer for free.
*/
export class ServiceCommandTimeoutError extends ServiceOpError {
/**
* @param {string} message
*/
constructor(message) {
super(message)
this.name = 'ServiceCommandTimeoutError'
}
}

/**
* Env var that opts a test back into driving the host's own service
* manager. Deliberately awkward to type: there is no legitimate use for
Expand Down Expand Up @@ -115,24 +133,58 @@ function serviceManagerSpawnRefusal(bin, args) {
* {@link ensureOk}). Rejects without spawning under the test runner (see
* {@link serviceManagerSpawnRefusal}).
*
* `timeoutMs` bounds how long the child may take. Opt-in, because the
* commands that reach here are not alike: a mutation the user is answering
* a password dialog for may legitimately take minutes, while a read-only
* probe run from `hyp status` that has not answered in seconds is not going
* to. Whoever knows which one it is sets the bound. On expiry the child is
* killed and the promise rejects with {@link ServiceCommandTimeoutError};
* `SIGKILL` rather than `SIGTERM` because the case worth bounding is a
* process blocked on a GUI keychain prompt, which is exactly the state that
* ignores a polite signal.
*
* @param {string} bin
* @param {string[]} args
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<ServiceCommandResult>}
*/
export function runServiceCommand(bin, args) {
export function runServiceCommand(bin, args, opts = {}) {
const refusal = serviceManagerSpawnRefusal(bin, args)
// Reject rather than throw: callers such as `installLaunchAgent`'s
// best-effort bootout attach a `.catch()` to the returned promise, which a
// synchronous throw would sail straight past.
if (refusal) return Promise.reject(refusal)
const { timeoutMs } = opts
return new Promise(function(resolve, reject) {
const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
let timedOut = false
/** @type {NodeJS.Timeout | undefined} */
let timer
if (timeoutMs !== undefined) {
timer = setTimeout(function() {
timedOut = true
proc.kill('SIGKILL')
reject(new ServiceCommandTimeoutError(
`'${[bin, ...args].join(' ')}' did not finish within ${timeoutMs}ms and was killed`
))
}, timeoutMs)
// Nothing should be kept alive waiting for this: the timer exists to
// end a wait, never to extend the process past one.
timer.unref()
}
proc.stdout.on('data', function(chunk) { stdout += chunk.toString('utf8') })
proc.stderr.on('data', function(chunk) { stderr += chunk.toString('utf8') })
proc.on('error', reject)
proc.on('error', function(err) {
if (timer) clearTimeout(timer)
reject(err)
})
proc.on('close', function(code) {
if (timer) clearTimeout(timer)
// The close that follows our own SIGKILL is not an answer, and the
// promise has already rejected with the one that is.
if (timedOut) return
resolve({ exitCode: code === null ? -1 : code, stdout, stderr })
})
})
Expand Down
48 changes: 40 additions & 8 deletions src/core/daemon/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
readLocalOnlyDirs,
} from '../usage-policy/index.js'
import { readFirstSyncDeadline } from '../usage-policy/first_sync_hold.js'
import { readLocalCaInfo } from '../tls/ca.js'
import { displayableCaHosts, readLocalCaInfo } from '../tls/ca.js'
import { isCaTrusted as probeCaTrusted } from '../tls/darwin_trust.js'
import { isLaunchdEnvSet as probeLaunchdEnvSet } from './launchd_env.js'
import { resolveClientSettingsPath } from './client_settings_path.js'
Expand Down Expand Up @@ -1463,8 +1463,10 @@ export async function collectHypAwareStatus(opts = {}) {
const proxyTrust = await collectProxyTrust({
platform,
stateRoot,
isCaTrustedFn: opts.isCaTrusted ?? probeCaTrusted,
isLaunchdEnvSetFn: opts.isLaunchdEnvSet ?? probeLaunchdEnvSet,
isCaTrustedFn: opts.isCaTrusted
?? ((args) => probeCaTrusted({ ...args, timeoutMs: TRUST_PROBE_TIMEOUT_MS })),
isLaunchdEnvSetFn: opts.isLaunchdEnvSet
?? (() => probeLaunchdEnvSet({ timeoutMs: TRUST_PROBE_TIMEOUT_MS })),
})

// ----- recent errors -----
Expand Down Expand Up @@ -1523,6 +1525,19 @@ export async function collectHypAwareStatus(opts = {}) {
}
}

/**
* How long either trust probe may take before `hyp status` gives up on it.
*
* Both are table reads (`security verify-cert` against a local root with no
* AIA to chase, `launchctl getenv`), so the bound is not a performance
* budget: it is there because a locked login keychain can put `security`
* behind a GUI prompt, and `hyp status` is a report, not a dialog - nobody
* is watching it who could decide to stop waiting. Timing out reports
* `unknown` for that half, which is the honest answer and is exactly what
* the probe-failure path already renders.
*/
const TRUST_PROBE_TIMEOUT_MS = 5_000

/**
* Proxy mode's two invisible preconditions, read once so `hyp status` can
* state them: does the login keychain still trust the CA on disk
Expand All @@ -1539,10 +1554,26 @@ export async function collectHypAwareStatus(opts = {}) {
* The two probes shell out, so each is caught independently: a probe that
* could not run reports `null` (unknown), never `false`, because "the
* dialog was cancelled" and "`security` did not run" are different answers
* and only the first is actionable. Nothing here carries text from another
* process onto the terminal - the fingerprint is computed locally from the
* DER and is `[0-9A-F:]` by construction, and probe stderr is deliberately
* not surfaced - so no LLP 0225 sanitizing applies.
* and only the first is actionable. The fingerprint is computed locally from
* the DER and is `[0-9A-F:]` by construction, and probe stderr is deliberately
* not surfaced, so neither needs bounding.
*
* The permitted host set travels with the fingerprint because the grant is
* wider than any one install uses: the CA is constrained to the whole static
* provider set, so a user who trusts it while capturing only Claude still
* carries a grant covering `api.openai.com` and `chatgpt.com`. The attach
* dialog names them; so must this, or the standing grant is only ever stated
* once, at the moment it is asked for. The strings come from the DER's own
* permitted subtrees, so they are the grant itself rather than a
* config-derived guess that could drift from it.
*
* That last property is also why the hosts are the one field here that does
* need sanitizing (LLP 0225): they are bytes off disk rather than strings we
* wrote, so a foreign or damaged certificate at the CA path can carry an
* `ESC` run, a newline, or ten thousand subtrees into a line `hyp status`
* prints. `displayableCaHosts` is that policy, shared with the attach dialog
* that names the same grant, and applied here at collection like every other
* label in this file so `--json` carries exactly what was printed.
*
* @param {object} args
* @param {NodeJS.Platform} args.platform
Expand All @@ -1551,6 +1582,7 @@ export async function collectHypAwareStatus(opts = {}) {
* @param {() => Promise<boolean>} args.isLaunchdEnvSetFn
* @returns {Promise<ProxyTrustReport | null>}
* @ref LLP 0237#consequences [implements]: hyp status reports the trust state alongside the CA fingerprint, so a cancelled dialog is diagnosable without re-running attach
* @ref LLP 0238#consequences [implements]: hyp status names all permitted hosts, so a grant wider than the configured providers stays informed
* @ref LLP 0239#terminals-predating-attach [implements]: hyp status reports whether the variable is present in the launchd environment
*/
async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchdEnvSetFn }) {
Expand Down Expand Up @@ -1580,7 +1612,7 @@ async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchd
launchdEnvSet = null
}

return { caFingerprint: ca.fingerprint, trusted, launchdEnvSet }
return { caFingerprint: ca.fingerprint, hosts: displayableCaHosts(ca.hosts), trusted, launchdEnvSet }
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/core/daemon/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,17 @@ export interface ServiceState {
export interface ProxyTrustReport {
/** SHA-256 fingerprint of the CA on disk, colon-separated uppercase hex. */
caFingerprint: string
/**
* The CA's permitted `dNSName` subtrees: every host this grant lets the CA
* vouch for, which is the full provider set and not the subset this install
* captures (LLP 0238#full-provider-constraints). Empty only for a
* certificate carrying no dNSName constraints at all. Passed through
* `displayableCaHosts`, because the bytes come from the certificate on disk
* rather than from us (LLP 0225): entries are sanitized, and a list longer
* than any real CA's ends in a `(+N more ...)` entry rather than being
* silently shortened.
*/
hosts: string[]
/** `security verify-cert -p ssl` against the CA, or null when it could not run. */
trusted: boolean | null
/** `launchctl getenv NODE_USE_SYSTEM_CA` is `1`, or null when it could not run. */
Expand Down
Loading
Loading