diff --git a/cac b/cac index 309f566..295fef1 100755 --- a/cac +++ b/cac @@ -11,7 +11,7 @@ VERSIONS_DIR="$CAC_DIR/versions" # ── utils: colors, read/write, UUID, proxy parsing ─────────────────────── # shellcheck disable=SC2034 # used in build-concatenated cac script -CAC_VERSION="1.5.2-beta.1" +CAC_VERSION="1.5.2" _read() { [[ -f "$1" ]] && tr -d '[:space:]' < "$1" || echo "${2:-}"; } _die() { printf '%b\n' "$(_red "error:") $*" >&2; exit 1; } @@ -1273,7 +1273,9 @@ if [[ -f "$_env_dir/persona" ]]; then fi # ── NS-level DNS interception ── -if [[ -f "$CAC_DIR/cac-dns-guard.js" ]]; then +# Use -r (readable) not -f (exists) — root-owned files with mode 600 exist but +# can't be read by normal user, causing bun/node to crash silently. +if [[ -r "$CAC_DIR/cac-dns-guard.js" ]]; then case "${NODE_OPTIONS:-}" in *cac-dns-guard.js*) ;; # already injected, skip *) export NODE_OPTIONS="${NODE_OPTIONS:-} --require $CAC_DIR/cac-dns-guard.js" ;; @@ -1284,7 +1286,7 @@ if [[ -f "$CAC_DIR/cac-dns-guard.js" ]]; then esac fi # fallback layer: HOSTALIASES (gethostbyname level) -[[ -f "$CAC_DIR/blocked_hosts" ]] && export HOSTALIASES="$CAC_DIR/blocked_hosts" +[[ -r "$CAC_DIR/blocked_hosts" ]] && export HOSTALIASES="$CAC_DIR/blocked_hosts" # ── mTLS client certificate ── if [[ -f "$_env_dir/client_cert.pem" ]] && [[ -f "$_env_dir/client_key.pem" ]]; then @@ -1312,7 +1314,7 @@ fi [[ -f "$_env_dir/machine_id" ]] && export CAC_MACHINE_ID=$(tr -d '[:space:]' < "$_env_dir/machine_id") export CAC_USERNAME="user-$(echo "$_name" | cut -c1-8)" export USER="$CAC_USERNAME" LOGNAME="$CAC_USERNAME" -if [[ -f "$CAC_DIR/fingerprint-hook.js" ]]; then +if [[ -r "$CAC_DIR/fingerprint-hook.js" ]]; then case "${NODE_OPTIONS:-}" in *fingerprint-hook.js*) ;; *) export NODE_OPTIONS="--require $CAC_DIR/fingerprint-hook.js ${NODE_OPTIONS:-}" ;; @@ -1581,9 +1583,24 @@ _ensure_initialized() { if [[ -z "$_self_dir" ]] || [[ ! -f "$_self_dir/relay.js" ]]; then _self_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" fi - [[ -f "$_self_dir/fingerprint-hook.js" ]] && cp "$_self_dir/fingerprint-hook.js" "$CAC_DIR/fingerprint-hook.js" - [[ -f "$_self_dir/relay.js" ]] && cp "$_self_dir/relay.js" "$CAC_DIR/relay.js" + # Warn if running as root — files written here become root-owned and break + # normal-user invocations (wrapper has set -e and will silently exit). + if [[ $EUID -eq 0 ]]; then + echo "[cac] warning: running as root may corrupt ~/.cac/ file ownership" >&2 + echo "[cac] hint: run as your normal user instead" >&2 + fi + # rm -f first: user owns ~/.cac/ dir so can delete root-owned files even if can't overwrite them + if [[ -f "$_self_dir/fingerprint-hook.js" ]]; then + rm -f "$CAC_DIR/fingerprint-hook.js" 2>/dev/null || true + cp "$_self_dir/fingerprint-hook.js" "$CAC_DIR/fingerprint-hook.js" 2>/dev/null || true + fi + if [[ -f "$_self_dir/relay.js" ]]; then + rm -f "$CAC_DIR/relay.js" 2>/dev/null || true + cp "$_self_dir/relay.js" "$CAC_DIR/relay.js" 2>/dev/null || true + fi + rm -f "$CAC_DIR/cac-dns-guard.js" 2>/dev/null || true _write_dns_guard_js 2>/dev/null || true + rm -f "$CAC_DIR/blocked_hosts" 2>/dev/null || true _write_blocked_hosts 2>/dev/null || true # PATH (idempotent — always ensure it's in rc file) @@ -1730,7 +1747,7 @@ _env_cmd_create() { mkdir -p "$env_dir" [[ -n "$proxy_url" ]] && echo "$proxy_url" > "$env_dir/proxy" echo "$(_new_uuid)" > "$env_dir/uuid" - echo "$(_new_user_id)" > "$env_dir/user_id" + touch "$env_dir/user_id" echo "$(_new_machine_id)" > "$env_dir/machine_id" echo "$(_new_hostname)" > "$env_dir/hostname" echo "$(_new_mac)" > "$env_dir/mac_address" @@ -1823,7 +1840,6 @@ MERGE_EOF if [[ -d "$env_dir/.claude" ]]; then export CLAUDE_CONFIG_DIR="$env_dir/.claude" fi - _update_claude_json_user_id "$(_read "$env_dir/user_id")" 2>/dev/null || true local elapsed; elapsed=$(_timer_elapsed) echo @@ -1919,7 +1935,6 @@ _env_cmd_activate() { export CLAUDE_CONFIG_DIR="$ENVS_DIR/$name/.claude" fi - _update_claude_json_user_id "$(_read "$ENVS_DIR/$name/user_id")" # Relay lifecycle _relay_stop 2>/dev/null || true @@ -2429,23 +2444,18 @@ cmd_check() { else _id_issues+=("repo hash not spoofed") fi - # user_id consistency + # user_id tracking: sync from .claude.json after login (real userID wins) local _uid_ok=true - local _env_uid; _env_uid=$(_read "$env_dir/user_id" "") - if [[ -n "$_env_uid" ]]; then - local _config_dir="${CLAUDE_CONFIG_DIR:-$ENVS_DIR/$current/.claude}" - local _cj="$_config_dir/.claude.json" - [[ -f "$_cj" ]] || _cj="$HOME/.claude.json" - if [[ -f "$_cj" ]]; then - local _actual_uid - _actual_uid=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('userID',''))" "$_cj" 2>/dev/null || true) + local _config_dir="${CLAUDE_CONFIG_DIR:-$ENVS_DIR/$current/.claude}" + local _cj="$_config_dir/.claude.json" + [[ -f "$_cj" ]] || _cj="$HOME/.claude.json" + if [[ -f "$_cj" ]]; then + local _actual_uid + _actual_uid=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('userID',''))" "$_cj" 2>/dev/null || true) + if [[ -n "$_actual_uid" ]]; then (( _id_total++ )) || true - if [[ -n "$_actual_uid" ]] && [[ "$_actual_uid" != "$_env_uid" ]]; then - _uid_ok=false - _id_issues+=("user_id mismatch") - else - (( _id_ok++ )) || true - fi + echo "$_actual_uid" > "$env_dir/user_id" 2>/dev/null || true + (( _id_ok++ )) || true fi fi # billing header diff --git a/docs/changelog.mdx b/docs/changelog.mdx index e9602be..c865367 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,24 @@ description: Release history and notable changes All notable changes to this project are documented here. Each entry links to the corresponding PR or issue. +## v1.5.2 +2026-04-01 + +**Fix: `sudo cac` corrupts `~/.cac/` file ownership** — [PR #59](https://github.com/nmhjklnm/cac/pull/59) + +Running `cac` as root (e.g. `sudo cac `) overwrites hook files (`fingerprint-hook.js`, `cac-dns-guard.js`, etc.) with root-owned copies. Subsequent normal-user runs load the stale root-owned version, causing claude to hang at startup with no error output. + +- **Self-healing writes**: `rm -f` before each hook file write — user owns `~/.cac/` directory so can always unlink files in it, even root-owned ones. Next `cac` command automatically replaces any stale root-owned files with the current version. +- **Readability checks**: wrapper now uses `[[ -r ... ]]` instead of `[[ -f ... ]]` for hook files — skips injection gracefully if a file exists but is unreadable (mode 600), rather than crashing silently. +- **Root warning**: `_ensure_initialized` now prints a warning when running as root. + +**Fix: `user_id mismatch` false positive after `/login`** — [PR #59](https://github.com/nmhjklnm/cac/pull/59) + +After OAuth login, Claude Code writes the real account `userID` to `.claude.json`, which `cac env check` flagged as a mismatch against the environment's stored fake ID — confusing and misleading. + +- Removed fake `userID` generation on `env create` — the fake ID provided no real protection (`account_uuid` is still sent via OAuth token and cannot be spoofed). +- `cac env check` now auto-syncs `env/user_id` from `.claude.json` after login instead of reporting an error. + ## v1.5.1 2026-03-31 @@ -37,7 +55,7 @@ Based on comprehensive static analysis of Claude Code v2.1.86 (679,353 lines deo - **Trusted Device Token** [P1]: `tengu_sessions_elevated_auth_enforcement` gate is currently off but mechanism is ready (stores token in macOS Keychain). Per-environment `CLAUDE_TRUSTED_DEVICE_TOKEN` preemptively overrides. `cac env check` now detects Keychain residuals. - **Billing header disabled** [P2]: `CLAUDE_CODE_ATTRIBUTION_HEADER=0` suppresses `x-anthropic-billing-header` (cc_version, cc_entrypoint, cc_workload). - **Datadog domain blocked** [P2]: `http-intake.logs.us5.datadoghq.com` added to DNS block list (event dual-write target, 48 whitelisted events only). -- **metadata.user_id consistency check** [P2]: `cac env check` now verifies `.claude.json` `userID` matches environment file — detects if Claude Code overwrote the spoofed ID on startup. +- **metadata.user_id tracking** [P2]: `cac env check` tracks the logged-in account `userID` per environment (removed in v1.5.2 — see above). **New commands** diff --git a/docs/zh/changelog.mdx b/docs/zh/changelog.mdx index 0a37975..b42dd90 100644 --- a/docs/zh/changelog.mdx +++ b/docs/zh/changelog.mdx @@ -5,6 +5,24 @@ description: 版本发布历史和重要变更 所有重要变更记录在此,每个条目关联对应的 PR 或 Issue。 +## v1.5.2 +2026-04-01 + +**修复:`sudo cac` 导致 `~/.cac/` 文件所有权污染** — [PR #59](https://github.com/nmhjklnm/cac/pull/59) + +以 root 身份运行 `cac`(如 `sudo cac `)会将 hook 文件(`fingerprint-hook.js`、`cac-dns-guard.js` 等)覆写为 root 所有的版本。后续普通用户运行时加载的是旧版 root 文件,导致 claude 启动时无任何报错地卡死。 + +- **自愈写入**:写入 hook 文件前先 `rm -f`——用户拥有 `~/.cac/` 目录的写权限,可以删除其中的 root 所有文件。下次运行任意 `cac` 命令即自动替换为当前版本。 +- **可读性检查**:wrapper 改用 `[[ -r ... ]]` 替代 `[[ -f ... ]]` 检查 hook 文件——文件存在但不可读(mode 600)时优雅跳过,而非 silent crash。 +- **root 警告**:`_ensure_initialized` 以 root 身份运行时打印警告。 + +**修复:`/login` 后 `user_id mismatch` 误报** — [PR #59](https://github.com/nmhjklnm/cac/pull/59) + +OAuth 登录后,Claude Code 将真实账号 `userID` 写入 `.claude.json`,`cac env check` 将其与环境存储的伪造 ID 对比报错——令用户困惑且无实际意义。 + +- 移除 `env create` 时生成假 `userID` 的逻辑——假 ID 提供的保护极为有限(`account_uuid` 通过 OAuth token 传输,无法伪造)。 +- `cac env check` 现在在登录后自动从 `.claude.json` 同步 `env/user_id`,不再报错。 + ## v1.5.1 2026-03-31 @@ -37,7 +55,7 @@ description: 版本发布历史和重要变更 - **Trusted Device Token 预防** [P1]:`tengu_sessions_elevated_auth_enforcement` gate 当前关闭但机制已完备(token 存 macOS Keychain)。每环境 `CLAUDE_TRUSTED_DEVICE_TOKEN` 预防性覆盖。`cac env check` 检测 Keychain 残留。 - **Billing header 禁用** [P2]:`CLAUDE_CODE_ATTRIBUTION_HEADER=0` 抑制 `x-anthropic-billing-header`。 - **Datadog 域名拦截** [P2]:`http-intake.logs.us5.datadoghq.com` 加入 DNS 拦截列表。 -- **metadata.user_id 一致性检查** [P2]:`cac env check` 验证 `.claude.json` 的 `userID` 与环境文件一致——检测 Claude Code 是否在启动时覆写了伪造的 ID。 +- **metadata.user_id 追踪** [P2]:`cac env check` 按环境追踪已登录账号的 `userID`(v1.5.2 中行为已调整,见上)。 **新命令** diff --git a/src/cmd_check.sh b/src/cmd_check.sh index 43cebd8..cd991b7 100644 --- a/src/cmd_check.sh +++ b/src/cmd_check.sh @@ -120,23 +120,18 @@ cmd_check() { else _id_issues+=("repo hash not spoofed") fi - # user_id consistency + # user_id tracking: sync from .claude.json after login (real userID wins) local _uid_ok=true - local _env_uid; _env_uid=$(_read "$env_dir/user_id" "") - if [[ -n "$_env_uid" ]]; then - local _config_dir="${CLAUDE_CONFIG_DIR:-$ENVS_DIR/$current/.claude}" - local _cj="$_config_dir/.claude.json" - [[ -f "$_cj" ]] || _cj="$HOME/.claude.json" - if [[ -f "$_cj" ]]; then - local _actual_uid - _actual_uid=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('userID',''))" "$_cj" 2>/dev/null || true) + local _config_dir="${CLAUDE_CONFIG_DIR:-$ENVS_DIR/$current/.claude}" + local _cj="$_config_dir/.claude.json" + [[ -f "$_cj" ]] || _cj="$HOME/.claude.json" + if [[ -f "$_cj" ]]; then + local _actual_uid + _actual_uid=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('userID',''))" "$_cj" 2>/dev/null || true) + if [[ -n "$_actual_uid" ]]; then (( _id_total++ )) || true - if [[ -n "$_actual_uid" ]] && [[ "$_actual_uid" != "$_env_uid" ]]; then - _uid_ok=false - _id_issues+=("user_id mismatch") - else - (( _id_ok++ )) || true - fi + echo "$_actual_uid" > "$env_dir/user_id" 2>/dev/null || true + (( _id_ok++ )) || true fi fi # billing header diff --git a/src/cmd_env.sh b/src/cmd_env.sh index 7d5de8c..36993c1 100644 --- a/src/cmd_env.sh +++ b/src/cmd_env.sh @@ -95,7 +95,7 @@ _env_cmd_create() { mkdir -p "$env_dir" [[ -n "$proxy_url" ]] && echo "$proxy_url" > "$env_dir/proxy" echo "$(_new_uuid)" > "$env_dir/uuid" - echo "$(_new_user_id)" > "$env_dir/user_id" + touch "$env_dir/user_id" echo "$(_new_machine_id)" > "$env_dir/machine_id" echo "$(_new_hostname)" > "$env_dir/hostname" echo "$(_new_mac)" > "$env_dir/mac_address" @@ -188,7 +188,6 @@ MERGE_EOF if [[ -d "$env_dir/.claude" ]]; then export CLAUDE_CONFIG_DIR="$env_dir/.claude" fi - _update_claude_json_user_id "$(_read "$env_dir/user_id")" 2>/dev/null || true local elapsed; elapsed=$(_timer_elapsed) echo @@ -284,7 +283,6 @@ _env_cmd_activate() { export CLAUDE_CONFIG_DIR="$ENVS_DIR/$name/.claude" fi - _update_claude_json_user_id "$(_read "$ENVS_DIR/$name/user_id")" # Relay lifecycle _relay_stop 2>/dev/null || true diff --git a/src/cmd_setup.sh b/src/cmd_setup.sh index 350df52..ba73f8d 100644 --- a/src/cmd_setup.sh +++ b/src/cmd_setup.sh @@ -19,9 +19,24 @@ _ensure_initialized() { if [[ -z "$_self_dir" ]] || [[ ! -f "$_self_dir/relay.js" ]]; then _self_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" fi - [[ -f "$_self_dir/fingerprint-hook.js" ]] && cp "$_self_dir/fingerprint-hook.js" "$CAC_DIR/fingerprint-hook.js" - [[ -f "$_self_dir/relay.js" ]] && cp "$_self_dir/relay.js" "$CAC_DIR/relay.js" + # Warn if running as root — files written here become root-owned and break + # normal-user invocations (wrapper has set -e and will silently exit). + if [[ $EUID -eq 0 ]]; then + echo "[cac] warning: running as root may corrupt ~/.cac/ file ownership" >&2 + echo "[cac] hint: run as your normal user instead" >&2 + fi + # rm -f first: user owns ~/.cac/ dir so can delete root-owned files even if can't overwrite them + if [[ -f "$_self_dir/fingerprint-hook.js" ]]; then + rm -f "$CAC_DIR/fingerprint-hook.js" 2>/dev/null || true + cp "$_self_dir/fingerprint-hook.js" "$CAC_DIR/fingerprint-hook.js" 2>/dev/null || true + fi + if [[ -f "$_self_dir/relay.js" ]]; then + rm -f "$CAC_DIR/relay.js" 2>/dev/null || true + cp "$_self_dir/relay.js" "$CAC_DIR/relay.js" 2>/dev/null || true + fi + rm -f "$CAC_DIR/cac-dns-guard.js" 2>/dev/null || true _write_dns_guard_js 2>/dev/null || true + rm -f "$CAC_DIR/blocked_hosts" 2>/dev/null || true _write_blocked_hosts 2>/dev/null || true # PATH (idempotent — always ensure it's in rc file) diff --git a/src/templates.sh b/src/templates.sh index 89a8cea..e58bbba 100644 --- a/src/templates.sh +++ b/src/templates.sh @@ -334,7 +334,9 @@ if [[ -f "$_env_dir/persona" ]]; then fi # ── NS-level DNS interception ── -if [[ -f "$CAC_DIR/cac-dns-guard.js" ]]; then +# Use -r (readable) not -f (exists) — root-owned files with mode 600 exist but +# can't be read by normal user, causing bun/node to crash silently. +if [[ -r "$CAC_DIR/cac-dns-guard.js" ]]; then case "${NODE_OPTIONS:-}" in *cac-dns-guard.js*) ;; # already injected, skip *) export NODE_OPTIONS="${NODE_OPTIONS:-} --require $CAC_DIR/cac-dns-guard.js" ;; @@ -345,7 +347,7 @@ if [[ -f "$CAC_DIR/cac-dns-guard.js" ]]; then esac fi # fallback layer: HOSTALIASES (gethostbyname level) -[[ -f "$CAC_DIR/blocked_hosts" ]] && export HOSTALIASES="$CAC_DIR/blocked_hosts" +[[ -r "$CAC_DIR/blocked_hosts" ]] && export HOSTALIASES="$CAC_DIR/blocked_hosts" # ── mTLS client certificate ── if [[ -f "$_env_dir/client_cert.pem" ]] && [[ -f "$_env_dir/client_key.pem" ]]; then @@ -373,7 +375,7 @@ fi [[ -f "$_env_dir/machine_id" ]] && export CAC_MACHINE_ID=$(tr -d '[:space:]' < "$_env_dir/machine_id") export CAC_USERNAME="user-$(echo "$_name" | cut -c1-8)" export USER="$CAC_USERNAME" LOGNAME="$CAC_USERNAME" -if [[ -f "$CAC_DIR/fingerprint-hook.js" ]]; then +if [[ -r "$CAC_DIR/fingerprint-hook.js" ]]; then case "${NODE_OPTIONS:-}" in *fingerprint-hook.js*) ;; *) export NODE_OPTIONS="--require $CAC_DIR/fingerprint-hook.js ${NODE_OPTIONS:-}" ;; diff --git a/src/utils.sh b/src/utils.sh index 20c00ac..b3a9316 100644 --- a/src/utils.sh +++ b/src/utils.sh @@ -1,7 +1,7 @@ # ── utils: colors, read/write, UUID, proxy parsing ─────────────────────── # shellcheck disable=SC2034 # used in build-concatenated cac script -CAC_VERSION="1.5.2-beta.1" +CAC_VERSION="1.5.2" _read() { [[ -f "$1" ]] && tr -d '[:space:]' < "$1" || echo "${2:-}"; } _die() { printf '%b\n' "$(_red "error:") $*" >&2; exit 1; }