`runSubagent` bypasses `disable-model-invocation` when `agentName` is omitted
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
## Requirements
Version: 1.136.0-insider (user setup)
Commit: d5ceefbe0b296c4d8fbbf3664aff8cad73cd1b5d
Date: 2026-08-28T16:34:03Z
Electron: 42.10.0
ElectronBuildId: 15109253
Chromium: 148.0.7778.280
Node.js: 24.18.1
V8: 14.8.178.38-electron.0
@github/copilot: 1.0.81-0
@github/copilot-sdk: 1.0.11
OS: Windows_NT x64 10.0.26200
- Feature (e.g. agent/edit/ask mode): agent mode, custom agent with `agents:` frontmatter delegating via the `agent` tool (`runSubagent`)
- Selected model: Local/BYOK model (Qwen 3.8-27B) — noted for completeness, but the underlying issue is in `runSubagent`'s dispatch logic and isn't model-specific
## Summary
An agent file with `disable-model-invocation: true` declares that it must not be run as a subagent. When such an agent is the active mode, calling `runSubagent` **without** `agentName` clones the current mode into a subagent anyway — bypassing the flag entirely, and without the model ever needing to name the hidden agent.
More generally: `disable-model-invocation` has **no runtime enforcement at all** for agents. It is only a prompt-visibility filter.
Issue confirmed by a human but details populated by Opus 5.
## Steps to reproduce
1. Create `.github/agents/Foo.agent.md`:
```markdown
---
name: Foo
description: Manual-only agent
disable-model-invocation: true
---
Do the thing.
```
2. Select `Foo` as the active agent mode in Chat.
3. Prompt in a way that induces a `runSubagent` call, e.g. *"delegate this to a subagent."*
4. The model calls `runSubagent({ prompt, description })` with no `agentName`.
**Expected:** the call is rejected, since the only thing it can invoke by default is an agent that has opted out of model invocation.
**Actual:** a subagent is spawned with `subAgentName: "Foo"` and `modeInstructions` equal to Foo's instructions, tool references, and allowlist.
## Root cause
[`[runSubagentTool.ts#L170-L231](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L170-L231)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L170-L231):
```ts
const subAgentName = this.normalizeRequestedAgentName(args.agentName);
const effectiveSubAgentName = subAgentName ?? currentModeInstructions?.name;
if (subAgentName) {
this.validateSubagentAllowed(subAgentName, currentModeInstructions);
subagent = await this.getSubAgentByName(subAgentName);
// ...
} else {
modeInstructions = currentModeInstructions; // no validation
}
```
Both values flow directly into the agent request at [[L334-L350](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L334-L350)](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L334-L350).
The underlying reason the check is missing: `IChatRequestModeInstructions` ([`[chatModel.ts#L368](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/model/chatModel.ts#L368)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/model/chatModel.ts#L368)) carries `allowedSubagents` — which is why *that* rule is enforceable inside the tool — but carries no invocability field. `visibility.agentInvocable` is computed at [`[promptsServiceImpl.ts#L1750](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts#L1750)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts#L1750) and never propagated into the request.
## Scope: the flag is advisory, not enforced
`grep -n "disableModelInvocation\|agentInvocable" runSubagentTool.ts` returns nothing. The only consumer of `visibility.agentInvocable` outside of parsing is [`[computeAutomaticInstructions.ts#L516](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts#L516)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts#L516), which decides whether the agent is listed in the `` prompt block.
The two gates in the tool check neither:
- `getSubAgentByName` filters on `agent.name === name && agent.enabled` only.
- `validateSubagentAllowed` ([[L592-L596](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L592-L596)](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L592-L596)) checks the `agents:` allowlist only.
So an **explicit** `agentName: "Foo"` bypasses the flag as well. Omitting `agentName` is simply the bypass that requires no knowledge of the hidden agent's name.
Because an agent marked `disable-model-invocation: true` is by construction one the user drives manually, it is typically the active mode — so the omission path is the common case, not a corner case.
An existing test currently locks the behavior in: `runSubagentTool.test.ts:1214`, *"inherits the current agent instructions when agentName is omitted"*, asserts `subAgentName === 'CurrentAgent'` and deep-equal `modeInstructions`.
## Secondary: the tool schema cannot express the constraint
[`[getToolData()](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L95-L135)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L95-L135) takes no context and hardcodes `required: ['prompt', 'description']`, with `agentName` documented as *"If not provided, uses the current agent."* When the caller is non-model-invocable, that default is precisely the thing that should be unavailable, so `agentName` ought to be required in that state.
Two obstacles:
- `RunSubagentTool` declares `_onDidUpdateToolData` (L72) and **never fires it**, so the re-registration hook wired up in [`[tools.ts#L86-L95](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts#L86-L95)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts#L86-L95) is dead.
- Even if fired, tool registration is window-global rather than session-scoped, so a per-session predicate can't be expressed in a single static JSON schema.
The practical place for the constraint is therefore runtime rejection in `prepareToolInvocation`, which already has `chatSessionResource` and already calls `getCurrentModeInstructions`.
Relatedly, the error string at [[L215](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L215)](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts#L215) actively advertises the bypass:
> `Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit agentName to use the current agent.`
## Suggested fix
1. Propagate `agentInvocable` into `IChatRequestModeInstructions` when the request is built.
2. In `invoke()` and `prepareToolInvocation()`, reject on the `agentName`-omitted branch when the current mode is non-model-invocable, instead of silently cloning it.
3. Have `getSubAgentByName` (or `validateSubagentAllowed`) filter on `visibility.agentInvocable` so the explicit-name path is closed too.
4. Update the L215 message so it no longer points at the escape hatch, and update `runSubagentTool.test.ts:1214` to cover both the invocable and non-invocable current mode.
## Note on impact
Blast radius is capped at one level: `chat.subagents.allowInvocationsFromSubagents` defaults to `false` ([`[chat.shared.contribution.ts#L2365](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts#L2365)`](https://github.com/microsoft/vscode/blob/004a1fbb1658e61048b29d76e2ce380adfa18680/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts#L2365)), which sets `maxDepth = 0` and disables `runSubagent` inside the spawned clone. That is an unrelated limiter that happens to contain the recursion; it does not address the bypass.
Contributor guide
Assessment
This issue has not been assessed yet.