도구 실행 파이프라인: 3단 폭포와 단조 Guard
pre-execute에서 post-execute까지 3단 관선, Guard는 조일 수만 있고 허용할 수 없습니다. 핵심 소스:packages/core/tools/src/index.ts。
packages/core/tools/src/index.ts와 docs/tool-execution-pipeline.zh.md에 대응합니다. 플레이할 때 하나만 보세요: 어느 한 단계가 거부 이유를 주면 뒤에서는 아무도 뒤집지 못합니다.먼저 문제를 말하겠습니다. 권한 검사, 사람 승인, 타임아웃, 결과 개서, UI 렌더 — 전부 도구 실행이라는 한 동작에 걸고 싶어합니다. 도구마다 스스로 처리하면 도구 40개에 권한 코드 40벌입니다. DSH는 도구 실행을 파이프라인으로 만들고, 정책은 고정 스테이션에 두며, 도구 본체는 한 일만 합니다: 실행하고 값을 반환.
파이프라인 순서는 docs/tool-execution-pipeline.zh.md 8행에 있습니다: tools/pre-execute가 먼저, 그다음 단조 가드, 이어서 tools/execute와 tools/post-execute. 폭포(waterfall)는 DSH의 리스너 대기열입니다: 각 리스너가 (exec, next)를 받고 next()로 결정권을 넘기거나, 결정을 바로 반환해 현장에서 확정합니다.
3단 분담은 분명합니다. 1단 pre-execute는 도구가 돌기 전에 표결하고, 반환은 allow·deny·ask(사람 승인)뿐입니다. ask는 승인 서비스의 allowed-once를 받아야 계속하고, 승인 채널이 없으면 deny로 칩니다. 2단 execute는 감싸기: 타임아웃·재시도·지표가 실제 실행을 감싸며, 취소 신호는 바꿀 수 있어도 호출 신원은 못 바꿉니다. 3단 post-execute는 결과가 나온 뒤 검사: 그대로 수락, 내용 교체, 값 교체, 또는 block으로 교정 에러로 바꿉니다.
거부는 침묵이 아님deny된 호출은 Error: 이유의 isError 결과로 물화되고, post-execute와 tools/result도 그대로 탑니다. 모델은 왜 거부됐는지 보고, 루프는 거부 한 번에 멈추지 않습니다.
파라미터는 못 바꿈pre-execute는 거부할 수 있어도 파라미터를 개서하지는 못합니다. tool/call 이벤트가 실행 전에 로그에 남고, UI의 대기 카드도 원 파라미터로 이미 렌더됐기 때문에, 바꾸면 역사·화면·실행이 어긋납니다(index.ts 583–586행 타입 주석이 이 제외를 명시).
Guard는 동기 최종 심의Guard는 pre-execute가 모두 표결한 뒤, 도구 본체 전에 돌며 시그니처는 동기 함수입니다: 문자열이면 거부 이유, undefined면 기권. 전역 Guard를 먼저 묻고, agent 스코프 체인을 먼 쪽에서 가까운 쪽으로 묻습니다(index.ts 1118–1127행).
경계 문제부터: pre-execute 리스너 둘 — 하나는 allow, 하나는 ask — 누구 말을 듣나? 앞에 선 쪽입니다. 폭포는 숏서킷이라 next()를 안 부르고 결정을 반환한 첫 리스너가 확정합니다. 그래서 pre-execute는 본질적으로 순서에 민감하고, 플러그인 로드 순서가 바뀌면 보안 결론도 바뀔 수 있습니다.
DSH의 해법은 pre-execute 뒤에 순서에 둔감한 최종 심의를 두는 것입니다. Guard 반환 타입은 두 가지뿐입니다: 문자열(거부 이유) 또는 undefined(기권). 동의를 표현하는 반환값은 없습니다. Guard를 열 개 등록하든 백 개든, 어떻게 늘어놓아도 결론은 더 엄해질 수만 있고 느슨해질 수는 없습니다. 타입 정의가 증거입니다:
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
* @param execution - the identity-protected call after extensible pre-execute policy completed.
* @returns a final denial reason, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
packages/core/tools/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.주석의 그 문장은 옮겨 적을 만합니다: “guards have no allow result, listener ordering cannot turn a denial back into permission.” 거부된 호출을 허용하려는 악의적 플러그인은 런타임 방어가 필요 없습니다 — 타입 시스템에 그 동작을 쓸 수 없거든요. 런타임에 허용 권한을 검사하는 것보다 깔끔하고, 문제 부류 자체가 사라집니다.
거부 뒤에 무엇이 일어나는지 봅시다. 스케줄러의 확정 로직은 두 단계입니다: pre-execute 결정이 allow(승인된 ask 포함)일 때만 Guard가 하나씩 표결하고; pre-execute 거부 이유와 Guard 거부 이유는 같은 변수로 모이며, 어느 쪽이든 이유를 주면 호출은 그 자리에서 Error: 이유 에러 결과로 물화됩니다. 도구 본체는 손도 대지 않지만, 이 결과는 post-result 표시를 달고 post-execute와 최종 관찰자에게 계속 넘어갑니다.
그래서 감사 플러그인·컨텍스트 주입 플러그인은 거부 장면에서도 평소처럼 일하고, 거부는 파이프라인 나머지에게 그저 보통 결과입니다. 도구 예외와 UNKNOWN_TOOL도 같은 정규화 경로를 탑니다.
출처: 확정과 물화는 packages/core/tools/src/index.ts 1486–1499행, 예외와 UNKNOWN_TOOL 정규화는 1546–1555행, 확인일 2026-08-13.
Claude Code는 권한 판단을 Tool 인터페이스 메서드에 흩뿌립니다: 도구마다 checkPermissions·validateInput·isReadOnly를 갖고, BashTool은 화이트리스트와 ML 분류기까지 이읍니다(study/chapters/02-tool-system.md 350–376행). 외장 확장은 PreToolUse / PostToolUse hooks. 흥미로운 점은 DSH가 CC hooks 브리지 플러그인 packages/hooks/hooks-claude-code를 직접 구현해 CC hook을 DSH 폭포에 걸어 돌리며, 브리지 문서가 프로토콜 차이 두 가지를 슬쩍 드러낸다는 것입니다:
「PreToolUse는 일부만 지원:deny와ask결정은 가능;allow는 사전 승인하지 않고,defer는 미지원,additionalContext는 무시되며,updatedInput은 기록+경고만 하고 적용하지 않음」
이 두 제한에는 다른 이유도 있습니다: 파이프라인 불변식이 길을 막고 있어요. CC 네이티브 hook은 allow 사전 승인과 updatedInput으로 도구 파라미터 개서가 가능하지만, DSH 브리지는 전자를 강등하고 후자는 로그만 남기며 실행하지 않습니다 — DSH에서 허용권은 빌려주지 않고, 파라미터는 tool/call 로그 이후 불변입니다. 같은 CC hook 설정이 호스트만 바꿔도 할 수 있는 일이 줄어드니, 두 프로토콜의 표현력 경계를 재는 셈입니다. 또한 여러 CC hook은 브리지에서 가장 엄격하게 접히고(deny > ask > allow), 접힘 결과는 순서와 무관합니다(README 49행) — Guard의 단조 사고와 한 줄기입니다.
Grok Build의 hooks 시스템(crates/codegen/xai-grok-hooks)은 pre_tool_use 한 지점만 막을 수 있고, 결정 타입은 Allow 또는 Deny 두 값이며(src/result.rs 5–10행), 모듈 주석이 실패 의미를 바로 적습니다:
“-pre_tool_usehooks can deny/allow (blocking); all others are non-blocking
- Fail-open by default: hook failures do not block normal operation”
Fail-open은 hook 자신이 죽거나 타임아웃해도 호출이 그대로 통과한다는 뜻입니다. DSH는 반대로: pre-execute 리스너가 예외를 던지면 이번 호출을 바로 에러 결과로 정규화합니다 — 차라리 과잉 차단. 둘 다 말이 됩니다. Grok은 hooks를 외장 강화로 보고 사용자 스크립트가 주 흐름을 무너뜨리지 않게 하고; DSH는 정책을 파이프라인의 정식 스테이션으로 보아 스테이션이 무너지면 호출이 지나면 안 됩니다. Grok 도구 시스템의 레지스트리와 읽기 전용 의미는 사이트의 ToolKind가 기본 읽기 전용 의미를 제공 레슨에 온전히 풀어 두었습니다.
rm -rf 전체 경로를 손으로 추적
배포에 pre-execute 리스너 둘(먼저 CC hooks 브리지에 ask 규칙; 다음 화이트리스트 플러그인이 rm에 allow 반환)과 샌드박스 Guard 하나(워크스페이스 밖 쓰기 명령에 이유 반환)가 등록돼 있습니다. 모델이 bash: rm -rf /tmp/x를 냅니다. 1문: 승인 팝업이 뜨나? 2문: pre-execute 둘의 등록 순서를 바꾸면 답이 달라지나? 3문: 샌드박스 Guard 결론이 이 순서에 영향받나? 왜? (힌트: 폭포 숏서킷 + 1486행 denialReason은 allow 이후에만 Guard를 물음.)