From 8b08eafa31b71ae1b3167ed4ac492f06b10b7f47 Mon Sep 17 00:00:00 2001 From: Kirill Kit Date: Wed, 2 Sep 2026 01:07:50 +0300 Subject: [PATCH 1/2] =?UTF-8?q?gate:=20=D0=B3=D0=BB=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BB=D0=B5=D0=B3=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=201=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D1=81=D1=83?= =?UTF-8?q?=D0=B1=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все dk-роли имеют Bash, а DELEGATE_KIT_DEPTH получают только дети agent-run: нативный implementer мог сам вызвать `agent-run run` и завести воркера второго уровня. Хуки из settings.json срабатывают внутри субагентов и несут agent_type во входе — gate.sh отклоняет `agent-run run|resume` и `agent-wt lock`, когда это поле есть; префикс подтверждения этого не открывает. Под Codex-родителем нативные роли держат правило инструкцией, сказано в external.md. tests/gate.sh — 24 проверки, включая существующие паттерны gate, которые до сих пор были без тестов; шаг в CI. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 + skills/delegate-kit/hooks/gate.sh | 12 +++++ skills/delegate-kit/references/external.md | 2 +- skills/delegate-kit/references/hosts.md | 2 +- skills/delegate-kit/tests/gate.sh | 52 ++++++++++++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100755 skills/delegate-kit/tests/gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 620a482..04103a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: run: bash skills/delegate-kit/tests/caps.sh - name: Routing policy run: bash skills/delegate-kit/tests/route.sh + - name: Safety gate + run: bash skills/delegate-kit/tests/gate.sh - name: SKILL.md stays a policy, not a manual run: | size=$(wc -c < skills/delegate-kit/SKILL.md) diff --git a/skills/delegate-kit/hooks/gate.sh b/skills/delegate-kit/hooks/gate.sh index 6cce2c5..0254087 100755 --- a/skills/delegate-kit/hooks/gate.sh +++ b/skills/delegate-kit/hooks/gate.sh @@ -15,6 +15,18 @@ INPUT=$(cat) CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) [ -z "$CMD" ] && exit 0 +# Delegation depth is 1. External workers get DELEGATE_KIT_DEPTH from agent-run; a native +# subagent gets no such variable but its hook input carries agent_type (Claude Code sets +# agent_id/agent_type on tool events fired inside a subagent). Starting a worker or taking +# a worktree lock from there is denied outright — no confirmation prefix reopens it, so this +# check sits before the confirmation bypass below. +AGENT=$(printf '%s' "$INPUT" | jq -r '.agent_type // empty' 2>/dev/null || true) +if [ -n "$AGENT" ] && printf '%s' "$CMD" | grep -Eq '(^|[;&|(`[:space:]/])(agent-run[[:space:]]+(run|resume)|agent-wt[[:space:]]+lock)([[:space:]]|$)'; then + msg="delegate-kit gate: delegation depth is 1 — a worker ($AGENT) does not start workers or take worktree locks. Return what you have; the coordinator dispatches." + jq -cn --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}' + exit 0 +fi + # explicit confirmation prefix (visible in the approval UI / transcript) if printf '%s' "$CMD" | grep -Eq '^[[:space:]]*DELEGATE_KIT_CONFIRMED=1[[:space:]]'; then exit 0; fi [ "${DELEGATE_KIT_CONFIRMED:-}" = "1" ] && exit 0 diff --git a/skills/delegate-kit/references/external.md b/skills/delegate-kit/references/external.md index c143d64..e7de64f 100644 --- a/skills/delegate-kit/references/external.md +++ b/skills/delegate-kit/references/external.md @@ -54,7 +54,7 @@ Per-role defaults for one user, in the same file — the shipped table in `scrip ## Limits and safety -- **Delegation depth is 1.** `agent-run` disables subagents on both CLIs; the shipped `dk-*` definitions carry no `Agent` tool. +- **Delegation depth is 1.** `agent-run` disables subagents on both CLIs; the shipped `dk-*` definitions carry no `Agent` tool; and `hooks/gate.sh` denies `agent-run run|resume` and `agent-wt lock` from inside a Claude Code subagent, which it recognises by the `agent_type` field of the hook input. Under a Codex parent the native roles hold that line by their instructions only. - **Writer cap 3, ceiling 8; workers = writers + 3**, so a `panel` or `led` review fits beside a full set of writers. `agent-run run` counts external writers machine-wide plus the native locks (`agent-wt lock`) of the repository it writes into; `agent-wt lock` counts the locked worktrees of its repository. The cap is raised per task: the coordinator states the **partition** — one ticket per writer, disjoint write scopes — the user says yes, and `--max-writers N` on the run or the lock carries it (`DELEGATE_KIT_MAX_WRITERS` for the session; `DELEGATE_KIT_MAX_WORKERS` overrides the total, floored at writers + 1). The ceiling holds against every override; past it the work goes in waves. A run refused by a cap or a locked worktree fails before anything is spawned, `--detach` included: the parent prints the reason. A refusal that lands inside the supervisor (a race) is recorded as `failed`, so `status` shows it. Counting and taking a slot happen under one mutex, so concurrent starts respect the cap too. N sessions on one subscription hit the rate limit together; `--fallback none` keeps a fleet from all retrying on the other family at once. - **Writers** run in a worktree under the backend's own sandbox (`workspace-write` / `acceptEdits`); the dangerous modes are outside this skill. `agent-run` refuses a worktree locked for a native writer, and the reverse. - **Read-only roles** run under `codex -s read-only` / `claude --permission-mode plan` — the enforced boundary a native role lacks. When it matters (an untrusted diff, a risk zone), dispatch that role externally even inside the family. diff --git a/skills/delegate-kit/references/hosts.md b/skills/delegate-kit/references/hosts.md index 0020642..2763089 100644 --- a/skills/delegate-kit/references/hosts.md +++ b/skills/delegate-kit/references/hosts.md @@ -15,7 +15,7 @@ Serial is a dispatch mode, not a failure: one long-lived worker resumed with eac Choose external even inside the family when one matters: -- **An enforced sandbox.** External read-only roles run under `claude --permission-mode plan` / `codex -s read-only`. A native role is read-only by instruction and tool list — fine for a reviewer you dispatched yourself, thin as the boundary around an untrusted diff. +- **An enforced sandbox.** External read-only roles run under `claude --permission-mode plan` / `codex -s read-only`. A native role is read-only by instruction and tool list — fine for a reviewer you dispatched yourself, thin as the boundary around an untrusted diff. What the gate does enforce for a native role: no `agent-run run|resume`, no `agent-wt lock` (`external.md`). - **The strict result schema, ledger, run id, timeout, quota fallback.** All live in `agent-run`. - **The write-lock for free.** `agent-run --cwd` takes it; a native writer needs `agent-wt lock` before and `agent-wt release` after — the lock is also what the writer cap counts (`external.md`). diff --git a/skills/delegate-kit/tests/gate.sh b/skills/delegate-kit/tests/gate.sh new file mode 100755 index 0000000..ea0bb2b --- /dev/null +++ b/skills/delegate-kit/tests/gate.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Стенд hooks/gate.sh: что просит подтверждения, что пропускает, и правило +# глубины делегирования — субагент (agent_type во входе хука) не запускает +# воркеров и не берёт lock на worktree, префикс подтверждения этого не открывает. +# +# ./gate.sh +set -u +GATE="$(cd "$(dirname "$0")/../hooks" && pwd)/gate.sh" +LAST="${TMPDIR:-/tmp}/dk-gate-test.$$.json"; trap 'rm -f "$LAST"' EXIT +PASS=0; FAIL=0 +ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; } +# gate HARNESS AGENT_TYPE COMMAND → decision (allow | ask | deny); сырой ответ в $LAST для reason +gate(){ + jq -cn --arg c "$3" --arg a "$2" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}} + (if $a == "" then {} else {agent_type:$a} end)' \ + | DELEGATE_KIT_CONFIRMED= bash "$GATE" --harness "$1" > "$LAST" + [ -s "$LAST" ] || echo '{}' > "$LAST" + jq -r '.hookSpecificOutput.permissionDecision // "allow"' "$LAST" +} +reason(){ jq -r '.hookSpecificOutput.permissionDecisionReason // ""' "$LAST"; } + +echo "── опасные команды: ask на Claude, deny с инструкцией на Codex" +ok "rm -rf → ask" "$(gate claude "" 'rm -rf build')" "ask" +ok "причина названа" "$(reason | grep -c 'rm -rf')" "1" +ok "sudo → ask" "$(gate claude "" 'sudo systemctl restart nginx')" "ask" +ok "git push --force → ask" "$(gate claude "" 'git push --force origin main')" "ask" +ok "DROP TABLE → ask" "$(gate claude "" 'psql -c "DROP TABLE users"')" "ask" +ok "codex: deny с префиксом подтверждения" "$(gate codex "" 'rm -rf build')" "deny" +ok "codex: инструкция про DELEGATE_KIT_CONFIRMED" "$(reason | grep -c 'DELEGATE_KIT_CONFIRMED=1')" "1" +ok "префикс подтверждения пропускает" "$(gate codex "" 'DELEGATE_KIT_CONFIRMED=1 rm -rf build')" "allow" + +echo "── обычные команды проходят" +ok "ls" "$(gate claude "" 'ls -la')" "allow" +ok "git push без force" "$(gate claude "" 'git push origin feature')" "allow" +ok "rm одного файла" "$(gate claude "" 'rm build/out.txt')" "allow" +ok "не Bash-вход (нет command)" "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"}}' | bash "$GATE" --harness claude; echo "exit $?")" "exit 0" + +echo "── глубина делегирования: субагент не запускает воркеров" +ok "координатор: agent-run run проходит" "$(gate claude "" 'agent-run run --role reviewer --backend codex --brief b.md')" "allow" +ok "субагент: agent-run run → deny" "$(gate claude "dk-implementer" 'agent-run run --role reviewer --backend codex --brief b.md')" "deny" +ok "причина — depth 1 с именем агента" "$(reason | grep -c 'depth is 1.*dk-implementer')" "1" +ok "субагент: agent-run resume → deny" "$(gate claude "dk-planner" 'agent-run resume 2026-x --brief n.md')" "deny" +ok "субагент: agent-wt lock → deny" "$(gate claude "dk-implementer" 'agent-wt lock slice-a')" "deny" +ok "субагент: путь к скрипту тоже" "$(gate claude "dk-implementer" '~/.claude/skills/delegate-kit/scripts/agent-run run --role planner --brief b.md')" "deny" +ok "субагент: в цепочке команд" "$(gate claude "dk-implementer" 'cd /repo && agent-run run --role planner --brief b.md')" "deny" +ok "субагент: префикс подтверждения не открывает" "$(gate claude "dk-implementer" 'DELEGATE_KIT_CONFIRMED=1 agent-run run --role planner --brief b.md')" "deny" +ok "субагент на Codex тоже deny" "$(gate codex "dk-reviewer" 'agent-run run --role verifier --brief b.md')" "deny" +ok "субагент: status/list/wait проходят" "$(gate claude "dk-reviewer" 'agent-run status 2026-x; agent-run list; agent-wt status s')" "allow" +ok "субагент: agent-wt diff проходит" "$(gate claude "dk-reviewer" 'agent-wt diff slice-a > review.diff')" "allow" +ok "субагент: обычные команды проходят" "$(gate claude "dk-implementer" 'npm test')" "allow" + +echo; echo "Пройдено: $PASS, провалено: $FAIL" +exit $((FAIL > 0)) From 345beb68f4620840df3dd86568cbaf9570d43c81 Mon Sep 17 00:00:00 2001 From: Kirill Kit Date: Wed, 2 Sep 2026 01:15:50 +0300 Subject: [PATCH 2/2] =?UTF-8?q?gate:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8F=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D0=B2=D1=8B=D1=87=D0=BA=D0=B0=D1=85=20?= =?UTF-8?q?=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20env/node=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=20=D0=BF=D0=BE=D0=B4=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D0=BE=D0=BC=20=D0=B3=D0=BB=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По ревью PR #7 (Codex P1): "agent-run" run и '/path/agent-wt' lock проходили мимо шаблона, требовавшего пробел сразу за словом. Три регрессионные проверки. Co-Authored-By: Claude Fable 5.1 --- skills/delegate-kit/hooks/gate.sh | 2 +- skills/delegate-kit/tests/gate.sh | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/skills/delegate-kit/hooks/gate.sh b/skills/delegate-kit/hooks/gate.sh index 0254087..eefd600 100755 --- a/skills/delegate-kit/hooks/gate.sh +++ b/skills/delegate-kit/hooks/gate.sh @@ -21,7 +21,7 @@ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || # a worktree lock from there is denied outright — no confirmation prefix reopens it, so this # check sits before the confirmation bypass below. AGENT=$(printf '%s' "$INPUT" | jq -r '.agent_type // empty' 2>/dev/null || true) -if [ -n "$AGENT" ] && printf '%s' "$CMD" | grep -Eq '(^|[;&|(`[:space:]/])(agent-run[[:space:]]+(run|resume)|agent-wt[[:space:]]+lock)([[:space:]]|$)'; then +if [ -n "$AGENT" ] && printf '%s' "$CMD" | grep -Eq '(^|[;&|(`[:space:]/"'"'"'])(agent-run["'"'"']?[[:space:]]+(run|resume)|agent-wt["'"'"']?[[:space:]]+lock)([[:space:]]|$)'; then msg="delegate-kit gate: delegation depth is 1 — a worker ($AGENT) does not start workers or take worktree locks. Return what you have; the coordinator dispatches." jq -cn --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}' exit 0 diff --git a/skills/delegate-kit/tests/gate.sh b/skills/delegate-kit/tests/gate.sh index ea0bb2b..65697fc 100755 --- a/skills/delegate-kit/tests/gate.sh +++ b/skills/delegate-kit/tests/gate.sh @@ -41,6 +41,9 @@ ok "причина — depth 1 с именем агента" "$(reason | grep -c ok "субагент: agent-run resume → deny" "$(gate claude "dk-planner" 'agent-run resume 2026-x --brief n.md')" "deny" ok "субагент: agent-wt lock → deny" "$(gate claude "dk-implementer" 'agent-wt lock slice-a')" "deny" ok "субагент: путь к скрипту тоже" "$(gate claude "dk-implementer" '~/.claude/skills/delegate-kit/scripts/agent-run run --role planner --brief b.md')" "deny" +ok "субагент: исполняемый файл в кавычках" "$(gate claude "dk-implementer" '"agent-run" run --role planner --brief b.md')" "deny" +ok "субагент: путь в кавычках" "$(gate claude "dk-implementer" "'/home/me/bin/agent-wt' lock task")" "deny" +ok "субагент: через env/node" "$(gate claude "dk-implementer" 'env DELEGATE_KIT_PRESET=auto node /x/agent-run run --role planner --brief b.md')" "deny" ok "субагент: в цепочке команд" "$(gate claude "dk-implementer" 'cd /repo && agent-run run --role planner --brief b.md')" "deny" ok "субагент: префикс подтверждения не открывает" "$(gate claude "dk-implementer" 'DELEGATE_KIT_CONFIRMED=1 agent-run run --role planner --brief b.md')" "deny" ok "субагент на Codex тоже deny" "$(gate codex "dk-reviewer" 'agent-run run --role verifier --brief b.md')" "deny"