모델이 판단하기 전에: Agent! 셸 가드레일 내부 들여다보기
ShellSafetyService, 데몬 측 재검사, 그리고 Jev의 두 번째 의견이 AI 에이전트가 Mac을 지워 버리는 일을 어떻게 막는지 실제 Swift 코드를 따라가며 살펴봅니다.
이번 주 TechRadar는 한 코딩 에이전트가 짧은 순간에 약 48,000개의 파일을 삭제한 뒤 사과했다고 보도했습니다. 사과는 아무것도 바꾸지 못했습니다. 파일은 이미 사라진 뒤였습니다.
Agent!는 Launch Agent를 통해 사용자 권한으로, Launch Daemon을 통해 root 권한으로 셸 명령을 실행할 수 있습니다. SD 카드에 이미지를 굽거나, 권한을 고치거나, 빌드 폴더를 정리할 수 있다는 점이 바로 Agent!를 유용하게 만드는 요소입니다. 하지만 이는 동시에 "모델이 아마 조심하겠지"라는 기대가 보안 모델이 될 수 없다는 뜻이기도 합니다. 그래서 Agent!는 모델에게 조심해 달라고 부탁하지 않습니다. 무엇이든 실행되기 전에, 모든 명령을 코드로 세 단계에 걸쳐 검사합니다.
1단계: 모델이 말로 설득할 수 없는 하드코딩된 가드레일
ShellSafetyService는 Agent/Services/ShellSafetyService.swift에 있는 평범한 Swift enum입니다. 파일 맨 위의 문서 주석이 방향을 분명히 합니다. 이 서비스는 모든 실행 경로보다 먼저 동작하며, 치명적인 명령을 전달하지 않고 거부합니다. 시스템 프롬프트는 있는 그대로의 역할, 즉 강제 계층이 아니라 최후의 보조 장치로 규정됩니다.
진입점은 명령, 명령이 실행될 컨텍스트, 그리고 탭의 프로젝트 폴더를 인자로 받습니다.
static func check(_ command: String,
context: Context = .userAgent,
projectFolder: String = "") -> Verdict
Verdict는 allowed, 사람이 읽을 수 있는 reason, 그리고 감사 로그용 짧은 rule ID로 구성됩니다. reason은 모델을 위해 작성됩니다. 도구 결과로 돌아가기 때문에 LLM은 왜 거부되었는지 이해하고, 무작정 다시 시도하지 않습니다.
셸과 같은 방식으로 복합 명령을 읽는다
단순한 필터는 문자열의 시작 부분만 검사합니다. 공격자나 혼란에 빠진 모델은 그런 방식에 맞춰 주지 않습니다. check는 명령을 ;, &&, ||, |, 줄바꿈 기준으로 나눈 뒤 각 세그먼트 를 검사합니다. 그래서 앞부분이 무해하더라도 ls; rm -rf /는 차단됩니다.
이 순서에는 의도적인 예외가 하나 있습니다. 고전적인 포크 폭탄 :(){ :|:& };:는 분리기가 쪼개는 바로 그 문자인 ;와 |에 의존합니다. 그래서 포크 폭탄 검사는 분리하기 전에 명령 전체를 대상으로 실행됩니다.
위장을 벗겨 낸다
rm을 매칭하기 전에 stripPrefixWrappers라는 헬퍼가 명령의 동작을 바꾸지 않는 래퍼들, 즉 sudo, exec, command, builtin, eval, doas를 벗겨 냅니다. FOO=bar 같은 앞쪽의 환경 변수 할당도 제거합니다. 따라서 sudo rm -rf ~와 FOO=1 exec rm -rf ~는 맨 명령과 똑같은 규칙에 걸립니다.
플래그 역시 패턴 매칭이 아니라 파싱으로 처리합니다. -rf, -fr, -Rf, -r -f, --recursive --force가 모두 인식되는데, 파서가 모든 짧은 플래그 묶음에서 r과 f를 찾고 긴 형식도 함께 확인하기 때문입니다.
무엇을 거부하는가
소스 코드에 등장하는 규칙 ID는 다음과 같습니다.
| 규칙 | 차단 대상 |
|---|---|
rm.catastrophic | /, *나 ./* 같은 맨 glob, 또는 어떤 표기로든 홈 디렉터리(~, ~/*, $HOME, ${HOME}/*, 또는 홈 경로 그대로)에 대한 rm -rf |
rm.no-preserve-root | / 보호를 명시적으로 우회하는 --no-preserve-root |
rm.project-folder | 에이전트가 작업 중인 프로젝트 폴더를 재귀적으로 삭제하거나, 그 안의 모든 항목을 glob으로 지우는 행위. 이름을 지정한 하위 폴더 삭제는 계속 허용됩니다. |
rm.dangerous-target | 사용자 수준 경로에서의 기타 위험한 rm 대상 |
fork-bomb | 스스로 복제하는 프로세스 폭탄 |
mv.to-devnull | 파일을 /dev/null로 옮겨서 "삭제"하는 행위 |
find.delete-broad-root | 광범위한 루트에서 실행하는 find … -delete |
perms.recursive-on-root | 루트 수준 경로에 대한 재귀적 권한 변경 |
프로젝트 폴더 규칙은 잠시 짚고 넘어갈 가치가 있습니다. 앞서 언급한 사고와 가장 직접적으로 연결되는 규칙이기 때문입니다. 요청이 어떻게 표현되든, 에이전트는 작업을 맡은 바로 그 프로젝트를 지워서는 안 됩니다.
root는 의도적으로 다르게 취급한다
root 데몬에는 가장 엄격한 규칙이 적용될 거라고 예상할 수 있습니다. 실제로는 가장 좁은 규칙이 적용됩니다. 주석이 그 이유를 설명합니다. 데몬은 디스크 복제나 mkfs 같은 시스템 수준의 작업을 하기 위해 존재하며, "우리와 싸워서는 안 된다"는 것입니다. 그래서 .rootDaemon 컨텍스트에서 check는 곧바로 checkCatastrophicRm으로 넘어가며, 이 함수는 복구 불가능한 세 가지 패턴(/, 맨 glob, 홈)과 --no-preserve-root, 프로젝트 폴더 삭제만 차단합니다. 나머지는 모두 운영자의 판단에 맡깁니다.
이는 본받을 만한 설계 선택입니다. 정당한 관리 작업까지 막는 가드레일은 결국 꺼지게 됩니다. 복구할 수 없는 실수만 막는 가드레일은 계속 켜진 채로 남습니다.
2단계: 데몬이 다시 검사한다
앱은 무엇이든 전달하기 전에 ShellSafetyService.check를 실행합니다. 하지만 헬퍼는 이를 그대로 믿지 않습니다. Shared/DaemonCore.swift에서 데몬은 감사 로그 항목을 기록한 직후, 자기 쪽에서 동일한 검사 를 실행합니다.
// Defense-in-depth: the app already runs this same check before
// dispatching, but any same-team-signed client can reach the mach
// service directly.
let verdict = ShellSafetyService.check(
script,
context: auditCategory == .launchDaemon ? .rootDaemon : .userAgent,
projectFolder: workingDirectory
)
차단된 명령은 규칙 ID와 함께 거부로 기록되고 종료 상태 126을 받습니다. /bin/zsh에는 절대 도달하지 않습니다. 이것이 중요한 이유는 XPC 리스너가 같은 팀이 서명한 클라이언트라면 무엇이든 받아들이기 때문입니다. 같은 팀이 서명한 다른 무언가가 mach 서비스에 직접 연결하더라도 여전히 가드레일에 걸립니다.
3단계: 패턴이 놓치는 것을 위한 두 번째 의견, Jev
패턴 규칙은 정확하지만, 여러분이 적어 둔 패턴만 알고 있습니다. 파괴적인 명령 중에는 rm -rf /처럼 생기지 않은 것도 많습니다. 엉뚱한 파일에 대한 truncate, CLI로 파이프된 SQL DROP, 방향이 뒤바뀐 dd 같은 것들입니다.
이것이 JevAdvisor.swift에 있는 선택형 자문 계층 Jev 의 역할입니다. 이미 ShellSafetyService를 통과한 모든 명령은 Jev로 보내지고, Jev는 그 명령이 데이터를 되돌릴 수 없게 파괴할 가능성을 평가합니다. 설정한 임계값을 넘으면 Agent!는 명확한 메시지와 함께 실행을 거부합니다.
Refused: Jev rated this command 85% likely to irreversibly destroy data.
Command: …
Narrow the target or run it yourself if this is intentional.
몇 가지 세부 사항을 보면 적용 범위를 얼마나 신중하게 정했는지 알 수 있습니다.
- 보완할 뿐, 대체하지 않습니다. 코드 주석은 분명합니다.
ShellSafetyService가 강제 계층이고, Jev는 패턴 규칙이 놓친 것만 잡아냅니다. 그래서 임계값을 일부러 높게 잡았습니다. 기본값은 70% 이며, 설정에서 0부터 100%까지 10% 단위로 조정할 수 있습니다. - 실패 시 허용하지만, 요란하게 알립니다. 키가 없거나, 토글이 꺼져 있거나, 네트워크 장애가 있으면 "의견 없음"으로 처리되므로 불안정한 서비스 때문에 작업이 멈추는 일은 없습니다. 그래도 실패는 로그에 기록되므로(
⚠️ Jev check failed, command allowed), 만료된 키를 "Jev가 안전하다고 판단함"으로 오해할 일은 없습니다. - 취소는 곧 취소입니다. Jev가 판단하는 동안 작업을 중지하면 명령은 실행되지 않습니다.
- 모든 판정이 드러납니다. 검사마다 위험도 비율, 허용 또는 거부 여부, 응답한 모델, 토큰 수가 기록됩니다.
가정이 아닌 테스트로 검증
AgentTests/ShellSafetyServiceTests.swift에는 가드레일을 위한 30개의 테스트가 들어 있습니다. 또한 모든 헬퍼 명령은 실행되기 전에 감사 로그에 기록되므로, 에이전트가 무엇을 하려 했는지, 그리고 무엇을 허락받지 못했는지까지 언제든 재구성할 수 있습니다.
에이전트를 만드는 모든 이를 위한 교훈
- 프롬프트가 아니라 코드로 강제하세요. 프롬프트는 제안일 뿐입니다. Swift
enum은 그렇지 않습니다. - 셸이 파싱하는 방식대로 파싱하세요. 복합 명령을 분리하고, 래퍼를 벗겨 내고, 플래그를 정규화하세요.
- 권한 경계에서 다시 검사하세요. 여러분의 클라이언트가 유일한 호출자라고 믿지 마세요.
- 특이한 것이 아니라 복구 불가능한 것을 막으세요. 좁은 규칙은 살아남고, 시끄러운 규칙은 꺼집니다.
- 그 위에 판단을 더하되, 실패가 눈에 보이게 하세요. 두 번째 의견은 그것이 응답하지 않았을 때 알 수 있어야만 가치가 있습니다.
이 모든 내용은 GitHub에 공개되어 있습니다. 읽어 보고, 허점을 찾아보고, 막혔어야 할 명령을 발견하면 이슈를 열어 주세요.