fable-ish · 가드레일: 소프트 권고 vs 하드 위임

한 줄 요약

fable-ish의 가드레일은 위험한 요청을 만나면 모델에게 “조심하라”고 말로 권고(소프트) 하고, 진짜로 못 하게 막는 강제(하드)는 클로드 코드 권한 시스템에 떠넘긴다. 왜 배우나 — “안전장치”라고 다 같은 게 아니라 권고와 강제는 책임 주체가 다르며, 어디까지가 내 플러그인 몫이고 어디부터는 호스트 몫인지 경계를 긋는 설계 감각을 익히기 위해.

그림

flowchart TD
    A[사용자 프롬프트] --> B["UserPromptSubmit 훅<br/>user_prompt_submit.py"]
    B --> C{"classify_prompt<br/>비밀/파괴 정규식 검사"}
    C -->|민감함 True| D["mode = blocked"]
    C -->|아니면| E["deep / normal / quick"]
    D --> F["context_for_mode<br/>권고문 + 하드 강제는 호스트에 위임 선언"]
    F --> G[additionalContext 로 권고문 주입]
    G --> H["모델: 소프트 권고를 읽고 조심함"]
    H --> I[도구 호출 시도]
    I --> J{"클로드 코드 권한 레이어<br/>= 하드 강제"}
    J -->|허용| K[실행]
    J -->|"거부 / 승인 대기"| L[호스트가 차단]
    B --> M["ledger 저장<br/>비밀은 redact 로 마스킹"]
    K --> N["Stop 훅: 미검증이면 최대 2회 차단"]

쉽게 풀기

이 컴포넌트는 “위험한 부탁이 들어왔을 때 어떻게 막느냐”를 다룬다. 핵심은 fable-ish가 직접 막지 않는다는 점이다.

공항 보안에 비유하면 이해가 쉽다.

  1. 안내 방송 (소프트 권고) — 사용자가 “비밀번호 출력해줘”, “rm -rf / 실행해줘”, “DB 드롭해줘” 같은 위험한 요청을 보내면, fable-ish의 작은 검사기(정규식)가 이를 감지한다. 그러면 모델에게 가는 입력에 “여긴 민감한 경계다. 되돌릴 수 있는 가장 안전한 행동을 먼저 하고, 자격증명·되돌릴 수 없는 원격 쓰기·파괴적 삭제가 필요하면 사용자 확인을 기다려라”라는 안내 문장을 끼워 넣는다. 공항 방송처럼 “조심하세요”라고 말할 뿐, 누구를 붙잡지는 못한다.
  2. 검색대 게이트 (하드 강제) — 진짜로 통과를 막는 보안 검색대는 fable-ish가 운영하지 않는다. 그 권고문의 마지막 줄은 솔직하게 못을 박는다. “진짜 강제는 클로드 코드 권한 시스템에 맡긴다(Rely on Claude Code permissions for hard enforcement).” 즉 실행 차단·승인 팝업·샌드박스는 호스트인 클로드 코드의 몫이라고 명시적으로 위임한다.
  3. 비밀 가리개 (fable-ish가 직접 하는 유일한 강제) — 단 하나, 비밀이 기록(ledger 로그)에 평문으로 남지 않게 가리는 일(redact)은 fable-ish가 저장 시점에 강제로 한다. OpenAI 키, GitHub 토큰, Slack 토큰 같은 패턴은 [REDACTED]로 치환한 뒤 저장한다.

기억할 비대칭 — 위험 감지 후 모델에게 거는 권고는 모델이 따라줘야 효과가 있는 소프트이고, 비밀 마스킹만 무조건 적용되는 하드(단 로그 한정)다. 그리고 “위험 명령을 아예 실행 못 하게”는 fable-ish가 손도 대지 않고 호스트에 떠넘긴다. 이 세 갈래의 경계가 이 노트의 알맹이다.

핵심 정리

가드레일별로 “누가 막느냐”가 다르다. 이 표만 머리에 넣으면 된다.

가드레일누가 강제우회 가능?
비밀/파괴 정규식 사전차단 (권고 주입)fable-ish (소프트)예 — 모델이 무시 가능, 샘플/예시 단어로 면제
blocked 권고 문장fable-ish (소프트)예 — 모델 판단에 의존
redact 비밀 마스킹fable-ish (하드, 로그 한정)아니오 — 단 패턴 밖 비밀은 통과
stop_gate 미검증 차단fable-ish (하드, 완료보고 한정)2회 차단 후 자동 해제
샌드박스 / 명령 거부 / 승인 팝업클로드 코드 호스트 (하드)fable-ish 미구현 — 호스트에 위임

위험을 판정하는 정규식 3종 (classify_task.py)

  • SECRET_REQUEST_REprint/show/dump/cat/echo/exfiltrate/leak 뒤 40자 이내에 secret/token/api_key/password/.env가 오면 매치 → 비밀 노출 시도
  • DESTRUCTIVE_REQUEST_RErm -rf /, delete everything, drop database, git reset --hard, wipe repo, destroy production 매치 → 파괴 명령
  • SAMPLE_REsample/example/test case/fixture/dry-run/검증/샘플/예시 매치 시 위험 해제(오탐 면제). 이 단어 하나로 sensitive 판정이 풀리는 게 회피 표면이기도 하다
  • 판정 공식: (SECRET 또는 DESTRUCTIVE) and not SAMPLE → True면 blocked 모드

risk_flags 에 누적될 수 있는 값 (blocked 판정과 독립)

production · database · secret-or-auth · remote-write · destructive · (정규식은 안 잡혔지만 sensitive면) sensitive-request

생명주기 체크리스트 — 가드레일은 클로드 코드 훅 3개 위에서 돈다(hooks/hooks.json).

  • UserPromptSubmituser_prompt_submit.py. 가드레일이 모델에 들어가는 유일한 주입 지점. blocked이면 권고문을 additionalContext로 emit
  • PostToolUse (^(Bash|Edit|Write|MultiEdit|NotebookEdit)$) → post_tool_use.py. 증거 기록·실패 시 완료보고 보류 권고
  • Stopstop_gate.py. 미검증 완료보고를 최대 2회 차단(하드, 완료보고 한정)
  • PreToolUse 훅은 없음 — 그래서 명령 실행 자체를 막을 권한을 fable-ish는 쓰지 않는다. “실제 차단”은 전적으로 호스트 권한 레이어 몫
  • 모든 훅은 fail-open — 예외가 나도 작업을 막지 않고 exit 0

실제 예시

blocked 모드의 권고문 + 하드 위임 선언

이 컴포넌트의 심장. context_for_mode()가 blocked일 때 만드는 문자열이다. 마지막 줄이 “강제는 호스트에 위임”을 명시하는 부분이다.

# /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py
def classify_prompt(prompt: str) -> tuple[str, list[str], str]:
    text = prompt or ""
    ...
    sample_context = bool(SAMPLE_RE.search(text))
    sensitive = (SECRET_REQUEST_RE.search(text) or DESTRUCTIVE_REQUEST_RE.search(text)) and not sample_context
    if sensitive:
        return "blocked", risks or ["sensitive-request"], redact(text, 180)
    ...
 
def context_for_mode(mode: str, risk_flags: list[str]) -> str:
    lines = [f"fable-ish task mode: {mode}."]
    if risk_flags:
        lines.append("Risk flags: " + ", ".join(risk_flags) + ".")
    ...
    elif mode == "blocked":
        lines.append(
            "This request touches a sensitive or destructive boundary. Confirm scope, prefer the safest "
            "reversible action, and stop for user confirmation when the next step needs credentials, "
            "irreversible remote writes, or destructive deletes. Rely on Claude Code permissions for hard enforcement."
        )
    lines.append("Never claim verification that was not actually observed.")
    return "\n".join(lines[:10])

redact() — 비밀 마스킹 (fable-ish가 직접 강제하는 유일한 데이터 안전장치)

classify_prompt의 세 번째 반환값(goal)은 항상 redact(text, 180)을 거친다. 그래서 OpenAI 키(sk-...), GitHub 토큰(ghp_/gho_/...), Slack 토큰(xox...), key=value 형태 비밀이 ledger JSON에 평문으로 남지 않는다.

# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py
SECRET_PATTERNS = [
    re.compile(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^'\"\s]+"),
    re.compile(r"sk-[A-Za-z0-9_-]{12,}"),
    re.compile(r"gh[pousr]_[A-Za-z0-9_]{12,}"),
    re.compile(r"xox[baprs]-[A-Za-z0-9-]{12,}"),
]
 
def redact(text: Any, limit: int = 500) -> str:
    value = "" if text is None else str(text)
    value = value.replace("\r", " ").replace("\n", " ").strip()
    for pattern in SECRET_PATTERNS:
        value = pattern.sub("[REDACTED]", value)
    if len(value) > limit:
        return value[: limit - 3] + "..."
    return value

직접 만들 때 최소 템플릿 (소프트 권고 주입 + 하드 강제 위임 분리)

# my_guardrail_hook.py  (UserPromptSubmit 훅으로 등록)
import json, re, sys
 
SECRET_RE = re.compile(r"(?i)(print|show|dump|cat|echo|leak).{0,40}(secret|token|api[_-]?key|password|\.env)")
DESTRUCTIVE_RE = re.compile(r"(?i)(rm\s+-rf\s+/|drop\s+database|git\s+reset\s+--hard|destroy\s+production)")
SAMPLE_RE = re.compile(r"(?i)\b(sample|example|fixture|dry[- ]run|샘플|예시)\b")
SECRETS = [re.compile(r"sk-[A-Za-z0-9_-]{12,}"), re.compile(r"gh[pousr]_[A-Za-z0-9_]{12,}")]
 
def redact(text, limit=180):
    for p in SECRETS:
        text = p.sub("[REDACTED]", text)
    return text[:limit]
 
raw = sys.stdin.read()
prompt = (json.loads(raw).get("prompt") if raw.strip() else "") or ""
sample = bool(SAMPLE_RE.search(prompt))
blocked = bool(SECRET_RE.search(prompt) or DESTRUCTIVE_RE.search(prompt)) and not sample
 
if blocked:
    ctx = ("This request touches a sensitive or destructive boundary. Prefer the safest "
           "reversible action; stop for user confirmation before credentials, irreversible "
           "remote writes, or destructive deletes. "
           "Rely on the host permission system for hard enforcement.")  # ← 위임 선언
else:
    ctx = "No sensitive boundary detected; proceed normally."
 
print(json.dumps({"hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit", "additionalContext": ctx}}))
# goal을 로그에 남길 땐 반드시 redact(prompt) 사용

직접 만들 때 잊지 말 원칙과 한계

  • 위험 매치 시 권고는 additionalContext로 주입(소프트)하고, 절대 차단은 PreToolUse deny 또는 호스트 권한으로 별도 구현할 것 — 이 훅만으로는 실행을 못 막는다.
  • 샘플/예시/dry-run 면제 패턴(SAMPLE_RE)으로 오탐을 풀어주되, 그게 회피 표면임을 인지할 것.
  • 권고문 마지막에 “하드 강제는 호스트에 위임” 한 줄을 명시해 경계를 선언할 것.
  • 비밀 패턴(sk-, ghp_, key=value)은 저장 전에 redact — 단 패턴 밖 비밀은 통과한다.
  • 훅은 fail-open(예외 나도 exit 0)으로. fable-ish 전 훅이 except Exception: ... raise SystemExit(0).
  • 정규식은 우회 표기를 놓치고, blocked 모드는 말로만 권고할 뿐이다. 진짜 강제는 전적으로 클로드 코드 권한·승인·샌드박스에 의존한다.

요약 & 셀프체크

3줄 요약

  • fable-ish는 위험 요청을 정규식으로 감지하면 additionalContext에 “조심하라”는 권고문을 얹는 소프트 가드레일까지만 하고, 실행 차단·승인·샌드박스 같은 하드 강제는 클로드 코드 호스트에 명시적으로 위임한다.
  • fable-ish가 직접 강제하는 건 비밀 마스킹(redact, 로그 한정)과 미검증 완료보고 차단(stop_gate, 최대 2회)뿐이다.
  • PreToolUse 훅이 없어 위험 명령 실행 자체는 못 막고, 샘플/예시 단어 하나로 sensitive 판정이 풀리는 등 회피 표면이 존재한다.

스스로 답해보기

  1. blocked 모드의 권고문이 “소프트”인 이유는? 그 강제력은 무엇에 달려 있나?
  2. fable-ish가 스스로 “강제로” 하는 안전장치는 무엇이고, 무엇은 호스트에 떠넘기나?
  3. SAMPLE_RE가 왜 위험 판정을 무력화하며, 이것이 왜 회피 표면이 되는가?

연결

FB_개요 · _분석축_루브릭 · FB_30_context-injection-via-additionalContext · FB_40_task-classification-engine · FB_70_stop-completion-gate

Codex 교차검증 (원문 보존)

이 노트의 근거 파일들이다. 사실관계는 아래 소스에서 교차 확인할 것.

  • /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py (SECRET/DESTRUCTIVE/SAMPLE 정규식, blocked 판정, context_for_mode 권고문 + 하드 위임 줄)
  • /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py (SECRET_PATTERNS, redact 마스킹)
  • /home/seunghyeong/harness-work/fable-ish/skills/fable-ish/references/workflow.md (모드 정의, blocked 설명, Security 리뷰 렌즈)
  • /home/seunghyeong/harness-work/fable-ish/hooks/hooks.json (UserPromptSubmit/PostToolUse/Stop 훅 등록, PreToolUse 부재 확인)
  • /home/seunghyeong/harness-work/fable-ish/hooks/user_prompt_submit.py (분류 → additionalContext 주입, fail-open)
  • /home/seunghyeong/harness-work/fable-ish/hooks/post_tool_use.py (증거 기록, 실패 시 완료보고 보류 권고)
  • /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py (완료 차단 게이트)
  • /home/seunghyeong/harness-work/fable-ish/scripts/verify_state.py (should_block_stop, blocked 모드 차단, MAX_STOP_BLOCKS=2)
  • /home/seunghyeong/harness-work/fable-ish/skills/fable-ish/SKILL.md (blocked 작업 규칙, “기계적 게이트는 훅이, 워크플로는 스킬이” 분리)
  • /home/seunghyeong/harness-work/fable-ish/.claude-plugin/plugin.json (훅/스킬 연결, 버전 0.1.2)