diff --git a/README.md b/README.md index 024bd3a0..224472ca 100644 --- a/README.md +++ b/README.md @@ -332,31 +332,38 @@ Such a client is then pointed at the gateway with `HTTPS_PROXY` and - **A machine-local certificate authority is generated** under `~/.hyp/hypaware/tls`, readable only by you, and name-constrained so it cannot vouch for any host outside the provider set HypAware intercepts. - On macOS it can also be added to your **login keychain** as a user-domain - trusted root, for a client whose transport trusts only the keychain: macOS - raises its own password dialog, and declining it leaves capture working - 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, every host the CA is permitted to vouch for, and whether the - keychain still trusts it. `hyp detach ` keeps the CA and the - trust, so re-attaching does not ask again; `hyp detach --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, + Trust stays file-scoped to the proxied client's own settings: nothing + installs it into any OS trust store, including your login keychain, and + anything wider is your own decision. Earlier releases attached Claude + Code by proxy and did add the CA to the macOS **login keychain** as a + user-domain trusted root, for a transport that trusts only the keychain; + macOS raised its own password dialog for that, it never needed admin + rights, and the machine-wide system keychain was never touched. If you + ran one of those releases, that trust setting is still on your account + until you remove it. `hyp status` shows + the fingerprint, every host the CA is permitted to vouch for, and whether + the login keychain still trusts it. `hyp detach ` leaves the CA + and any trust an earlier release was granted in place, because a detach is + not a statement about the certificate and no attach re-creates the grant; + `hyp detach --purge` and `hyp daemon uninstall` remove both. +- **On macOS, an earlier proxy attach also left 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 + whose only job is to re-run that command at each login. No attach writes + either one today; on a machine that ran one of those releases the agent + stays a login item until it is removed, and the variable stays session-wide + for other Node programs to 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 ` unsets the - variable and removes the agent, as do `hyp detach --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. + must be fully quit and reopened. `hyp detach --purge` and `hyp + daemon uninstall` clear both unconditionally, and `hyp attach claude` + unwinds them when it migrates a previously proxied machine. A plain `hyp + detach ` only clears them while that client's attach marker still + records a proxy attach, so on a machine already migrated to another attach + mode it is not the command that removes the leftover. `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 diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index d237091f..b2779cc5 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -69,36 +69,42 @@ outside the provider set HypAware's client adapters intercept (today `api.anthropic.com`, `api.openai.com`, `chatgpt.com`). All IP addresses are excluded. -**Where it is trusted.** On macOS, attach installs the CA into your **login -keychain** as a user-domain trusted root, because Claude Code's Remote -Control transport trusts only the keychain and nothing else. This does -change your account's certificate trust settings, which is why macOS itself -raises the password dialog: an application running as you that consults the +**Where it is trusted.** Trust stays file-scoped to the proxied client's own +settings: nothing HypAware runs installs the CA into an OS trust store, +including your login keychain, and anything wider is your own decision. +Earlier releases attached Claude Code by proxy and did install it into your +**login keychain** as a user-domain trusted root, because that client's +Remote Control transport trusted only the keychain and nothing else. That +changed your account's certificate trust settings, which is why macOS itself +raised the password dialog: an application running as you that consults the login keychain will accept certificates this CA signs, for those hosts. -Declining the dialog is supported and capture keeps working without it, with -only Remote Control's inbound channel lost. The change never needs admin -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. - -**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 +Declining the dialog was supported and capture kept working without it, with +only Remote Control's inbound channel lost. The change never needed admin +rights, and the machine-wide system keychain and other user accounts were +never modified. If you ran one of those releases, that trust setting is +still on your account until you remove it. + +**What else an earlier macOS attach left behind.** The keychain root only +took effect if `NODE_USE_SYSTEM_CA=1` was in the environment before Claude +Code started, so that attach also ran `launchctl setenv NODE_USE_SYSTEM_CA 1` +and installed 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`. +process, no HypAware code in it, and nothing is sent anywhere. No attach +writes either one today. On a machine that ran one of those releases it is +still a login item, and still a session-wide variable that other Node +programs will also read. **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. +the launchd variable is live. `hyp detach claude --purge` and `hyp daemon +uninstall` remove the CA, its keychain trust, the launchd variable, and the +login agent. A plain `hyp detach claude` leaves the CA and any trust an +earlier release was granted in place, because a detach is not a statement +about the certificate and no attach re-creates the grant; it clears the +launchd variable and its agent only while that client's attach marker still +records a proxy attach. ## Where it goes diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index e8c1ced7..80f0e12d 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -327,9 +327,10 @@ function buildCoreCommands(registry) { '', 'Detaching stops future capture for that client; it does not delete', 'anything already recorded (see hyp purge for that).', - 'A proxy-mode detach keeps the local interception CA and its keychain', - 'trust so a later re-attach needs no new password dialog; --purge', - 'removes both as well.', + 'A detach keeps the local interception CA, and any OS trust store', + 'grant an earlier release was given: no attach re-creates that grant,', + 'so it is a leftover rather than a convenience being held for you.', + '--purge removes the CA and that trust as well.', '--dry-run reports what would change without writing.', ].join('\n'), aliases: ['unattach'], diff --git a/test/core/proxy-trust-claims.test.js b/test/core/proxy-trust-claims.test.js new file mode 100644 index 00000000..b410056f --- /dev/null +++ b/test/core/proxy-trust-claims.test.js @@ -0,0 +1,236 @@ +// @ts-check + +// LLP 0262 moved Claude Code off proxy capture, and the last production caller +// that installed the interception CA into a trust store went with it. The +// product prose did not all move. The README's "Proxy mode" section, the +// privacy document, and `hyp detach --help` all still described attach adding +// the CA to the macOS login keychain, still described macOS raising a password +// dialog for it, and still sold a detach as sparing the reader that dialog on +// the next attach. +// +// The last one is the expensive kind of wrong: it reads the keychain trust an +// older release left on the account as a convenience being held for you, when +// it is a leftover nothing re-creates and only `hyp detach --purge` or +// `hyp daemon uninstall` clears. +// +// This is a lint over documents, not a behavior check. It pairs a fact about +// the tree (the two trust writers have no caller outside their own modules) +// with the claims those documents may not make while that fact holds, so the +// prose cannot drift back without the fact drifting back too. +// +// @ref LLP 0262#migration [tests]: attach offers the CA purge and never re-creates the keychain grant, so no document may promise one is waiting + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +// The two writers a proxy attach used to run, each paired with the module that +// may still name it: its own, which keeps exporting it for the day another +// client needs it. The keychain root and the launchd variable that made the +// root count are one grant in two halves, so no half may be described in the +// present tense while neither half has a caller. +const TRUST_INSTALLERS = [ + { symbol: 'installCaTrust', home: 'src/core/tls/darwin_trust.js' }, + { symbol: 'installLaunchdEnv', home: 'src/core/daemon/launchd_env.js' }, +] + +/** + * Every document that carries the claim, with its own banned phrases and its + * own required replacements. Per document, not one shared list: these say the + * same thing in three registers (product prose, privacy prose, terminal help), + * and a phrase that has to be present in the README reads as a non-sequitur in + * `hyp detach --help`. Adding a fourth document means writing its own pair of + * lists, which is the point: the gate widens by stating what that document + * must say, never by demanding another's sentences. + * + * `banned` catches a revert. `required` catches a rewrite: a paragraph + * re-documented from scratch could re-assert every banned claim in fresh words + * and trip nothing, so each document must also still carry the sentences that + * make the leftover explicit. Dropping one fails here even though it matches no + * banned phrase. + * + * Reword a `required` phrase freely, but say the same thing and update this + * list in the same commit; the list is the claim, not the wording. Phrases + * match whitespace-insensitively, so reflowing a paragraph does not hide one. + */ +const SCANNED = [ + { + file: 'README.md', + banned: [ + { + phrase: 'added to your **login keychain**', + truth: 'no attach installs the CA into a trust store; an earlier release did, and that trust is a leftover the reader has to purge', + }, + { + phrase: 'raises its own password dialog', + truth: 'nothing HypAware runs raises that dialog any more, so the present tense promises a step that never comes', + }, + { + phrase: 'so re-attaching does not ask again', + truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', + }, + { + phrase: 'a proxy attach also leaves a login-session variable behind', + truth: 'no attach runs launchctl setenv NODE_USE_SYSTEM_CA 1 or installs the login agent; on a machine that ran an earlier release both are leftovers to remove', + }, + { + phrase: '`hyp detach ` unsets the variable and removes the agent', + truth: 'a plain detach releases the launchd environment only for a marker that still records a proxy attach, so on a migrated machine it is not the command that clears the leftover', + }, + ], + required: [ + { + phrase: 'nothing installs it into any OS trust store', + why: 'the CA bullet has to say outright that no trust store is written, or a reader takes the keychain install to be current', + }, + { + phrase: 'that trust setting is still on your account until you remove it', + why: 'the keychain grant an earlier release was given is a leftover to purge, and this is where the reader learns it is theirs to clear', + }, + { + phrase: 'No attach writes either one today', + why: 'the launchd variable and its login agent are leftovers on the same terms, and the bullet says so only while this sentence survives', + }, + { + phrase: 'only clears them while that client\'s attach marker still records a proxy attach', + why: 'releaseProxyModeLaunchdEnv returns early on a non-proxy marker, so pointing a migrated machine at a plain detach would name a command that does nothing for it', + }, + ], + }, + { + file: 'docs/PRIVACY.md', + banned: [ + { + phrase: 'attach installs the CA into your **login keychain**', + truth: 'no attach installs the CA into a trust store; an earlier release did, and the privacy document is the last place that may imply otherwise', + }, + { + phrase: 'which is why macOS itself raises the password dialog', + truth: 'nothing HypAware runs raises that dialog any more', + }, + { + phrase: 'so attach also runs `launchctl setenv NODE_USE_SYSTEM_CA 1`', + truth: 'no attach runs it today; on a machine that ran an earlier release the variable and its login agent are leftovers to remove', + }, + { + phrase: 'so re-attaching later does not ask for your password again', + truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', + }, + ], + required: [ + { + phrase: 'nothing HypAware runs installs the CA into an OS trust store', + why: 'the privacy document is where a reader checks what touched their machine, so it has to state that no trust store is written', + }, + { + phrase: 'that trust setting is still on your account until you remove it', + why: 'a reader who ran an earlier release learns here that the grant is theirs to clear', + }, + { + phrase: 'No attach writes either one today', + why: 'the launchd variable and its login agent are leftovers on the same terms', + }, + { + phrase: 'no attach re-creates the grant', + why: 'without it the lifetime paragraph reads as a detach holding a convenience open, which is the claim this gate exists to keep out', + }, + ], + }, + { + file: 'src/core/cli/core_commands.js', + banned: [ + { + phrase: 'needs no new password dialog', + truth: 'the detach help text is read at the moment the user is deciding, and no attach re-creates the grant it promises to spare them', + }, + ], + required: [ + { + phrase: 'no attach re-creates that grant', + why: 'the help text has to say the kept trust is a leftover, or a terminal contradicts the README a line at a time', + }, + ], + }, +] + +/** + * @param {string} phrase + * @returns {RegExp} + */ +function phrasePattern(phrase) { + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(escaped.replace(/\s+/g, '\\s+')) +} + +/** @returns {string[]} one ` names ` entry per production module outside the writer's home that names it */ +function trustInstallerNamers() { + const listed = execFileSync('git', ['ls-files', '-z', '*.js'], { cwd: REPO_ROOT, encoding: 'utf8' }) + const candidates = listed + .split('\0') + .filter(file => file !== '' && !file.startsWith('test/')) + // A tracked path can be absent from the working tree mid-rebase. That is + // not a caller, and this gate must not die on it. + .filter(file => fs.existsSync(path.join(REPO_ROOT, file))) + /** @type {string[]} */ + const namers = [] + for (const file of candidates) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const { symbol, home } of TRUST_INSTALLERS) { + // Word-bounded, so a future `installCaTrustForHost` is not read as this + // one. Still only a mention, not proof of a call: a `{@link}` in a + // comment trips it too, which is why the failure below says "names" + // rather than accusing the file of installing anything. + if (file !== home && new RegExp(`\\b${symbol}\\b`).test(text)) namers.push(`${file} names ${symbol}`) + } + } + return namers +} + +/** @returns {string[]} one entry per stale claim still standing in the scanned documents */ +function staleClaims() { + /** @type {string[]} */ + const found = [] + for (const { file, banned } of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const claim of banned) { + const match = phrasePattern(claim.phrase).exec(text) + if (!match) continue + const line = text.slice(0, match.index).split('\n').length + found.push(`${file}:${line} "${claim.phrase}"\n true instead: ${claim.truth}`) + } + } + return found +} + +/** @returns {string[]} one entry per required truth a scanned document no longer states */ +function missingTruths() { + /** @type {string[]} */ + const missing = [] + for (const { file, required } of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const truth of required) { + if (phrasePattern(truth.phrase).test(text)) continue + missing.push(`${file} "${truth.phrase}"\n why it has to stay: ${truth.why}`) + } + } + return missing +} + +test('no document promises a trust step that no attach performs', () => { + assert.deepEqual( + trustInstallerNamers(), + [], + 'a production module outside its home names a proxy trust writer: if it now calls one, the claims this gate bans are true again and the gate needs rewriting rather than the docs; if it only mentions one in prose, reword the mention' + ) + + const found = staleClaims() + assert.deepEqual(found, [], `stale trust-store claims:\n${found.join('\n')}`) + + const missing = missingTruths() + assert.deepEqual(missing, [], `a document dropped what it has to say instead:\n${missing.join('\n')}`) +})