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