3계층 하네스 해부

서브에이전트 정의와 PreToolUse 게이트, 정책 문서를 묶어 하네스로 만든 구조와 판정 순서

메인 세션의 편집을 막으려면 작업을 넘겨받을 대상과 위임 기준도 필요했다. 하네스에는 역할을 나눈 서브에이전트, 메인 세션의 편집을 제한하는 PreToolUse 게이트, 위임 기준을 적은 정책 문서를 함께 뒀다.

flowchart TB
  accTitle: 오케스트레이션 하네스의 세 계층
  accDescr: 정책 문서가 메인 세션에 라우팅 근거를 주고, PreToolUse 게이트가 메인 세션의 편집을 제한하며, 게이트를 면제받는 세 서브에이전트와 Codex가 실제 구현과 검토를 맡는다.
  POLICY[정책 계층<br/>orchestration.md·CLAUDE.md] --> MAIN[메인 세션<br/>계획·분해·위임·종합]
  MAIN --> GATE{PreToolUse 게이트<br/>요청당 2회·크기 제한}
  GATE -->|차단| DELEGATE[위임하라는 거부 메시지]
  DELEGATE --> MAIN
  GATE -->|통과| SMALL[사소한 편집 직접 처리]
  MAIN --> DR[deep-reasoner<br/>쓰기 도구 없음·설계와 검토]
  MAIN --> DW[default-worker<br/>쓰기 전체·구현]
  MAIN --> TW[task-worker<br/>Bash 없음·기계적 작업]
  MAIN --> CX[Codex<br/>대량 생성]
  DR -.게이트 면제.-> FILES[(저장소 파일)]
  DW -.게이트 면제.-> FILES
  TW -.게이트 면제.-> FILES
  CX --> FILES

세 에이전트의 도구 권한

Claude Code는 ~/.claude/agents/에 둔 마크다운 파일로 서브에이전트를 정의한다. frontmatter에 이름, 설명, 사용할 모델과 도구를 적고 본문에 역할을 쓴다. 세 개를 뒀다.

deep-reasoner는 설계와 트레이드오프 판단, 어려운 디버깅, 그리고 Codex가 쓴 코드의 리뷰를 맡는다. 추론에 강한 모델을 쓰고 Read, Grep, Glob, Bash를 준다. default-worker는 위임받은 구현을 끝까지 수행한다. 코드를 읽고, 고치고, 테스트를 돌린다. 쓰기 도구를 전부 준다. task-worker는 포맷팅, 리네임, 단순 설정 변경을 맡는다. 가장 가벼운 모델을 쓰고 Bash는 주지 않는다.

두 에이전트의 제한 방식은 다르다. task-worker는 도구 목록에서 Bash를 제외했다. deep-reasoner에는 Edit과 Write가 없지만 Bash가 있으므로 파일을 쓸 수 있다. 구현하지 말라는 제한은 정의 본문의 지침에 의존한다.

You do not implement: return analysis and a recommendation; the
orchestrator routes implementation to a worker.

git diff를 읽어야 검토가 되므로 Bash를 뺄 수는 없었다. task-worker의 Bash 실행은 도구 목록이 막지만 deep-reasoner의 파일 쓰기 금지는 지침에 의존한다. 게이트를 만들 때도 두 종류를 따로 다뤘다.

메인 세션은 작업을 분류할 때 각 정의의 설명을 읽고 맞는 워커로 보낸다. deep-reasoner의 설명은 이렇게 시작한다.

Use when a task needs hard design or architecture reasoning, tradeoff
analysis, non-trivial debugging that requires sustained thought, or
reviewing Codex-authored diffs.

파일 게이트의 판정 순서

훅은 셋이다. orch-file-gate.sh는 Edit·Write 도구 호출을 보고, orch-bash-gate.sh는 셸 명령이 파일을 쓰려 하는지 보고, orch-gate-reset.sh는 요청 경계에서 카운터를 지운다. 앞의 둘이 PreToolUse, 마지막이 UserPromptSubmit에 걸린다.

파일 편집을 막는 orch-file-gate.sh는 stdin으로 받은 도구 호출 JSON에서 다섯 가지를 뽑는다. 호출한 주체(agent_id), 세션 식별자, 대상 파일 경로, 쓰려는 내용의 줄 수와 바이트 수다. 바이트 게이트는 기본값이 꺼져 있어서 실제 판정에 쓰인 것은 앞의 넷이다. jq가 있으면 jq로, 없으면 python3로 파싱한다. 둘 다 없으면 차단으로 끝낸다.

if command -v jq >/dev/null 2>&1; then
  _parsed=$(printf '%s' "$input" | jq -r '...' 2>/dev/null)
elif command -v python3 >/dev/null 2>&1; then
  _parsed=$(printf '%s' "$input" | python3 -c '...' 2>/dev/null)
else
  printf 'orchestration gate: neither jq nor python3 found; failing closed. Install jq to proceed.\n' >&2
  exit 2
fi

판정할 수 없는 상황을 차단하는 방식은 fail-closed다. 파서를 찾지 못한 상태에서도 편집을 허용하지 않도록 했다.

게이트는 위에서부터 이 순서로 확인한다.

  1. 킬스위치. ORCH_GATE_OFF=1이나 ~/.claude/orch-gate-off 파일이 있으면 즉시 통과한다. stdin을 읽기 전에 확인하므로 페이로드가 깨져도 복구할 수 있다.
  2. 파싱. 실패하면 차단한다.
  3. 서브에이전트 면제. agent_id가 있으면 통과한다. 이 필드는 호출이 서브에이전트 안에서 났을 때만 들어온다.
  4. 인프라 보호. 메인 세션이 게이트 스크립트나 설정 파일을 고치려 하면 확장자와 무관하게 차단한다.
  5. 코드 파일 필터. 대상이 코드 확장자가 아니면 통과한다.
  6. 크기 게이트. 100줄이 넘는 단일 쓰기는 차단한다. 카운터를 소비하기 전에 확인해서 큰 쓰기가 슬롯을 먹지 않게 한다.
  7. 카운터. 여기까지 온 편집을 하나 세고, 요청당 두 번을 넘으면 차단한다.
flowchart TD
  accTitle: 파일 게이트의 판정 순서
  accDescr: 킬스위치, 파싱, 서브에이전트 면제, 인프라 보호, 코드 확장자 필터, 크기 게이트, 카운터 순으로 확인하며 판정할 수 없는 경우는 모두 차단으로 보낸다.
  IN[Edit·Write 호출] --> KILL{킬스위치 켜짐}
  KILL -->|예| PASS([통과])
  KILL -->|아니오| PARSE{JSON 파싱 성공}
  PARSE -->|실패| BLOCK([차단])
  PARSE -->|성공| AGENT{agent_id 있음}
  AGENT -->|예 서브에이전트| PASS
  AGENT -->|아니오 메인 세션| INFRA{게이트 인프라 경로}
  INFRA -->|예| BLOCK
  INFRA -->|아니오| CODE{코드 확장자}
  CODE -->|아니오 문서·설정| PASS
  CODE -->|예| SIZE{제한 줄 수 초과}
  SIZE -->|예| BLOCK
  SIZE -->|아니오| COUNT{요청당 편집 2회 초과}
  COUNT -->|예| BLOCK
  COUNT -->|아니오| PASS

서브에이전트 면제가 코드 필터보다 앞에 있다. 워커는 어떤 파일이든 제한 없이 고치고, 게이트는 메인 세션 한 곳만 조인다. 순서를 바꾸면 워커까지 걸린다.

요청 하나 단위로 세기

카운터는 세션별 파일에 저장한다. 그대로 두면 편집 횟수가 세션 내내 쌓여서 두 번째 요청부터는 아무것도 못 고친다. 세 번째 훅이 여기에 붙는다. orch-gate-reset.sh는 UserPromptSubmit에 걸려서 사용자가 새 프롬프트를 보낼 때마다 그 세션의 카운터를 지운다.

리셋 훅은 파서가 없거나 JSON이 깨지면 카운터를 지우지 않는다. 이 경우 다음 요청에서 편집이 더 일찍 차단된다. 반대로 파일 게이트가 파싱 실패를 통과시키면 편집 제한을 적용할 수 없다.

카운터를 세는 데는 잠금이 필요하다. 한 요청에서 편집 도구가 연달아 불릴 때 카운터 파일을 동시에 건드리면 값이 어긋난다. macOS 기본 bash는 3.2라 flock이 없어서 디렉터리 생성의 원자성을 잠금으로 썼다.

until mkdir "$lock_dir" 2>/dev/null; do
  _lock_tries=$((_lock_tries + 1))
  if [ "$_lock_tries" -ge 25 ]; then
    # Stale lock from a crashed hook - remove and take it.
    rm -rf "$lock_dir"
    if ! mkdir "$lock_dir" 2>/dev/null; then
      printf 'orchestration gate: could not acquire counter lock; failing closed.\n' >&2
      exit 2
    fi
    break
  fi
  sleep 0.2
done

# Release lock on every exit path, including blocked exits.
trap 'rm -rf "$lock_dir"' EXIT

mkdir은 이미 있는 디렉터리에 실패한다. 이 성질을 잠금으로 쓰고, 5초쯤 기다려도 안 풀리면 크래시로 남은 잠금으로 보고 회수한다. trap은 차단 종료를 포함한 모든 경로에서 잠금을 푼다.

차단 뒤의 행동을 정하는 문서

게이트는 금지된 호출과 거부 이유를 알려준다. 차단 뒤에 작업을 누구에게 넘길지는 정책 문서에 적었다.

~/.claude/rules/orchestration.md에 메인 세션의 역할과 토큰 경제에 따른 라우팅, 그리고 두 AI가 서로의 코드를 검토하는 규칙을 적었다. CLAUDE.md는 이 정책을 요약해 세션이 항상 보게 한다. 게이트에 걸린 세션이 우회로 대신 워커를 띄우는 것은 이 문서가 위임이라는 선택지를 미리 알려줬기 때문이다.

설치하고 발동 확인하기

실제로 쓴 게이트 세 개와 42개 테스트를 번들로 묶어 뒀다. 받아서 사용자 스코프에 설치한다.

curl -fsSLO https://juntiger-assets.pages.dev/agent-harness-gates.zip
unzip -q agent-harness-gates.zip
mkdir -p ~/.claude/hooks
cp agent-harness-gates/orch-*.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/orch-*.sh

settings.example.json의 hooks 키를 ~/.claude/settings.json에 병합하고 세션을 새로 연다. 메인 세션에서 코드 파일을 세 번 연속 고쳐보면 세 번째 편집에서 도구 호출이 막힌다. 잠시 끄려면 touch ~/.claude/orch-gate-off, 다시 켜려면 그 파일을 지운다.

게이트의 판정은 몇 가지 가정에 의존한다. agent_id가 있으면 서브에이전트다, 확장자가 코드면 코드 파일이다, 줄 수를 세면 크기를 안다. 가정에서 벗어난 호출은 차단되지 않고 통과한다. 이 가정들을 어디까지 우회할 수 있는지 확인하려고 Codex에 공격 경로 탐색을 맡겼다.

참고

Comments

댓글

    이미지 확대