지식위키

내 패턴 · 가드레일: 권한·승인·샌드박스

내 패턴 · 가드레일: 권한·승인·샌드박스

한 줄 요약

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 검색대(가방을 실제로 열어 위험물 차단) ③ 게이트 앞 안내문(“라이터 금지” — 애초에 안 가져오게). 에이전트 가드레일도 이 세 겹이다.

층위정체비유 / 한계
① 정적 allowlistsettings.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보다 우선)
skipDangerousModePermissionPromptbooleantrue면 위험모드 진입 확인 생략
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) 거부
  • askallow보다 우선하도록 순서 검사
  • CLAUDE.md에 권한 분리 규칙(writer≠reviewer) 명시
  • 훅 deny 사유에 다음 행동 안내를 넣어 모델이 우회 경로를 찾게 함

요약 & 셀프체크

3줄 요약

  1. 가드레일은 정적 allowlist(빠르지만 무딤) + 훅 동적 판정(똑똑하지만 우회 가능) + 프롬프트 헌법(강제력 없음)을 겹친 3중 잠금장치다.
  2. 작동 시점이 다르다 — 헌법은 결정 전, allowlist는 결정 직후, 훅은 실행 직전. 훅만이 모델이 거부할 수 없는 런타임 강제력을 갖는다.
  3. 위험 셸문자는 패턴 매칭보다 먼저 전면 거부, 경로는 심링크 해소 후 레포 밖·민감경로(.env/.git/.ssh/secrets)를 막는다. 훅은 fail-open이어야 한다.

스스로 답해보기

  • Bash(sudo npm:*)sudo npm install은 통과시키고 sudo npmfoo는 막는 이유는?
  • 훅이 실패했을 때 fail-open({continue:true})을 반환해야 하는 이유는? fail-closed면 어떤 문제가 생기나?
  • 같은 패턴이 allowask에 동시에 있으면 어느 쪽이 이기며, 그 순서가 왜 안전한가?

연결

MINE_개요 · _분석축_루브릭

[!tip] Codex 교차검증 (원문 노트에 별도 Codex 교차검증 섹션이 없었음 — 재작성 시 신규 사실을 추가하지 않고 보존 차원에서 비워 둠. 추후 검증 결과가 생기면 이 콜아웃에 누적한다.)