4. 함부로 실행하지 않는 Agent
Agent 가 닿을 수 있는 범위 좁히기
이 차시를 마치면
Agent 가 닿을 수 있는 범위를 코드 상수로 고정합니다.
Agent 가 닿을 수 있는 범위를 코드로 고정합니다. 이 차시의 코드는 동작 테스트까지 마쳤습니다.
학습팩의 규칙
- 반드시 지킬 것 — Tool 입출력 contract, 실제 side effect 가 있는지 명확하게 구분, Tool description 을 실제 기능과 일치
- 임의 수정 금지 — read-only Tool 을 write Tool 로 몰래 변경, 설명은 '조회' 인데 내부에서 삭제나 수정 수행
- 실무 권장 — Tool 에 business logic 전체를 넣지 않고 얇게 유지
"몰래 변경 금지" 를 구조로 바꾸기
"몰래 변경하지 마라"는 사람의 주의에 기대는 규칙입니다. 이것을 구조로 바꾸는 방법은 읽기 도구와 쓰기 도구를 서로 다른 파일에 두고, Agent 에게 노출하는 목록을 상수로 고정하는 것입니다.
그러면 쓰기 기능을 추가하려면 파일과 목록을 함께 고쳐야 하므로, 변경이 리뷰에서 눈에 띕니다. 분석
코드
"""보안 특화 Tool 설계 예 — 읽기 도구와 쓰기 도구를 파일 수준에서 분리한다.
raw/07_CODE_ARCHITECTURE/01_AREA_BY_AREA/02_tools_영역.md의
"read-only tool을 write tool로 몰래 변경 금지" 규칙을 구조로 강제한 형태다.
"""
from pathlib import Path
# 이 Agent가 읽어도 되는 유일한 디렉터리. 상수로 고정한다.
ALLOWED_ROOT = Path("./workspace").resolve()
MAX_READ_BYTES = 200_000
class ToolPermissionError(Exception):
"""도구가 허용 범위를 벗어난 대상에 접근하려 할 때 발생한다."""
def _resolve_inside_allowed_root(relative_path: str) -> Path:
"""경로를 확정하고 허용 디렉터리 밖이면 거부한다.
`..`를 이용해 상위 디렉터리로 빠져나가는 입력을 차단한다.
"""
candidate = (ALLOWED_ROOT / relative_path).resolve()
if candidate != ALLOWED_ROOT and ALLOWED_ROOT not in candidate.parents:
raise ToolPermissionError(f"허용 디렉터리 밖의 경로입니다: {relative_path}")
return candidate
def read_workspace_file(relative_path: str) -> str:
"""Read a UTF-8 text file inside the agent workspace. This tool never writes."""
target = _resolve_inside_allowed_root(relative_path)
if not target.is_file():
return f"[없음] 파일을 찾을 수 없습니다: {relative_path}"
if target.stat().st_size > MAX_READ_BYTES:
return f"[초과] 파일이 {MAX_READ_BYTES} bytes를 넘습니다: {relative_path}"
return target.read_text(encoding="utf-8", errors="replace")
def list_workspace_files(subdir: str = "") -> list[str]:
"""List file names inside the agent workspace. This tool never writes."""
target = _resolve_inside_allowed_root(subdir)
if not target.is_dir():
return []
return sorted(p.name for p in target.iterdir() if p.is_file())
# 읽기 도구만 Agent에게 노출한다. 쓰기 도구는 이 목록에 넣지 않는다.
READ_ONLY_TOOLS = [read_workspace_file, list_workspace_files]
이 코드는 동작 테스트 5항목을 통과했습니다. 허용 파일 읽기, ../ 탈출 차단, 절대경로 차단, 없는 파일 처리, 목록 조회.
각 요소가 지키는 원칙
| 코드 요소 | 대응하는 안전 원칙 |
|---|---|
ALLOWED_ROOT 고정, .. 와 절대경로 차단 | isolated work directory · filesystem mount 제한 (원칙 1, 5) |
MAX_READ_BYTES | CPU 와 memory 제한의 축소판 (원칙 3) 분석 |
| docstring 에 "This Tool never writes" 명시 | Tool description 을 실제 기능과 일치 |
| 없는 파일에 예외 대신 메시지 반환 | Tool error 처리 — LLM 이 스스로 복구할 수 있는 형태 분석 |
경로 차단이 왜 필요한가
비유
ALLOWED_ROOT 없이 "workspace 폴더만 읽어" 라고 prompt 에 적는 것은 손님에게 "2층만 구경하세요" 라고 말로 부탁하는 것입니다.
_resolve_inside_allowed_root() 는 2층 외 층에서 엘리베이터 버튼이 눌리지 않게 만드는 것입니다.
../secret.txt 같은 입력은 "계단으로 올라가면 되지 않나" 에 해당하고, 이 함수는 그 계단도 막습니다.
왜 @tool 데코레이터를 붙이지 않았는가
두 가지 이유입니다. 분석
- 학습팩이 "일반 Python 함수 또는
@tool을 이용해 Tool 을 정의할 수 있다" 고 적혀 있습니다 - 데코레이터 없이 두면 langchain 없이도 단위 테스트를 실행할 수 있어 다음 차시의 검증이 가능해집니다
쓰기 도구는 어디에 두는가
쓰기 도구는 READ_ONLY_TOOLS 목록에 넣지 않습니다. 대신 승인 Node 뒤에서만 호출되는 별도 함수로 둡니다. 다음 차시에서 그 배치를 다룹니다.
이 목록이 강제하는 것과 하지 않는 것
솔직하게 짚습니다. READ_ONLY_TOOLS 는 선언입니다. 이 목록을 모델에 묶는 코드는 이 교재에 없습니다. 분석
| 확인되는 것 | 확인되지 않는 것 |
|---|---|
| 목록에 담긴 두 함수가 쓰기를 하지 않는다 · 모듈 전체에 쓰기 경로가 없다 · 허용 폴더 밖을 읽지 못한다 | 모델이 이 목록만 쓴다 — 실제 Agent 를 조립할 때 이 목록만 바인딩했는지는 그 코드가 보장해야 합니다 |
즉 구조로 강제할 준비를 한 상태이고, 강제는 바인딩하는 쪽에서 완성됩니다. 이 구분을 흐리면 "목록에 넣었으니 안전하다" 는 잘못된 안심으로 이어집니다.