claude-cac 1.4.1 → 1.4.3

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.
package/cac CHANGED
@@ -11,7 +11,7 @@ VERSIONS_DIR="$CAC_DIR/versions"
11
11
  # ── utils: colors, read/write, UUID, proxy parsing ───────────────────────
12
12
 
13
13
  # shellcheck disable=SC2034 # used in build-concatenated cac script
14
- CAC_VERSION="1.4.1"
14
+ CAC_VERSION="1.4.3"
15
15
 
16
16
  _read() { [[ -f "$1" ]] && tr -d '[:space:]' < "$1" || echo "${2:-}"; }
17
17
  _die() { printf '%b\n' "$(_red "error:") $*" >&2; exit 1; }
@@ -1750,6 +1750,7 @@ MERGE_EOF
1750
1750
  }
1751
1751
 
1752
1752
  _env_cmd_ls() {
1753
+ _require_setup
1753
1754
  if [[ ! -d "$ENVS_DIR" ]] || [[ -z "$(ls -A "$ENVS_DIR" 2>/dev/null)" ]]; then
1754
1755
  echo "$(_dim " No environments yet.")"
1755
1756
  echo " Run $(_green "cac env create <name>") to get started."
@@ -0,0 +1,326 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // cac-dns-guard.js
3
+ // DNS-level telemetry domain blocking | mTLS certificate injection | fetch leak prevention
4
+ // Injected into Claude Code process via NODE_OPTIONS="--require <this>"
5
+ // ═══════════════════════════════════════════════════════════════
6
+ 'use strict';
7
+
8
+ var dns = require('dns');
9
+ var net = require('net');
10
+ var tls = require('tls');
11
+ var http = require('http');
12
+ var https = require('https');
13
+ var fs = require('fs');
14
+
15
+ // ─── 1. DNS-level telemetry domain blocking ──────────────────────────────────
16
+
17
+ var BLOCKED_DOMAINS = new Set([
18
+ 'statsig.anthropic.com',
19
+ 'sentry.io',
20
+ 'o1137031.ingest.sentry.io',
21
+ 'cdn.growthbook.io',
22
+ ]);
23
+
24
+ /**
25
+ * Check if domain is in block list (including subdomain matching)
26
+ * e.g. "foo.sentry.io" matches "sentry.io"
27
+ */
28
+ function isDomainBlocked(hostname) {
29
+ if (!hostname) return false;
30
+ var h = hostname.toLowerCase().replace(/\.$/,'');
31
+ if (BLOCKED_DOMAINS.has(h)) return true;
32
+ var parts = h.split('.');
33
+ for (var i = 1; i < parts.length - 1; i++) {
34
+ if (BLOCKED_DOMAINS.has(parts.slice(i).join('.'))) return true;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ function makeBlockedError(hostname, syscall) {
40
+ var msg = 'connect ECONNREFUSED (blocked by cac): ' + hostname;
41
+ var err = new Error(msg);
42
+ err.code = 'ECONNREFUSED';
43
+ err.errno = -111;
44
+ err.hostname = hostname;
45
+ err.syscall = syscall || 'connect';
46
+ return err;
47
+ }
48
+
49
+ // ── 1a. intercept dns.lookup ──
50
+ var _origLookup = dns.lookup;
51
+ dns.lookup = function cacLookup(hostname, options, callback) {
52
+ if (typeof options === 'function') { callback = options; options = {}; }
53
+ if (isDomainBlocked(hostname)) {
54
+ var err = makeBlockedError(hostname, 'getaddrinfo');
55
+ if (typeof callback === 'function') process.nextTick(function() { callback(err); });
56
+ return {};
57
+ }
58
+ return _origLookup.call(dns, hostname, options, callback);
59
+ };
60
+
61
+ // ── 1b. intercept dns.resolve / resolve4 / resolve6 ──
62
+ ['resolve','resolve4','resolve6'].forEach(function(method) {
63
+ var orig = dns[method];
64
+ if (!orig) return;
65
+ dns[method] = function(hostname) {
66
+ var args = Array.prototype.slice.call(arguments);
67
+ var cb = args[args.length - 1];
68
+ if (isDomainBlocked(hostname)) {
69
+ var err = makeBlockedError(hostname, 'query');
70
+ if (typeof cb === 'function') process.nextTick(function() { cb(err); });
71
+ return;
72
+ }
73
+ return orig.apply(dns, args);
74
+ };
75
+ });
76
+
77
+ // ── 1c. intercept dns.promises ──
78
+ if (dns.promises) {
79
+ var _origPLookup = dns.promises.lookup;
80
+ if (_origPLookup) {
81
+ dns.promises.lookup = function cacPromiseLookup(hostname, options) {
82
+ if (isDomainBlocked(hostname)) return Promise.reject(makeBlockedError(hostname, 'getaddrinfo'));
83
+ return _origPLookup.call(dns.promises, hostname, options);
84
+ };
85
+ }
86
+ ['resolve','resolve4','resolve6'].forEach(function(method) {
87
+ var orig = dns.promises[method];
88
+ if (!orig) return;
89
+ dns.promises[method] = function(hostname) {
90
+ if (isDomainBlocked(hostname)) return Promise.reject(makeBlockedError(hostname, 'query'));
91
+ return orig.apply(dns.promises, arguments);
92
+ };
93
+ });
94
+ }
95
+
96
+ // ── 1d. network layer safety net: intercept net.connect to telemetry domains ──
97
+ var _origNetConnect = net.connect;
98
+ var _origNetCreateConn = net.createConnection;
99
+
100
+ function getHostFromArgs(args) {
101
+ if (typeof args[0] === 'object') return args[0].host || args[0].hostname || '';
102
+ return '';
103
+ }
104
+
105
+ function makeBlockedSocket(host) {
106
+ var sock = new net.Socket();
107
+ var err = makeBlockedError(host, 'connect');
108
+ process.nextTick(function() { sock.destroy(err); });
109
+ return sock;
110
+ }
111
+
112
+ net.connect = function cacNetConnect() {
113
+ var host = getHostFromArgs(arguments);
114
+ if (isDomainBlocked(host)) return makeBlockedSocket(host);
115
+ return _origNetConnect.apply(net, arguments);
116
+ };
117
+ net.createConnection = function cacNetCreateConnection() {
118
+ var host = getHostFromArgs(arguments);
119
+ if (isDomainBlocked(host)) return makeBlockedSocket(host);
120
+ return _origNetCreateConn.apply(net, arguments);
121
+ };
122
+
123
+
124
+ // ─── 2. mTLS certificate injection ──────────────────────────────────
125
+
126
+ var mtlsCert = process.env.CAC_MTLS_CERT;
127
+ var mtlsKey = process.env.CAC_MTLS_KEY;
128
+ var mtlsCa = process.env.CAC_MTLS_CA;
129
+ var proxyHostPort = process.env.CAC_PROXY_HOST || '';
130
+
131
+ if (mtlsCert && mtlsKey) {
132
+ var certData, keyData, caData;
133
+ try {
134
+ certData = fs.readFileSync(mtlsCert);
135
+ keyData = fs.readFileSync(mtlsKey);
136
+ if (mtlsCa) caData = fs.readFileSync(mtlsCa);
137
+ } catch(e) {
138
+ certData = null; keyData = null;
139
+ }
140
+
141
+ if (certData && keyData) {
142
+ // inject mTLS cert only for proxy connections
143
+ var proxyHost = proxyHostPort.split(':')[0];
144
+ var proxyPort = parseInt(proxyHostPort.split(':')[1], 10) || 0;
145
+
146
+ // 2a. intercept tls.connect, inject client cert for proxy connections
147
+ var _origTlsConnect = tls.connect;
148
+ tls.connect = function cacTlsConnect() {
149
+ // normalize parameters: tls.connect(options[, cb]) or tls.connect(port[, host][, options][, cb])
150
+ var args = Array.prototype.slice.call(arguments);
151
+ var options, callback;
152
+
153
+ if (typeof args[0] === 'object') {
154
+ options = args[0];
155
+ callback = (typeof args[1] === 'function') ? args[1] : undefined;
156
+ } else {
157
+ // tls.connect(port, host, options, cb) form
158
+ var port = args[0];
159
+ var host = (typeof args[1] === 'string') ? args[1] : 'localhost';
160
+ var optIdx = (typeof args[1] === 'string') ? 2 : 1;
161
+ options = (typeof args[optIdx] === 'object') ? args[optIdx] : {};
162
+ options.port = port;
163
+ options.host = host;
164
+ callback = args[args.length - 1];
165
+ if (typeof callback !== 'function') callback = undefined;
166
+ }
167
+
168
+ // inject only for proxy connections (exact host:port match)
169
+ var targetHost = options.host || options.hostname || '';
170
+ var targetPort = options.port || 0;
171
+ if (proxyHost && targetHost === proxyHost &&
172
+ (proxyPort === 0 || targetPort === proxyPort)) {
173
+ if (!options.cert) {
174
+ options.cert = certData;
175
+ options.key = keyData;
176
+ if (caData) {
177
+ options.ca = options.ca
178
+ ? [].concat(options.ca, caData)
179
+ : [caData];
180
+ }
181
+ }
182
+ }
183
+
184
+ if (callback) return _origTlsConnect.call(tls, options, callback);
185
+ return _origTlsConnect.call(tls, options);
186
+ };
187
+
188
+ // 2b. inject CA into https.globalAgent (trust CA only, no client private key)
189
+ if (caData && https.globalAgent && https.globalAgent.options) {
190
+ https.globalAgent.options.ca = https.globalAgent.options.ca
191
+ ? [].concat(https.globalAgent.options.ca, caData)
192
+ : [caData];
193
+ }
194
+ }
195
+ }
196
+
197
+
198
+ // ─── 3. fetch telemetry interception patch ──────────────────────────────────
199
+ // Wrap native fetch to block telemetry domains by URL check
200
+ // (do NOT replace with node-fetch — its Response.body lacks ReadableStream.cancel(),
201
+ // which breaks Bun-based Claude Code streaming)
202
+
203
+ (function patchFetch() {
204
+ if (typeof globalThis === 'undefined' || typeof globalThis.fetch !== 'function') return;
205
+
206
+ var _origFetch = globalThis.fetch;
207
+ globalThis.fetch = function cacFetch(input, init) {
208
+ var hostname;
209
+ try {
210
+ var urlStr = typeof input === 'string' ? input :
211
+ (input && input.url) ? input.url : '';
212
+ if (urlStr) hostname = new URL(urlStr).hostname;
213
+ } catch(e) { /* ignore */ }
214
+
215
+ if (hostname && isDomainBlocked(hostname)) {
216
+ return Promise.reject(makeBlockedError(hostname, 'fetch'));
217
+ }
218
+ return _origFetch(input, init);
219
+ };
220
+ })();
221
+
222
+
223
+ // ─── 4. health check bypass (in-process interception, URL-specific) ────────────────
224
+ // Claude Code pings https://api.anthropic.com/api/hello on startup
225
+ // Cloudflare blocks Node.js TLS fingerprint (JA3/JA4) -> 403
226
+ // Solution: intercept this request at Node.js layer, return 200 directly, no network traffic
227
+ // Only intercepts health check, does not affect OAuth/API or other requests
228
+
229
+ function isHealthCheck(url) {
230
+ if (!url) return false;
231
+ // match https://api.anthropic.com/api/hello or variants with query params
232
+ return /^https?:\/\/api\.anthropic\.com\/api\/hello/.test(url);
233
+ }
234
+
235
+ function makeHealthResponse(callback) {
236
+ var EventEmitter = require('events');
237
+ var body = '{"message":"hello"}';
238
+
239
+ // Fake IncomingMessage
240
+ var res = new EventEmitter();
241
+ res.statusCode = 200;
242
+ res.headers = { 'content-type': 'application/json' };
243
+ res.setEncoding = function() { return res; };
244
+
245
+ // Callback first (so caller attaches handlers), then emit data/end
246
+ if (typeof callback === 'function') {
247
+ process.nextTick(function() {
248
+ callback(res);
249
+ process.nextTick(function() {
250
+ res.emit('data', body);
251
+ res.emit('end');
252
+ });
253
+ });
254
+ } else {
255
+ process.nextTick(function() {
256
+ res.emit('data', body);
257
+ res.emit('end');
258
+ });
259
+ }
260
+
261
+ // Fake ClientRequest (no-op)
262
+ var req = new EventEmitter();
263
+ req.end = function() { return req; };
264
+ req.write = function() { return true; };
265
+ req.destroy = function() {};
266
+ req.setTimeout = function() { return req; };
267
+ req.on = function(ev, fn) { EventEmitter.prototype.on.call(req, ev, fn); return req; };
268
+ return req;
269
+ }
270
+
271
+ // 4a. intercept https.get / https.request
272
+ var _origHttpsRequest = https.request;
273
+ var _origHttpsGet = https.get;
274
+
275
+ https.request = function cacHttpsRequest(urlOrOpts, optsOrCb, cb) {
276
+ var url = '';
277
+ if (typeof urlOrOpts === 'string') {
278
+ url = urlOrOpts;
279
+ } else if (urlOrOpts && typeof urlOrOpts === 'object' && urlOrOpts.href) {
280
+ url = urlOrOpts.href;
281
+ } else if (urlOrOpts && typeof urlOrOpts === 'object') {
282
+ var proto = urlOrOpts.protocol || 'https:';
283
+ var host = urlOrOpts.hostname || urlOrOpts.host || '';
284
+ var path = urlOrOpts.path || '/';
285
+ url = proto + '//' + host + path;
286
+ }
287
+ var callback = typeof optsOrCb === 'function' ? optsOrCb : cb;
288
+ if (isHealthCheck(url)) return makeHealthResponse(callback);
289
+ return _origHttpsRequest.apply(https, arguments);
290
+ };
291
+
292
+ https.get = function cacHttpsGet(urlOrOpts, optsOrCb, cb) {
293
+ var url = '';
294
+ if (typeof urlOrOpts === 'string') {
295
+ url = urlOrOpts;
296
+ } else if (urlOrOpts && typeof urlOrOpts === 'object' && urlOrOpts.href) {
297
+ url = urlOrOpts.href;
298
+ } else if (urlOrOpts && typeof urlOrOpts === 'object') {
299
+ var proto = urlOrOpts.protocol || 'https:';
300
+ var host = urlOrOpts.hostname || urlOrOpts.host || '';
301
+ var path = urlOrOpts.path || '/';
302
+ url = proto + '//' + host + path;
303
+ }
304
+ var callback = typeof optsOrCb === 'function' ? optsOrCb : cb;
305
+ if (isHealthCheck(url)) return makeHealthResponse(callback);
306
+ return _origHttpsGet.apply(https, arguments);
307
+ };
308
+
309
+ // 4b. intercept fetch (undici bypasses https module)
310
+ (function patchHealthFetch() {
311
+ if (typeof globalThis === 'undefined' || typeof globalThis.fetch !== 'function') return;
312
+ var _prevFetch = globalThis.fetch;
313
+ globalThis.fetch = function cacHealthFetch(input, init) {
314
+ var url = '';
315
+ try {
316
+ url = typeof input === 'string' ? input : (input && input.url) ? input.url : '';
317
+ } catch(e) {}
318
+ if (isHealthCheck(url)) {
319
+ return Promise.resolve(new Response('{"message":"hello"}', {
320
+ status: 200,
321
+ headers: { 'content-type': 'application/json' }
322
+ }));
323
+ }
324
+ return _prevFetch(input, init);
325
+ };
326
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cac",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Isolate, protect, and manage your Claude Code — versions, environments, identity, and proxy.",
5
5
  "bin": {
6
6
  "cac": "cac"
@@ -9,6 +9,7 @@
9
9
  "cac",
10
10
  "cac.ps1",
11
11
  "cac.cmd",
12
+ "cac-dns-guard.js",
12
13
  "fingerprint-hook.js",
13
14
  "relay.js",
14
15
  "scripts/postinstall.js",
@@ -1,32 +1,79 @@
1
1
  #!/usr/bin/env node
2
- const { execSync } = require('child_process');
3
- const path = require('path');
4
- const fs = require('fs');
2
+ var path = require('path');
3
+ var fs = require('fs');
5
4
 
6
- const cacBin = path.join(__dirname, '..', 'cac');
5
+ var pkgDir = path.join(__dirname, '..');
6
+ var cacBin = path.join(pkgDir, 'cac');
7
+ var home = process.env.HOME || process.env.USERPROFILE || '';
8
+ var cacDir = path.join(home, '.cac');
7
9
 
8
10
  // Ensure cac is executable
9
- try {
10
- fs.chmodSync(cacBin, 0o755);
11
- } catch (e) {
12
- // Windows or insufficient permissions — ignore
13
- }
11
+ try { fs.chmodSync(cacBin, 0o755); } catch (e) {}
14
12
 
15
- // Auto-regenerate wrapper + runtime JS files on install/upgrade
16
- // This ensures bug fixes (e.g. dns-guard, wrapper crash) take effect immediately
13
+ // Auto-sync runtime files on install/upgrade
14
+ // Pure Node.js no bash/zsh dependency
15
+ // Ensures bug fixes (dns-guard, relay, fingerprint-hook) take effect immediately
17
16
  try {
18
- execSync('"' + cacBin + '" -v', { stdio: 'ignore', timeout: 10000 });
17
+ fs.mkdirSync(cacDir, { recursive: true });
18
+ var files = ['cac-dns-guard.js', 'relay.js', 'fingerprint-hook.js'];
19
+ for (var i = 0; i < files.length; i++) {
20
+ var src = path.join(pkgDir, files[i]);
21
+ var dst = path.join(cacDir, files[i]);
22
+ if (fs.existsSync(src)) {
23
+ fs.copyFileSync(src, dst);
24
+ }
25
+ }
19
26
  } catch (e) {
20
- // First install or no environment yet — fine, _ensure_initialized runs on first cac command
27
+ // Non-fatal _ensure_initialized will catch it on first cac command
21
28
  }
22
29
 
23
- console.log(`
24
- claude-cac installed successfully
30
+ // Patch existing wrapper for known bugs — pure Node.js, no shell execution needed.
31
+ // Users who upgrade via npm install keep their old ~/.cac/bin/claude until _ensure_initialized
32
+ // runs (triggered by any cac command). This patch fixes critical bugs immediately.
33
+ var wrapperPath = path.join(cacDir, 'bin', 'claude');
34
+ if (home && fs.existsSync(wrapperPath)) {
35
+ try {
36
+ var wrapperContent = fs.readFileSync(wrapperPath, 'utf8');
37
+ var patched = wrapperContent;
38
+ // Fix: pgrep returns exit 1 when no claude process exists; under set -euo pipefail
39
+ // this aborts the wrapper before launching claude (claude appears to do nothing).
40
+ var buggyPgrep = '_claude_count=$(pgrep -x "claude" 2>/dev/null | wc -l | tr -d \'[:space:]\')';
41
+ var fixedPgrep = buggyPgrep + ' || _claude_count=0';
42
+ if (patched.indexOf(buggyPgrep) !== -1 && patched.indexOf(fixedPgrep) === -1) {
43
+ patched = patched.replace(buggyPgrep, fixedPgrep);
44
+ }
45
+ if (patched !== wrapperContent) {
46
+ fs.writeFileSync(wrapperPath, patched);
47
+ }
48
+ } catch (e) {
49
+ // Non-fatal
50
+ }
51
+ }
25
52
 
26
- Quick start:
27
- cac env create <name> [-p <proxy>] Create an isolated environment
28
- cac <name> Switch environment
29
- claude Start Claude Code
53
+ // Trigger _ensure_initialized to fully regenerate wrapper to current version.
54
+ // cac env ls now calls _require_setup (fixed in 1.4.3+).
55
+ if (home) {
56
+ try {
57
+ var spawnSync = require('child_process').spawnSync;
58
+ spawnSync(cacBin, ['env', 'ls'], {
59
+ stdio: 'ignore',
60
+ timeout: 8000,
61
+ env: Object.assign({}, process.env, { HOME: home })
62
+ });
63
+ } catch (e) {
64
+ // Non-fatal
65
+ }
66
+ }
30
67
 
31
- Docs: https://cac.nextmind.space/docs
32
- `);
68
+ console.log([
69
+ '',
70
+ ' claude-cac installed successfully',
71
+ '',
72
+ ' Quick start:',
73
+ ' cac env create <name> [-p <proxy>] Create an isolated environment',
74
+ ' cac <name> Switch environment',
75
+ ' claude Start Claude Code',
76
+ '',
77
+ ' Docs: https://cac.nextmind.space/docs',
78
+ ''
79
+ ].join('\n'));