macOS 권한 상속 문제를 posix_spawn으로 해결한 이야기
macOS 권한 상속 문제를 posix_spawn으로 해결한 이야기
ttapp 데스크탑은 Tauri(Rust)로 만들어진 앱이다. 내부적으로 Claude CLI를 자식 프로세스로 실행해 AI 작업을 처리한다. 오랫동안 잘 동작했는데, 어느 날 자동 업데이트 이후 권한 재요청 팝업이 반복적으로 뜨는 문제가 발견됐다.
문제: 업데이트마다 "전체 디스크 접근" 재요청
macOS에는 TCC(Transparency, Consent, and Control) 라는 권한 프레임워크가 있다. 파일 접근, 카메라, 마이크 등 민감한 리소스에 대한 권한을 앱 단위로 관리한다.
ttapp에는 전체 디스크 접근(Full Disk Access, FDA) 권한이 부여되어 있다. Claude Code가 프로젝트 파일을 자유롭게 읽고 수정하려면 반드시 필요한 권한이다.
문제는 자식 프로세스인 Claude CLI였다. Claude CLI는 Node.js 스크립트가 아니라 Mach-O arm64 네이티브 바이너리다. macOS TCC는 각 바이너리를 독립적으로 추적하기 때문에, 부모 프로세스(ttapp)가 FDA를 가지고 있어도 자식 프로세스(Claude CLI)는 별도로 권한을 요청받게 된다.
자동 업데이트로 Claude CLI 바이너리가 교체되면 코드서명이 달라지고, TCC 데이터베이스에서 이전 권한 기록이 무효화된다. 결과적으로 업데이트마다 사용자에게 권한 팝업이 뜨는 것이다.
[업데이트 전] ttapp(FDA 있음) → Claude CLI v1 (TCC 기록 있음) → 정상
[업데이트 후] ttapp(FDA 있음) → Claude CLI v2 (TCC 기록 없음) → ❌ 권한 팝업
해결책 탐색: fork vs spawn
처음에는 단순히 std::process::Command로 자식 프로세스를 실행하는 기존 방식이 문제라고 생각했다. 하지만 핵심은 프로세스가 어떻게 **책임 관계(responsibility)**를 선언하느냐에 있었다.
macOS는 responsibility_spawnattrs_setdisclaim이라는 비공개 API를 통해 자식 프로세스가 부모의 TCC 권한을 상속받도록 설정할 수 있다. 이 API를 사용하면 자식 프로세스가 부모의 TCC 컨텍스트를 그대로 이어받는다.
핵심은 posix_spawn 을 직접 사용하는 것이다.
구현: macos_spawn.rs
Rust에서 posix_spawn + responsibility_spawnattrs_setdisclaim을 직접 호출하는 모듈을 새로 만들었다.
// desktop/src-tauri/src/macos_spawn.rs
extern "C" {
fn responsibility_spawnattrs_setdisclaim(
attr: *mut posix_spawnattr_t,
disclaim: i32,
) -> i32;
}
pub fn spawn_disclaim(program: &str, args: &[&str], envs: &[(String, String)])
-> Result<MacChild, SpawnError>
{
unsafe {
let mut attr: posix_spawnattr_t = std::mem::zeroed();
posix_spawnattr_init(&mut attr);
// 핵심: disclaim(0) = 부모의 TCC 책임을 자식에게 위임
responsibility_spawnattrs_setdisclaim(&mut attr, 0);
// ... 파이프, 환경변수 설정 후 posix_spawn 호출
let pid = posix_spawn(/* ... */);
Ok(MacChild::new(pid))
}
}
responsibility_spawnattrs_setdisclaim(attr, 0)의 두 번째 인자 0은 "disclaim = false"가 아니라 "부모의 책임을 내려받겠다(disclaim from parent)"는 의미다. 이 한 줄로 Claude CLI가 ttapp의 TCC 컨텍스트를 상속받게 된다.
추가 수정: double-wait hang 방지
단순히 spawn만 바꿨다면 새로운 문제가 생겼을 것이다. 기존 코드는 ClaudeChildHandle이 pid_t를 직접 들고 있었는데, 여러 곳에서 waitpid를 중복 호출할 경우 데드락(hang) 이 발생할 수 있다.
이를 해결하기 위해 MacChild 구조체를 도입했다:
pub struct MacChild {
pid: pid_t,
// waitpid는 딱 한 번, 백그라운드 태스크에서만 호출
exit_rx: tokio::sync::watch::Receiver<Option<i32>>,
}
백그라운드 tokio::spawn 태스크 하나가 waitpid를 담당하고, 결과를 watch 채널로 브로드캐스트한다. 나머지 코드는 채널을 구독하기만 하면 된다. waitpid 중복 호출로 인한 hang이 원천적으로 불가능해졌다.
추가 수정: 좀비 프로세스 방지
파이프 래핑 단계에서 실패가 발생하면, 이미 spawn된 프로세스가 처리되지 않아 좀비 프로세스로 남을 수 있다. reap_child_on_setup_failure()를 추가해 실패 시 즉시 SIGKILL + waitpid를 동기적으로 실행하도록 했다.
fn reap_child_on_setup_failure(pid: pid_t) {
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, std::ptr::null_mut(), 0);
}
}
플랫폼 격리
이 모든 변경은 macOS 전용이다. cfg 게이트로 Windows/Linux와 완전히 분리했다:
#[cfg(target_os = "macos")]
mod macos_spawn;
// Windows/Linux는 기존 tokio::process::Command 경로 유지
#[cfg(not(target_os = "macos"))]
fn run_claude_cross_platform(/* ... */) { /* 기존 코드 */ }
결과
- ✅ 자동 업데이트 후 "전체 디스크 접근" 팝업 없음
- ✅ Claude CLI가 ttapp의 TCC 권한을 자동 상속
- ✅ double-wait hang 제거
- ✅ 좀비 프로세스 방지
- ✅ Windows/Linux 기존 동작 무영향
macOS의 TCC 시스템은 문서화가 부족한 편이라 삽질이 많았다. responsibility_spawnattrs_setdisclaim은 Private API라서 Swift/ObjC 레퍼런스조차 찾기 어려웠다. Rust FFI로 직접 바인딩하면서 해결한 경험이었다.
ttapp — 모바일에서 Claude Code를 원격으로 제어하는 서비스
이 글 공유하기
// SPONSORED
[>]댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요!