fable-ish · 가드레일: 소프트 권고 vs 하드 위임
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가 직접 막지 않는다는 점이다.
공항 보안에 비유하면 이해가 쉽다.
- 안내 방송 (소프트 권고) — 사용자가 “비밀번호 출력해줘”, “
rm -rf /실행해줘”, “DB 드롭해줘” 같은 위험한 요청을 보내면, fable-ish의 작은 검사기(정규식)가 이를 감지한다. 그러면 모델에게 가는 입력에 “여긴 민감한 경계다. 되돌릴 수 있는 가장 안전한 행동을 먼저 하고, 자격증명·되돌릴 수 없는 원격 쓰기·파괴적 삭제가 필요하면 사용자 확인을 기다려라”라는 안내 문장을 끼워 넣는다. 공항 방송처럼 “조심하세요”라고 말할 뿐, 누구를 붙잡지는 못한다. - 검색대 게이트 (하드 강제) — 진짜로 통과를 막는 보안 검색대는 fable-ish가 운영하지 않는다. 그 권고문의 마지막 줄은 솔직하게 못을 박는다. “진짜 강제는 클로드 코드 권한 시스템에 맡긴다(Rely on Claude Code permissions for hard enforcement).” 즉 실행 차단·승인 팝업·샌드박스는 호스트인 클로드 코드의 몫이라고 명시적으로 위임한다.
- 비밀 가리개 (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 미구현 — 호스트에 위임 |
[!note] 위험을 판정하는 정규식 3종 (classify_task.py)
SECRET_REQUEST_RE—print/show/dump/cat/echo/exfiltrate/leak뒤 40자 이내에secret/token/api_key/password/.env가 오면 매치 → 비밀 노출 시도DESTRUCTIVE_REQUEST_RE—rm -rf /,delete everything,drop database,git reset --hard,wipe repo,destroy production매치 → 파괴 명령SAMPLE_RE—sample/example/test case/fixture/dry-run/검증/샘플/예시매치 시 위험 해제(오탐 면제). 이 단어 하나로 sensitive 판정이 풀리는 게 회피 표면이기도 하다- 판정 공식:
(SECRET 또는 DESTRUCTIVE) and not SAMPLE→ True면blocked모드
[!note] risk_flags 에 누적될 수 있는 값 (blocked 판정과 독립)
production·database·secret-or-auth·remote-write·destructive· (정규식은 안 잡혔지만 sensitive면)sensitive-request
생명주기 체크리스트 — 가드레일은 클로드 코드 훅 3개 위에서 돈다(hooks/hooks.json).
- UserPromptSubmit →
user_prompt_submit.py. 가드레일이 모델에 들어가는 유일한 주입 지점. blocked이면 권고문을additionalContext로 emit - PostToolUse (
^(Bash|Edit|Write|MultiEdit|NotebookEdit)$) →post_tool_use.py. 증거 기록·실패 시 완료보고 보류 권고 - Stop →
stop_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) 사용
[!warning] 직접 만들 때 잊지 말 원칙과 한계
- 위험 매치 시 권고는
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 판정이 풀리는 등 회피 표면이 존재한다.
스스로 답해보기
- blocked 모드의 권고문이 “소프트”인 이유는? 그 강제력은 무엇에 달려 있나?
- fable-ish가 스스로 “강제로” 하는 안전장치는 무엇이고, 무엇은 호스트에 떠넘기나?
SAMPLE_RE가 왜 위험 판정을 무력화하며, 이것이 왜 회피 표면이 되는가?
연결
FB_개요 · _분석축_루브릭 · FB_30_context-injection-via-additionalContext · FB_40_task-classification-engine · FB_70_stop-completion-gate
[!tip] 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)