* 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>
90 lines
3.3 KiB
Markdown
90 lines
3.3 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
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
|
|
|
|
1. Bind the model once — from the `init.*` payload field, or `query resolve-model` when the
|
|
agent type is runtime-determined
|
|
2. Declare the bound field on the workflow's parse line
|
|
3. Pass `model="{bound_field}"` on each `Agent()` spawn
|
|
4. Omit `model=` entirely whenever the bound value is `"inherit"` or empty
|