* test(#2684): failing-first guard for unbound model= dispatch placeholders Extends the #2517 omit-on-inherit bucket with a behavioral binding guard: every model="{X}" in a workflow must name a field that workflow actually binds — an init-payload key (queried for real), a shell assignment, or a declared parse field. scan.md ({resolved_model}) and ship.md ({balanced_model}) substitute names nothing emits, so the orchestrator invents the value (ADR-1411). Also pins the shipped reference that seeded the placeholder and instructs the #2517-forbidden model="inherit". RED expected on scan.md, ship.md, and references/model-profile-resolution.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2684): bind scan.md and ship.md model dispatch to fields their workflows resolve scan.md:85 passed model="{resolved_model}" and ship.md:486 passed model="{balanced_model}" — names neither workflow's init payload emits. init.map-codebase emits mapper_model; init.phase-op emits no model field at all. With no source for the substitution the orchestrator invents a value, so model_overrides/model_policy are silently inert at both sites — the invisible partial application ADR-1411 prohibits. - scan.md: declare the init fields in a Parse JSON line and substitute {mapper_model}, matching its sibling map-codebase.md. - ship.md: ref.agent is only known at runtime, so resolve it per hook via query resolve-model and dispatch with {HOOK_AGENT_MODEL}, following the same NAME=$(...) → model="{NAME}" convention every other shell-resolved dispatch in the corpus uses (code-review.md, secure-phase.md, ui-phase.md). - Both sites now carry the #2517 rule: omit model= entirely when the resolved value is "inherit" or empty. A bare rename would have traded a dangling placeholder for model="", which 404s on non-Claude runtimes — an agent type absent from the profile table resolves to the empty string, which is exactly ship.md's case. - references/model-profile-resolution.md was the seam: it shipped the copy-pasteable {resolved_model} snippet scan.md inherited, used the stale Task( spelling, and instructed passing model="inherit" outright. Rewritten to teach the real binding convention and the omit rule. Two defects surfaced while fixing this and fixed inline rather than deferred: 1. ref.agent originates in a capability manifest, which may be third-party. Resolving it at runtime made this the first place that value reaches a shell command, so ship.md now validates its shape before interpolating and skips the hook when it fails — matching code-review.md's existing defense-in-depth pattern. Covered by a test that runs the shipped regex against real agent names and injection payloads. 2. The reference doc's omit example first placed a literal model= inside an Agent(...) comment, which tripped the #2284 fail-closed Hermes projection guard (bin/install.js:3704) and refused the install outright. Moved out of the call span; gen:golden is green across all 19 runtimes. Guard tests extend the existing #2517 bucket: every model="{X}" in a workflow must name a field that workflow binds — an init-payload key queried for real, a shell assignment, or a declared parse field. Behavior change (Hyrum's Law): the scan mapper now runs on the catalog-resolved model rather than the session model. The omit path is unchanged. Disclosed in the changeset body. Fixes #2684 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2684): validate capability-supplied ref.agent in-context, not in the shell The first cut of the ref.agent guard was placed after the injection point it was meant to close. It instructed the orchestrator to substitute the untrusted manifest value into a shell assignment and test it there: HOOK_AGENT="<the ref.agent value>" if [[ "$HOOK_AGENT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then … Substitution happens before bash parses anything, so a manifest supplying `x"; touch /tmp/pwned; echo "` yields three statements and runs the middle one unconditionally — the regex fires afterwards and protects nothing. ship.md now requires the check to run in-context, the same way the workflow already reads activeHooks ("do NOT pipe it through a shell parser"), and to skip the hook outright on a mismatch. Only a value that has already matched ^[A-Za-z0-9][A-Za-z0-9._-]*$ ever reaches a command line. The guard test asserts the ordering — the in-context requirement and the absence of any raw shell assignment — not just that the pattern rejects metacharacters, since a pattern alone was exactly what gave false assurance here. Also corrects the empty-string explanation in references/model-profile- resolution.md. model_profile:"inherit" resolves to the literal "inherit", not "" (model-resolver.cts:395), and an unknown agent takes the empty-string path via resolve_model_ids:"omit" rather than by absence alone — the doc claimed all three produced "". The workflow instructions were already correct (omit on "inherit" OR empty); only the rationale in the citable reference was wrong. Both found by the isolated adversarial review pass for #2684. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * chore(#2684): backfill changeset PR number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3.3 KiB
Model Profile Resolution
Resolve each agent's model through gsd-tools, then pass it to the Agent() spawn — or
omit the parameter entirely when nothing resolved.
Resolution Pattern
Prefer the field your workflow's own init.* payload already emits (every orchestrator
init that spawns subagents carries one — mapper_model, planner_model,
executor_model, doc_writer_model, …). Declare it in the workflow's parse line so the
binding is stated, not implied:
Parse JSON for: `mapper_model`, …
When the agent type is only known at runtime — a capability hook naming its own agent, for example — resolve it directly instead:
AGENT_MODEL=$(gsd_run query resolve-model "<agent-type>" --raw)
Both surfaces return the same thing: a model string for the active runtime, or the empty string when nothing resolved.
Lookup Table
@~/.claude/gsd-core/references/model-profiles.md
Passing the model to a spawn
Agent(
prompt="...",
subagent_type="gsd-planner",
model="{planner_model}"
)
Substitute the field this workflow bound — never a generic placeholder name.
#2517 — omit, do not emit an empty model. When the resolved value is "inherit" or
empty, omit the model= parameter entirely:
Agent(
prompt="...",
subagent_type="gsd-planner"
)
No model parameter is passed at all — planner_model resolved to "inherit" or empty, and
omitting it inherits the orchestrator's model. Passing either value through as an argument
instead 404s on non-Claude runtimes.
This is not cosmetic, and both values really occur. model_profile: "inherit" — and any
opus-tier agent — resolves to the literal string "inherit". resolve_model_ids: "omit"
resolves to the empty string whenever the project sets it explicitly or the active
runtime has no native tier aliases; an agent type absent from the profile table takes that
same empty-string path, because it has no tier for the earlier steps to resolve. Emitting
either value verbatim fails the spawn on every runtime without native tier aliases.
#2684 — substitute a field your workflow actually bound. model="{…}" must name a key
your own init.* payload emits, a shell variable you assigned, or a field on your declared
parse line. A placeholder that resolves to nothing does not fail loudly — the orchestrator
silently invents a value, which is the invisible partial application ADR-1411 prohibits.
tests/model-omit-when-inherit-guard.test.cjs enforces both rules.
Profile semantics
Note: Opus-tier agents resolve to "inherit" (not "opus"). This causes the agent to
use the parent session's model, avoiding conflicts with organization policies that may
block specific opus versions — and, per the rule above, means the model= parameter is
omitted rather than set.
If model_profile is "adaptive", agents resolve to role-based assignments (opus/sonnet/
haiku based on agent type).
If model_profile is "inherit", all agents resolve to "inherit" (useful for OpenCode
/model).
Usage
- Bind the model once — from the
init.*payload field, orquery resolve-modelwhen the agent type is runtime-determined - Declare the bound field on the workflow's parse line
- Pass
model="{bound_field}"on eachAgent()spawn - Omit
model=entirely whenever the bound value is"inherit"or empty