troxy-cli 1.29.2 → 1.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ // Source-inspection tests for glue code that can't be unit-tested directly
2
+ // without heavy mocking (reprovisionKeyConsumers does real execSync/fs
3
+ // calls against installService etc. - same reason init.test.js and
4
+ // tool_detect.test.js's own "wiring" section never call it directly either).
5
+ //
6
+ // Covers two things from the plan's Layer 3 spec:
7
+ // 1. reprovisionKeyConsumers's settings.json-writing gate is widened from
8
+ // hasClaude alone to (hasClaude || hasDesktop) for the Stop/PreToolUse
9
+ // hook step - a desktop-only machine used to get zero settings.json
10
+ // writes at all.
11
+ // 2. bin/troxy.js registers `troxy proxy status/enable/disable`, wired to
12
+ // src/proxy.js, and is NOT listed in the default help banner (it's
13
+ // deliberately undocumented per the plan's rollout order - not surfaced
14
+ // until after real hands-on validation).
15
+
16
+ import { describe, it } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const initSrc = readFileSync(join(__dirname, '..', 'init.js'), 'utf8');
24
+ const binSrc = readFileSync(join(__dirname, '..', '..', 'bin', 'troxy.js'), 'utf8');
25
+ const uninstallSrc = readFileSync(join(__dirname, '..', 'uninstall.js'), 'utf8');
26
+
27
+ describe('reprovisionKeyConsumers: desktop detection gate', () => {
28
+ it('computes hasDesktop via hasClaudeDesktop()', () => {
29
+ assert.match(initSrc, /const hasDesktop = hasClaudeDesktop\(\);/);
30
+ });
31
+
32
+ it('gates patchClaudeCodeHooks on (hasClaude || hasDesktop), not hasClaude alone', () => {
33
+ const hooksIdx = initSrc.indexOf('patchClaudeCodeHooks(claudeCodeSettingsPath())');
34
+ assert.ok(hooksIdx !== -1, 'patchClaudeCodeHooks call not found');
35
+ const preceding = initSrc.slice(Math.max(0, hooksIdx - 200), hooksIdx);
36
+ assert.match(
37
+ preceding,
38
+ /if\s*\(hasClaude\s*\|\|\s*hasDesktop\)\s*\{/,
39
+ 'the Stop/PreToolUse hook step must be gated on hasClaude || hasDesktop',
40
+ );
41
+ });
42
+
43
+ // Matches the exact `if (<condition>) {` guarding each call, whatever
44
+ // comments sit in between - robust to the block's own prose being edited,
45
+ // unlike a fixed-size slice of preceding characters.
46
+ function _guardCondition(callSnippet) {
47
+ const callIdx = initSrc.indexOf(callSnippet);
48
+ assert.ok(callIdx !== -1, `call not found: ${callSnippet}`);
49
+ const before = initSrc.slice(0, callIdx);
50
+ const ifMatches = [...before.matchAll(/if\s*\(([^)]*)\)\s*\{/g)];
51
+ assert.ok(ifMatches.length > 0, `no guarding if() found before: ${callSnippet}`);
52
+ return ifMatches[ifMatches.length - 1][1].trim();
53
+ }
54
+
55
+ it('keeps patchClaudeCodeConfig (project-scoped MCP registration) gated on hasClaude alone - terminal only', () => {
56
+ assert.equal(_guardCondition('patchClaudeCodeConfig(claudeCodeConfigPath(), key)'), 'hasClaude');
57
+ });
58
+
59
+ it('calls maybeEnableTroxyInterception (not the old base-URL substitution) gated on hasClaude alone', () => {
60
+ assert.equal(_guardCondition('await maybeEnableTroxyInterception(proxyOptIn, hasDesktop);'), 'hasClaude');
61
+ });
62
+
63
+ it('no longer calls maybeEnableClaudeCodeProxy from reprovisionKeyConsumers - terminal fully switched to interception', () => {
64
+ assert.ok(!initSrc.includes('await maybeEnableClaudeCodeProxy'));
65
+ });
66
+
67
+ it('maybeEnableTroxyInterception runs migration before the enable/proxyOptIn decision, so it always fires', () => {
68
+ const fnStart = initSrc.indexOf('async function maybeEnableTroxyInterception');
69
+ assert.ok(fnStart !== -1, 'maybeEnableTroxyInterception not found');
70
+ const fnBody = initSrc.slice(fnStart, fnStart + 1000);
71
+ const migrateIdx = fnBody.indexOf('migrateBaseUrlProxyToInterception(');
72
+ const optInIdx = fnBody.indexOf('proxyOptIn === true');
73
+ assert.ok(migrateIdx !== -1, 'migrateBaseUrlProxyToInterception call not found in function body');
74
+ assert.ok(optInIdx !== -1, 'proxyOptIn check not found in function body');
75
+ assert.ok(migrateIdx < optInIdx, 'migration must run unconditionally, before the enable/proxyOptIn decision');
76
+ });
77
+ });
78
+
79
+ describe('bin/troxy.js: proxy subcommand wiring', () => {
80
+ it("registers case 'proxy' importing from src/proxy.js", () => {
81
+ assert.ok(binSrc.includes("case 'proxy'"));
82
+ assert.ok(binSrc.includes("'../src/proxy.js'"));
83
+ });
84
+
85
+ it('dispatches status/enable/disable to the matching proxy.js export', () => {
86
+ const proxyBlock = binSrc.slice(binSrc.indexOf("case 'proxy'"), binSrc.indexOf("case 'proxy'") + 800);
87
+ assert.match(proxyBlock, /runProxyEnable/);
88
+ assert.match(proxyBlock, /runProxyDisable/);
89
+ assert.match(proxyBlock, /runProxyStatus/);
90
+ });
91
+
92
+ it('is NOT listed in the default help banner - deliberately undocumented until rollout step 5', () => {
93
+ const helpBannerStart = binSrc.indexOf("Troxy: a secure control layer for AI agents");
94
+ const helpBanner = binSrc.slice(helpBannerStart);
95
+ assert.ok(!helpBanner.includes('troxy proxy '), 'troxy proxy must not appear in the printed help banner yet');
96
+ });
97
+ });
98
+
99
+ describe('uninstall.js: settings.json symmetry (fail-open layer 6)', () => {
100
+ it('calls all four unpatch functions', () => {
101
+ for (const fn of ['unpatchClaudeCodeConfig', 'unpatchClaudeCodeHooks', 'unpatchClaudeCodeProxy', 'unpatchClaudeCodeInterception']) {
102
+ assert.ok(uninstallSrc.includes(`${fn}(`), `${fn} is not called from uninstall.js`);
103
+ }
104
+ });
105
+
106
+ it('reverts settings.json BEFORE deleting ~/.troxy, not after', () => {
107
+ const revertIdx = uninstallSrc.indexOf('unpatchClaudeCodeConfig(');
108
+ const deleteIdx = uninstallSrc.indexOf("path.join(os.homedir(), '.troxy')");
109
+ assert.ok(revertIdx !== -1 && deleteIdx !== -1);
110
+ assert.ok(revertIdx < deleteIdx, 'settings.json must be reverted before ~/.troxy is deleted - this is the exact fail-open gap the plan requires closed');
111
+ });
112
+
113
+ it('wraps the revert step in try/catch so a broken config file never blocks the rest of uninstall', () => {
114
+ const revertIdx = uninstallSrc.indexOf('unpatchClaudeCodeConfig(');
115
+ const surrounding = uninstallSrc.slice(Math.max(0, revertIdx - 200), revertIdx + 400);
116
+ assert.match(surrounding, /try\s*\{[\s\S]*unpatchClaudeCodeConfig[\s\S]*\}\s*catch/);
117
+ });
118
+ });
@@ -0,0 +1,222 @@
1
+ // Layer 2 of the Live Model Policy Enforcement plan: the certificate
2
+ // authority that lets the local interceptor terminate TLS for
3
+ // api.anthropic.com only. This is the single most security-sensitive file
4
+ // in the whole feature - a cert that impersonates Anthropic's API is
5
+ // powerful, so the nameConstraints extension (permitted;DNS:api.anthropic.com,
6
+ // critical) is the concrete, cryptographic answer to "this must never
7
+ // become a general MITM tool," not just a comment saying so. node-forge has
8
+ // no built-in friendly encoding for nameConstraints (verified directly
9
+ // against its source - only keyUsage/basicConstraints/extKeyUsage/
10
+ // subjectAltName/etc. are natively supported), so it's hand-built from raw
11
+ // ASN.1 here - exactly the kind of thing that's easy to get subtly wrong in
12
+ // a way that LOOKS right until a real client actually tries to validate it.
13
+ // The last test in this file does a genuine TLS handshake, not just DER
14
+ // inspection, specifically to catch that class of mistake.
15
+
16
+ import { describe, it, beforeEach, afterEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import tls from 'node:tls';
22
+ import net from 'node:net';
23
+ import forge from 'node-forge';
24
+
25
+ import {
26
+ generateCA,
27
+ generateLeaf,
28
+ certIsValid,
29
+ certExpiresWithin,
30
+ ensureInterceptionCerts,
31
+ } from '../tls-ca.js';
32
+
33
+ let dir;
34
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-tls-ca-')); });
35
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
36
+
37
+ describe('generateCA', () => {
38
+ it('is a CA certificate with pathlen:0', () => {
39
+ const { certPem } = generateCA('test-host');
40
+ const cert = forge.pki.certificateFromPem(certPem);
41
+ const bc = cert.getExtension('basicConstraints');
42
+ assert.equal(bc.cA, true);
43
+ assert.equal(bc.pathLenConstraint, 0);
44
+ assert.equal(bc.critical, true);
45
+ });
46
+
47
+ it('has keyCertSign + cRLSign key usage, critical', () => {
48
+ const { certPem } = generateCA('test-host');
49
+ const cert = forge.pki.certificateFromPem(certPem);
50
+ const ku = cert.getExtension('keyUsage');
51
+ assert.equal(ku.keyCertSign, true);
52
+ assert.equal(ku.cRLSign, true);
53
+ assert.equal(ku.critical, true);
54
+ });
55
+
56
+ it('has a nameConstraints extension, critical, restricted to api.anthropic.com', () => {
57
+ const { certPem } = generateCA('test-host');
58
+ const cert = forge.pki.certificateFromPem(certPem);
59
+ const nc = cert.getExtension({ id: '2.5.29.30' });
60
+ assert.ok(nc, 'nameConstraints extension is missing entirely');
61
+ assert.equal(nc.critical, true, 'nameConstraints must be critical - a non-critical ' +
62
+ 'constraint a client does not understand is required to be IGNORED per RFC 5280, ' +
63
+ 'which would silently defeat the whole scoping guarantee');
64
+ });
65
+
66
+ it('CN identifies this as a Troxy-issued interception CA, with the hostname, for cert-viewer clarity', () => {
67
+ const { certPem } = generateCA('my-macbook');
68
+ const cert = forge.pki.certificateFromPem(certPem);
69
+ const cn = cert.subject.getField('CN').value;
70
+ assert.match(cn, /Troxy/);
71
+ assert.match(cn, /my-macbook/);
72
+ });
73
+
74
+ it('is not expired and does not expire within the next year (long validity, not a leaf)', () => {
75
+ const { certPem } = generateCA('test-host');
76
+ assert.equal(certIsValid(certPem), true);
77
+ assert.equal(certExpiresWithin(certPem, 365), false);
78
+ });
79
+ });
80
+
81
+ describe('generateLeaf', () => {
82
+ it('chains to the issuing CA (verifiable, not just self-consistent)', () => {
83
+ const ca = generateCA('test-host');
84
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
85
+ const caCert = forge.pki.certificateFromPem(ca.certPem);
86
+ const leafCert = forge.pki.certificateFromPem(leaf.certPem);
87
+ assert.equal(caCert.verify(leafCert), true);
88
+ });
89
+
90
+ it('has serverAuth extended key usage', () => {
91
+ const ca = generateCA('test-host');
92
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
93
+ const cert = forge.pki.certificateFromPem(leaf.certPem);
94
+ const eku = cert.getExtension('extKeyUsage');
95
+ assert.equal(eku.serverAuth, true);
96
+ });
97
+
98
+ it('carries the requested hostname as a subjectAltName dNSName', () => {
99
+ const ca = generateCA('test-host');
100
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
101
+ const cert = forge.pki.certificateFromPem(leaf.certPem);
102
+ const san = cert.getExtension('subjectAltName');
103
+ const names = san.altNames.map(n => n.value);
104
+ assert.deepEqual(names, ['api.anthropic.com']);
105
+ });
106
+
107
+ it('defaults to a 90-day validity window', () => {
108
+ const ca = generateCA('test-host');
109
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
110
+ assert.equal(certExpiresWithin(leaf.certPem, 91), true);
111
+ assert.equal(certExpiresWithin(leaf.certPem, 1), false);
112
+ });
113
+ });
114
+
115
+ describe('ensureInterceptionCerts', () => {
116
+ const opts = () => ({ hostname: 'test-host', leafDnsNames: ['api.anthropic.com'] });
117
+
118
+ it('generates and writes CA + leaf files on first run', () => {
119
+ const result = ensureInterceptionCerts(dir, opts());
120
+ assert.ok(fs.existsSync(result.caCertPath));
121
+ assert.ok(fs.existsSync(result.caKeyPath));
122
+ assert.ok(fs.existsSync(result.leafCertPath));
123
+ assert.ok(fs.existsSync(result.leafKeyPath));
124
+ });
125
+
126
+ it('directory is 0700 and every file is 0600 - same convention as ~/.troxy/config.json', () => {
127
+ const result = ensureInterceptionCerts(dir, opts());
128
+ const dirMode = fs.statSync(path.dirname(result.caCertPath)).mode & 0o777;
129
+ assert.equal(dirMode, 0o700);
130
+ for (const p of [result.caCertPath, result.caKeyPath, result.leafCertPath, result.leafKeyPath]) {
131
+ assert.equal(fs.statSync(p).mode & 0o777, 0o600, `${p} is not 0600`);
132
+ }
133
+ });
134
+
135
+ it('re-running when everything is still healthy is idempotent - same CA, not regenerated', () => {
136
+ const first = ensureInterceptionCerts(dir, opts());
137
+ const second = ensureInterceptionCerts(dir, opts());
138
+ assert.equal(second.caCertPem, first.caCertPem);
139
+ assert.equal(second.leafCertPem, first.leafCertPem);
140
+ });
141
+
142
+ it('regenerates the CA when the existing one is corrupt, rather than throwing', () => {
143
+ fs.mkdirSync(path.join(dir, 'tls'), { recursive: true, mode: 0o700 });
144
+ fs.writeFileSync(path.join(dir, 'tls', 'troxy-local-ca.crt'), 'not a real cert', { mode: 0o600 });
145
+ assert.doesNotThrow(() => ensureInterceptionCerts(dir, opts()));
146
+ const result = ensureInterceptionCerts(dir, opts());
147
+ assert.equal(certIsValid(result.caCertPem), true);
148
+ });
149
+
150
+ it('re-issues an expiring leaf without touching the CA', () => {
151
+ const first = ensureInterceptionCerts(dir, opts());
152
+ // Force the leaf to look imminently-expiring by writing one that's
153
+ // already past the renewal window, signed by the SAME CA on disk.
154
+ const ca = { certPem: first.caCertPem, keyPem: fs.readFileSync(first.caKeyPath, 'utf8') };
155
+ const staleLeaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com'], { days: 1 });
156
+ fs.writeFileSync(first.leafCertPath, staleLeaf.certPem, { mode: 0o600 });
157
+ fs.writeFileSync(first.leafKeyPath, staleLeaf.keyPem, { mode: 0o600 });
158
+
159
+ const renewed = ensureInterceptionCerts(dir, opts());
160
+ assert.equal(renewed.caCertPem, first.caCertPem, 'CA must not be touched by a leaf renewal');
161
+ assert.notEqual(renewed.leafCertPem, staleLeaf.certPem, 'leaf was not actually renewed');
162
+ assert.equal(certExpiresWithin(renewed.leafCertPem, 30), false);
163
+ });
164
+ });
165
+
166
+ describe('name-constraint enforcement - a real TLS handshake, not just DER inspection', () => {
167
+ // This is the test that actually proves "can't become a general MITM
168
+ // tool" rather than asserting it. A client that trusts this CA must
169
+ // accept a leaf for api.anthropic.com and MUST REJECT a leaf for any
170
+ // other host, enforced by the TLS stack itself via the CA's
171
+ // nameConstraints extension - not by any code in this project choosing
172
+ // to behave correctly.
173
+ function listenWithCert(certPem, keyPem) {
174
+ return new Promise((resolve) => {
175
+ const server = tls.createServer({ cert: certPem, key: keyPem }, (socket) => {
176
+ socket.end();
177
+ });
178
+ server.listen(0, '127.0.0.1', () => resolve(server));
179
+ });
180
+ }
181
+
182
+ function attemptHandshake(port, caCertPem, servername) {
183
+ return new Promise((resolve) => {
184
+ const socket = tls.connect(
185
+ { host: '127.0.0.1', port, servername, ca: [caCertPem], rejectUnauthorized: true },
186
+ () => { socket.end(); resolve({ ok: true }); },
187
+ );
188
+ socket.on('error', (err) => resolve({ ok: false, error: err }));
189
+ });
190
+ }
191
+
192
+ it('a leaf for api.anthropic.com, signed by this CA, is trusted', async () => {
193
+ const ca = generateCA('test-host');
194
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
195
+ const server = await listenWithCert(leaf.certPem, leaf.keyPem);
196
+ try {
197
+ const result = await attemptHandshake(server.address().port, ca.certPem, 'api.anthropic.com');
198
+ assert.equal(result.ok, true, `expected the handshake to succeed, got: ${result.error}`);
199
+ } finally {
200
+ server.close();
201
+ }
202
+ });
203
+
204
+ it('a leaf for github.com, signed by the SAME CA, is REJECTED by name constraints - ' +
205
+ 'a leaked CA key still cannot impersonate any other host', async () => {
206
+ const ca = generateCA('test-host');
207
+ // Sign a leaf for an out-of-scope host directly with generateLeaf,
208
+ // simulating what an attacker with the CA key (but not this codebase's
209
+ // cooperation) could attempt.
210
+ const rogueLeaf = generateLeaf(ca.certPem, ca.keyPem, ['github.com']);
211
+ const server = await listenWithCert(rogueLeaf.certPem, rogueLeaf.keyPem);
212
+ try {
213
+ const result = await attemptHandshake(server.address().port, ca.certPem, 'github.com');
214
+ assert.equal(result.ok, false,
215
+ 'a client trusting this CA accepted a certificate for github.com - the ' +
216
+ 'nameConstraints extension is not actually being enforced, which defeats ' +
217
+ 'this entire feature\'s core security guarantee');
218
+ } finally {
219
+ server.close();
220
+ }
221
+ });
222
+ });
package/src/tls-ca.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The certificate authority for the local interceptor (Layer 2 of the Live
3
+ * Model Policy Enforcement plan). Generated once per install, entirely on
4
+ * this machine - the private key never leaves it and is never sent to
5
+ * Troxy. Scoped to intercept exactly one thing, cryptographically: a
6
+ * nameConstraints extension (permitted;DNS:api.anthropic.com, critical)
7
+ * means even a leaked CA key cannot sign a certificate any client trusting
8
+ * this CA would accept for any host other than api.anthropic.com. See
9
+ * tls-ca.test.js's last two tests - a real TLS handshake, not just DER
10
+ * inspection - for the proof.
11
+ *
12
+ * node-forge has no built-in friendly encoding for nameConstraints (only
13
+ * keyUsage/basicConstraints/extKeyUsage/subjectAltName/etc. are natively
14
+ * supported - verified directly against its source before writing this),
15
+ * so it's hand-built from raw ASN.1 per RFC 5280 in _nameConstraintsValue.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import forge from 'node-forge';
20
+
21
+ const CA_VALIDITY_DAYS = 730; // ~2 years
22
+ const LEAF_VALIDITY_DAYS = 90;
23
+ const LEAF_RENEW_WITHIN_DAYS = 30; // re-issue once fewer than this many days remain
24
+ const KEY_BITS = 2048;
25
+
26
+ function _daysFromNow(days) {
27
+ const d = new Date();
28
+ d.setDate(d.getDate() + days);
29
+ return d;
30
+ }
31
+
32
+ function _randomSerial() {
33
+ // Positive, non-zero, RFC 5280-compliant serial - a leading 00 byte
34
+ // avoids the value being misread as negative (DER INTEGERs are
35
+ // two's-complement) when the high bit of the first random byte is set.
36
+ let hex = forge.util.bytesToHex(forge.random.getBytesSync(16));
37
+ if (parseInt(hex[0], 16) >= 8) hex = '00' + hex;
38
+ return hex;
39
+ }
40
+
41
+ // NameConstraints ::= SEQUENCE { permittedSubtrees [0] GeneralSubtrees OPTIONAL, ... }
42
+ // GeneralSubtrees ::= SEQUENCE OF GeneralSubtree
43
+ // GeneralSubtree ::= SEQUENCE { base GeneralName, ... }
44
+ // GeneralName ::= CHOICE { ..., dNSName [2] IA5String, ... } (RFC 5280 s4.2.1.10)
45
+ function _nameConstraintsValue(permittedDnsNames) {
46
+ const asn1 = forge.asn1;
47
+ const generalSubtrees = permittedDnsNames.map(name => {
48
+ const dnsGeneralName = asn1.create(asn1.Class.CONTEXT_SPECIFIC, 2, false, name);
49
+ return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [dnsGeneralName]);
50
+ });
51
+ const permittedSubtrees = asn1.create(
52
+ asn1.Class.CONTEXT_SPECIFIC, 0, true, generalSubtrees,
53
+ );
54
+ const nameConstraints = asn1.create(
55
+ asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [permittedSubtrees],
56
+ );
57
+ return asn1.toDer(nameConstraints).getBytes();
58
+ }
59
+
60
+ /**
61
+ * Generates a new, self-signed CA restricted to `permittedDnsNames` (only
62
+ * api.anthropic.com is ever passed today - see providers.js). `hostname`
63
+ * is cosmetic only (goes in the CN, for a real human looking at this in a
64
+ * certificate viewer), not a security boundary.
65
+ */
66
+ export function generateCA(hostname, permittedDnsNames = ['api.anthropic.com']) {
67
+ const keys = forge.pki.rsa.generateKeyPair(KEY_BITS);
68
+ const cert = forge.pki.createCertificate();
69
+ cert.publicKey = keys.publicKey;
70
+ cert.serialNumber = _randomSerial();
71
+ cert.validity.notBefore = new Date();
72
+ cert.validity.notAfter = _daysFromNow(CA_VALIDITY_DAYS);
73
+
74
+ const subject = [{ name: 'commonName', value: `Troxy Local Interception CA (${hostname})` },
75
+ { name: 'organizationName', value: 'Troxy (local, unaffiliated with Anthropic)' }];
76
+ cert.setSubject(subject);
77
+ cert.setIssuer(subject);
78
+
79
+ cert.setExtensions([
80
+ { name: 'basicConstraints', cA: true, critical: true, pathLenConstraint: 0 },
81
+ { name: 'keyUsage', critical: true, keyCertSign: true, cRLSign: true },
82
+ { id: '2.5.29.30', critical: true, value: _nameConstraintsValue(permittedDnsNames) },
83
+ ]);
84
+
85
+ cert.sign(keys.privateKey, forge.md.sha256.create());
86
+
87
+ return {
88
+ certPem: forge.pki.certificateToPem(cert),
89
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Signs a leaf certificate for `dnsNames` using the given CA. Only ever
95
+ * called (in production) with dnsNames matching the CA's own
96
+ * nameConstraints - a mismatched call would still produce a cert, but no
97
+ * client trusting the CA would ever accept it (see tls-ca.test.js's
98
+ * name-constraint enforcement tests). `days` defaults to the short,
99
+ * frequently-renewed leaf lifetime, not the CA's.
100
+ */
101
+ export function generateLeaf(caCertPem, caKeyPem, dnsNames, { days = LEAF_VALIDITY_DAYS } = {}) {
102
+ const caCert = forge.pki.certificateFromPem(caCertPem);
103
+ const caKey = forge.pki.privateKeyFromPem(caKeyPem);
104
+ const keys = forge.pki.rsa.generateKeyPair(KEY_BITS);
105
+
106
+ const cert = forge.pki.createCertificate();
107
+ cert.publicKey = keys.publicKey;
108
+ cert.serialNumber = _randomSerial();
109
+ cert.validity.notBefore = new Date();
110
+ cert.validity.notAfter = _daysFromNow(days);
111
+ cert.setSubject([{ name: 'commonName', value: dnsNames[0] }]);
112
+ cert.setIssuer(caCert.subject.attributes);
113
+
114
+ cert.setExtensions([
115
+ { name: 'basicConstraints', cA: false, critical: true },
116
+ { name: 'keyUsage', critical: true, digitalSignature: true, keyEncipherment: true },
117
+ { name: 'extKeyUsage', serverAuth: true },
118
+ { name: 'subjectAltName', altNames: dnsNames.map(value => ({ type: 2, value })) },
119
+ ]);
120
+
121
+ cert.sign(caKey, forge.md.sha256.create());
122
+
123
+ return {
124
+ certPem: forge.pki.certificateToPem(cert),
125
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
126
+ };
127
+ }
128
+
129
+ export function certIsValid(certPem) {
130
+ try {
131
+ const cert = forge.pki.certificateFromPem(certPem);
132
+ const now = new Date();
133
+ return now >= cert.validity.notBefore && now <= cert.validity.notAfter;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ export function certExpiresWithin(certPem, days) {
140
+ try {
141
+ const cert = forge.pki.certificateFromPem(certPem);
142
+ return cert.validity.notAfter <= _daysFromNow(days);
143
+ } catch {
144
+ return true; // unparseable = treat as already-expired, err toward renewing
145
+ }
146
+ }
147
+
148
+ function _writeSecret(filePath, contents) {
149
+ fs.writeFileSync(filePath, contents, { mode: 0o600 });
150
+ try { fs.chmodSync(filePath, 0o600); } catch {} // mode is ignored if the file already existed
151
+ }
152
+
153
+ /**
154
+ * Ensures a healthy CA + leaf pair exists on disk under `dir` (normally
155
+ * ~/.troxy), generating or renewing whatever is missing/unhealthy.
156
+ * Idempotent when everything is already valid - re-running does not
157
+ * regenerate the CA (or the leaf, unless it's within its renewal window).
158
+ * Follows the exact ~/.troxy/ permission convention already established by
159
+ * config.js/auth.js: 0700 dir (with the required follow-up chmodSync,
160
+ * since mkdirSync's mode is ignored if the dir already exists), 0600 files.
161
+ */
162
+ export function ensureInterceptionCerts(dir, { hostname, leafDnsNames, leafRenewWithinDays = LEAF_RENEW_WITHIN_DAYS } = {}) {
163
+ const tlsDir = path.join(dir, 'tls');
164
+ fs.mkdirSync(tlsDir, { recursive: true, mode: 0o700 });
165
+ try { fs.chmodSync(tlsDir, 0o700); } catch {}
166
+
167
+ const caCertPath = path.join(tlsDir, 'troxy-local-ca.crt');
168
+ const caKeyPath = path.join(tlsDir, 'troxy-local-ca.key');
169
+ const leafCertPath = path.join(tlsDir, 'leaf-api.anthropic.com.crt');
170
+ const leafKeyPath = path.join(tlsDir, 'leaf-api.anthropic.com.key');
171
+
172
+ let caCertPem, caKeyPem;
173
+ const existingCa = fs.existsSync(caCertPath) && fs.existsSync(caKeyPath)
174
+ ? { certPem: _tryRead(caCertPath), keyPem: _tryRead(caKeyPath) }
175
+ : null;
176
+ if (existingCa?.certPem && certIsValid(existingCa.certPem)) {
177
+ ({ certPem: caCertPem, keyPem: caKeyPem } = existingCa);
178
+ } else {
179
+ ({ certPem: caCertPem, keyPem: caKeyPem } = generateCA(hostname, leafDnsNames));
180
+ _writeSecret(caCertPath, caCertPem);
181
+ _writeSecret(caKeyPath, caKeyPem);
182
+ }
183
+
184
+ let leafCertPem, leafKeyPem;
185
+ const existingLeaf = fs.existsSync(leafCertPath) && fs.existsSync(leafKeyPath)
186
+ ? { certPem: _tryRead(leafCertPath), keyPem: _tryRead(leafKeyPath) }
187
+ : null;
188
+ const leafStillHealthy = existingLeaf?.certPem
189
+ && certIsValid(existingLeaf.certPem)
190
+ && !certExpiresWithin(existingLeaf.certPem, leafRenewWithinDays)
191
+ // A leaf issued by a CA that just got regenerated above no longer
192
+ // chains to the CA now on disk - must be re-issued too, not just
193
+ // treated as "still valid" on its own terms.
194
+ && forge.pki.certificateFromPem(caCertPem).verify(forge.pki.certificateFromPem(existingLeaf.certPem));
195
+ if (leafStillHealthy) {
196
+ ({ certPem: leafCertPem, keyPem: leafKeyPem } = existingLeaf);
197
+ } else {
198
+ ({ certPem: leafCertPem, keyPem: leafKeyPem } = generateLeaf(caCertPem, caKeyPem, leafDnsNames));
199
+ _writeSecret(leafCertPath, leafCertPem);
200
+ _writeSecret(leafKeyPath, leafKeyPem);
201
+ }
202
+
203
+ return { caCertPath, caKeyPath, leafCertPath, leafKeyPath, caCertPem, caKeyPem, leafCertPem, leafKeyPem };
204
+ }
205
+
206
+ function _tryRead(filePath) {
207
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
208
+ }
package/src/uninstall.js CHANGED
@@ -5,6 +5,11 @@ import { execSync } from 'child_process';
5
5
  import readline from 'readline';
6
6
  import { loadConfig } from './config.js';
7
7
  import { api } from './api.js';
8
+ import {
9
+ claudeCodeConfigPath, claudeCodeSettingsPath,
10
+ unpatchClaudeCodeConfig, unpatchClaudeCodeHooks,
11
+ unpatchClaudeCodeProxy, unpatchClaudeCodeInterception,
12
+ } from './init.js';
8
13
 
9
14
  const MCP_CLIENTS = [
10
15
  {
@@ -111,7 +116,30 @@ export async function runUninstall() {
111
116
  console.log('none found');
112
117
  }
113
118
 
114
- // 3. Revoke all OTHER agent keys (cloud agents, other machines) using the
119
+ // 3. Revert everything written into Claude Code's own config files -
120
+ // project-scoped MCP registration, the global Stop/PreToolUse hooks, the
121
+ // base-URL model proxy, and (Layer 3) the local-interceptor env vars.
122
+ // Fail-open layer 6 of the Live Model Policy Enforcement plan: before this,
123
+ // uninstall deleted ~/.troxy but left every one of these in place, so a
124
+ // machine that had `HTTPS_PROXY`/`ANTHROPIC_BASE_URL` pointed at Troxy kept
125
+ // pointing there after Troxy itself was gone - exactly the "stuck, nothing
126
+ // is listening" outage this plan's fail-open design exists to prevent.
127
+ // Each is independently best-effort: a missing/corrupt config file must
128
+ // never stop the rest of uninstall from proceeding.
129
+ process.stdout.write(' Reverting Claude Code settings... ');
130
+ try {
131
+ unpatchClaudeCodeConfig(claudeCodeConfigPath());
132
+ unpatchClaudeCodeHooks(claudeCodeSettingsPath());
133
+ unpatchClaudeCodeProxy(claudeCodeSettingsPath());
134
+ unpatchClaudeCodeInterception(claudeCodeSettingsPath());
135
+ console.log('✓');
136
+ } catch (err) {
137
+ console.log('✗');
138
+ console.log(` Couldn't fully revert Claude Code settings (${err.message}).`);
139
+ console.log(` Check ~/.claude/settings.json and ~/.claude.json by hand.\n`);
140
+ }
141
+
142
+ // 4. Revoke all OTHER agent keys (cloud agents, other machines) using the
115
143
  // local API key for auth — no troxy login needed. The key proves identity
116
144
  // to the server, and /mcp/revoke-all-others kills every other active token
117
145
  // for the same user. We do this BEFORE self-revoke so the key is still valid
@@ -135,7 +163,7 @@ export async function runUninstall() {
135
163
  console.log(' Go to dash.troxy.io → API Keys to revoke them manually.\n');
136
164
  }
137
165
 
138
- // 4. Revoke THIS machine's API key (self-revoke). Done after revoke-all-others
166
+ // 5. Revoke THIS machine's API key (self-revoke). Done after revoke-all-others
139
167
  // because self-revoke kills the key we're using to authenticate.
140
168
  const prefix = apiKey.slice(0, 12) + '...';
141
169
  process.stdout.write(` Revoking local API key ${prefix}... `);
@@ -149,7 +177,7 @@ export async function runUninstall() {
149
177
  }
150
178
  }
151
179
 
152
- // 5. Delete ~/.troxy config
180
+ // 6. Delete ~/.troxy config
153
181
  process.stdout.write(' Removing config (~/.troxy)... ');
154
182
  const configDir = path.join(os.homedir(), '.troxy');
155
183
  if (fs.existsSync(configDir)) {
@@ -159,7 +187,7 @@ export async function runUninstall() {
159
187
  console.log('not found');
160
188
  }
161
189
 
162
- // 6. Remove npm package
190
+ // 7. Remove npm package
163
191
  process.stdout.write(' Uninstalling troxy CLI... ');
164
192
  try {
165
193
  execSync('npm uninstall -g troxy-cli 2>/dev/null || npm uninstall -g troxy 2>/dev/null', { stdio: 'pipe' });