privacy: remove external data transmissions & add GitHub release workflow

Remove three active external data transmission paths:

1. WebFetch domain blocklist (api.anthropic.com/api/web/domain_info)
   - src/tools/WebFetchTool/utils.ts
   - Was sending every domain a user tried to fetch to Anthropic
   - Replaced with always-allowed stub; tool permission dialog is
     the primary security boundary

2. Codex API router (chatgpt.com/backend-api/codex/responses)
   - src/services/api/codex-fetch-adapter.ts
   - Would have forwarded full conversation content to OpenAI
   - createCodexFetch now returns HTTP 403 stub

3. OpenAI API adapter (api.openai.com/v1/chat/completions)
   - src/utils/codex-fetch-adapter.ts
   - Would have forwarded messages to OpenAI
   - fetchCodexResponse now throws immediately

Already-disabled paths (no changes needed):
- Analytics logEvent/logEventAsync: empty stubs in services/analytics/index.ts
- GrowthBook/Statsig: local cache only, no outbound requests
- Auto-updater GCS: already guarded by CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
- MCP registry: already guarded by CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
- Release notes GitHub: already guarded by isEssentialTrafficOnly()

Add .github/workflows/release.yml:
- Builds self-contained binaries for macOS (x64+arm64), Linux (x64+arm64),
  Windows (x64) using bun compile on each native runner
- Triggers on version tags (v*.*.*) or manual workflow_dispatch
- Publishes binaries + SHA256SUMS.txt as a GitHub Release with
  per-platform install instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-14 15:46:47 +08:00
parent 9ba783f10b
commit 7dd3095974
4 changed files with 199 additions and 144 deletions

169
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,169 @@
name: Build & Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v2.1.88)'
required: false
default: ''
permissions:
contents: write
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: linux-x64
runner: ubuntu-latest
artifact: claude-linux-x64
- os: linux-arm64
runner: ubuntu-24.04-arm
artifact: claude-linux-arm64
- os: macos-x64
runner: macos-13
artifact: claude-macos-x64
- os: macos-arm64
runner: macos-latest
artifact: claude-macos-arm64
- os: windows-x64
runner: windows-latest
artifact: claude-windows-x64.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.11'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build binary
run: bun run compile
- name: Rename binary (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p release
cp dist/cli release/${{ matrix.artifact }}
chmod +x release/${{ matrix.artifact }}
- name: Rename binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path release
Copy-Item dist/cli.exe release/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: release/${{ matrix.artifact }}
retention-days: 7
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release/
- name: Flatten release directory
run: |
find release/ -type f | while read f; do
mv "$f" release/$(basename "$f")
done
find release/ -type d -empty -delete
- name: Determine release tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.tag }}" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
fi
- name: Generate checksums
run: |
cd release
sha256sum claude-linux-x64 claude-linux-arm64 claude-macos-x64 claude-macos-arm64 claude-windows-x64.exe > SHA256SUMS.txt 2>/dev/null || true
cat SHA256SUMS.txt
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Claude Code ${{ steps.tag.outputs.tag }}
draft: false
prerelease: false
generate_release_notes: true
body: |
## 安装说明 / Installation
### macOS (Apple Silicon)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-macos-arm64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### macOS (Intel)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-macos-x64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Linux (x64)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-linux-x64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Linux (ARM64)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-linux-arm64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Windows (x64)
下载 `claude-windows-x64.exe`,将其重命名为 `claude.exe` 并添加到 PATH。
### 验证 / Verify
```bash
claude --version
```
### 隐私说明 / Privacy
本构建已移除以下外部数据传输:
- ✅ 已删除 WebFetch 域名检查(不再向 Anthropic 上报访问域名)
- ✅ 已禁用 Codex API 路由(不再将对话转发至 OpenAI chatgpt.com
- ✅ Analytics/遥测已为空存根(无实际数据发送)
- ✅ GrowthBook/Statsig 仅使用本地缓存(无远程请求)
files: |
release/claude-linux-x64
release/claude-linux-arm64
release/claude-macos-x64
release/claude-macos-arm64
release/claude-windows-x64.exe
release/SHA256SUMS.txt

View File

@@ -736,77 +736,27 @@ async function translateCodexStreamToAnthropic(
// ── Main fetch interceptor ────────────────────────────────────────── // ── Main fetch interceptor ──────────────────────────────────────────
const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex/responses'
/** /**
* Creates a fetch function that intercepts Anthropic API calls and routes them to Codex. * createCodexFetch is disabled: routing conversations to chatgpt.com would
* @param accessToken - The Codex access token for authentication * send full user conversation content to OpenAI's backend, which is a
* @returns A fetch function that translates Anthropic requests to Codex format * privacy violation. The function is kept as a stub that always returns an
* error so existing call sites don't break at compile time.
*/ */
export function createCodexFetch( export function createCodexFetch(
accessToken: string, _accessToken: string,
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> { ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
const accountId = extractAccountId(accessToken) return async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const errorBody = {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { type: 'error',
const url = input instanceof Request ? input.url : String(input) error: {
type: 'api_error',
// Only intercept Anthropic API message calls message:
if (!url.includes('/v1/messages')) { 'Codex API routing is disabled. External Codex forwarding has been removed for privacy reasons.',
return globalThis.fetch(input, init)
}
// Parse the Anthropic request body
let anthropicBody: Record<string, unknown>
try {
const bodyText =
init?.body instanceof ReadableStream
? await new Response(init.body).text()
: typeof init?.body === 'string'
? init.body
: '{}'
anthropicBody = JSON.parse(bodyText)
} catch {
anthropicBody = {}
}
// Get current token (may have been refreshed)
const tokens = getCodexOAuthTokens()
const currentToken = tokens?.accessToken || accessToken
// Translate to Codex format
const { codexBody, codexModel } = translateToCodexBody(anthropicBody)
// Call Codex API
const codexResponse = await globalThis.fetch(CODEX_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${currentToken}`,
'chatgpt-account-id': accountId,
originator: 'pi',
'OpenAI-Beta': 'responses=experimental',
}, },
body: JSON.stringify(codexBody),
})
if (!codexResponse.ok) {
const errorText = await codexResponse.text()
const errorBody = {
type: 'error',
error: {
type: 'api_error',
message: `Codex API error (${codexResponse.status}): ${errorText}`,
},
}
return new Response(JSON.stringify(errorBody), {
status: codexResponse.status,
headers: { 'Content-Type': 'application/json' },
})
} }
return new Response(JSON.stringify(errorBody), {
// Translate streaming response status: 403,
return translateCodexStreamToAnthropic(codexResponse, codexModel) headers: { 'Content-Type': 'application/json' },
})
} }
} }

View File

@@ -68,18 +68,8 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
ttl: CACHE_TTL_MS, ttl: CACHE_TTL_MS,
}) })
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
// fetching two paths on the same domain triggers two identical preflight
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
max: 128,
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
})
export function clearWebFetchCache(): void { export function clearWebFetchCache(): void {
URL_CACHE.clear() URL_CACHE.clear()
DOMAIN_CHECK_CACHE.clear()
} }
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB // Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
@@ -115,9 +105,6 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
// Prevents hanging indefinitely on slow/unresponsive servers. // Prevents hanging indefinitely on slow/unresponsive servers.
const FETCH_TIMEOUT_MS = 60_000 const FETCH_TIMEOUT_MS = 60_000
// Timeout for the domain blocklist preflight check (10 seconds).
const DOMAIN_CHECK_TIMEOUT_MS = 10_000
// Cap same-host redirect hops. Without this a malicious server can return // Cap same-host redirect hops. Without this a malicious server can return
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS // a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS
// resets on every hop, hanging the tool until user interrupt. 10 matches // resets on every hop, hanging the tool until user interrupt. 10 matches
@@ -174,32 +161,12 @@ type DomainCheckResult =
| { status: 'check_failed'; error: Error } | { status: 'check_failed'; error: Error }
export async function checkDomainBlocklist( export async function checkDomainBlocklist(
domain: string, _domain: string,
): Promise<DomainCheckResult> { ): Promise<DomainCheckResult> {
if (DOMAIN_CHECK_CACHE.has(domain)) { // Remote domain-blocklist check removed: no user domain names are sent to
return { status: 'allowed' } // external servers. Users explicitly approve each domain via the tool
} // permission dialog, which is the primary security boundary.
try { return { status: 'allowed' }
const response = await axios.get(
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
)
if (response.status === 200) {
if (response.data.can_fetch === true) {
DOMAIN_CHECK_CACHE.set(domain, true)
return { status: 'allowed' }
}
return { status: 'blocked' }
}
// Non-200 status but didn't throw
return {
status: 'check_failed',
error: new Error(`Domain check returned status ${response.status}`),
}
} catch (e) {
logError(e)
return { status: 'check_failed', error: e as Error }
}
} }
/** /**

View File

@@ -83,53 +83,22 @@ function convertToOpenAIMessage(message: Message): OpenAIMessage {
} }
/** /**
* Make a request to OpenAI Codex API * fetchCodexResponse is disabled: sending conversation content to
* api.openai.com would leak user data to a third-party service.
* This function is retained as a stub to avoid breaking any call sites.
*/ */
export async function fetchCodexResponse( export async function fetchCodexResponse(
messages: Message[], _messages: Message[],
model: string, _model: string,
options: { _options: {
apiKey?: string apiKey?: string
baseUrl?: string baseUrl?: string
stream?: boolean stream?: boolean
} = {} } = {}
): Promise<OpenAIResponse> { ): Promise<OpenAIResponse> {
const { apiKey, baseUrl = 'https://api.openai.com/v1', stream = false } = options throw new Error(
'OpenAI Codex API calls are disabled for privacy. External data forwarding has been removed.',
if (!apiKey) { )
throw new Error('OpenAI API key is required for Codex requests')
}
const openAIMessages = messages.map(convertToOpenAIMessage)
const requestBody = {
model,
messages: openAIMessages,
stream,
temperature: 0.7,
max_tokens: 4096,
}
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
}
const data = await response.json() as OpenAIResponse
return data
} catch (error) {
logError(error)
throw error
}
} }
/** /**