claude-cac 1.4.0 → 1.4.2

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.0"
14
+ CAC_VERSION="1.4.2"
15
15
 
16
16
  _read() { [[ -f "$1" ]] && tr -d '[:space:]' < "$1" || echo "${2:-}"; }
17
17
  _die() { printf '%b\n' "$(_red "error:") $*" >&2; exit 1; }
@@ -1356,15 +1356,20 @@ fi
1356
1356
  # ── Concurrent session check ──
1357
1357
  _max_sessions=10
1358
1358
  [[ -f "$CAC_DIR/max_sessions" ]] && _ms=$(tr -d '[:space:]' < "$CAC_DIR/max_sessions") && [[ -n "$_ms" ]] && _max_sessions="$_ms"
1359
- _claude_count=$(pgrep -x "claude" 2>/dev/null | wc -l | tr -d '[:space:]')
1359
+ # pgrep exits 1 when no match; with pipefail + set -e that would abort the wrapper
1360
+ _claude_count=$(pgrep -x "claude" 2>/dev/null | wc -l | tr -d '[:space:]') || _claude_count=0
1360
1361
  if [[ "$_claude_count" -gt "$_max_sessions" ]]; then
1361
1362
  echo "[cac] warning: $_claude_count claude sessions running (threshold: $_max_sessions)" >&2
1362
1363
  echo "[cac] hint: concurrent sessions on the same device may trigger detection" >&2
1363
1364
  echo "[cac] hint: adjust threshold with: echo '{\"max_sessions\": 20}' > ~/.cac/settings.json" >&2
1364
1365
  fi
1365
1366
 
1367
+ # claude non-zero exit must not leave _ec unset (set -u) or abort before cleanup (set -e)
1368
+ _ec=0
1369
+ set +e
1366
1370
  "$_real" "$@"
1367
1371
  _ec=$?
1372
+ set -e
1368
1373
  _cleanup_all
1369
1374
  exit "$_ec"
1370
1375
  WRAPPER_EOF
@@ -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.0",
3
+ "version": "1.4.2",
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,29 +1,41 @@
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
- // 确保 cac 可执行
10
+ // Ensure cac is executable
11
+ try { fs.chmodSync(cacBin, 0o755); } catch (e) {}
12
+
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
9
16
  try {
10
- fs.chmodSync(cacBin, 0o755);
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
+ }
11
26
  } catch (e) {
12
- // Windows 或权限不足时忽略
27
+ // Non-fatal — _ensure_initialized will catch it on first cac command
13
28
  }
14
29
 
15
- console.log(`
16
- ✅ claude-cac 安装成功
17
-
18
- 首次使用:
19
- cac setup 初始化(自动配置 PATH)
20
- cac add <名字> <host:port:u:p> 添加代理配置
21
- cac <名字> 切换配置
22
- claude 启动 Claude Code
23
-
24
- 其他命令:
25
- cac -v 查看版本和安装方式
26
- cac delete 卸载 cac
27
-
28
- 更多信息:https://github.com/nmhjklnm/cac
29
- `);
30
+ console.log([
31
+ '',
32
+ ' claude-cac installed successfully',
33
+ '',
34
+ ' Quick start:',
35
+ ' cac env create <name> [-p <proxy>] Create an isolated environment',
36
+ ' cac <name> Switch environment',
37
+ ' claude Start Claude Code',
38
+ '',
39
+ ' Docs: https://cac.nextmind.space/docs',
40
+ ''
41
+ ].join('\n'));