Bound explicit skill prompts and avoid duplicate host injection work
What is the bug?
Explicit skill injection has two paths that can place unbounded data into model-visible context:
core-skillsappliesMAX_SKILL_PROMPT_BYTESonly to agent-plugin skills, so explicitly mentioned host, repo, user, and system skills can inject an arbitrarily largeSKILL.mdbody.- A prompt-hidden executor skill can be invoked explicitly without going through
skills.list, bypassing the 2 KiB handle validation used there. Its authority/package/resource handles are then serialized into<resource_access>JSON.
There is also duplicated work when the skills extension successfully injects a host prompt: the legacy core path rereads and truncates the same file before filtering its duplicate item at the end. This can emit the same truncation warning twice.
How can it be reproduced?
- Create a host skill whose
SKILL.mdis larger thanMAX_SKILL_PROMPT_BYTESand mention it explicitly. - With catalog listings disabled, the full body reaches the request.
- With catalog listings enabled, the extension and legacy paths both process the prompt, producing duplicate truncation warnings.
- Separately, provide a prompt-hidden executor catalog entry with a package/resource handle larger than the
skills.listlimit and invoke it explicitly; the oversized metadata reaches the injected resource-access fragment.
Expected behavior
- Every explicitly injected skill body should respect
MAX_SKILL_PROMPT_BYTES, regardless of skill source. - Resource-access handles should use the same validation for explicit invocation and
skills.list. - A host prompt already injected by the extension should not be reread by the legacy path, while invocation telemetry should still be recorded.
Design history: why the unbounded host path looks intentional, and why I read it as an inconsistency
- #36309 explicitly preserved the legacy behavior ("preserving Core's full prompt injection for selected host skills") and pinned it with
production_turn_keeps_full_snapshot_host_skill_prompt. That reads as migration compatibility while the skills extension was being wired in, rather than a durable design guarantee. - #37027 introduced
MAX_SKILL_PROMPT_BYTESas part of the Agent Plugin runtime boundaries and deliberately scoped it to agent plugins ("without changing legacy plugins"), which is how host/repo/user/system skills were left unbounded on the legacy path. - The extension injection path has bounded every skill source with the same constant since v1 (#26167,
truncate_main_prompt_contents). So today the same host skill is truncated toMAX_SKILL_PROMPT_BYTESwhen the extension injects its prompt and unbounded when the legacy core path injects it. - Truncation is recoverable for file-backed skills: the injected fragment keeps
<path>, so the model can read the remainder from disk, consistent with the progressive-disclosure instructions in the catalog prompt.
If the team instead considers the unbounded legacy host path intentional, the remaining scope would be the hidden-skill handle validation and the duplicate host injection work.
Implementation notes
Non-obvious parts for whoever picks this up:
- Connector mentions: once the legacy path stops rereading extension-injected host prompts,
$connectormentions inside those skill bodies no longer reachcollect_explicit_app_ids_from_skill_itemsthrough the legacy skill items. The extension turn-input items need to be scanned as well, filtered to actual skill fragments (<skill>/</skill>markers) so the catalog listing fragment (plain markdown) is not scanned. - Telemetry: the skip path should still record the explicit
SkillInvocationand the skill-injected metric. - Path matching:
InjectedHostSkillPromptsstores raw and normalized paths; the skip check should compare the same lossy-string form the currentturn.rsfilter uses. - Pinned tests:
production_turn_keeps_full_snapshot_host_skill_promptandproduction_turn_keeps_core_host_injection_when_catalog_listings_are_disabledassert the full contents and would need to flip to asserting the bound plus exactly one truncation warning. - Handle validation: the three checks in
list.rs::listed_skill(authority.id,id.0,main_promptagainstMAX_HANDLE_BYTES) can be extracted and applied in the extension's turn-input path only when a<resource_access>block would be serialized (prompt-hidden entry with a recognized authority), failing closed with a warning. Prompt-visible entries keep their current behavior. - Dead plumbing: with the bound unconditional, the
is_agent_plugin/agent_plugin_skill_pathsthreading throughSkillLoadOutcome,LoadedSkillRoot,HostSkillRootSnapshot, and the host-merge path becomes unused. (The identically namedis_agent_pluginhelpers on the MCP/plugin-manifest side are unrelated.)
Proposed focused fix
- Apply the existing prompt bound to all legacy skill injections.
- Skip legacy reads for host paths recorded by
InjectedHostSkillPrompts. - Reuse the existing handle validation before building hidden-skill resource access.
- Add integration coverage for both bounds and for the single-warning behavior.
I have a working focused fix with integration coverage (both bounds, the single-warning behavior, and rejection of oversized hidden-skill resource metadata) and can submit it if a maintainer would like to invite a PR, per docs/contributing.md.