← 전체 글

모델이 판단하기 전에: 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개의 테스트가 들어 있습니다. 또한 모든 헬퍼 명령은 실행되기 전에 감사 로그에 기록되므로, 에이전트가 무엇을 하려 했는지, 그리고 무엇을 허락받지 못했는지까지 언제든 재구성할 수 있습니다.

에이전트를 만드는 모든 이를 위한 교훈

  1. 프롬프트가 아니라 코드로 강제하세요. 프롬프트는 제안일 뿐입니다. Swift enum은 그렇지 않습니다.
  2. 셸이 파싱하는 방식대로 파싱하세요. 복합 명령을 분리하고, 래퍼를 벗겨 내고, 플래그를 정규화하세요.
  3. 권한 경계에서 다시 검사하세요. 여러분의 클라이언트가 유일한 호출자라고 믿지 마세요.
  4. 특이한 것이 아니라 복구 불가능한 것을 막으세요. 좁은 규칙은 살아남고, 시끄러운 규칙은 꺼집니다.
  5. 그 위에 판단을 더하되, 실패가 눈에 보이게 하세요. 두 번째 의견은 그것이 응답하지 않았을 때 알 수 있어야만 가치가 있습니다.

이 모든 내용은 GitHub에 공개되어 있습니다. 읽어 보고, 허점을 찾아보고, 막혔어야 할 명령을 발견하면 이슈를 열어 주세요.