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/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
+ };