지식위키

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. 가드레일은 두 층 — 1층 훅은 도구 호출을 진짜 차단, 2층 워커 권한은 프롬프트에 문장으로 부탁만 한다.
  2. 차단=permissionDecision:'deny', 조언=additionalContext, 배시 자동허용=decision.behavior:'allow' — 단 위험 셸 기호가 있으면 무조건 탈락.
  3. 워커 권한은 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.mjspermissionDecision:'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.tsprocessPermissionRequest, 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.tsWorkerPermissions, advisory 주석, ReDoS-safe matchGlob, SECURE_DENY_DEFAULTS, formatPermissionInstructions, findPermissionViolations
  • /home/seunghyeong/harness-work/oh-my-claudecode/src/team/mcp-team-bridge.tsbuildEffectivePermissions, 프롬프트 빌드, 사후 snapshot diff → findPermissionViolations → enforce 모드 실패 처리
  • /home/seunghyeong/harness-work/oh-my-claudecode/scripts/run.cjs — 크로스플랫폼 Node 훅 런처

연결

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