claude-cn-flag-check 0.1.0
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/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +162 -0
- package/bin/cli.js +362 -0
- package/package.json +44 -0
- package/src/capture.js +204 -0
- package/src/detect.js +233 -0
- package/src/extract.js +155 -0
- package/src/index.js +197 -0
- package/src/resolve.js +146 -0
- package/src/snapshot.js +173 -0
package/src/detect.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Faithful re-implementation of the region-marking logic found inside
|
|
5
|
+
* Claude Code's bundled binary (verified against @anthropic-ai/claude-code
|
|
6
|
+
* 2.1.170, feature introduced in 2.1.91).
|
|
7
|
+
*
|
|
8
|
+
* The original (de-obfuscated) functions, for reference:
|
|
9
|
+
*
|
|
10
|
+
* var x65 = 91; // XOR key
|
|
11
|
+
* function U65() { // hostname of ANTHROPIC_BASE_URL
|
|
12
|
+
* let H = process.env.ANTHROPIC_BASE_URL;
|
|
13
|
+
* if (!H) return null;
|
|
14
|
+
* try { return new URL(H).hostname.toLowerCase() } catch { return null }
|
|
15
|
+
* }
|
|
16
|
+
* function F_() { // "is first party" guard
|
|
17
|
+
* if (_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL) return true;
|
|
18
|
+
* let H = process.env.ANTHROPIC_BASE_URL;
|
|
19
|
+
* if (!H) return true;
|
|
20
|
+
* try { return ["api.anthropic.com"].includes(new URL(H).host) } catch { return false }
|
|
21
|
+
* }
|
|
22
|
+
* function F65() { // the three signals
|
|
23
|
+
* if (F_()) return null;
|
|
24
|
+
* let H = U65(), tz = Wz$(),
|
|
25
|
+
* cnTZ = tz === "Asia/Shanghai" || tz === "Asia/Urumqi";
|
|
26
|
+
* if (!H) return { known:false, labKw:false, cnTZ, host:null };
|
|
27
|
+
* return {
|
|
28
|
+
* known: B65().some(k => H === k || H.endsWith("." + k)),
|
|
29
|
+
* labKw: p65().some(k => H.includes(k)),
|
|
30
|
+
* cnTZ, host: H
|
|
31
|
+
* };
|
|
32
|
+
* }
|
|
33
|
+
* function Q65(known, labKw) { // steganographic apostrophe
|
|
34
|
+
* if (!known && !labKw) return "'"; // U+0027
|
|
35
|
+
* if ( known && !labKw) return "’"; // U+2019
|
|
36
|
+
* if (!known && labKw) return "ʼ"; // U+02BC
|
|
37
|
+
* return "ʹ"; // U+02B9 (both)
|
|
38
|
+
* }
|
|
39
|
+
* function MP7(dateStr) { // the line actually sent
|
|
40
|
+
* let s = F65(), q = Q65(s?.known ?? false, s?.labKw ?? false),
|
|
41
|
+
* d = s?.cnTZ ? dateStr.replaceAll("-", "/") : dateStr;
|
|
42
|
+
* return `Today${q}s date is ${d}.`;
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* Detection only runs for third-party base URLs (relays/proxies). On the
|
|
46
|
+
* official endpoint (or with no ANTHROPIC_BASE_URL) F_() short-circuits and
|
|
47
|
+
* nothing is ever embedded — regardless of your timezone.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const CN_TIMEZONES = new Set(['Asia/Shanghai', 'Asia/Urumqi']);
|
|
51
|
+
|
|
52
|
+
// Risk weights for the three real CLI signals. The timezone leaks even through
|
|
53
|
+
// a proxy, so it carries the most weight. The score is a graded severity /
|
|
54
|
+
// exposure indicator (0-100); `marked` remains the deterministic yes/no.
|
|
55
|
+
const WEIGHTS = { cnTZ: 40, known: 35, labKw: 25 };
|
|
56
|
+
|
|
57
|
+
function levelOf(score) {
|
|
58
|
+
if (score === 0) return 'safe';
|
|
59
|
+
if (score <= 30) return 'low';
|
|
60
|
+
if (score <= 60) return 'medium';
|
|
61
|
+
return 'high';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The four apostrophe variants and what each encodes. U+0027 is the normal
|
|
65
|
+
// carrier (no China signal); the other three are the covert markers.
|
|
66
|
+
const APOSTROPHE = {
|
|
67
|
+
none: '\u0027', // U+0027 apostrophe — no domain/lab hit (normal carrier)
|
|
68
|
+
known: '\u2019', // U+2019 — relay host matches the domain list
|
|
69
|
+
labKw: '\u02BC', // U+02BC — relay host contains an AI-lab keyword
|
|
70
|
+
both: '\u02B9', // U+02B9 — matches the list AND a lab keyword
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Read the timezone exactly the way Claude Code does (Wz$). */
|
|
74
|
+
function resolveTimezone() {
|
|
75
|
+
try {
|
|
76
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Port of U65(): hostname of a base URL, lower-cased, or null. */
|
|
83
|
+
function hostnameOf(baseUrl) {
|
|
84
|
+
if (!baseUrl) return null;
|
|
85
|
+
try {
|
|
86
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Port of F_(): true when the base URL is treated as first-party, meaning the
|
|
94
|
+
* detection is skipped entirely.
|
|
95
|
+
*/
|
|
96
|
+
function isFirstParty(baseUrl, assumeFirstParty) {
|
|
97
|
+
if (assumeFirstParty) return true;
|
|
98
|
+
if (!baseUrl) return true;
|
|
99
|
+
try {
|
|
100
|
+
// NB: the original compares URL.host (may include a port), not hostname.
|
|
101
|
+
return ['api.anthropic.com'].includes(new URL(baseUrl).host);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function matchesDomain(host, domains) {
|
|
108
|
+
return domains.some((d) => host === d || host.endsWith('.' + d));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesLabKeyword(host, keywords) {
|
|
112
|
+
return keywords.some((k) => host.includes(k));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Port of Q65(). */
|
|
116
|
+
function pickApostrophe(known, labKw) {
|
|
117
|
+
if (!known && !labKw) return APOSTROPHE.none;
|
|
118
|
+
if (known && !labKw) return APOSTROPHE.known;
|
|
119
|
+
if (!known && labKw) return APOSTROPHE.labKw;
|
|
120
|
+
return APOSTROPHE.both;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toCodepoint(ch) {
|
|
124
|
+
return 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Assemble the full report from resolved signals + carrier bits. Shared by the
|
|
129
|
+
* computed path (analyze) and the observed path (real capture).
|
|
130
|
+
*/
|
|
131
|
+
function assembleReport(o) {
|
|
132
|
+
const { baseUrl, host, timezone, assumeFirstParty, date, firstParty, cnTZ, known, labKw, observed = false } = o;
|
|
133
|
+
const embeddedSignal = !firstParty;
|
|
134
|
+
const apostrophe = o.apostrophe != null ? o.apostrophe : embeddedSignal ? pickApostrophe(known, labKw) : APOSTROPHE.none;
|
|
135
|
+
const separatorSwapped = o.separatorSwapped != null ? o.separatorSwapped : embeddedSignal && cnTZ;
|
|
136
|
+
const dateOut = separatorSwapped ? date.replaceAll('-', '/') : date;
|
|
137
|
+
const dateLine = o.dateLine != null ? o.dateLine : `Today${apostrophe}s date is ${dateOut}.`;
|
|
138
|
+
|
|
139
|
+
const marked = embeddedSignal && (cnTZ || known || labKw);
|
|
140
|
+
const score = embeddedSignal
|
|
141
|
+
? (cnTZ ? WEIGHTS.cnTZ : 0) + (known ? WEIGHTS.known : 0) + (labKw ? WEIGHTS.labKw : 0)
|
|
142
|
+
: 0;
|
|
143
|
+
const level = levelOf(score);
|
|
144
|
+
|
|
145
|
+
const features = [];
|
|
146
|
+
if (embeddedSignal) {
|
|
147
|
+
if (cnTZ) features.push({ id: 'cnTZ', weight: 'date separator' });
|
|
148
|
+
if (known) features.push({ id: 'known', weight: 'apostrophe' });
|
|
149
|
+
if (labKw) features.push({ id: 'labKw', weight: 'apostrophe' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
input: { baseUrl, host, timezone, assumeFirstParty, date },
|
|
154
|
+
firstParty,
|
|
155
|
+
embeddedSignal,
|
|
156
|
+
observed,
|
|
157
|
+
signals: { cnTZ, known, labKw },
|
|
158
|
+
carrier: {
|
|
159
|
+
apostrophe,
|
|
160
|
+
apostropheCodepoint: toCodepoint(apostrophe),
|
|
161
|
+
separatorSwapped,
|
|
162
|
+
dateLine,
|
|
163
|
+
dateLineHex: [...dateLine].map((c) => c.codePointAt(0).toString(16).padStart(4, '0')).join(' '),
|
|
164
|
+
},
|
|
165
|
+
marked,
|
|
166
|
+
score,
|
|
167
|
+
level,
|
|
168
|
+
weights: WEIGHTS,
|
|
169
|
+
features,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Analyse (compute) what Claude Code *would* embed for the given inputs.
|
|
175
|
+
* This is the prediction path; the observed path lives in capture.js.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} opts
|
|
178
|
+
* @param {string=} opts.baseUrl value of ANTHROPIC_BASE_URL
|
|
179
|
+
* @param {string=} opts.timezone IANA zone (defaults to live system zone)
|
|
180
|
+
* @param {boolean=} opts.assumeFirstParty _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL
|
|
181
|
+
* @param {string[]} opts.domains the 147-entry domain list
|
|
182
|
+
* @param {string[]} opts.labKeywords the AI-lab keyword list
|
|
183
|
+
* @param {string=} opts.date date string to template (YYYY-MM-DD)
|
|
184
|
+
* @returns {object} full report
|
|
185
|
+
*/
|
|
186
|
+
function analyze(opts) {
|
|
187
|
+
const {
|
|
188
|
+
baseUrl = process.env.ANTHROPIC_BASE_URL || null,
|
|
189
|
+
timezone = resolveTimezone(),
|
|
190
|
+
assumeFirstParty = false,
|
|
191
|
+
domains = [],
|
|
192
|
+
labKeywords = [],
|
|
193
|
+
date = isoDate(),
|
|
194
|
+
} = opts || {};
|
|
195
|
+
|
|
196
|
+
const host = hostnameOf(baseUrl);
|
|
197
|
+
const cnTZ = timezone != null && CN_TIMEZONES.has(timezone);
|
|
198
|
+
const firstParty = isFirstParty(baseUrl, assumeFirstParty);
|
|
199
|
+
|
|
200
|
+
let known = false;
|
|
201
|
+
let labKw = false;
|
|
202
|
+
if (!firstParty && host) {
|
|
203
|
+
known = matchesDomain(host, domains);
|
|
204
|
+
labKw = matchesLabKeyword(host, labKeywords);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return assembleReport({ baseUrl, host, timezone, assumeFirstParty, date, firstParty, cnTZ, known, labKw, observed: false });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Local calendar date as YYYY-MM-DD (the shape Claude Code templates). */
|
|
211
|
+
function isoDate(d = new Date()) {
|
|
212
|
+
const y = d.getFullYear();
|
|
213
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
214
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
215
|
+
return `${y}-${m}-${day}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
CN_TIMEZONES,
|
|
220
|
+
WEIGHTS,
|
|
221
|
+
APOSTROPHE,
|
|
222
|
+
levelOf,
|
|
223
|
+
resolveTimezone,
|
|
224
|
+
hostnameOf,
|
|
225
|
+
isFirstParty,
|
|
226
|
+
matchesDomain,
|
|
227
|
+
matchesLabKeyword,
|
|
228
|
+
pickApostrophe,
|
|
229
|
+
toCodepoint,
|
|
230
|
+
isoDate,
|
|
231
|
+
assembleReport,
|
|
232
|
+
analyze,
|
|
233
|
+
};
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract the live domain / lab-keyword lists from an installed Claude Code
|
|
5
|
+
* binary, so the checker always reflects the version you actually run.
|
|
6
|
+
*
|
|
7
|
+
* The lists are stored as base64 blobs that are XOR-encoded with a single-byte
|
|
8
|
+
* key (91 in 2.1.170). Minified identifiers change between releases, so instead
|
|
9
|
+
* of grepping for a specific variable name we scan every long base64 literal,
|
|
10
|
+
* brute-force the XOR key, and keep whichever blob decodes to a plausible list
|
|
11
|
+
* containing a known sentinel token.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
const DOMAIN_SENTINELS = ['baidu.com', 'alibaba-inc.com', 'bytedance.net'];
|
|
18
|
+
const KEYWORD_SENTINELS = ['deepseek', 'moonshot', 'dashscope'];
|
|
19
|
+
|
|
20
|
+
/** Best-effort resolve the path of the installed `claude` binary. */
|
|
21
|
+
function resolveBinaryPath() {
|
|
22
|
+
const envPath = process.env.CLAUDE_BINARY;
|
|
23
|
+
if (envPath && fs.existsSync(envPath)) return fs.realpathSync(envPath);
|
|
24
|
+
|
|
25
|
+
const candidates = [];
|
|
26
|
+
try {
|
|
27
|
+
const which = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['claude'], {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
})
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
for (const p of which) {
|
|
33
|
+
try {
|
|
34
|
+
candidates.push(fs.realpathSync(p));
|
|
35
|
+
} catch {
|
|
36
|
+
candidates.push(p);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
/* `which`/`where` not available or claude not on PATH */
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
candidates.push(
|
|
44
|
+
'/usr/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe',
|
|
45
|
+
'/usr/local/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe',
|
|
46
|
+
`${process.env.HOME || ''}/.local/share/claude/claude.exe`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const p of candidates) {
|
|
50
|
+
try {
|
|
51
|
+
if (p && fs.statSync(p).size > 0) return p;
|
|
52
|
+
} catch {
|
|
53
|
+
/* not here */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function xorDecode(buf, key) {
|
|
60
|
+
let s = '';
|
|
61
|
+
for (const b of buf) s += String.fromCharCode(b ^ key);
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function looksLikeList(str, sentinels) {
|
|
66
|
+
if (!str.includes(',')) return false;
|
|
67
|
+
const parts = str.split(',');
|
|
68
|
+
if (parts.length < 3) return false;
|
|
69
|
+
// Reject blobs that decoded to garbage: entries should be host-ish tokens.
|
|
70
|
+
const clean = parts.filter((p) => /^[a-z0-9][a-z0-9.\-]*$/.test(p));
|
|
71
|
+
if (clean.length / parts.length < 0.8) return false;
|
|
72
|
+
return sentinels.some((s) => parts.includes(s) || str.includes(s));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string=} binaryPath path to claude binary (auto-detected if omitted)
|
|
77
|
+
* @returns {{domains:string[], labKeywords:string[], xorKey:number,
|
|
78
|
+
* binaryPath:string, version:?string} | null}
|
|
79
|
+
*/
|
|
80
|
+
function extractLists(binaryPath) {
|
|
81
|
+
const bin = binaryPath || resolveBinaryPath();
|
|
82
|
+
if (!bin) return null;
|
|
83
|
+
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
text = fs.readFileSync(bin, 'latin1');
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Candidate base64 literals. The keyword list is short (~116 chars); the
|
|
92
|
+
// domain list is large. Keep the floor low enough to catch both.
|
|
93
|
+
const blobs = new Set();
|
|
94
|
+
const re = /["'`]([A-Za-z0-9+/]{80,}={0,2})["'`]/g;
|
|
95
|
+
let m;
|
|
96
|
+
while ((m = re.exec(text)) !== null) blobs.add(m[1]);
|
|
97
|
+
|
|
98
|
+
// Candidate XOR keys: prefer any that appear in `fromCharCode(x ^ N)`, then
|
|
99
|
+
// fall back to the full byte range.
|
|
100
|
+
const keyHints = new Set();
|
|
101
|
+
const keyRe = /fromCharCode\([^)]*\^\s*(\d{1,3})\s*\)/g;
|
|
102
|
+
while ((m = keyRe.exec(text)) !== null) keyHints.add(Number(m[1]));
|
|
103
|
+
const keys = [...keyHints, ...Array.from({ length: 256 }, (_, i) => i)];
|
|
104
|
+
|
|
105
|
+
let domains = null;
|
|
106
|
+
let labKeywords = null;
|
|
107
|
+
let usedKey = null;
|
|
108
|
+
|
|
109
|
+
for (const b64 of blobs) {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = Buffer.from(b64, 'base64');
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (raw.length < 8) continue;
|
|
117
|
+
// Once we know the real key (from the domain blob), try it first so the
|
|
118
|
+
// short keyword blob resolves on the first attempt instead of brute force.
|
|
119
|
+
const tryKeys = usedKey != null ? [usedKey, ...keys] : keys;
|
|
120
|
+
for (const key of tryKeys) {
|
|
121
|
+
const decoded = xorDecode(raw, key);
|
|
122
|
+
if (!domains && looksLikeList(decoded, DOMAIN_SENTINELS)) {
|
|
123
|
+
domains = decoded.split(',');
|
|
124
|
+
usedKey = key;
|
|
125
|
+
} else if (!labKeywords && looksLikeList(decoded, KEYWORD_SENTINELS)) {
|
|
126
|
+
labKeywords = decoded.split(',');
|
|
127
|
+
usedKey = usedKey ?? key;
|
|
128
|
+
}
|
|
129
|
+
if (domains && labKeywords) break;
|
|
130
|
+
}
|
|
131
|
+
if (domains && labKeywords) break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!domains && !labKeywords) return null;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
domains: domains || [],
|
|
138
|
+
labKeywords: labKeywords || [],
|
|
139
|
+
xorKey: usedKey,
|
|
140
|
+
binaryPath: bin,
|
|
141
|
+
version: detectVersion(bin),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function detectVersion(bin) {
|
|
146
|
+
// Try the package.json that ships next to the binary.
|
|
147
|
+
try {
|
|
148
|
+
const pkg = bin.replace(/bin[\\/][^\\/]+$/, 'package.json');
|
|
149
|
+
return JSON.parse(fs.readFileSync(pkg, 'utf8')).version || null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { resolveBinaryPath, extractLists };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const detect = require('./detect');
|
|
4
|
+
const snapshot = require('./snapshot');
|
|
5
|
+
const { extractLists, resolveBinaryPath } = require('./extract');
|
|
6
|
+
const { resolveConfig } = require('./resolve');
|
|
7
|
+
const { captureReal, CaptureError } = require('./capture');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the signal lists. By default we extract them live from the installed
|
|
14
|
+
* Claude Code binary (so the answer matches the version you actually run) and
|
|
15
|
+
* fall back to the bundled snapshot if extraction fails.
|
|
16
|
+
*
|
|
17
|
+
* @param {object=} opts
|
|
18
|
+
* @param {boolean=} opts.preferSnapshot skip extraction, use the bundled lists
|
|
19
|
+
* @param {string=} opts.binaryPath explicit path to the claude binary
|
|
20
|
+
* @returns {{domains,labKeywords,xorKey,source,version,binaryPath}}
|
|
21
|
+
*/
|
|
22
|
+
function getSignalLists(opts = {}) {
|
|
23
|
+
if (!opts.preferSnapshot) {
|
|
24
|
+
const live = extractLists(opts.binaryPath);
|
|
25
|
+
if (live && live.domains.length) {
|
|
26
|
+
return { ...live, source: 'binary' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
domains: snapshot.domains,
|
|
31
|
+
labKeywords: snapshot.labKeywords,
|
|
32
|
+
xorKey: snapshot.xorKey,
|
|
33
|
+
version: snapshot.sourceVersion,
|
|
34
|
+
binaryPath: null,
|
|
35
|
+
source: 'snapshot',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* High-level check of the current (or a hypothetical) environment.
|
|
41
|
+
*
|
|
42
|
+
* Base URL / timezone are resolved from real config sources (settings.json env
|
|
43
|
+
* blocks, system timezone) — not just process.env — unless overridden via opts.
|
|
44
|
+
*
|
|
45
|
+
* @param {object=} opts overrides for analyze() plus list-loading options
|
|
46
|
+
* @returns {object} report with `.lists` and `.resolution` metadata attached
|
|
47
|
+
*/
|
|
48
|
+
function check(opts = {}) {
|
|
49
|
+
const lists = getSignalLists(opts);
|
|
50
|
+
const resolved = resolveConfig({ cwd: opts.cwd, home: opts.home });
|
|
51
|
+
|
|
52
|
+
const baseUrl = opts.baseUrl !== undefined ? opts.baseUrl : resolved.baseUrl.value || null;
|
|
53
|
+
const timezone = opts.timezone !== undefined ? opts.timezone : resolved.timezone.value;
|
|
54
|
+
const assumeFirstParty =
|
|
55
|
+
opts.assumeFirstParty !== undefined ? opts.assumeFirstParty : resolved.assumeFirstParty.value;
|
|
56
|
+
|
|
57
|
+
const report = detect.analyze({
|
|
58
|
+
baseUrl,
|
|
59
|
+
timezone,
|
|
60
|
+
assumeFirstParty,
|
|
61
|
+
domains: lists.domains,
|
|
62
|
+
labKeywords: lists.labKeywords,
|
|
63
|
+
date: opts.date,
|
|
64
|
+
});
|
|
65
|
+
report.lists = {
|
|
66
|
+
source: lists.source,
|
|
67
|
+
version: lists.version,
|
|
68
|
+
binaryPath: lists.binaryPath,
|
|
69
|
+
domainCount: lists.domains.length,
|
|
70
|
+
keywordCount: lists.labKeywords.length,
|
|
71
|
+
};
|
|
72
|
+
report.resolution = resolved;
|
|
73
|
+
report.overrides = {
|
|
74
|
+
baseUrl: opts.baseUrl !== undefined,
|
|
75
|
+
timezone: opts.timezone !== undefined,
|
|
76
|
+
assumeFirstParty: opts.assumeFirstParty !== undefined,
|
|
77
|
+
};
|
|
78
|
+
return report;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Convenience boolean: would this environment be flagged as a China request? */
|
|
82
|
+
function isClaudeChinaUser(opts = {}) {
|
|
83
|
+
return check(opts).marked;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Map an observed apostrophe character back to the domain/keyword signals.
|
|
87
|
+
const APOSTROPHE_SIGNALS = {
|
|
88
|
+
[detect.APOSTROPHE.none]: { known: false, labKw: false },
|
|
89
|
+
[detect.APOSTROPHE.known]: { known: true, labKw: false },
|
|
90
|
+
[detect.APOSTROPHE.labKw]: { known: false, labKw: true },
|
|
91
|
+
[detect.APOSTROPHE.both]: { known: true, labKw: true },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function readUserModel(home) {
|
|
95
|
+
try {
|
|
96
|
+
const j = JSON.parse(fs.readFileSync(path.join(home || os.homedir(), '.claude', 'settings.json'), 'utf8'));
|
|
97
|
+
return typeof j.model === 'string' ? j.model : undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* REAL check: run Claude Code once and observe the marker it actually emits.
|
|
105
|
+
* Falls back to computed analysis only for what-if overrides, first-party
|
|
106
|
+
* endpoints (certain-safe, nothing to observe), or when explicitly allowed.
|
|
107
|
+
*
|
|
108
|
+
* @param {object=} opts
|
|
109
|
+
* opts.noCapture — skip capture, use computed prediction
|
|
110
|
+
* opts.captureTimeoutMs, opts.log — passthrough to captureReal
|
|
111
|
+
* @returns {Promise<object>} report (report.observed indicates real capture)
|
|
112
|
+
*/
|
|
113
|
+
async function checkLive(opts = {}) {
|
|
114
|
+
const lists = getSignalLists(opts);
|
|
115
|
+
const resolved = resolveConfig({ cwd: opts.cwd, home: opts.home });
|
|
116
|
+
|
|
117
|
+
const whatIf = opts.baseUrl !== undefined || opts.timezone !== undefined;
|
|
118
|
+
const baseUrl = opts.baseUrl !== undefined ? opts.baseUrl : resolved.baseUrl.value || null;
|
|
119
|
+
const timezone = opts.timezone !== undefined ? opts.timezone : resolved.timezone.value;
|
|
120
|
+
const assumeFirstParty =
|
|
121
|
+
opts.assumeFirstParty !== undefined ? opts.assumeFirstParty : resolved.assumeFirstParty.value;
|
|
122
|
+
|
|
123
|
+
const attach = (report, extra) => {
|
|
124
|
+
report.lists = {
|
|
125
|
+
source: lists.source,
|
|
126
|
+
version: lists.version,
|
|
127
|
+
binaryPath: lists.binaryPath,
|
|
128
|
+
domainCount: lists.domains.length,
|
|
129
|
+
keywordCount: lists.labKeywords.length,
|
|
130
|
+
};
|
|
131
|
+
report.resolution = resolved;
|
|
132
|
+
report.overrides = {
|
|
133
|
+
baseUrl: opts.baseUrl !== undefined,
|
|
134
|
+
timezone: opts.timezone !== undefined,
|
|
135
|
+
assumeFirstParty: opts.assumeFirstParty !== undefined,
|
|
136
|
+
};
|
|
137
|
+
report.capture = extra;
|
|
138
|
+
return report;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const firstParty = detect.isFirstParty(baseUrl, assumeFirstParty);
|
|
142
|
+
|
|
143
|
+
// Cases where a real capture is impossible or pointless → computed result.
|
|
144
|
+
if (whatIf) {
|
|
145
|
+
return attach(check({ ...opts }), { mode: 'computed', reason: 'what-if 覆盖,无法实测假设环境' });
|
|
146
|
+
}
|
|
147
|
+
if (firstParty) {
|
|
148
|
+
// First-party is certain-safe by the guard logic (verified in the binary);
|
|
149
|
+
// there is no marker to observe.
|
|
150
|
+
return attach(check({ ...opts }), { mode: 'first-party', reason: '第一方端点,确定不打标记,无需实测' });
|
|
151
|
+
}
|
|
152
|
+
if (opts.noCapture) {
|
|
153
|
+
return attach(check({ ...opts }), { mode: 'computed', reason: '--no-capture 指定,使用计算预测' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Real capture.
|
|
157
|
+
const host = detect.hostnameOf(baseUrl);
|
|
158
|
+
const obs = await captureReal({
|
|
159
|
+
host,
|
|
160
|
+
baseUrl,
|
|
161
|
+
model: readUserModel(opts.home),
|
|
162
|
+
timeoutMs: opts.captureTimeoutMs,
|
|
163
|
+
log: opts.log,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const sig = APOSTROPHE_SIGNALS[obs.apostrophe] || { known: false, labKw: false };
|
|
167
|
+
const report = detect.assembleReport({
|
|
168
|
+
baseUrl,
|
|
169
|
+
host,
|
|
170
|
+
timezone,
|
|
171
|
+
assumeFirstParty,
|
|
172
|
+
date: obs.date.replace(/\//g, '-'),
|
|
173
|
+
firstParty: false,
|
|
174
|
+
cnTZ: obs.separatorSwapped,
|
|
175
|
+
known: sig.known,
|
|
176
|
+
labKw: sig.labKw,
|
|
177
|
+
apostrophe: obs.apostrophe,
|
|
178
|
+
separatorSwapped: obs.separatorSwapped,
|
|
179
|
+
dateLine: obs.dateLine,
|
|
180
|
+
observed: true,
|
|
181
|
+
});
|
|
182
|
+
return attach(report, { mode: 'observed', reason: '真实运行 claude 并捕获其发出的 system prompt' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
...detect,
|
|
187
|
+
extractLists,
|
|
188
|
+
resolveBinaryPath,
|
|
189
|
+
resolveConfig,
|
|
190
|
+
captureReal,
|
|
191
|
+
CaptureError,
|
|
192
|
+
snapshot,
|
|
193
|
+
getSignalLists,
|
|
194
|
+
check,
|
|
195
|
+
checkLive,
|
|
196
|
+
isClaudeChinaUser,
|
|
197
|
+
};
|