I Replaced a Gate That Accepted Everyone With a Gate That Accepted No One. My Tests Couldn't Tell the Difference.
모두 통과시키던 게이트를 아무도 통과시키지 못하는 게이트로 바꿨는데, 테스트는 차이를 찾지 못했습니다
사람의 승인을 요구하는 실행 게이트를 만들면서 테스트가 우회와 고장을 모두 놓친 과정을 분석합니다. `isatty()`와 `/dev/tty`가 서로 다른 조건을 확인한다는 점, 테스트가 실제 터미널 경계를 검증해야 한다는 점을 재현 코드와 변이 테스트로 보여줍니다.
- 주제
에디터 노트
이 글의 가장 무서운 대목은 버그 자체가 아니라 테스트가 버그를 가렸다는 점입니다. 모두를 받아들이는 v0과 아무도 받아들이지 못하는 v1이 똑같이 초록불을 받았습니다. 테스트가 검증한 건 게이트가 아니라 게이트의 모조품이었습니다. 작성자의 솔직한 인정이 핵심입니다. PTY 테스트를 도입해도 이것은 신원 확인이 아니라 '우발적 우회를 명시적 우회로 바꾸는' 수준에 그친다고 합니다. 게이트의 평가 기준은 테스트 통과 개수가 아니라 '평범한 자동화가 사람과의 경계를 건너뛰고 통과할 수 있는가'입니다. 보안 장치를 의심하기 전에, 그것을 증명한다는 테스트를 먼저 의심해야 합니다.
AI 요약
사람의 승인을 받아야 에이전트가 명령을 실행하는 게이트를 만들었지만, 테스트는 게이트가 모든 호출을 허용하던 버전과 실제 사용자를 모두 거부하던 버전을 구분하지 못했습니다. 작성자는 그 원인을 세 번의 구현 실수와 테스트 환경의 사각지대에서 찾습니다. 글의 중심은 isatty() 검사와 /dev/tty를 통한 입력이 서로 다른 조건을 확인하며, 두 경계를 따로 테스트하면 초록색 테스트 결과만으로는 게이트가 작동한다고 말할 수 없다는 점입니다.
`isatty()`와 `/dev/tty`는 다른 질문입니다
초기 구현은 표준 입력과 표준 출력에 isatty()를 호출해 사람의 존재를 판단하고, 승인 입력은 /dev/tty에서 읽었습니다. 하지만 두 검사는 같은 대상을 확인하지 않습니다. isatty()는 특정 파일 디스크립터가 터미널인지 확인하고, /dev/tty는 프로세스의 제어 터미널에 접근할 수 있는지 확인합니다. 표준 입출력이 리디렉션돼도 제어 터미널은 열려 있을 수 있습니다.
작성자는 script(1) 안에서 실행하는 표준 라이브러리 기반 탐침 코드로 이 차이를 재현합니다. --redirected 모드에서는 표준 입력과 출력의 isatty() 결과가 모두 거짓인데 /dev/tty는 열립니다. 기본 설정으로 실제 터미널에서 실행한 pytest도 같은 상황을 만듭니다. pytest가 표준 스트림을 캡처해 isatty()는 거짓으로 바꾸지만, /dev/tty 접근은 유지하기 때문입니다. 반대로 pytest -s에서는 표준 스트림과 제어 터미널 검사가 일치합니다.
승인값을 인자로 받으면 호출자가 게이트를 우회합니다
첫 버전은 승인 과정에서 입력할 작업 ID와 절차 ID를 함수 인자로 받았습니다. 테스트 픽스처가 두 문자열을 넘기면 승인 절차 없이 게이트를 통과할 수 있었습니다. 테스트는 사람의 승인을 확인한 게 아니라, 올바른 값이 전달됐는지만 확인한 셈입니다. 작성자는 정책 설정을 잠시 바꾸고 테스트를 실행한 뒤 실제 저장소를 담은 압축 파일 열 개가 상태 디렉터리에 생긴 것을 발견했습니다. 두 번의 실행에서 한 작업은 여덟 개, 다른 작업은 두 개를 만들었습니다.
이 흔적만으로 명령이 실제로 시작됐다고 단정할 수는 없습니다. 임시 디렉터리는 명령 실행 루프보다 먼저 만들어지므로, 디렉터리의 존재는 실행기에 진입했다는 사실만 보여줍니다. 영수증 파일에서도 해당 실행 기록을 찾지 못했지만, 기록이 임시 경로에 쓰였다가 사라졌는지, 기록 전에 예외가 났는지는 확인하지 못했다고 선을 긋습니다. 남은 증거가 뒷받침하는 범위까지만 주장하는 태도도 글의 중요한 부분입니다.
`/dev/tty`를 열었지만 실제 터미널도 거부했습니다
두 번째 버전은 승인값을 함수 인자에서 없애고 /dev/tty에서 읽도록 바꿨습니다. 하지만 macOS에서 open("/dev/tty", "r+")는 CPython 3.9.6과 3.13.9 모두에서 실패했습니다. 업데이트 모드의 입출력 계층이 탐색 가능한 스트림을 요구하지만 터미널은 탐색할 수 없어 io.UnsupportedOperation을 던집니다. 이 예외는 OSError의 하위 클래스이며 errno=None을 가집니다. 기존 except OSError 처리문은 이를 “제어 터미널이 없다”는 거부 사유로 바꿨습니다. 실제 터미널 앞에 앉은 사용자도 승인할 수 없었지만, 실패를 닫힌 방향으로 처리해 테스트는 통과했습니다.
테스트는 이 경로를 직접 확인하지 않았습니다. v0에서는 승인 ID를 함수 인자로 넣었고, v1에서는 테스트가 read_confirmation을 이름으로 교체했습니다. 둘 다 사람과 상호작용하는 경계를 검사하지 않았습니다. 그래서 승인 절차를 우회하는 게이트와 사용자를 거부하는 게이트가 모두 초록색 결과를 얻었습니다.
조건을 하나로 모으고 실제 경계를 시험합니다
수정 버전은 /dev/tty를 읽기와 쓰기용 핸들로 따로 엽니다. ENXIO, ENODEV, ENOTTY, ENOENT처럼 터미널을 사용할 수 없는 상황을 나타내는 오류만 거부 사유로 처리하고, 나머지 예외는 다시 던집니다. require_human()이라는 이름도 require_controlling_terminal()로 바꿨습니다. 이 코드가 확인하는 것은 사람이 있다는 사실이 아니라 터미널에서 승인을 받을 수 있다는 사실이기 때문입니다.
게이트와 승인 입력이 서로 다른 조건을 검사하지 않도록 터미널 검사도 하나의 함수로 통합했습니다. 변이 테스트에서는 r+ 방식 복구가 네 테스트에서, 모든 OSError를 터미널 부재로 취급하는 변경이 세 테스트에서, 오래된 isatty() 검사가 아홉 테스트와 하위 테스트에서 잡혔습니다. 반면 io.UnsupportedOperation을 따로 검사하는 줄을 지워도 131개 테스트가 모두 통과했습니다. 해당 예외의 errno가 이미 None이라 뒤의 검사가 다시 던지기 때문입니다. 작성자는 이 줄을 보호 장치가 아니라 문서 역할이라고 표시합니다.
전체 131개 테스트 중 PTY를 사용하는 테스트는 다섯 개이고, 프로세스 안에서 /dev/tty를 여는 테스트가 하나 더 있습니다. 버전별로 실제 터미널 경계를 여는 테스트는 v0에서 0개, v1에서도 0개, v2에서 2개, v3에서 PTY 기반 5개와 프로세스 내부 테스트 1개로 늘었습니다. 최종 실행은 131 passed, 25 subtests passed였습니다. 글은 PTY를 물리적 터미널이나 실제 사람의 증거로 취급하지 않습니다. PTY를 만들 수 있는 권한이 있는 자동화도 게이트를 통과할 수 있으므로, 이 방식은 신원 확인이나 인증이 아닙니다. 우발적인 우회를 명시적인 우회로 바꾸는 수준이라고 한정합니다.
테스트 상태 격리도 기본값으로 강제합니다
테스트가 실제 상태 파일을 수정해 저장소 압축 파일과 영수증이 남은 문제도 발견했습니다. 처음에는 테스트 작성자가 상태 경로를 임시 디렉터리로 바꾸도록 요청했지만, 작성자 본인의 테스트가 실제 영수증 파일에 기록하면서 이 규칙은 충분하지 않다는 점이 드러났습니다. 수정 후에는 자동 적용되는 pytest 픽스처가 영수증, nonce, 시도 기록 경로를 임시 디렉터리로 돌립니다. 실제 상태를 건드리는 테스트만 @pytest.mark.live_state로 명시해야 합니다. 픽스처를 끄면 이를 검증하는 테스트 네 개가 실패하는 것도 확인했습니다.
마지막으로 글은 사람 확인 게이트를 평가할 질문을 제시합니다. “테스트가 게이트를 통과할 수 있는가?”가 아니라 “일반적인 자동화가 의도한 상호작용 경계를 거치지 않고 게이트를 통과할 수 있는가?”를 물어야 합니다. 그리고 테스트가 실제 경계를 열어 보는지 확인해야 합니다. 테스트가 모두 그 경계를 대체한다면, 테스트 통과는 게이트가 작동한다는 증거가 아닙니다.
dev.to 반응
- @nomad-link-id — 에이전트에 ‘사람 확인 게이트’를 넣는 사람이라면 이 구조를 경계해야 합니다. 같은 테스트 묶음이 모두를 받아들이는 게이트(승인값을 함수 인자로 받음)와 아무도 받아들이지 못하는 게이트(고장 난
/dev/tty경로)를 모두 인증했습니다. 두 버전 모두 테스트가 상호작용 경계를 교체했기 때문입니다. 테스트가 초록색이라는 건 제어가 작동한다는 뜻이 아니었습니다. 우회 코드가 여전히 컴파일된다는 뜻이었습니다. 승인이나 정책, ‘사람이 있음’ 제어라면 두 가지 검사를 가져가겠습니다. 실제 경계를 여는 테스트가 있나요? 모든 경우에read_confirmation을 패치하거나 입력 ID를 함수 인자로 넣는다면 테스트는 스텁을 채점하는 셈입니다. 그리고 정상 경로가 아니라 제어 자체에 변이 테스트를 해보세요. 잘못된 열기 모드를 복구하고, 예외 처리를 넓히고, 오래된isatty()검사를 다시 넣었을 때 아무것도 실패하지 않는다면 그 줄은 게이트가 아니라 문서입니다. 당신의 표현처럼, 우발적인 우회를 명시적인 우회로 바꾸는 일은 가치가 있지만 신원 확인은 아닙니다. 게이트를 평가할 기준은 ‘일반적인 자동화가 의도한 상호작용을 거치지 않고 통과하는가?’이지 ‘테스트 131개가 통과했는가?’가 아닙니다.- @kenielzep97 — 두 번째 검사가 실제로 효과를 냈고, 가장 유용한 결과는 아무것도 실패하지 않았다는 점이었습니다. 제가 보호 장치라고 생각했던
io.UnsupportedOperation의isinstance검사를 변이했습니다. 그 줄을 지워도 실패하는 테스트가 없었습니다. 예외의errno가 이미None이라 그 검사가 다시 던지기 때문입니다. 보호 장치로 세지 않도록 줄을 남기되, 소스에 문서용이라고 표시했습니다. 다만 세 번째 검사도 추가하고 싶습니다. 두 검사만으로는 제가 마지막에 발견한 결함을 잡지 못합니다. 조건식이 몇 개인지 세어야 합니다. 제 게이트는 “표준 입력과 출력이 tty인가?”를 물었고, 승인 입력은 “/dev/tty를 열 수 있는가?”를 물었습니다. 한 제어 경로에서 서로 다른 질문을 한 셈입니다. 제어 터미널이 없으면 두 결과가 모두 거짓이라 변이 테스트로 드러나지 않습니다. 표준 스트림을 리디렉션했는데 제어 터미널은 있는 경우에만 달라집니다. 실제 터미널에서 실행한 일반적인pytest가 그런 경우입니다. 그때조차 오래된 검사가 먼저 거부해 실제 읽기 경로에 도달하지 않으므로 테스트에서 보이지 않았습니다. 그래서 터미널을 전혀 열 수 없던 버전도 초록색을 유지했습니다. 세 번째 질문은 이겁니다. 이 게이트는 몇 가지 서로 다른 질문을 하고 있나요? 그 질문을 하나의 함수로 모았나요? - @nomad-link-id —
isinstance검사 변이에서 아무 테스트도 실패하지 않았다는 결과는 더 많은 팀이 공개했으면 하는 사례입니다. 줄을 지워도 아무것도 달라지지 않았으니 그 줄은 제어 장치가 아니었습니다. 소스에 문서라고 표시한 건 옳습니다. 그렇지 않으면 다음 리뷰어가 보호 장치로 셀 테니까요. 세 번째 검사는 제가 제안한 두 검사보다 날카롭습니다. 테스트 픽스처가 두 질문에 늘 같은 답을 하는 경우, 변이만으로는 경로 분리 결함을 잡지 못합니다. 표준 스트림의isatty()와 “/dev/tty를 열 수 있는가?”는 같은 말이 아닙니다. 제어 터미널은 있고 표준 스트림은 리디렉션된 경우에만 결과가 달라집니다. 바로 터미널에서 실행한 일반적인pytest상황입니다. 어떤 게이트든 평가 계약으로 가져갈 것은 이겁니다. 질문 하나, 함수 하나. 게이트가 조건 두 개를 묻는다면 정책을 검토하는 사람이 읽을 수 있는 단일 API 뒤에 두 조건을 넣도록 이름과 구조를 바꾸세요. 조건이 달라지는 픽스처를 강제로 만드세요. 적어도 하나의 테스트는 표준 스트림은 tty가 아니고 제어 터미널은 있는 환경을 실행해야 합니다. 그 사례가 빠졌다면 ‘조건을 세어라’는 말은 여전히 설계 검토일 뿐 평가가 아닙니다. headless CI 환경에서만 결과가 일치하는 제어는 실제 상호작용 경로를 거부하면서도 영원히 초록색일 수 있습니다. 평가는 자동화가 의도한 상호작용을 거치지 않고 통과하는지 확인해야 합니다. 오래된 검사가 먼저 빠져나갔을 픽스처까지 포함해서요. - @kenielzep97 — 맞습니다. 저는 그 사례를 갖고 있지 않았습니다. 제가 그 환경을 다룬다고 생각한 테스트는 실행 환경에 맞춰 적응했기 때문에, headless 환경에서는 두 검사 모두 거짓으로 나오고 그냥 통과했습니다. 환경을 강제하는 테스트를 만들었고, 만들면서 같은 종류의 실수 두 가지를 더 찾았습니다. 첫 번째 버전은
script -q /dev/null cmd를 실행했는데, 그건 BSD 방식입니다. GNUscript는-c로 명령을 받습니다. 그래서 Linux에서는 자식 명령이 실행되지 않았을 것이고,script가 없으면 테스트는 건너뛰었습니다. 리뷰어의 컴퓨터에서 건너뛰는 환경 분기 테스트는 한 단계 더 올라간 무의미한 통과입니다. 이제 표준 라이브러리인pty.fork를 사용합니다. POSIX 환경에서 PTY를 열고, 자식 프로세스를 포크하고,setsid를 호출한 뒤 제어 터미널을 얻습니다. 그런 다음 파일 디스크립터 0과 1에/dev/null을dup2해 표준 스트림만 tty가 아니게 만들고 제어 터미널은 유지합니다. 두 번째 실수가 더 중요합니다. 원래 테스트는sys.stdin.isatty()와sys.stdout.isatty()로 환경이 만들어졌는지 확인했습니다. 그런데dup2호출을 지워 가드가 실패하는지 확인했더니 테스트가 통과했습니다. 기본pytest캡처에서 해당 객체가 이미 교체되어isatty()가 거짓을 반환했기 때문입니다. 픽스처가 한 작업을 테스트 러너가 대신 보장한 셈입니다. 실제 코드가 제어하는 건 파일 디스크립터이므로os.isatty(0)과os.isatty(1)로 바꾸자 비로소 실패했습니다. 기본 캡처와-s양쪽에서 테스트가 정상 통과하고, 두 조건 게이트를 복구하거나 리디렉션을 빼면 실패하는 매트릭스를 만들었습니다. 파일 디스크립터 검사로 바꾸기 전에는 기본 캡처에서 마지막 경우가 초록색이었습니다. 글에서 다룬 실패가 한 단계 더 깊이 반복된 것입니다.
- @kenielzep97 — 두 번째 검사가 실제로 효과를 냈고, 가장 유용한 결과는 아무것도 실패하지 않았다는 점이었습니다. 제가 보호 장치라고 생각했던
- @howcani_howcani_77e786a89 — 게이트의 세 버전은 한 버그가 세 단계로 나타난 사례입니다. 그 버그를 보이지 않게 만든 건 코드가 아니라 픽스처입니다. v0은 승인값이 함수 인자로 들어와 모두를 받아들였습니다. 호출자는 원하는 값을 넘길 수 있으니까요. v1은 표준 스트림의
isatty()를 확인하면서 입력은/dev/tty에서 받아 아무도 통과시키지 못했습니다. 둘은 같은 결함을 한 단계씩 달리 보여줍니다. 확인하는 대상과 값이 들어오는 대상이 서로 다릅니다. 해결책은 더 나은 대리 조건을 만드는 게 아니라 단일 경로를 쓰는 것입니다. 게이트가 확인하는 것과 같은 디스크립터를 통해 승인을 받아야 합니다. 그래야 게이트가 실제 사용하는 것과 다른 대상을 인증하지 않습니다. 이어서nomad-link-id가 말한 부분을 덧붙이겠습니다. 변이 테스트만으로 이 문제를 제거할 수 없는 이유는 구조에 있습니다. 변이는 코드에 있지만 결함은 코드와 실행 환경의 관계에 있습니다. 모든 픽스처가 한 환경에서만 동작하면isatty()와/dev/tty가 같은 결과를 내므로 어떤 변이를 해도 둘을 구별하지 못할 수 있습니다. 해결책은 환경을 상수로 두지 말고 픽스처 입력으로 만드는 것입니다. 표준 입력이 tty인지, 표준 출력이 tty인지,/dev/tty를 열 수 있는지를 조합해 각 경우에 게이트가 거부할지 확인해야 합니다. 그러면 결과가 다른 환경에서 변이된 줄이 실패합니다. 같은 결과를 내는 환경에서 변이가 살아남는 사실 자체도 발견입니다. 그 조건이 어떤 환경에서는 역할을 하고 다른 환경에서는 장식이라는 뜻이니까요. 변이 점수는 변이와 픽스처의 조합을 대상으로 계산해야 한다는 일반 원칙을 여기서 얻을 수 있습니다. 환경 하나만 쓰면 환경에 민감한 코드는 구조상 죽일 수 없습니다. 이때 변이가 하나도 잡히지 않는다는 결과는 보호 장치가 아니라 픽스처 매트릭스에 관한 말입니다. 서로 다른 두 가지 이유로 모두 0이 나올 수 있습니다. 영수증 조사도 같은 문제를 보여줍니다. 열 개의 디렉터리가 증명하는 것은 실행기에 진입했다는 사실입니다.frozen_env가 첫 작업이고 그 뒤에 명령 실행 루프가 오기 때문입니다. 어느 흔적도 진입, 실행 시작, 실행 완료를 구별하지 못하고, 영수증도 그 차이를 메워주지 않습니다. 기록된 행은 53분 뒤 다른 실행에서 만들어졌기 때문입니다. 가장 간단한 수정은 실행기가 부작용을 일으키기 전에 시작 기록을 남기게 하는 것입니다. 그래야 세 결과를 구별할 수 있습니다. 작업 전에 만들어진 흔적은 진입의 증거이지 작업의 증거가 아닙니다. 모든 종료 상황에서 쓰는 영수증도 실행이 그 지점까지 도달하지 않았다면 아무것도 말해주지 않습니다. 여기서 탐침 코드를 다시 실행할 수는 없어 읽어서 확인했고, 두 가지를 확인했습니다.io.UnsupportedOperation은OSError와ValueError의 하위 클래스이고, 메시지로 생성하면errno는None입니다. 그래서isinstance검사가 아무 차이를 만들지 않는 변이이며, 당신이 쓴 설명보다 조금 더 강하게 말할 수 있습니다.errno를 기준으로 작성한 어떤 테스트도 해당 예외에 대한 검사 유무를 구별할 수 없습니다. 양쪽 모두errno=None이고 같은 조건에서 다시 던지기 때문입니다. 소스에 문서라고 표시한 게 옳습니다.
원문: dev.to / 번역·요약: Trawling