Summary
herdctl consumers currently have to hand-enumerate the full built-in tool allowlist to get "a normal, fully-functional agent." There is no default/standard toolset primitive, and under the headless CLI runtime (claude -p) an incomplete allowed_tools is not just a skipped prompt — it is a silent denial of any prompt-requiring tool. Because Claude Code keeps adding built-in tools (e.g. Skill), any hand-typed allowlist silently drifts and breaks over time with no error.
This is a follow-on to #184 (which added the tools availability whitelist). #184 fixed the naming/availability confusion; this issue is about the headless denial semantics and the missing default-toolset primitive — neither of which #184 addresses.
Problem
1. No "standard/default toolset" primitive. There is no wildcard for built-ins, no preset, and no DEFAULT_TOOLS constant anywhere in packages/core/src (grep confirms). To run a normal agent a consumer must either enumerate every built-in by hand, or use bypassPermissions (which herdctl's own docs correctly say to reserve for Docker-isolated agents). Both are bad defaults.
2. Headless (-p) turns an incomplete allowed_tools into a silent denial, not a skipped prompt. The CLI runtime always spawns in print/headless mode and defaults to acceptEdits:
packages/core/src/runner/runtime/cli-runtime.ts:138 — const args: string[] = ["-p"];
packages/core/src/runner/runtime/cli-runtime.ts:141 — permission_mode defaults to acceptEdits
packages/core/src/runner/runtime/cli-runtime.ts:157-159 — allowed_tools is passed straight through to --allowedTools
There is no permission callback / canUseTool / permissionPromptTool wiring in core (grep for all three returns nothing). So under -p, when the model calls a tool that requires approval and is not pre-approved via --allowedTools, there is no one to approve it and it is effectively denied. Under the default acceptEdits mode, only file ops (Read/Write/Edit) auto-approve — so Bash, Task, Skill, TodoWrite, etc. all require pre-approval or they die. This makes allowed_tools a de-facto availability gate for prompt-requiring tools in the headless runtime, which is the opposite of what the docs describe.
3. The docs describe the interactive model only. docs/src/content/docs/configuration/permissions.md:156-158 and :229-233 state that allowed_tools "only controls permission prompts, NOT tool availability." That is true interactively, but there is no warning anywhere that under the CLI/-p runtime an un-pre-approved prompt-requiring tool is effectively denied. A grep of the permissions/agent-config/runner docs for headless / print mode / -p / "effectively denied" returns nothing.
4. Empty-array vs unset is a sharp edge — for tools and allowed_tools. Both fields are gated on ?.length, so an empty array is indistinguishable from unset and the flag is simply not passed:
allowed_tools: cli-runtime.ts:157, sdk-adapter.ts:160
tools: cli-runtime.ts:167, sdk-adapter.ts:169
So allowed_tools: [] does not produce a tool-less agent — and neither does tools: [] (it is also swallowed by the guard, contrary to what you might expect from the tools whitelist). The only way to actually get tool-lessness today is a denied_tools blocklist; the shipped examples/hello-world/agents/hello-world.yaml proves this by pairing allowed_tools: [] with a full denied_tools list rather than relying on either empty array.
Evidence / repro (the motivating bug)
A @herdctl/core consumer (Paddock) set:
defaults:
allowed_tools: [Read, Edit, Write, Bash, Glob, Grep, WebFetch, WebSearch]
This list omitted Skill (and Task/TodoWrite/NotebookEdit). Because agents run headless (claude -p) with no approver, every Skill/subagent/todo invocation was denied instantly (~1ms) — no error surfaced to config validation, just silently broken skills. The allowlist had been correct when written; it drifted the moment Claude Code shipped the Skill tool. This is exactly the invisible-drift failure mode: a hand-typed allowlist silently breaks as the built-in toolset evolves.
Proposed solutions (options, not over-prescribed)
(a) A documented, versioned DEFAULT allowlist that herdctl applies when allowed_tools is unset. herdctl tracks Claude Code's evolving built-in toolset so consumers don't have to. This makes "unset" mean "a sensible, current, fully-functional standard agent" instead of "whatever survives headless denial."
(b) A preset/token meaning "all standard built-in tools" — e.g. allowed_tools: [std] or a *-style built-in wildcard — that expands to the current built-in set at run time. Composable with extra entries (allowed_tools: [std, "Bash(git *)"]).
(c) Docs clarification that under the CLI/-p runtime, un-pre-approved prompt-requiring tools are effectively denied (i.e. allowed_tools is a de-facto headless availability gate under default/acceptEdits), plus a note that allowed_tools: [] == unset and tools: [] == unset (both no-ops), and that real tool-lessness needs a denied_tools blocklist or a non-empty tools whitelist.
(a) and (b) are complementary; (c) is worth doing regardless.
Why it belongs in herdctl
Permission/toolset definition is the orchestration layer's job. herdctl already owns the mapping from config to --allowedTools/--tools/--disallowedTools and chooses the headless -p runtime. The gap between the interactive mental model the docs teach and the headless denial the runtime enforces is precisely what forces every consumer to hardcode a full allowlist and then silently drift. Centralizing a tracked default toolset (or a preset token) fixes it once for all consumers.
Refs: #184 (added the tools field), anthropics/claude-code#20242 (allowed_tools naming confusion).
Summary
herdctl consumers currently have to hand-enumerate the full built-in tool allowlist to get "a normal, fully-functional agent." There is no default/standard toolset primitive, and under the headless CLI runtime (
claude -p) an incompleteallowed_toolsis not just a skipped prompt — it is a silent denial of any prompt-requiring tool. Because Claude Code keeps adding built-in tools (e.g.Skill), any hand-typed allowlist silently drifts and breaks over time with no error.This is a follow-on to #184 (which added the
toolsavailability whitelist). #184 fixed the naming/availability confusion; this issue is about the headless denial semantics and the missing default-toolset primitive — neither of which #184 addresses.Problem
1. No "standard/default toolset" primitive. There is no wildcard for built-ins, no preset, and no
DEFAULT_TOOLSconstant anywhere inpackages/core/src(grep confirms). To run a normal agent a consumer must either enumerate every built-in by hand, or usebypassPermissions(which herdctl's own docs correctly say to reserve for Docker-isolated agents). Both are bad defaults.2. Headless (
-p) turns an incompleteallowed_toolsinto a silent denial, not a skipped prompt. The CLI runtime always spawns in print/headless mode and defaults toacceptEdits:packages/core/src/runner/runtime/cli-runtime.ts:138—const args: string[] = ["-p"];packages/core/src/runner/runtime/cli-runtime.ts:141—permission_modedefaults toacceptEditspackages/core/src/runner/runtime/cli-runtime.ts:157-159—allowed_toolsis passed straight through to--allowedToolsThere is no permission callback /
canUseTool/permissionPromptToolwiring in core (grep for all three returns nothing). So under-p, when the model calls a tool that requires approval and is not pre-approved via--allowedTools, there is no one to approve it and it is effectively denied. Under the defaultacceptEditsmode, only file ops (Read/Write/Edit) auto-approve — soBash,Task,Skill,TodoWrite, etc. all require pre-approval or they die. This makesallowed_toolsa de-facto availability gate for prompt-requiring tools in the headless runtime, which is the opposite of what the docs describe.3. The docs describe the interactive model only.
docs/src/content/docs/configuration/permissions.md:156-158and:229-233state thatallowed_tools"only controls permission prompts, NOT tool availability." That is true interactively, but there is no warning anywhere that under the CLI/-pruntime an un-pre-approved prompt-requiring tool is effectively denied. A grep of the permissions/agent-config/runner docs forheadless/print mode/-p/ "effectively denied" returns nothing.4. Empty-array vs unset is a sharp edge — for
toolsandallowed_tools. Both fields are gated on?.length, so an empty array is indistinguishable from unset and the flag is simply not passed:allowed_tools:cli-runtime.ts:157,sdk-adapter.ts:160tools:cli-runtime.ts:167,sdk-adapter.ts:169So
allowed_tools: []does not produce a tool-less agent — and neither doestools: [](it is also swallowed by the guard, contrary to what you might expect from thetoolswhitelist). The only way to actually get tool-lessness today is adenied_toolsblocklist; the shippedexamples/hello-world/agents/hello-world.yamlproves this by pairingallowed_tools: []with a fulldenied_toolslist rather than relying on either empty array.Evidence / repro (the motivating bug)
A
@herdctl/coreconsumer (Paddock) set:This list omitted
Skill(andTask/TodoWrite/NotebookEdit). Because agents run headless (claude -p) with no approver, everySkill/subagent/todo invocation was denied instantly (~1ms) — no error surfaced to config validation, just silently broken skills. The allowlist had been correct when written; it drifted the moment Claude Code shipped theSkilltool. This is exactly the invisible-drift failure mode: a hand-typed allowlist silently breaks as the built-in toolset evolves.Proposed solutions (options, not over-prescribed)
(a) A documented, versioned DEFAULT allowlist that herdctl applies when
allowed_toolsis unset. herdctl tracks Claude Code's evolving built-in toolset so consumers don't have to. This makes "unset" mean "a sensible, current, fully-functional standard agent" instead of "whatever survives headless denial."(b) A preset/token meaning "all standard built-in tools" — e.g.
allowed_tools: [std]or a*-style built-in wildcard — that expands to the current built-in set at run time. Composable with extra entries (allowed_tools: [std, "Bash(git *)"]).(c) Docs clarification that under the CLI/
-pruntime, un-pre-approved prompt-requiring tools are effectively denied (i.e.allowed_toolsis a de-facto headless availability gate underdefault/acceptEdits), plus a note thatallowed_tools: []== unset andtools: []== unset (both no-ops), and that real tool-lessness needs adenied_toolsblocklist or a non-emptytoolswhitelist.(a) and (b) are complementary; (c) is worth doing regardless.
Why it belongs in herdctl
Permission/toolset definition is the orchestration layer's job. herdctl already owns the mapping from config to
--allowedTools/--tools/--disallowedToolsand chooses the headless-pruntime. The gap between the interactive mental model the docs teach and the headless denial the runtime enforces is precisely what forces every consumer to hardcode a full allowlist and then silently drift. Centralizing a tracked default toolset (or a preset token) fixes it once for all consumers.Refs: #184 (added the
toolsfield), anthropics/claude-code#20242 (allowed_tools naming confusion).