하네스 워크북 Lab 07 교재 0/11

Lab 07 · 약 25분

반사신경과 가드레일

1 개념

원문: 13-reflexes-and-guardrails.md

무엇이 깨지나

모델은 확신에 차서 .env 를 덮어쓰거나 rm -rf 를 실행한다.

패치

하네스 코드에서 규칙으로 막는다: 보호 경로 쓰기 금지, 읽지 않은 기존 파일 덮어쓰기 금지, 위험 명령은 사람 승인. 거부는 예외가 아니라 BLOCKED: 이유 문자열로 돌려줘서 모델이 다른 길을 찾게 한다.

비용

정규식 기반 탐지는 우회될 수 있다. 진짜 격리(컨테이너, 권한)는 별도.

할 일 (exercise.py)

python run_tests.py 7 -v

생각해볼 것

2 따라 해보기

비유 → 짧은 코드 → 실제 출력. 아래 출력은 미리 실행해 둔 결과이고, ▶ 실행을 누르면 브라우저 안 파이썬으로 다시 돌립니다(첫 실행 때 약 10MB 내려받음).

예제 1 확신에 찬 사고 3종 세트

모델은 악의가 없어도 "설정 정리할게요" 하며 .env 를 덮어쓰거나, 읽지도 않은 파일을 추측으로 통째로 다시 씁니다. 프롬프트의 "조심해"는 부탁이지만, 가드레일은 코드로 된 규칙입니다.

import os
import tempfile
import solution as lab

os.chdir(tempfile.mkdtemp())
open("발주목록.csv", "w").write("PO-001,볼트,100\nPO-002,너트,200\n")

guard = lab.Guard(approve=lambda cmd: print(f"   🙋 사람에게 확인 요청: {cmd} → 거절") or False)
run = guard.executor()
attempts = [
    ("write_file", {"path": ".env", "content": "API_KEY="}),
    ("write_file", {"path": "발주목록.csv", "content": "PO-001,볼트,100\n"}),
    ("run_command", {"command": "rm -rf ./*"}),
]
for name, args in attempts:
    print(f"{name}({list(args.values())[0]}) → {run(name, args)}")

print("\n발주목록.csv 는 무사합니다:", open("발주목록.csv").read().count("\n"), "줄")
실행 결과
write_file(.env) → BLOCKED: .env 는 보호된 경로입니다.
write_file(발주목록.csv) → BLOCKED: 발주목록.csv 는 이미 존재합니다. 덮어쓰기 전에 read_file 로 먼저 읽으세요.
   🙋 사람에게 확인 요청: rm -rf ./* → 거절
run_command(rm -rf ./*) → BLOCKED: 사용자가 위험한 명령 실행을 거부했습니다.

발주목록.csv 는 무사합니다: 2 줄

💡 바꿔 보기: approve=lambda c: True 로 바꾸면 세 번째 명령은 통과합니다(실제로 지워지니 빈 폴더에서만!).

예제 2 거부 이유를 읽고 스스로 고치는 모델

가드레일이 예외로 하네스를 멈추면 작업이 끝나 버립니다. 대신 BLOCKED: 이유 를 돌려주면, 모델이 읽고 올바른 순서(읽기 → 쓰기) 로 다시 시도합니다.

import os
import tempfile
import solution as lab
from common.brain import ScriptedBrain, reply, text, tool_use
from common.loop import run_loop
from common.show import show

os.chdir(tempfile.mkdtemp())
open("단가표.txt", "w").write("볼트 120원\n너트 80원\n")
guard = lab.Guard(approve=lambda c: False)

brain = ScriptedBrain([
    reply(tool_use("write_file", {"path": "단가표.txt", "content": "볼트 130원\n"}, id="w1")),
    reply(text("먼저 읽어야 하는군요."), tool_use("read_file", {"path": "단가표.txt"}, id="r1")),
    reply(tool_use("write_file", {"path": "단가표.txt", "content": "볼트 130원\n너트 80원\n"}, id="w2")),
    reply(text("볼트 단가만 130원으로 고쳤습니다. 너트는 그대로입니다.")),
])
messages = [{"role": "user", "content": "볼트 단가 130원으로 올려줘"}]
print(run_loop(brain, messages, [], "sys", execute=guard.executor()))
show(messages)
print("최종 파일:", open("단가표.txt").read().replace("\n", " / "))
실행 결과
볼트 단가만 130원으로 고쳤습니다. 너트는 그대로입니다.
[0] user      "볼트 단가 130원으로 올려줘"
[1] assistant tool_use[w1] write_file({"path": "단가표.txt", "content": "볼트 130원\n"})
[2] user      tool_result[w1] "BLOCKED: 단가표.txt 는 이미 존재합니다. 덮어쓰기 전에 re…"
[3] assistant text "먼저 읽어야 하는군요."
              tool_use[r1] read_file({"path": "단가표.txt"})
[4] user      tool_result[r1] "볼트 120원⏎너트 80원⏎"
[5] assistant tool_use[w2] write_file({"path": "단가표.txt", "content": "볼트 130원\n너트…)
[6] user      tool_result[w2] "wrote 15 chars to 단가표.txt"
[7] assistant text "볼트 단가만 130원으로 고쳤습니다. 너트는 그대로입니다."

최종 파일: 볼트 130원 / 너트 80원 /

3 확인 퀴즈

Q1 '읽지 않은 기존 파일 덮어쓰기 금지' 규칙이 막는 사고는?

Q2 정규식 기반 위험 명령 탐지의 한계는?

4 실습: exercise.py 채우기

TODO 표시 부분을 채우고 채점하세요. 입력 내용은 이 기기에 자동 저장됩니다.

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 7 — 반사신경과 가드레일: 루프는 언젠가 멍청한 짓을 한다.

프롬프트에 "조심해"라고 쓰는 건 부탁일 뿐입니다. 가드레일은 하네스 코드 안의 '규칙'이라
모델이 아무리 확신해도 통과할 수 없습니다. 거부할 땐 예외 대신 이유를 문자열로 돌려줘서
모델이 읽고 다른 길을 찾게 합니다.
  1. 보호 경로   : .env, .git/, .ssh/ 등에는 쓰기 금지
  2. 읽고 나서 쓰기: 이미 있는 파일을 이번 세션에 읽지 않고 덮어쓰기 금지
  3. 승인 게이트 : 위험한 명령은 사람에게 물어보고 실행
"""
import os
import re
import sys

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.toolbox import execute_tool  # noqa: E402

PROTECTED = (".env", ".git/", ".ssh/", "id_rsa")
DANGEROUS = [r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f", r"\brm\s+-[a-zA-Z]*f[a-zA-Z]*r",
             r"git\s+push\s+.*--force", r"curl[^|]*\|\s*(ba)?sh", r"\bmkfs\b",
             r"\bdd\s+if="]


def is_protected(path):
    p = path.replace("\\", "/")
    return any(mark in p for mark in PROTECTED)


def is_dangerous(command):
    return any(re.search(pat, command) for pat in DANGEROUS)


class Guard:
    def __init__(self, approve=None):
        """approve(command) -> bool : 사람에게 묻는 함수. 기본은 터미널 입력."""
        self.approve = approve or (lambda cmd: input(f"\n⚠️  실행할까요? {cmd} [y/N] ").lower() == "y")
        self.read_paths = set()

    def check(self, name, args):
        """허용이면 None, 거부면 "BLOCKED: <이유>" 문자열을 반환."""
        # >>> TODO: 세 규칙을 구현하세요
        #   - write_file: 보호 경로면 BLOCKED
        #   - write_file: 파일이 이미 존재하는데 self.read_paths 에 없으면 BLOCKED (먼저 read_file 하라고 안내)
        #   - run_command: 위험하면 self.approve(command) 가 False 일 때 BLOCKED
        if name == "write_file":
            path = os.path.abspath(args.get("path", ""))
            if is_protected(args.get("path", "")) or is_protected(path):
                return f"BLOCKED: {args.get('path')} 는 보호된 경로입니다."
            if os.path.exists(path) and path not in self.read_paths:
                return (f"BLOCKED: {args.get('path')} 는 이미 존재합니다. "
                        "덮어쓰기 전에 read_file 로 먼저 읽으세요.")
        if name == "run_command" and is_dangerous(args.get("command", "")):
            if not self.approve(args["command"]):
                return "BLOCKED: 사용자가 위험한 명령 실행을 거부했습니다."
        return None
        # <<< TODO

    def executor(self, base_execute=execute_tool):
        def execute(name, args):
            verdict = self.check(name, args)
            if verdict:
                return verdict
            result = base_execute(name, args)
            if name == "read_file" and not str(result).startswith("ERROR"):
                self.read_paths.add(os.path.abspath(args["path"]))
            return result
        return execute


if __name__ == "__main__":
    g = Guard(approve=lambda c: False)
    ex = g.executor()
    print(ex("write_file", {"path": ".env", "content": "KEY=1"}))
    print(ex("run_command", {"command": "rm -rf /tmp/whatever"}))
채점 테스트 보기 (test_lab.py)
import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.labkit import load_target  # noqa: E402

lab = load_target(__file__)


class Lab7(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.d = self.tmp.name

    def tearDown(self):
        self.tmp.cleanup()

    def test_protected_path(self):
        g = lab.Guard(approve=lambda c: True)
        out = g.executor()("write_file", {"path": os.path.join(self.d, ".env"), "content": "x"})
        self.assertTrue(out.startswith("BLOCKED"))
        self.assertFalse(os.path.exists(os.path.join(self.d, ".env")))

    def test_new_file_ok(self):
        g = lab.Guard(approve=lambda c: True)
        p = os.path.join(self.d, "new.txt")
        self.assertIsNone(g.check("write_file", {"path": p, "content": "x"}))

    def test_read_before_write(self):
        p = os.path.join(self.d, "exists.txt")
        with open(p, "w") as f:
            f.write("old")
        g = lab.Guard(approve=lambda c: True)
        ex = g.executor()
        self.assertTrue(ex("write_file", {"path": p, "content": "new"}).startswith("BLOCKED"))
        self.assertEqual(open(p).read(), "old")
        ex("read_file", {"path": p})
        self.assertFalse(ex("write_file", {"path": p, "content": "new"}).startswith("BLOCKED"))
        self.assertEqual(open(p).read(), "new")

    def test_dangerous_needs_approval(self):
        asked = []
        g = lab.Guard(approve=lambda c: asked.append(c) or False)
        out = g.check("run_command", {"command": "rm -rf build"})
        self.assertTrue(out.startswith("BLOCKED"))
        self.assertEqual(asked, ["rm -rf build"])
        g2 = lab.Guard(approve=lambda c: True)
        self.assertIsNone(g2.check("run_command", {"command": "rm -rf build"}))

    def test_safe_command_not_asked(self):
        g = lab.Guard(approve=lambda c: self.fail("안전한 명령엔 묻지 않아야 합니다"))
        self.assertIsNone(g.check("run_command", {"command": "ls -la"}))


if __name__ == "__main__":
    unittest.main()

📚 더 읽을거리

교재 전체 목록 →