OMC (oh-my-claudecode) · 가드레일 (PreToolUse 강제 + 권한 핸들러 + 워커 RBAC advisory)
OMC (oh-my-claudecode) · 가드레일 (PreToolUse 강제 + 권한 핸들러 + 워커 RBAC advisory)
한 줄 요약
OMC 가드레일은 AI가 도구를 쓰기 직전·직후에 끼어드는 두 층의 안전장치다. 1층(훅)은 진짜로 막고, 2층(워커 권한)은 부탁만 한다. 둘을 헷갈리면 “막힐 거라 믿고” 위험한 자동화를 풀어버리는 사고가 난다 — 그래서 배운다.
그림
flowchart TD
subgraph L1["1층 · 메커니컬 훅 · 진짜로 막는다"]
A[모델이 도구 호출] --> B{"PreToolUse<br/>pre-tool-enforcer"}
B -- deny --> C["실제 차단 + 사유 반환"]
B -- additionalContext --> D["조언만 주입 · 반복 억제"]
B -- 통과 --> E{"도구가 Bash인가?"}
E -- 예 --> F{"PermissionRequest<br/>permission-handler"}
F -- 안전 패턴 --> G[자동 승인]
F -- 그 외 --> H[사용자 승인 팝업]
E -- 아니오 --> I[도구 실행]
G --> I
I --> J{"PostToolUse<br/>post-tool-verifier"}
J --> K["실패 감지 · 경고 주입"]
end
subgraph L2["2층 · advisory 워커 권한 · 부탁만 한다"]
P[WorkerPermissions 규칙] --> Q[권한을 문장으로 변환]
Q --> R[워커 프롬프트에 텍스트로 삽입]
R --> S[워커가 full-auto로 실행]
S --> T[끝난 뒤 파일 비교로 위반 적발]
T -- enforce 모드 --> U["태스크 실패 처리 + 기록<br/>손은 못 막음"]
end
쉽게 풀기
가드레일을 건물 출입 통제로 비유하면 두 층이 선명해진다.
flowchart LR
subgraph 일층["1층 · 보안 게이트"]
direction TB
G1["차단봉 = Node 훅"] --> G2[우겨도 못 지나감]
end
subgraph 이층["2층 · 안내문"]
direction TB
N1[벽에 붙인 행동수칙] --> N2[무시하면 사후 적발만]
end
1층 = 진짜 보안 게이트(메커니컬, 훅). AI가 “파일을 쓰겠다”·“이 명령을 돌리겠다” 할 때마다 차단봉(작은 Node 스크립트)이 먼저 통과 여부를 결정한다.
pre-tool-enforcer— 도구 실행 직전. 규칙 위반(예: 잘못된 모델 라우팅)이면deny로 실제 차단, 알리고 싶은 정도면 “조언 쪽지”만 끼운다.permission-handler— 배시 전용 게이트.git status,npm run lint같은 “안전 패턴”이면 팝업 없이 자동 통과. 단;&&|같은 위험 기호가 하나라도 있으면 즉시 탈락.post-tool-verifier— 실행 직후 결과를 보고 “실패했어, 고쳐”라고 일러준다.
1층은 진짜 차단봉이라 AI가 우겨도 못 지나간다.
2층 = 사내 행동수칙 안내문(advisory, 워커 권한). 여러 워커를 팀으로 띄울 때 “너는 이 폴더만 만져라” 규칙을 준다. 하지만 이건 차단봉이 아니라 벽에 붙인 안내문이다. 소스 주석에 명시돼 있다 — “MCP 워커는 full-auto라 기계적 제한 불가, 권한은 프롬프트 지시문으로 넣어 LLM이 따르도록 유도할 뿐.”
즉 워커가 다른 폴더를 건드려도 실시간으로 손을 못 잡는다. CLI 워커는 실행 전후 스냅샷을 비교해 끝난 뒤 “규칙 어겼네” 하고 사후 벌점만 줄 수 있다.
[!note] 한 문장으로 “막는 가드레일은 훅(1층), 부탁하는 가드레일은 워커 권한(2층).” 진짜 차단이 필요하면 반드시 훅 층에서.
핵심 정리
| 구분 | 1층 · 훅 (메커니컬) | 2층 · 워커 권한 (advisory) |
|---|---|---|
| 막는 힘 | 진짜 차단 (deny) | 못 막음, 부탁만 |
| 작동 | 도구 호출을 가로챔 | 프롬프트에 문장 삽입 |
| 위반 처리 | 실행 자체 차단 | 끝난 뒤 적발·기록 |
핵심 신호 3가지만 기억하면 된다.
-
permissionDecision: 'deny'— PreToolUse에서 실제로 막는 신호 -
additionalContext— 막지 않고 조언만 다음 턴에 주입 -
decision.behavior: 'allow'— PermissionRequest에서 배시 자동 승인
[!note]- 펼쳐보기: 호출자가 못 덮는 보안 기본 거부 목록 워커 권한이 advisory라도, 아래 경로는 코드가 항상 맨 앞에 prepend해 보호한다.
.git/**,.env*,**/.env*,**/secrets/**,**/.ssh/**,**/node_modules/.cache/**
[!note]- 펼쳐보기: 안전장치 만들 때 반드시 지킬 7가지
- fail-open: 어떤 예외든 catch에서
{continue:true}(훅이 세션을 죽이면 안 됨)- 차단은
permissionDecision:'deny'+사유, 조언은additionalContext로 분리- 배시 자동허용은 위험 셸 기호 전면 거부 후에만 화이트리스트 적용
- 워커 권한은 advisory임을 명시 — 진짜 차단은 훅 층에서
- glob 매칭은 정규식 대신 문자단위 매처로 (ReDoS 회피)
- 같은 조언 반복은 해시+쿨다운으로 억제
- 킬스위치
DISABLE_OMC/OMC_SKIP_HOOKS존중
실제 예시
두 스키마 한눈에
flowchart TD H[hooks.json] --> H1["matcher → command → timeout"] H1 --> H2[node run.cjs 경유로 훅 실행] W[WorkerPermissions] --> W1["allowed/denied Paths · allowedCommands · maxFileSize"] W1 --> W2["formatPermissionInstructions → 프롬프트 문장"]
[!note]- 펼쳐보기: 전체 필드표 (훅 등록 · 워커 권한) 형식 1 — 훅 등록 스키마 (
hooks/hooks.json)
필드 타입 설명 hooks.<EventName>object[] 이벤트별 훅 그룹 ( PreToolUse,PermissionRequest,PostToolUse등)matcherstring 도구 필터. "*"=전체,"Bash"=배시만hooks[].commandstring 항상 node run.cjs <hook>.mjs(크로스플랫폼 런처 경유)hooks[].timeoutnumber 초 단위 (PreToolUse=3, PermissionRequest=5) 형식 2 — 워커 권한 스키마 (
WorkerPermissions)
필드 타입 설명 allowedPathsstring[] 수정 허용 glob. 빈 배열=전체 허용 deniedPathsstring[] 거부 glob (allowed보다 우선). 보안 기본값 항상 prepend allowedCommandsstring[] 허용 명령 접두사. 빈 배열=전체 maxFileSizenumber 파일당 최대 바이트. 기본 Infinity
[!note]- 펼쳐보기: 강제 불가의 근거 (파일 최상단 주석 전문)
// src/team/permissions.ts /** * RBAC-compatible advisory permission scoping for workers. * * NOTE: This is an advisory layer only. MCP workers run in full-auto mode * and cannot be mechanically restricted. Permissions are injected into * prompts as instructions for the LLM to follow. */
핵심 코드 ① — PreToolUse가 실제로 deny하는 지점
// scripts/pre-tool-enforcer.mjs (main 내부)
const ultragoalDenyReason = evaluateUltragoalPreToolEnforcement(stateDir, directory, sessionId, data);
if (ultragoalDenyReason) {
console.log(JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny', // ← 진짜 차단
permissionDecisionReason: ultragoalDenyReason
}
}));
return;
}
핵심 코드 ② — 배시 자동허용 + 셸 메타문자 1차 관문
안전 판정의 1차 관문은 셸 메타문자 전면 거부다. ; && | 가 하나라도 있으면 패턴을 보기도 전에 탈락한다.
// src/hooks/permission-handler/index.ts — 명령 체이닝/인젝션 차단
const DANGEROUS_SHELL_CHARS = /[;&|`$()<>\n\r\t\0\\{}\[\]*?~!#]/;
export function isSafeCommand(command: string): boolean {
const trimmed = command.trim();
if (DANGEROUS_SHELL_CHARS.test(trimmed)) return false; // ; && | 등 있으면 즉시 탈락
return SAFE_PATTERNS.some(pattern => pattern.test(trimmed));
}
[!note]- 펼쳐보기:
processPermissionRequest전문 (자동 승인 흐름)// src/hooks/permission-handler/index.ts export function processPermissionRequest(input: PermissionRequestInput): HookOutput { const toolName = input.tool_name.replace(/^proxy_/, ''); if (toolName !== 'Bash') return { continue: true }; const command = input.tool_input.command; if (!command || typeof command !== 'string') return { continue: true }; const shouldAskBashPermission = hasClaudePermissionAsk(input.cwd, 'Bash', command); // Auto-allow safe commands if (!shouldAskBashPermission && isSafeAutoApprovedCommand(command, input.cwd)) { return { continue: true, hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', reason: 'Safe read-only or test command' }, }, }; } return { continue: true }; // 그 외엔 네이티브 승인 흐름에 맡김 }
핵심 코드 ③ — advisory 권한 → 프롬프트 텍스트 (강제 아님의 증거)
워커 권한은 결국 워커 프롬프트에 붙는 ‘문장’으로 변환된다. 이 변환 자체가 “기계적 차단이 아니다”의 증거다.
// src/team/permissions.ts — 권한을 문장으로
export function formatPermissionInstructions(permissions: WorkerPermissions): string {
const lines: string[] = ['PERMISSION CONSTRAINTS:'];
if (permissions.allowedPaths.length > 0)
lines.push(`- You may ONLY modify files matching: ${permissions.allowedPaths.join(', ')}`);
if (permissions.deniedPaths.length > 0)
lines.push(`- You must NOT modify files matching: ${permissions.deniedPaths.join(', ')}`);
// ...allowedCommands / maxFileSize 동일 패턴
return lines.join('\n'); // ← 결국 프롬프트에 붙는 '문장'일 뿐
}
[!note]- 펼쳐보기: ReDoS-safe glob matcher 전문 + 보안 기본 거부값 정규식 대신 문자단위 백트래킹으로 ReDoS를 회피한다. 호출자가 못 덮는
SECURE_DENY_DEFAULTS와 함께 쓰인다.// src/team/permissions.ts /** * Simple glob matching for path patterns. * Supports: * (any non-/ chars), ** (any depth including /), ? (single non-/), exact. * Uses iterative character-by-character matching to avoid ReDoS risk from regex. */ function matchGlob(pattern: string, path: string): boolean { let pi = 0, si = 0, starPi = -1, starSi = -1; while (si < path.length) { if (pi < pattern.length - 1 && pattern[pi] === '*' && pattern[pi + 1] === '*') { pi += 2; // '**' = 모든 깊이(/ 포함) if (pi < pattern.length && pattern[pi] === '/') pi++; starPi = pi; starSi = si; continue; } if (pi < pattern.length && pattern[pi] === '*') { // '*' = / 제외 임의 pi++; starPi = pi; starSi = si; continue; } if (pi < pattern.length && pattern[pi] === '?' && path[si] !== '/') { pi++; si++; continue; } if (pi < pattern.length && pattern[pi] === path[si]) { pi++; si++; continue; } if (starPi !== -1) { // 불일치 → 마지막 별표로 백트랙 pi = starPi; starSi++; si = starSi; const wasSingleStar = starPi >= 2 && pattern[starPi - 2] === '*' && pattern[starPi - 1] === '*' ? false : starPi >= 1 && pattern[starPi - 1] === '*' ? true : false; if (wasSingleStar && si > 0 && path[si - 1] === '/') return false; // '*'는 / 못 넘음 continue; } return false; } while (pi < pattern.length) { // 남은 패턴이 트레일링 */** 면 OK if (pattern[pi] === '*' || pattern[pi] === '/') pi++; else break; } return pi === pattern.length; } const SECURE_DENY_DEFAULTS: string[] = [ '.git/**', '.env*', '**/.env*', '**/secrets/**', '**/.ssh/**', '**/node_modules/.cache/**', ];
[!note]- 펼쳐보기: 직접 만드는 최소 PreToolUse 훅 (
my-enforcer.mjs+ 등록 조각)// my-enforcer.mjs — 최소 차단 훅 예시 #!/usr/bin/env node import { readStdin } from './lib/stdin.mjs'; // stdin 타임아웃 보호 async function main() { // 킬스위치 존중 const skip = (process.env.OMC_SKIP_HOOKS || '').split(',').map(s => s.trim()); if (process.env.DISABLE_OMC === '1' || skip.includes('pre-tool-use')) { console.log(JSON.stringify({ continue: true })); return; } try { const data = JSON.parse(await readStdin()); const toolName = data.tool_name || data.toolName || ''; const input = data.tool_input || data.toolInput || {}; // 예: rm -rf / 형태 차단 if (toolName === 'Bash' && /\brm\s+-rf\s+\/(?!\w)/.test(input.command || '')) { console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Refusing destructive rm -rf / — confirm scope first.' } })); return; } // 차단 안 할 땐 조언만 console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Verify before destructive ops.' } })); } catch { console.log(JSON.stringify({ continue: true, suppressOutput: true })); // 항상 fail-open } } main();// hooks.json 조각 — 위 훅 등록 { "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/my-enforcer.mjs", "timeout": 3 } ] } ] }
요약 & 셀프체크
3줄 요약:
- 가드레일은 두 층 — 1층 훅은 도구 호출을 진짜 차단, 2층 워커 권한은 프롬프트에 문장으로 부탁만 한다.
- 차단=
permissionDecision:'deny', 조언=additionalContext, 배시 자동허용=decision.behavior:'allow'— 단 위험 셸 기호가 있으면 무조건 탈락. - 워커 권한은 advisory라 실시간 차단 불가, 사후 적발만 가능 — 진짜 차단은 훅 층에서.
스스로 답해보기:
- 워커에게 “이 폴더만”이라 줬는데 다른 폴더가 수정됐다. 왜 못 막았나? 어떻게 진짜로 막나?
git status && rm -rf .는 배시 자동허용을 통과할까, 탈락할까? 이유는?- 훅 예외 시 왜
{continue:true}를 반환해야 하나? (fail-open이 안전한 이유)
근거 파일
/home/seunghyeong/harness-work/oh-my-claudecode/hooks/hooks.json— PreToolUse(*)/PermissionRequest(Bash)/PostToolUse 등록,run.cjs경유/home/seunghyeong/harness-work/oh-my-claudecode/scripts/pre-tool-enforcer.mjs—permissionDecision:'deny'실제 차단, slop 경고, 모델 라우팅 강제, advisory throttle, fail-open/home/seunghyeong/harness-work/oh-my-claudecode/scripts/permission-handler.mjs— 얇은 래퍼;dist/hooks/permission-handler/index.js호출/home/seunghyeong/harness-work/oh-my-claudecode/src/hooks/permission-handler/index.ts—processPermissionRequest,DANGEROUS_SHELL_CHARS,SAFE_PATTERNS,isSafeAutoApprovedCommand/home/seunghyeong/harness-work/oh-my-claudecode/scripts/post-tool-verifier.mjs— 실행 후 실패/배경작업 감지, compaction 경고,additionalContext주입/home/seunghyeong/harness-work/oh-my-claudecode/src/team/permissions.ts—WorkerPermissions, advisory 주석, ReDoS-safematchGlob,SECURE_DENY_DEFAULTS,formatPermissionInstructions,findPermissionViolations/home/seunghyeong/harness-work/oh-my-claudecode/src/team/mcp-team-bridge.ts—buildEffectivePermissions, 프롬프트 빌드, 사후 snapshot diff →findPermissionViolations→ enforce 모드 실패 처리/home/seunghyeong/harness-work/oh-my-claudecode/scripts/run.cjs— 크로스플랫폼 Node 훅 런처