6. 직접 만들기: 회의록 정리 Agent
파일을 쓰는 도구, 그리고 백업
이 차시를 마치면
허용 폴더 안에만 쓰고 덮어쓰기 전 백업하는 도구를 만듭니다.
이제 쓰기가 들어옵니다. 여기서부터 되돌릴 수 없는 일이 생기므로 장치가 필요합니다.
파일을 나눕니다
쓰기 함수를 sec_tools.py 에 넣지 않습니다. sec_write_tools.py 를 새로 만듭니다.
왜 나누는가
3번 트랙의 Tool 영역 규칙은 "read-only Tool 을 write Tool 로 몰래 변경 금지" 입니다. "몰래 하지 마라" 는 사람의 주의에 기대는 규칙입니다.
파일을 나누면 쓰기 기능을 늘리려면 이 파일을 고쳐야 합니다. 변경이 리뷰에서 눈에 띕니다. 주의를 구조로 바꾼 것입니다.
코드
"""쓰기 도구 — 읽기 도구와 파일을 나눈다.
트랙 3 의 "read-only tool 을 write tool 로 몰래 변경 금지" 규칙을
파일 분리로 강제한다. 쓰기 기능을 늘리려면 이 파일을 고쳐야 하므로
변경이 리뷰에서 눈에 띈다.
경로 검사 규칙은 읽기 도구와 같은 것을 쓴다. 규칙이 두 벌이 되면
한쪽만 고쳐지는 사고가 난다.
"""
from datetime import datetime
from sec_tools import ALLOWED_ROOT, ToolPermissionError
from sec_tools import _resolve_inside_allowed_root as resolve_inside_allowed_root
BACKUP_DIR = ALLOWED_ROOT / ".backup"
def _guard_not_backup(target, relative_path):
"""백업 폴더를 건드리면 되돌리기가 무력해진다."""
if target == BACKUP_DIR or BACKUP_DIR in target.parents:
raise ToolPermissionError("백업 폴더에는 쓸 수 없습니다: %s" % relative_path)
def _make_backup(target, relative_path):
"""덮어쓰기 전 원본을 시각을 붙여 보관하고 파일 이름을 돌려준다."""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
flat = relative_path.replace("/", "_")
destination = BACKUP_DIR / ("%s.%s" % (flat, stamp))
destination.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
return destination.name
def write_workspace_file(relative_path, content):
"""Write a UTF-8 text file inside the workspace. Backs up any existing file first."""
target = resolve_inside_allowed_root(relative_path)
_guard_not_backup(target, relative_path)
backup = _make_backup(target, relative_path) if target.is_file() else None
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return {"written": relative_path, "backup": backup}
def list_backups(relative_path):
"""이 경로의 백업 파일 이름을 최신순으로 돌려준다."""
if not BACKUP_DIR.is_dir():
return []
prefix = relative_path.replace("/", "_") + "."
names = [p.name for p in BACKUP_DIR.iterdir() if p.name.startswith(prefix)]
return sorted(names, reverse=True)
def restore_from_backup(relative_path, backup_name):
"""백업에서 되돌린다. 이 동작도 고위험이므로 승인 대상이다."""
if backup_name not in list_backups(relative_path):
raise ToolPermissionError("모르는 백업입니다: %s" % backup_name)
target = resolve_inside_allowed_root(relative_path)
_guard_not_backup(target, relative_path)
source = BACKUP_DIR / backup_name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
return {"restored": relative_path, "from": backup_name}
# 쓰기 도구 목록. 읽기 목록(sec_tools.READ_ONLY_TOOLS)과 섞지 않는다.
WRITE_TOOLS = [write_workspace_file, restore_from_backup]
테스트 11개를 통과했습니다. 경로 차단 4항목, 백업 3항목, 되돌리기 4항목입니다.
경로 검사를 가져다 쓴 이유
from sec_tools import _resolve_inside_allowed_root as resolve_inside_allowed_root
읽기 도구의 함수를 그대로 씁니다. 새로 쓰지 않습니다. 앞 차시에서 말한 것과 같은 이유입니다 — 규칙이 두 벌이 되면 한쪽만 고쳐집니다.
읽기에서 막히는 것이 쓰기에서 막히지 않으면 훨씬 위험합니다. 읽기는 정보가 새는 것이고 쓰기는 파일이 망가지는 것이기 때문입니다. 분석
백업 방식
backup = _make_backup(target, relative_path) if target.is_file() else None
| 상황 | 백업 |
|---|---|
| 파일이 없음 (새로 만들기) | 하지 않습니다. 지울 원본이 없습니다 |
| 파일이 있음 (덮어쓰기) | 먼저 사본을 만들고 씁니다 |
백업 이름에 시각을 마이크로초까지 붙입니다. 같은 파일을 여러 번 덮어써도 사본이 겹치지 않습니다.
actions_TODO.md.20260815-061341-208193
백업 폴더를 막은 이유
def _guard_not_backup(target, relative_path):
"""백업 폴더를 건드리면 되돌리기가 무력해진다."""
if target == BACKUP_DIR or BACKUP_DIR in target.parents:
raise ToolPermissionError("백업 폴더에는 쓸 수 없습니다: %s" % relative_path)
.backup/ 도 workspace/ 안에 있습니다. 그래서 경로 검사만으로는 쓸 수 있습니다.
백업을 덮어쓸 수 있으면 어떻게 될까요. 파일을 망가뜨린 뒤 백업까지 같은 내용으로 덮으면 되돌릴 방법이 사라집니다. 되돌리기 장치가 있는데도 무력해집니다.
비유
금고 열쇠를 금고 안에 넣어 두는 것과 같습니다. 금고는 잠겨 있고 열쇠도 있지만, 정작 필요할 때 쓸 수 없습니다.
안전 장치는 그 장치 자체가 보호되어야 의미가 있습니다.
되돌리기도 고위험입니다
def restore_from_backup(relative_path, backup_name):
"""백업에서 되돌린다. 이 동작도 고위험이므로 승인 대상이다."""
if backup_name not in list_backups(relative_path):
raise ToolPermissionError("모르는 백업입니다: %s" % backup_name)
되돌리기는 현재 파일을 덮어씁니다. 그러므로 덮어쓰기와 같은 등급입니다. 실수로 되돌리면 최근 작업이 사라집니다.
목록에 없는 백업 이름은 거부합니다. 임의 경로를 넣어 파일을 복사하는 통로가 되면 안 되기 때문입니다.
노출 목록을 나눕니다
# 쓰기 도구 목록. 읽기 목록(sec_tools.READ_ONLY_TOOLS)과 섞지 않는다.
WRITE_TOOLS = [write_workspace_file, restore_from_backup]
테스트가 두 목록이 겹치지 않는지 확인합니다.
def test_write_tools_are_separate_from_read_tools(self):
read_names = {fn.__name__ for fn in sec_tools.READ_ONLY_TOOLS}
write_names = {fn.__name__ for fn in sec_write_tools.WRITE_TOOLS}
self.assertEqual(read_names & write_names, set())
확인 필요 4번 트랙 감사에서 밝힌 한계가 여기에도 그대로 적용됩니다. 이 목록은 선언입니다. 모델에 어떤 도구를 묶을지는 바인딩하는 코드가 정합니다. 목록을 나눈 것은 강제할 준비이지 강제 자체가 아닙니다.