내 패턴 · 가드레일: 권한·승인·샌드박스
내 패턴 · 가드레일: 권한·승인·샌드박스
한 줄 요약
AI 에이전트가 무언가를 실행하기 직전에 “이거 해도 돼?”를 자동 판정하는 3중 잠금장치다. 왜 배우나: 한 겹이 뚫려도 다음 겹이 받쳐주는 “겹겹 방어”의 구조를 알아야, 내가 직접 안전장치를 설계하고 고칠 수 있다.
그림
flowchart TD
A["모델: 도구 호출 결정"] --> B{"층위3: CLAUDE.md 규칙<br/>이미 컨텍스트에 주입됨"}
B --> C["PreToolUse * : pre-tool-enforcer.mjs<br/>모든 도구 실행 전 게이트"]
C -->|deny 게이트 매칭| X["permissionDecision: deny<br/>차단"]
C -->|통과| D{"층위1: settings.allow/ask 대조"}
D -->|ask 매칭| Q[사용자에게 질문]
D -->|allow 매칭| Y[즉시 허용]
D -->|"미매칭, Bash"| E["PermissionRequest:Bash<br/>permission-handler.mjs"]
E -->|위험 셸문자 발견| Q
E -->|"안전패턴 + 레포내 경로"| Y2["behavior: allow"]
E -->|그 외| Q
쉽게 풀기
비유: 공항 보안 3단계. ① 탑승권의 사전 통과 도장(빠르지만 목록뿐) ② X-ray 검색대(가방을 실제로 열어 위험물 차단) ③ 게이트 앞 안내문(“라이터 금지” — 애초에 안 가져오게). 에이전트 가드레일도 이 세 겹이다.
| 층위 | 정체 | 비유 / 한계 |
|---|---|---|
| ① 정적 allowlist | settings.json의 “묻지 말고 통과” 화이트리스트 (Bash(git status:*)) | 통과 도장 / 빠르지만 무딤 |
| ② 훅 동적 판정 | 실행 직전 스크립트가 명령 내용을 읽어 판정 | X-ray / 똑똑하지만 우회 가능 |
| ③ 프롬프트 헌법 | CLAUDE.md·AGENTS.md의 자연어 금지 규칙 | 안내문 / 강제력 없음 |
각 층은 작동 시점이 다르다. 한 층도 완벽하지 않아 셋을 겹친다 — 정적 목록은 무디고, 훅은 우회될 수 있고, 프롬프트는 강제력이 없다. 이 겹침이 이 챕터의 핵심이다.
flowchart LR P["③ 프롬프트 헌법<br/>결정 전 · 예방"] -.강제력 없음.-> A["① allowlist<br/>결정 직후 · 빠름"] A -.무딤.-> H["② 훅<br/>실행 직전 · 런타임 강제"] H -->|deny 사유 텍스트| P
왜 모델이 거부 못 하나? 훅은 모델이 거부할 수 없는 런타임 강제력(실행 차단)이고, 프롬프트는 모델의 판단을 형성할 뿐이다. 훅이 막을 때 내보내는 사유(permissionDecisionReason)는 다시 모델에게 피드백되어 다른 경로를 찾게 한다(예: model="sonnet"으로 재시도 안내).
핵심 정리
판정 출력 스키마(훅 stdout JSON) 체크포인트:
- 허용:
{ continue: true, hookSpecificOutput: { hookEventName, decision: { behavior:'allow', reason } } } - 차단:
permissionDecision: 'deny'(+permissionDecisionReason) - 어느 키도 없음 → CC 기본 권한 흐름(=사용자에게 물음)
[!note]- 펼쳐보기: 매칭 규칙(층위1)의 함정 allowlist의
*와일드카드는 아무 접미사나 허용하지 않는다. 층위2 코드commandMatchesPermissionPattern기준:
*없으면 → 완전 일치*있으면 → 접미사를 떼고 prefix 일치 + 다음 글자가 경계문자(공백 /:/=/(/ 따옴표)즉
Bash(sudo npm:*)는sudo npm install은 통과시키지만sudo npmfoo는 막는다. 경계문자 검사가 없으면 비슷한 이름으로 위장 우회가 가능해진다.
[!note]- 펼쳐보기: 전체 필드표 (3개 층위 스키마) 층위 1 — 정적 allowlist (
settings.json/settings.local.json)
필드 타입 설명 permissions.allowstring[] 자동 허용 패턴. ToolName또는ToolName(arg-pattern). Bash는Bash(prefix:*)와일드카드permissions.askstring[] 자동 허용에서 빼고 반드시 묻는 패턴(allow보다 우선) skipDangerousModePermissionPromptboolean true면 위험모드 진입 확인 생략 enabledPluginsobject 훅 공급 플러그인 on/off. 훅 가드 스위치 modelstring 세션 모델(예 opus[1m]) — 층위2가[1m]접미사를 라우팅 가드에서 검사층위 2 — 훅 등록 + 동적 판정
필드 타입 설명 PermissionRequest[].matcherstring 어떤 도구의 권한요청에 훅을 걸지. 여기선 "Bash"PreToolUse[].matcherstring 모든 도구 실행 전 가드. 여기선 "*"hooks[].commandstring 실행 스크립트(stdin으로 JSON 페이로드) hooks[].timeoutnumber 초 단위 — PermissionRequest 5s, PreToolUse 3s 층위 3 — 프로젝트 헌법(프롬프트 계약)
필드 타입 설명 금지 규칙 목록 자연어 markdown ”X 금지” 절대 규칙. 모델이 컨텍스트로 읽음 역할별 지침 markdown 섹션 writer/reviewer/crawler 역할별 허용·금지 분리 자체승인 금지 규칙 writer와 reviewer는 반드시 다른 에이전트(권한 분리)
실제 예시
디스크의 실제 파일로 세 층위를 차례로 본다.
층위 1 — 정적 allowlist: settings.local.json에 "allow": ["Bash(sudo npm:*)"], settings.json에 "model": "opus[1m]" · enabledPlugins · skipDangerousModePermissionPrompt: true.
층위 2 — 훅 등록: PreToolUse(matcher *)는 pre-tool-enforcer.mjs(timeout 3s), PermissionRequest(matcher Bash)는 permission-handler.mjs(timeout 5s)를 건다.
층위 2의 판정 흐름은 두 단계 검사다 — 위험 셸문자를 먼저 전면 거부하고, 그 다음 안전 패턴 + 레포내 경로일 때만 자동 허용한다.
flowchart TD
C[Bash 명령 수신] --> S{"위험 셸 메타문자?<br/>; & 파이프 $ 백틱 등"}
S -->|있음| D["거부 → 기본 흐름"]
S -->|없음| A{"ask 목록 매칭?"}
A -->|예| D
A -->|아니오| P{"SAFE_PATTERNS 매칭<br/>+ isSafeRepoPath?"}
P -->|예| OK["behavior: allow"]
P -->|아니오| D
[!note]- 펼쳐보기: 전체 코드 (settings · hooks.json · 판정 로직 · 경로 가드 · pre-tool-enforcer)
// /home/seunghyeong/.claude/settings.local.json (전문) { "permissions": { "allow": [ "Bash(sudo npm:*)" ] } }// /home/seunghyeong/.claude/settings.json (발췌) { "model": "opus[1m]", "enabledPlugins": { "oh-my-claudecode@omc": true, "dd@gptaku-plugins": true }, "skipDangerousModePermissionPrompt": true }// .../marketplaces/omc/hooks/hooks.json (발췌) "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/pre-tool-enforcer.mjs", "timeout": 3 } ] } ], "PermissionRequest": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/permission-handler.mjs", "timeout": 5 } ] } ]// .../dist/hooks/permission-handler/index.js (발췌) const SAFE_PATTERNS = [ /^git (status|diff|log|branch|show|fetch)/, /^npm run (lint|build|check|typecheck)/, /^ls( |$)/, // REMOVED: cat, head, tail - they allow reading arbitrary files ]; // 명령 체이닝·주입 가능 메타문자 전부 거부 (Issue #146) const DANGEROUS_SHELL_CHARS = /[;&|`$()<>\n\r\t\0\\{}\[\]*?~!#]/; export function isSafeCommand(command) { const trimmed = command.trim(); if (DANGEROUS_SHELL_CHARS.test(trimmed)) return false; // 메타문자 → 무조건 거부 return SAFE_PATTERNS.some(pattern => pattern.test(trimmed)); } export function processPermissionRequest(input) { const toolName = input.tool_name.replace(/^proxy_/, ''); if (toolName !== 'Bash') return { continue: true }; const command = input.tool_input.command; const shouldAsk = hasClaudePermissionAsk(input.cwd, 'Bash', command); // ask 우선 if (!shouldAsk && isSafeAutoApprovedCommand(command, input.cwd)) { return { continue: true, hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', reason: 'Safe read-only or test command' } } }; } return { continue: true }; // 그 외엔 기본 흐름(사용자에게 물음) }// 같은 파일 — 경로 탈출 방지: 레포 안쪽 + 민감경로 제외일 때만 허용 function isSensitiveRepoRelativePath(p) { const n = p.replace(/\\/g,'/').replace(/^\.\//,''); return (n === '.git' || n.startsWith('.git/') || n.includes('/.git/') || n === '.ssh' || n.startsWith('.ssh/') || n === '.env' || n.startsWith('.env.') || n.includes('/.env') || n === 'secrets' || n.startsWith('secrets/')); } function isSafeRepoPath(cwd, inputPath, { allowDirectory=false, requireExisting=true }={}) { const worktreeRoot = getWorktreeRoot(cwd); const canonical = fs.realpathSync(path.resolve(cwd, inputPath)); // 심링크 해소 const rel = path.relative(worktreeRoot, canonical); if (rel.startsWith('..') || path.isAbsolute(rel)) return false; // 레포 밖 → 거부 if (isSensitiveRepoRelativePath(rel)) return false; // .env/.git/secrets → 거부 return true; }// .../scripts/pre-tool-enforcer.mjs (발췌) — PreToolUse * 의 deny 게이트 // 예: ultragoal 활성 + 매칭 /goal 없음 → deny, Bedrock/Vertex에서 [1m] 서브에이전트 스폰 → deny const ultragoalDenyReason = evaluateUltragoalPreToolEnforcement(stateDir, directory, sessionId, data); if (ultragoalDenyReason) { console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ultragoalDenyReason } })); return; }
층위 3 — 프로젝트 헌법: 실제 규칙들은 자연어 markdown으로 모델 컨텍스트에 박힌다.
<!-- /mnt/d/human-token-workflow/AGENTS.md (발췌) -->
1. 추론·창작 금지. 모든 주장은 원문 근거 필수.
2. 1차 자료 보존. 01_sources/ 원문 수정 금지, 파생물만 편집.
- 유료 보고서 우회 접근 금지 / 저작권 콘텐츠 전문 저장 금지 / frontmatter 없는 파일 저장 금지
<!-- /mnt/d/akh2/CLAUDE.md (발췌) -->
build/ ← 100% 파생물. 직접 편집 금지, 항상 재생성
파생값 추방: quality_score·registry_id·인덱스류는 페이지에 안 박음. build가 계산.
slug rename/삭제 직접 금지 — 신규 slug + supersedes 경유만 허용
직접 만들 때 템플릿
[!note]- 펼쳐보기: 4단계 구현 템플릿 (allowlist · 훅 등록 · 동적 가드 · 프롬프트 계약) 1) 정적 allowlist —
~/.claude/settings.local.json:{ "permissions": { "allow": ["Bash(git status:*)", "Bash(npm run build:*)"], "ask": ["Bash(rm:*)", "Bash(git push:*)"] } }2) 훅 등록 — 플러그인
hooks/hooks.json(또는 settings의hooks):{ "hooks": { "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "node /path/guard.mjs", "timeout": 3 } ] } ] } }3) 최소 동적 가드 —
guard.mjs:#!/usr/bin/env node let input = ''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => { const data = JSON.parse(input || '{}'); const cmd = data.tool_input?.command || ''; const DANGEROUS = /[;&|`$()<>{}\[\]*?~!#]/; if (data.tool_name === 'Bash' && (DANGEROUS.test(cmd) || /rm -rf|\.env|\.ssh/.test(cmd))) { console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Blocked: shell metachar or sensitive path.' } })); return; } console.log(JSON.stringify({ continue: true })); // 그 외엔 기본 흐름 });4) 프롬프트 계약 —
CLAUDE.md에 추가:## 금지 사항 - build/ 등 파생물 디렉터리 직접 편집 금지 — 항상 재생성 - 1차 자료 수정 금지, 파생물만 편집 - writer와 reviewer는 다른 에이전트. 자체 승인 금지
만들 때 점검표
- allowlist
*는 prefix 뒤 경계문자 보호(임의 접미사 허용 안 됨) - 훅은 fail-open — 에러/타임아웃 시
{continue:true}(가드 IO 실패가 작업을 영구 차단하면 안 됨) - 위험문자(
; & | $`등)는 패턴 매칭 전에 전면 거부 - 경로 가드는
realpathSync로 심링크 해소 후 worktree 밖/민감경로(.env/.git/.ssh/secrets) 거부 -
ask가allow보다 우선하도록 순서 검사 - CLAUDE.md에 권한 분리 규칙(writer≠reviewer) 명시
- 훅 deny 사유에 다음 행동 안내를 넣어 모델이 우회 경로를 찾게 함
요약 & 셀프체크
3줄 요약
- 가드레일은 정적 allowlist(빠르지만 무딤) + 훅 동적 판정(똑똑하지만 우회 가능) + 프롬프트 헌법(강제력 없음)을 겹친 3중 잠금장치다.
- 작동 시점이 다르다 — 헌법은 결정 전, allowlist는 결정 직후, 훅은 실행 직전. 훅만이 모델이 거부할 수 없는 런타임 강제력을 갖는다.
- 위험 셸문자는 패턴 매칭보다 먼저 전면 거부, 경로는 심링크 해소 후 레포 밖·민감경로(.env/.git/.ssh/secrets)를 막는다. 훅은 fail-open이어야 한다.
스스로 답해보기
Bash(sudo npm:*)가sudo npm install은 통과시키고sudo npmfoo는 막는 이유는?- 훅이 실패했을 때 fail-open(
{continue:true})을 반환해야 하는 이유는? fail-closed면 어떤 문제가 생기나? - 같은 패턴이
allow와ask에 동시에 있으면 어느 쪽이 이기며, 그 순서가 왜 안전한가?
연결
[!tip] Codex 교차검증 (원문 노트에 별도 Codex 교차검증 섹션이 없었음 — 재작성 시 신규 사실을 추가하지 않고 보존 차원에서 비워 둠. 추후 검증 결과가 생기면 이 콜아웃에 누적한다.)