LLM to Agentic 4) 에이전트에게-파일을-쥐여주기-전에-할일
배경
에이전트는 결국 세 가지의 반복이다. 모델이 도구를 고르고, 우리가 실행하고, 결과를 다시 먹여 루프를 돈다. 지난 단계까지 우리는 이 루프가 어떤 실패에도 죽지 않게 만들었다. 없는 도구든 깨진 인자든, 실패는 예외가 아니라 모델이 읽는 관측이 된다.
그런데 여기까지 이 루프가 쥔 도구는 add_numbers 하나뿐이었다. 인자만 있으면 완결되는 순수 계산. 이제 진짜 하고 싶은 건 파일을 읽는 것이다.
그리고 read_file은 add_numbers와 다른 종류의 도구다. "2와 3을 더해라"는 세상에 손대지 않는다. "notes.txt를 읽어라"는 모델의 텍스트 상자 밖, 실제 파일시스템으로 손을 뻗는다. 이 순간 순수 함수였던 에이전트가 처음으로 환경에 접촉한다. 접촉에는 대가가 따른다.
문제 — "파일 도구를 추가한다"는 착각
흔히 이걸 기능 추가로 본다. "핸들러 하나 쓰면 되지." 하지만 파일을 여는 한 줄 안에는 세 개의 숨은 질문이 있다.
- 그 파일은 어느 workspace에 있는가? — 도구는 자기가 어디서 일하는지 모른다.
- 이 위험한 도구를 어디에 얹을 것인가? — 실행 로직은 이미 충분히 복잡하다.
- 그 경로가 workspace 밖으로 나가면? — 모델이
../../etc/passwd를 요청하면?
가장 쉬운 유혹은 이 셋을 read_file 안에서 한 방에 처리하는 것이다. 그러면 나중에 버그가 났을 때 기능이 틀렸는지, 격리가 뚫렸는지, 구조가 꼬였는지를 동시에 디버깅하게 된다.
그래서 질문을 바꾼다. "어떻게 파일 기능을 추가할까"가 아니라 — 에이전트에게 환경을 쥐여주기 전에, 무엇을 먼저 세워야 하는가. 세 단계(18·19·20)는 사용자에게 보이는 기능을 하나도 추가하지 않는다. 파일은 21에서야 열린다. 그런데도 굳이 나눈 이유가 여기 있다.
분석 — 권한을 쥐여주는 일은 세 개의 "어디"다
위험한 능력을 도구에 넘길 때, 저절로 세 질문이 따라온다. 각 질문이 하나의 단계가 되고, 그 순서는 뒤집을 수 없다.
| 질문 | 단계 | 세우는 것 |
|---|---|---|
| 도구는 어디서 일하는가 | 18 | 통로 — 런타임 의존성 주입 |
| 위험을 어디에 얹는가 | 19 | 구조 — 기능 전 복잡도 격리 |
| 도구는 어디까지 닿는가 | 20 | 경계 — 경로 봉인 |
통로 없이 파일을 열면 전역 상태에 의존해 격리가 깨지고, 구조 없이 경계를 얹으면 기능 버그와 구조 버그가 뒤섞이며, 경계 없이 경로를 결합하면 workspace가 뚫린다. 그래서 통로 → 구조 → 경계 순이다.
분석의 하이라이트 — "경계는 문자열이 아니라 구조로 긋는다"
가장 흥미로운 건 20의 경계였다. 순진한 방어는 이렇게 생각한다. "결과 경로가 workspace로 시작하는지 보면 되잖아?"
str(candidate).startswith(str(workspace))
이건 경계가 아니다. workspace가 /tmp/work면 /tmp/work-secret도 같은 문자열로 시작한다. 문자열 prefix는 우연히 같은 글자일 뿐, 경로의 포함 관계가 아니다.
해결은 "더 똑똑한 문자열 비교"가 아니었다. 판정의 층위를 바꾸는 것이었다 — 문자열이 아니라 path component 관계로(is_relative_to). 지난 글에서 "경계가 관측 가능성을 정한다"고 했듯, 여기서도 같은 형태의 통찰이 나온다. 무엇을 안전하다고 판정하느냐는, 어느 층에서 판정하느냐가 정한다.
해결 — 통로, 구조, 경계
통로(18) — 도구에 실행 맥락을 명시적으로 꽂는다. 핸들러 계약을 handler(arguments)에서 handler(arguments, context)로 넓힌다. 호출자가 만든 RunContext(workspace=...)가 run_agent()를 거쳐 모든 핸들러까지 흐른다. 딱 그 통로만 뚫는다 — 파일을 열지도, 경로를 붙이지도 않는다. 이 작은 결정에 원칙 셋이 박혀 있다. ① 모델이 보는 것과 호스트가 주입하는 것을 분리한다 — context는 모델 스키마에 없다. workspace 절대경로가 모델 출력이나 trace로 새지 않는 것도 이 분리 덕분이다. ② 미래 필드는 소비자가 생길 때 만든다 — run ID·sandbox lease는 넣지 않고 지금 쓸 workspace 하나만. ③ 기본값으로 결정을 숨기지 않는다 — Path.cwd()를 몰래 기본값으로 넣지 않아, 호출부만 읽어도 어느 workspace인지 보인다.
구조(19) — 위험을 얹기 전에 방을 치운다. 이 시점 run_agent()는 tool call 하나를 처리하며 여섯 질문(반복인가·JSON 파싱되나·핸들러 있나·인자 유효한가·예외 던졌나·무엇을 기록하나)을 한 자리에서 답했다. 중첩된 if/try/except 탓에 바깥의 ReAct 루프보다 에러 분기가 먼저 눈에 들어왔다. 새 기능 0개로, 실행 로직을 이름 있는 함수(execute_tool_call, is_repeated_tool_call)와 책임별 폴더로 뽑아 run_agent()엔 오케스트레이션만 남겼다. 참은 것이 오히려 인사이트다 — think()/act()는 만들지 않았다. think에 해당하는 코드는 한 줄뿐이라, 감싸면 복잡도가 줄기는커녕 함수만 하나 더 열게 된다. 복잡도가 실제로 모인 곳(도구 실행)만 분리했다. class도 아니다 — 상태도 lifecycle도 교체 구현체도 없으면, 함수가 가장 작은 경계다.
경계(20) — workspace 밖으로 못 나가게. 순진한 결합 workspace / requested_path는 상대·절대를 구분하지 않아 /etc/passwd·../../secret·바깥 symlink에 뚫린다. 그래서 다섯 단계로 봉인한다 — POSIX 경로로 해석 → 절대경로 거부 → .. 거부 → 실제 경로 계산(symlink 추적) → is_relative_to로 포함 관계 검사. ..는 계산상 안으로 돌아오더라도 거부한다. 모델에게 단순하고 일관된 계약을 주기 위해서다. 오류는 WorkspacePathError라는 typed exception이라, 21의 read_file이 이것만 골라 잡고 무관한 오류를 삼키지 않는다.
검증 — 각 경계를 테스트로 못박는다
세 단계 모두 실험은 같은 형태다. 계약을 단언하는 테스트를 먼저 RED로 깔고, 최소 구현으로 GREEN을 만든다.
- 통로(18) — 핵심은 "정의만 된 게 아니라 실제로 소비되는가"였다. 받은 context를 저장하는 핸들러를 심고, 모델이 그 도구를 부른 뒤
받은 context == 넘긴 context를 확인한다. dispatch 경로에서 통로가 실제로 뚫렸다는 증거다. - 구조(19) — 리팩터의 성패는 "동작이 하나도 안 변했다"의 증명이다. 9가지 상황의 결과를 표로 고정하고 회귀 37개 전부 통과로 못박는다.
- 경계(20) —
notes.txt·/etc/passwd·../../secret·내부 symlink·외부 symlink 6개 입력의 결과를 표로 고정하고, 42개까지 통과시킨다.
정직한 한계도 산출물이다. 20은 경로를 계산한 뒤 파일을 열기 직전에 symlink가 교체되는 TOCTOU race는 풀지 않는다. 이건 최종 Docker 제품에서 조작 직전 realpath 재검사로 막는다. 막은 것과 못 막은 것을 적어두는 것 자체가 경계다.
결과 — 능력 지도
세 단계를 지나면, 파일 도구가 딛고 설 바닥이 완성된다.
| 세운 것 | 코드 | 하는 일 |
|---|---|---|
| 통로 | RunContext → handler(args, context) | 도구가 자기 workspace를 안다 |
| 구조 | execute_tool_call / agent·llm·tools | 위험을 얹을 깨끗한 자리 |
| 경계 | resolve_workspace_path / WorkspacePathError | workspace 밖 요청을 거부 |
에이전트는 아직 파일을 못 읽는다. 하지만 이제 파일을 읽어도 어디서 읽는지 알고, 어디에 코드가 놓일지 정해졌고, 어디까지만 닿을지 봉인됐다.
인사이트
- 위험한 능력은 "기능"이 아니라 세 개의 "어디"다. 도구가 어디서 일하고(통로), 어디에 얹히고(구조), 어디까지 닿는지(경계)를 먼저 세운 뒤에야 기능을 올린다. 섞으면 버그도 섞인다.
- 경계는 문자열이 아니라 구조로 긋는다. 무엇을 안전하다 판정하느냐는 어느 층에서 판정하느냐가 정한다. 같은 원리로, 무엇을 모델에 노출하느냐도 의존성을 어디서 주입하느냐가 정한다 — 위치가 성질을 정한다.
- 제품 기능 0개짜리 단계도 독립할 자격이 있다. 기능을 붙이는 일과, 기능이 딛고 설 바닥을 놓는 일은 다른 일이다. 위험한 것일수록, 바닥을 먼저 그리고 따로 놓는다.