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 워커는 실행 전후 스냅샷을 비교해 끝난 뒤 “규칙 어겼네” 하고 사후 벌점만 줄 수 있다.

한 문장으로

“막는 가드레일은 훅(1층), 부탁하는 가드레일은 워커 권한(2층).” 진짜 차단이 필요하면 반드시 훅 층에서.


핵심 정리

구분1층 · 훅 (메커니컬)2층 · 워커 권한 (advisory)
막는 힘진짜 차단 (deny)못 막음, 부탁만
작동도구 호출을 가로챔프롬프트에 문장 삽입
위반 처리실행 자체 차단끝난 뒤 적발·기록

핵심 신호 3가지만 기억하면 된다.

  • permissionDecision: 'deny' — PreToolUse에서 실제로 막는 신호
  • additionalContext — 막지 않고 조언만 다음 턴에 주입
  • decision.behavior: 'allow' — PermissionRequest에서 배시 자동 승인

실제 예시

두 스키마 한눈에

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 → 프롬프트 문장"]

핵심 코드 ① — 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));
}

핵심 코드 ③ — 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');   // ← 결국 프롬프트에 붙는 '문장'일 뿐
}

요약 & 셀프체크

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_개요 · _분석축_루브릭