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번 트랙 감사에서 밝힌 한계가 여기에도 그대로 적용됩니다. 이 목록은 선언입니다. 모델에 어떤 도구를 묶을지는 바인딩하는 코드가 정합니다. 목록을 나눈 것은 강제할 준비이지 강제 자체가 아닙니다.

이해도 확인

문항을 불러오는 중입니다.