Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 34 additions & 24 deletions cac
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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" ;;
Expand All @@ -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
Expand Down Expand Up @@ -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:-}" ;;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<sub>2026-04-01</sub>

**Fix: `sudo cac` corrupts `~/.cac/` file ownership** — [PR #59](https://github.com/nmhjklnm/cac/pull/59)

Running `cac` as root (e.g. `sudo cac <env>`) 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
<sub>2026-03-31</sub>

Expand Down Expand Up @@ -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**

Expand Down
20 changes: 19 additions & 1 deletion docs/zh/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ description: 版本发布历史和重要变更

所有重要变更记录在此,每个条目关联对应的 PR 或 Issue。

## v1.5.2
<sub>2026-04-01</sub>

**修复:`sudo cac` 导致 `~/.cac/` 文件所有权污染** — [PR #59](https://github.com/nmhjklnm/cac/pull/59)

以 root 身份运行 `cac`(如 `sudo cac <env>`)会将 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
<sub>2026-03-31</sub>

Expand Down Expand Up @@ -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 中行为已调整,见上)

**新命令**

Expand Down
25 changes: 10 additions & 15 deletions src/cmd_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions src/cmd_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/cmd_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions src/templates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ;;
Expand All @@ -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
Expand Down Expand Up @@ -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:-}" ;;
Expand Down
2 changes: 1 addition & 1 deletion src/utils.sh
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down
Loading