무슨 일이 있었나
우리가 관리하는 CLI 도구 하나는 설치 단계에서 설정 파일을 사용자 홈 아래에 쓴다. 엉뚱한 곳에 쓰지 않도록, 쓰기 전에 '목적지가 관리 범위(홈 디렉터리) 안에 있는가'를 검사한다. 새 릴리스가 나간 뒤 macOS CI의 보안 사전 점검 작업이 실패하기 시작했다. 실패한 설치 테스트 113개는 모두 같은 문장을 남겼다: Destination /var/folders/…/home/.<tool> is outside managed scope /private/var/folders/…/home.
무엇이 잘못됐나
두 경로를 나란히 놓으면 바로 보인다. 범위는 /private/var/...로 시작하고 목적지는 /var/...로 시작한다. macOS에서 /var, /tmp, /etc는 /private 아래 실제 디렉터리로 가는 심볼릭 링크다. 코드는 범위 쪽 경로에만 realpath를 적용해 링크를 풀었고, 목적지는 받은 문자열 그대로 비교했다. 같은 디렉터리를 가리키는 두 표기를 문자열로 비교했으니, 홈 바로 아래 경로가 홈 밖으로 판정됐다.
왜 하루 종일 플레이크로 보였나
같은 macOS 작업이 이전 릴리스들에서 30분 시간 초과로 취소되는 일이 잦았다. 그래서 이번 빨간불도 '또 그 작업'으로 읽혔고, 재실행과 병합이 이어졌다. 그런데 이번 실패는 시간 초과가 아니라 113개의 단정 실패였다. 작업 로그를 한 번만 열었어도 구분됐을 차이다. 결국 이 문제는 홈이나 설정 디렉터리가 심볼릭 링크를 거치는 사용자에게 그대로 배포됐다.
재현 (리눅스에서도 된다)
macOS가 없어도 재현할 수 있다. real/home 디렉터리를 만들고 link -> real 심볼릭 링크를 건 뒤, 홈을 link/home으로 잡는다. 범위는 realpath(home) = .../real/home, 목적지는 link/home/.tool. Node로 두 값을 비교하면 '범위 밖'이 나온다. 목적지도 같은 방식으로 풀면 .../real/home/.tool이 되어 '범위 안'이 나온다.
고칠 때의 함정 1: 아직 없는 경로
목적지는 대개 아직 만들어지지 않은 파일이다. Node의 fs.realpathSync는 존재하지 않는 경로에 대해 ENOENT를 던진다. 그래서 존재하는 가장 가까운 조상 디렉터리까지 올라가 그것을 realpath로 풀고, 남은 경로 조각을 다시 붙인다. 파이썬의 os.path.realpath는 기본값이 엄격하지 않아서 존재하는 부분까지만 풀고 나머지를 그대로 붙여 준다. 언어마다 동작이 다르니 확인하고 쓴다.
고칠 때의 함정 2: 접두사 비교
정규화를 맞춘 뒤에도 dest.startsWith(scope)로 비교하면 /x/home-evil이 /x/home 안에 있다고 나온다. path.relative(scope, dest)를 구해서 결과가 빈 문자열이거나, ..으로 시작하지 않고 절대 경로도 아니면 안쪽이라고 판정한다.
규칙
비교하는 두 경로는 반드시 같은 함수로 정규화한다. 한쪽만 realpath를 적용하는 것이 가장 나쁜 조합이다. 둘 다 풀지 않으면 적어도 일관성은 있고, 둘 다 풀면 정확하다. 한쪽만 풀면 심볼릭 링크가 하나만 끼어도 틀린다.
테스트
심볼릭 링크를 거치는 홈 디렉터리를 만드는 테스트를 리눅스 CI에서도 돌린다. macOS 러너는 느리고 자주 시간 초과가 나서, 그 결과만 믿으면 이번처럼 진짜 실패가 소음에 묻힌다. 같은 작업이 연속된 릴리스에서 빨간색이면, 재실행하기 전에 로그부터 읽는다.
정리
'범위 밖입니다'라는 오류 메시지에 두 경로가 나란히 찍혀 있었다. 앞부분이 /var와 /private/var로 다르다는 것이 답의 전부였다. 경로 검사는 문자열 비교가 아니라 같은 규칙으로 정규화한 두 경로의 비교여야 한다.
What happened
One of the CLI tools we maintain writes config files under the user's home directory during setup. To avoid writing somewhere it shouldn't, it first checks that the destination is inside its managed scope, the home directory. After a new release went out, the macOS security preflight job in CI started failing. All 113 failing setup tests left the same sentence: Destination /var/folders/…/home/.<tool> is outside managed scope /private/var/folders/…/home.
What went wrong
Put the two paths side by side and it is obvious. The scope starts with /private/var/... and the destination starts with /var/.... On macOS, /var, /tmp, and /etc are symlinks to real directories under /private. The code ran realpath on the scope, which resolved the link, but compared the destination as the raw string it was given. Two spellings of the same directory were compared as strings, so a path directly under home was judged to be outside home.
Why it looked like a flake all day
In earlier releases the same macOS job had often been cancelled after a 30-minute timeout. So this red was read as "that job again", and reruns and merges followed. But this failure was not a timeout. It was 113 assertion failures, a difference that opening the job log once would have shown. The bug shipped to every user whose home or config directory crosses a symlink.
Reproduce it (Linux works too)
You don't need a Mac. Create real/home, add a symlink link -> real, and treat link/home as the home directory. The scope is realpath(home), which is .../real/home, and the destination is link/home/.tool. Compare them in Node and you get "outside". Resolve the destination the same way, giving .../real/home/.tool, and you get "inside".
Fix trap 1: the path does not exist yet
The destination is usually a file that has not been created yet. Node's fs.realpathSync throws ENOENT on a path that does not exist. So walk up to the nearest ancestor that exists, realpath that, and re-append the remaining segments. Python's os.path.realpath is non-strict by default and does this for you, resolving what exists and appending the rest. The behaviour differs by language, so check before you rely on it.
Fix trap 2: prefix comparison
Even with matching canonicalization, dest.startsWith(scope) says /x/home-evil is inside /x/home. Compute path.relative(scope, dest) instead. The destination is inside if the result is empty, or if it neither starts with .. nor is absolute.
The rule
Canonicalize both paths with the same function. Applying realpath to only one side is the worst combination. Resolving neither is at least consistent, and resolving both is correct. Resolving one breaks as soon as a single symlink is involved.
Testing
Run a test that builds a home directory reached through a symlink on Linux CI as well. macOS runners are slow and time out often, and if they are your only signal, a real failure gets buried in the noise, as it did here. When the same job is red across consecutive releases, read the log before you rerun.
Takeaway
The "outside managed scope" error printed both paths side by side. That one starts with /var and the other with /private/var was the whole answer. A path check is not a string comparison. It compares two paths canonicalized by the same rule.
发生了什么
我们维护的一个 CLI 工具,在安装步骤里会把配置文件写到用户主目录下。为了不写到不该写的地方,它在写入前先检查目标路径是否在受管范围(也就是主目录)之内。一个新版本发布之后,CI 里 macOS 的安全预检任务开始失败。失败的 113 个安装测试留下的是同一句话:Destination /var/folders/…/home/.<tool> is outside managed scope /private/var/folders/…/home。
问题出在哪里
把两条路径并排一放就看出来了:范围以 /private/var/... 开头,目标以 /var/... 开头。在 macOS 上,/var、/tmp、/etc 都是指向 /private 下真实目录的符号链接。代码只对范围一侧做了 realpath,把链接解析掉了;目标路径却按收到的原始字符串来比较。同一个目录的两种写法被当作字符串比较,于是主目录正下方的路径被判定成在主目录之外。
为什么一整天都像是偶发问题
在之前的几个版本里,同一个 macOS 任务经常因为 30 分钟超时被取消。所以这次变红也被读成“又是那个任务”,接着就是重跑和合并。可这次不是超时,而是 113 个断言失败,只要打开一次任务日志就能分清。结果这个问题原样发布给了所有主目录或配置目录经过符号链接的用户。
复现(Linux 上也行)
没有 Mac 也能复现。建一个 real/home 目录,加一个符号链接 link -> real,把 link/home 当成主目录。范围是 realpath(home),即 .../real/home;目标是 link/home/.tool。在 Node 里比较这两个值,结果是“在范围之外”。把目标也用同样方式解析成 .../real/home/.tool,结果就变成“在范围之内”。
修复时的坑一:路径还不存在
目标通常是一个还没创建的文件。Node 的 fs.realpathSync 对不存在的路径会抛出 ENOENT。所以要向上找到最近的已存在祖先目录,对它做 realpath,再把剩下的路径片段接回去。Python 的 os.path.realpath 默认是非严格模式,会自动这样处理:解析存在的部分,把其余部分原样接上。各语言行为不同,用之前先确认。
修复时的坑二:前缀比较
即使两边规范化一致,用 dest.startsWith(scope) 判断,仍会认为 /x/home-evil 在 /x/home 之内。应当计算 path.relative(scope, dest):结果为空字符串,或者既不以 .. 开头也不是绝对路径,才算在范围之内。
规则
比较的两条路径必须用同一个函数规范化。只对一边做 realpath 是最糟的组合。两边都不解析,至少是一致的;两边都解析,才是正确的。只解析一边,路径上只要出现一个符号链接就会判错。
测试
在 Linux CI 上也跑一个“主目录经过符号链接”的测试。macOS 运行器慢,而且经常超时;如果只靠它,真正的失败就会像这次一样被噪声淹没。同一个任务在连续几个版本里都是红的,重跑之前先读日志。
小结
“outside managed scope” 这条错误把两条路径并排打印了出来。一个以 /var 开头、一个以 /private/var 开头,这就是全部答案。路径检查不是字符串比较,而是比较按同一规则规范化后的两条路径。
何が起きたか
私たちが保守している CLI ツールの一つは、セットアップ時にユーザーのホームディレクトリ配下へ設定ファイルを書き込む。おかしな場所に書かないよう、書く前に「書き込み先が管理範囲(ホームディレクトリ)の内側か」を判定している。新しいリリースを出したあと、CI の macOS セキュリティ事前チェックのジョブが落ち始めた。失敗した 113 件のセットアップテストは、すべて同じ一文を残していた:Destination /var/folders/…/home/.<tool> is outside managed scope /private/var/folders/…/home。
何がまずかったか
二つのパスを並べればすぐわかる。範囲は /private/var/... で始まり、書き込み先は /var/... で始まっている。macOS では /var、/tmp、/etc は /private 配下の実ディレクトリへのシンボリックリンクだ。コードは範囲の側にだけ realpath をかけてリンクを解決し、書き込み先は受け取った文字列のまま比較していた。同じディレクトリの二通りの書き方を文字列として比べたので、ホーム直下のパスがホームの外と判定された。
なぜ丸一日フレークに見えたか
以前のリリースでは、同じ macOS ジョブが 30 分のタイムアウトで取り消されることがよくあった。そのため今回の赤も「またあのジョブか」と読まれ、再実行とマージが続いた。だが今回はタイムアウトではなく、113 件のアサーション失敗だった。ジョブのログを一度開けば見分けられた違いだ。結果として、ホームや設定ディレクトリがシンボリックリンクを経由するユーザーに、この不具合がそのまま配布された。
再現(Linux でもできる)
Mac がなくても再現できる。real/home を作り、link -> real というシンボリックリンクを張って、link/home をホームとして扱う。範囲は realpath(home) で .../real/home、書き込み先は link/home/.tool。Node でこの二つを比べると「範囲外」になる。書き込み先も同じやり方で解決して .../real/home/.tool にすると「範囲内」になる。
直すときの落とし穴 1:まだ存在しないパス
書き込み先はたいてい、まだ作られていないファイルだ。Node の fs.realpathSync は存在しないパスに対して ENOENT を投げる。だから、存在する最も近い祖先ディレクトリまでさかのぼってそれを realpath で解決し、残りのパスの断片を付け直す。Python の os.path.realpath はデフォルトで非厳格なので、存在する部分だけ解決して残りをそのまま付けてくれる。言語ごとに挙動が違うので、確かめてから使う。
直すときの落とし穴 2:前方一致での比較
正規化をそろえても、dest.startsWith(scope) で比べると /x/home-evil が /x/home の内側だと判定される。代わりに path.relative(scope, dest) を求め、結果が空文字列か、.. で始まらず絶対パスでもなければ内側と判定する。
ルール
比較する二つのパスは、必ず同じ関数で正規化する。片側にだけ realpath をかけるのが最悪の組み合わせだ。両方とも解決しなければ少なくとも一貫しているし、両方とも解決すれば正確になる。片側だけ解決すると、シンボリックリンクが一つ挟まっただけで間違える。
テスト
シンボリックリンク経由のホームディレクトリを作るテストを、Linux の CI でも回す。macOS のランナーは遅くてタイムアウトも多く、それだけを頼りにすると、今回のように本物の失敗がノイズに埋もれる。同じジョブが連続したリリースで赤なら、再実行の前にログを読む。
まとめ
「outside managed scope」というエラーは、二つのパスを並べて表示していた。片方が /var、もう片方が /private/var で始まっていること、それが答えのすべてだった。パスのチェックは文字列の比較ではない。同じ規則で正規化した二つのパスの比較だ。