Browse Source

feat(skills): fleet-worker — add FLEET_WORKER_PERMISSION_MODE

A bypassPermissions worker spawned from an auto-mode orchestrator is
hard-denied by the auto-mode classifier as "Create Unsafe Agents", with no
allow-rule able to save it. FLEET_WORKER_PERMISSION_MODE (default
bypassPermissions for back-compat) lets the fan-out run gated-but-
non-interactive instead — dontAsk + an allowlist. Both launchers validate
the mode and warn on dontAsk-without-allowlist; SKILL.md + spec document the
posture; tests cover the override/validation/advisory paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
18a183634b

+ 30 - 4
skills/fleet-worker/SKILL.md

@@ -85,6 +85,7 @@ same on-demand, progressively-disclosed procedural knowledge your orchestrator h
 | `FLEET_WORKER_SMALL_MODEL` | `GLM-4.5-Air` | background/cheap model (haiku mapping) |
 | `FLEET_WORKER_CONFIG_DIR` | `~/.fleet-worker/cfg` | isolated config dir — **one per parallel worker** |
 | `FLEET_WORKER_EFFORT` | `high` | seeded `effortLevel` in the worker's settings |
+| `FLEET_WORKER_PERMISSION_MODE` | `bypassPermissions` | worker `--permission-mode`; use `dontAsk` + an allowlist to spawn from an auto-mode orchestrator (see *Permission posture*) |
 
 Point `FLEET_WORKER_BASE_URL`/`FLEET_WORKER_MODEL` at any other Anthropic-compatible
 gateway (this is the documented Claude Code custom-endpoint mechanism) to drive a
@@ -163,10 +164,35 @@ Full walkthrough + recovery in [references/fleet-ops-handoff.md](references/flee
 
 ## Permission posture
 
-Headless `-p` can't answer a permission prompt — it would stall. The launcher
-bakes in `--permission-mode bypassPermissions`; safety comes from the cage
-(worktree + isolated config + merge gate), not the prompt. Optionally constrain
-with `--disallowedTools` (e.g. block `WebFetch`) or `--add-dir` scoping.
+Headless `-p` can't answer a permission prompt — it would stall, so the worker runs
+in a **non-interactive** mode. The default is `--permission-mode bypassPermissions`
+(set `FLEET_WORKER_PERMISSION_MODE` to override); safety comes from the **cage**
+(isolated worktree + isolated config + merge gate), not the prompt. Optionally
+constrain further with `--disallowedTools` (e.g. block `WebFetch`) or `--add-dir`.
+
+**Spawning from an auto-mode orchestrator.** If the session that runs `fleet-worker`
+is itself in **auto mode**, a `bypassPermissions` launch is *hard-denied* by the
+auto-mode classifier as **Create Unsafe Agents** (an agent spawning an ungated agent)
+— and no allow-rule saves it (broad/interpreter allow-rules are dropped on entry to
+auto mode). Two fixes, either works:
+
+1. **Launch from outside the auto-mode session** — run the fan-out from a plain
+   script / Task Scheduler / cron, or keep the orchestrator interactive
+   (`default`/`acceptEdits`). With no parent classifier in the loop, the worker's own
+   `bypassPermissions` is fine.
+2. **Give the worker gates** — `FLEET_WORKER_PERMISSION_MODE=dontAsk` plus an allowlist
+   (`--allowedTools "Read Edit Write Bash(npm:*) Bash(git:*)"`, or `permissions.allow`
+   in the worker's config). `dontAsk` is **equally non-interactive** — it auto-denies
+   non-allowlisted calls instead of stalling — but it is *not* an "unsafe agent," so an
+   auto-mode orchestrator will spawn it. The launcher warns if you select `dontAsk`
+   with no allowlist (the worker would otherwise auto-deny everything). Keep
+   `bypassPermissions` for the isolated-container variant.
+
+`FLEET_WORKER_PERMISSION_MODE` accepts any Claude Code mode
+(`default|acceptEdits|plan|auto|dontAsk|bypassPermissions`). See
+[../../docs/auto-mode-classifier.md](../../docs/auto-mode-classifier.md) for the full
+classifier model (the override rules, the broad-allow-rule drop, and §7.9 on running
+headless sessions).
 
 > **Worktree-under-`.claude/` gotcha:** Claude Code's sensitive-file guard runs
 > *before* `bypassPermissions` for anything under `.claude/`. Keep manual worker

+ 16 - 3
skills/fleet-worker/references/fleet-worker-spec.md

@@ -120,7 +120,8 @@ implement, in order: isolate `CLAUDE_CONFIG_DIR` and seed its `settings.json`
 (§4, §10); resolve the key from `ANTHROPIC_AUTH_TOKEN` → keyring
 (`FLEET_WORKER_KEYRING_SERVICE`/`_KEY`) → `ZHIPU_API_KEY`/`GLM_API_KEY` (never
 echoed); set `ANTHROPIC_BASE_URL` + the model mapping; `exec claude -p --model
-sonnet --permission-mode bypassPermissions "$@" </dev/null`.
+sonnet --permission-mode "$FLEET_WORKER_PERMISSION_MODE" "$@" </dev/null`
+(default `bypassPermissions`; override per §9).
 
 Notes:
 - **`--model sonnet`** maps to `FLEET_WORKER_MODEL` (default GLM-5.2). Mapping
@@ -141,7 +142,7 @@ fleet-worker [claude-flags…] < prompt.txt
 | Aspect | Value |
 |---|---|
 | Prompt | final positional arg, or piped on stdin (arg form recommended) |
-| Baked-in flags | `-p`, `--model sonnet`, `--permission-mode bypassPermissions` |
+| Baked-in flags | `-p`, `--model sonnet`, `--permission-mode $FLEET_WORKER_PERMISSION_MODE` (default `bypassPermissions`) |
 | Common extra flags | `--output-format {text,json,stream-json}`, `--add-dir`, `--max-turns N`, `--append-system-prompt`, `--allowedTools`/`--disallowedTools` |
 | Env knobs | `FLEET_WORKER_*` (see SKILL.md table) — give each parallel worker its own `FLEET_WORKER_CONFIG_DIR` |
 | CWD | the worker operates in the process CWD — spawn it `cd`'d into the target worktree |
@@ -202,17 +203,29 @@ the winners (hand to `fleet-ops`), discard/retry failures.
 
 ## 9. Permission modes
 
+Set via `FLEET_WORKER_PERMISSION_MODE` (default `bypassPermissions`).
+
 | Mode | Edits | Bash | Prompts | For a delegated worker |
 |---|---|---|---|---|
 | `default` | ask | ask | yes | ❌ hangs headless |
 | `acceptEdits` | auto | ask | partial | ⚠ Bash still prompts → can hang |
-| `bypassPermissions` | auto | auto | no | ✅ recommended |
+| `dontAsk` | allowlist | allowlist | no (auto-deny) | ✅ when an allowlist is supplied; spawnable from an auto-mode orchestrator |
+| `bypassPermissions` | auto | auto | no | ✅ default; cage-protected — but blocked when spawned from auto mode |
 
 **Safety comes from the cage, not the prompt:** dedicated worktree (blast radius),
 isolated `CLAUDE_CONFIG_DIR` (no host hooks/MCP/creds), optional `--disallowedTools`
 / `--add-dir` scoping, and the orchestrator's **merge gate** — nothing the worker
 writes reaches `main` without review. Standard headless-CI posture.
 
+**Spawning from an auto-mode parent.** The auto-mode classifier hard-denies a
+`bypassPermissions` child as *Create Unsafe Agents* (an agent spawning an ungated
+agent), and broad/interpreter allow-rules are dropped on entry to auto mode, so an
+allow-rule can't whitelist the launch. Either launch the fan-out from outside the
+auto-mode session (script / Task Scheduler, or an interactive orchestrator), or run
+the worker in `dontAsk` + an allowlist (`--allowedTools …` or `permissions.allow` in
+the worker config) — `dontAsk` is non-interactive yet not an "unsafe agent." The
+launcher warns on `dontAsk` with no allowlist. Full model: `docs/auto-mode-classifier.md`.
+
 ## 10. Effort control
 
 Headless `-p` has no interactive `/effort`. Seed it via the isolated config dir's

+ 28 - 1
skills/fleet-worker/scripts/fleet-worker

@@ -20,6 +20,10 @@
 #   FLEET_WORKER_MODEL        main model      (default GLM-5.2)
 #   FLEET_WORKER_SMALL_MODEL  background model (default GLM-4.5-Air)
 #   FLEET_WORKER_EFFORT       seeded effortLevel (default high)
+#   FLEET_WORKER_PERMISSION_MODE  worker --permission-mode (default bypassPermissions).
+#                             Use dontAsk + an allowlist to spawn FROM an auto-mode
+#                             orchestrator - a bypassPermissions launch is hard-denied
+#                             there as "Create Unsafe Agents". See ../SKILL.md.
 # Key resolution order (the key is never printed):
 #   1. ANTHROPIC_AUTH_TOKEN (already exported)            -> used as-is
 #   2. FLEET_WORKER_KEYRING_SERVICE + FLEET_WORKER_KEYRING_KEY -> `keyring get svc key`
@@ -85,7 +89,30 @@ export ANTHROPIC_DEFAULT_OPUS_MODEL="${FLEET_WORKER_MODEL:-GLM-5.2}"
 export ANTHROPIC_DEFAULT_SONNET_MODEL="${FLEET_WORKER_MODEL:-GLM-5.2}"
 export ANTHROPIC_DEFAULT_HAIKU_MODEL="${FLEET_WORKER_SMALL_MODEL:-GLM-4.5-Air}"
 
+# --- Permission mode ---------------------------------------------------------
+# Default bypassPermissions keeps back-compat; safety = the cage (isolated worktree
+# + config + the orchestrator's merge gate), not the prompt. But when this worker is
+# spawned FROM a parent session in auto mode, a bypassPermissions launch is hard-denied
+# by the auto-mode classifier as "Create Unsafe Agents" - no allow-rule saves it. The
+# fix is a gated but still-non-interactive mode: dontAsk + an allowlist. Full model in
+# ../SKILL.md "Permission posture" and docs/auto-mode-classifier.md.
+PERM_MODE="${FLEET_WORKER_PERMISSION_MODE:-bypassPermissions}"
+case "$PERM_MODE" in
+  default|acceptEdits|plan|auto|dontAsk|bypassPermissions) ;;
+  *) echo "fleet-worker: invalid FLEET_WORKER_PERMISSION_MODE: $PERM_MODE" >&2
+     echo "  (expected: default|acceptEdits|plan|auto|dontAsk|bypassPermissions)" >&2
+     exit "$EXIT_USAGE" ;;
+esac
+# dontAsk auto-denies anything not allow-listed; a worker with no allowlist does nothing.
+if [ "$PERM_MODE" = "dontAsk" ]; then
+  case " $* " in
+    *" --allowedTools "*|*" --allowed-tools "*) : ;;
+    *) grep -q '"allow"' "$GLM_CFG/settings.json" 2>/dev/null || \
+       echo "fleet-worker: permission-mode dontAsk with no allowlist - worker will auto-deny most tools; pass --allowedTools \"...\" or set permissions.allow in $GLM_CFG/settings.json" >&2 ;;
+  esac
+fi
+
 # `--model sonnet` resolves to $FLEET_WORKER_MODEL via the mapping above.
 # `</dev/null` avoids the ~3s "no stdin data received" wait when the prompt is
 # passed as an argument.
-exec claude -p --model sonnet --permission-mode bypassPermissions "$@" </dev/null
+exec claude -p --model sonnet --permission-mode "$PERM_MODE" "$@" </dev/null

+ 22 - 1
skills/fleet-worker/scripts/fleet-worker.ps1

@@ -16,6 +16,10 @@
 #   FLEET_WORKER_MODEL        main model      (default GLM-5.2)
 #   FLEET_WORKER_SMALL_MODEL  background model (default GLM-4.5-Air)
 #   FLEET_WORKER_EFFORT       seeded effortLevel (default high)
+#   FLEET_WORKER_PERMISSION_MODE  worker --permission-mode (default bypassPermissions).
+#                             Use dontAsk + an allowlist to spawn FROM an auto-mode
+#                             orchestrator - a bypassPermissions launch is hard-denied
+#                             there as "Create Unsafe Agents". See ../SKILL.md.
 # Key resolution order (the key is never printed):
 #   1. ANTHROPIC_AUTH_TOKEN (already set)                  -> used as-is
 #   2. FLEET_WORKER_KEYRING_SERVICE + FLEET_WORKER_KEYRING_KEY -> `keyring get svc key`
@@ -76,5 +80,22 @@ $env:ANTHROPIC_DEFAULT_OPUS_MODEL   = if ($env:FLEET_WORKER_MODEL) { $env:FLEET_
 $env:ANTHROPIC_DEFAULT_SONNET_MODEL = $env:ANTHROPIC_DEFAULT_OPUS_MODEL
 $env:ANTHROPIC_DEFAULT_HAIKU_MODEL  = if ($env:FLEET_WORKER_SMALL_MODEL) { $env:FLEET_WORKER_SMALL_MODEL } else { 'GLM-4.5-Air' }
 
-claude -p --model sonnet --permission-mode bypassPermissions @args
+# Permission mode: default bypassPermissions (back-compat; safety = the cage). When
+# spawned FROM an auto-mode orchestrator, a bypassPermissions launch is hard-denied as
+# "Create Unsafe Agents" - use dontAsk + an allowlist instead. See ../SKILL.md.
+$permMode = if ($env:FLEET_WORKER_PERMISSION_MODE) { $env:FLEET_WORKER_PERMISSION_MODE } else { 'bypassPermissions' }
+if ($permMode -notin @('default','acceptEdits','plan','auto','dontAsk','bypassPermissions')) {
+  # Direct stderr (not Write-Error) so exit 2 is honored under $ErrorActionPreference='Stop'.
+  [Console]::Error.WriteLine("fleet-worker: invalid FLEET_WORKER_PERMISSION_MODE: $permMode (expected default|acceptEdits|plan|auto|dontAsk|bypassPermissions)")
+  exit 2
+}
+if ($permMode -eq 'dontAsk') {
+  $hasAllow = ($args -contains '--allowedTools') -or ($args -contains '--allowed-tools') -or `
+              ((Test-Path $settings) -and ((Get-Content -Raw $settings) -match '"allow"'))
+  if (-not $hasAllow) {
+    [Console]::Error.WriteLine("fleet-worker: permission-mode dontAsk with no allowlist - worker will auto-deny most tools; pass --allowedTools '...' or set permissions.allow in $settings")
+  }
+}
+
+claude -p --model sonnet --permission-mode $permMode @args
 exit $LASTEXITCODE

+ 40 - 0
skills/fleet-worker/tests/run.sh

@@ -77,6 +77,46 @@ eh "bakes flags + forwards args"    "ARGS=-p --model sonnet --permission-mode by
 [ -f "$CFG/settings.json" ] && ok "seeds settings.json" || no "settings.json not seeded"
 eh "settings carries effortLevel"   "effortLevel" "$(cat "$CFG/settings.json" 2>/dev/null)"
 
+# Permission-mode knob: default stays bypassPermissions (asserted above); override flows through.
+: > "$PROBE"
+PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm" ANTHROPIC_AUTH_TOKEN="T" \
+  FLEET_WORKER_PERMISSION_MODE="dontAsk" \
+  "$WORKER" --allowedTools "Read" "hi" >/dev/null 2>&1
+eh "permission-mode override flows through" "--permission-mode dontAsk" "$(cat "$PROBE")"
+
+# Invalid permission mode -> usage exit 2
+PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm2" ANTHROPIC_AUTH_TOKEN="T" \
+  FLEET_WORKER_PERMISSION_MODE="bogus" \
+  "$WORKER" "hi" >/dev/null 2>&1; ee "invalid permission-mode -> 2" 2 $?
+
+# acceptEdits also flows through (any valid mode, not just dontAsk)
+: > "$PROBE"
+PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm7" ANTHROPIC_AUTH_TOKEN="T" \
+  FLEET_WORKER_PERMISSION_MODE="acceptEdits" "$WORKER" "hi" >/dev/null 2>&1
+eh "acceptEdits flows through" "--permission-mode acceptEdits" "$(cat "$PROBE")"
+
+# dontAsk advisory: warns on stderr when no allowlist is present (worker would auto-deny all)
+err="$(PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm3" ANTHROPIC_AUTH_TOKEN="T" \
+       FLEET_WORKER_PERMISSION_MODE="dontAsk" "$WORKER" "hi" 2>&1 1>/dev/null)"
+eh "dontAsk w/o allowlist warns" "auto-deny most tools" "$err"
+
+# ...suppressed when --allowedTools is passed
+err="$(PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm4" ANTHROPIC_AUTH_TOKEN="T" \
+       FLEET_WORKER_PERMISSION_MODE="dontAsk" "$WORKER" --allowedTools "Read" "hi" 2>&1 1>/dev/null)"
+case "$err" in *"auto-deny most tools"*) no "advisory wrongly fired with --allowedTools";; *) ok "no advisory when --allowedTools present";; esac
+
+# ...suppressed when the worker config already carries permissions.allow
+CFGALLOW="$SB/cfg-pm5"; mkdir -p "$CFGALLOW"
+printf '{ "hooks": {}, "permissions": { "allow": ["Read"] } }\n' > "$CFGALLOW/settings.json"
+err="$(PATH="$PC" FLEET_WORKER_CONFIG_DIR="$CFGALLOW" ANTHROPIC_AUTH_TOKEN="T" \
+       FLEET_WORKER_PERMISSION_MODE="dontAsk" "$WORKER" "hi" 2>&1 1>/dev/null)"
+case "$err" in *"auto-deny most tools"*) no "advisory wrongly fired with settings allow";; *) ok "no advisory when settings has permissions.allow";; esac
+
+# default (unset) mode must not emit the dontAsk advisory
+err="$(PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-pm6" ANTHROPIC_AUTH_TOKEN="T" \
+       "$WORKER" "hi" 2>&1 1>/dev/null)"
+case "$err" in *"auto-deny most tools"*) no "default mode wrongly warned";; *) ok "default mode emits no dontAsk advisory";; esac
+
 # Custom endpoint/model override
 : > "$PROBE"
 PATH="$PC" FLEET_WORKER_CONFIG_DIR="$SB/cfg-x" \