Bound explicit skill prompts and avoid duplicate host injection work

Open 💬 0 comments Opened Aug 7, 2026 by RenKoya1

What is the bug?

Explicit skill injection has two paths that can place unbounded data into model-visible context:

  1. core-skills applies MAX_SKILL_PROMPT_BYTES only to agent-plugin skills, so explicitly mentioned host, repo, user, and system skills can inject an arbitrarily large SKILL.md body.
  2. 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.md is larger than MAX_SKILL_PROMPT_BYTES and 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.list limit 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_BYTES as 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 to MAX_SKILL_PROMPT_BYTES when 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, $connector mentions inside those skill bodies no longer reach collect_explicit_app_ids_from_skill_items through 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 SkillInvocation and the skill-injected metric.
  • Path matching: InjectedHostSkillPrompts stores raw and normalized paths; the skip check should compare the same lossy-string form the current turn.rs filter uses.
  • Pinned tests: production_turn_keeps_full_snapshot_host_skill_prompt and production_turn_keeps_core_host_injection_when_catalog_listings_are_disabled assert 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_prompt against MAX_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_paths threading through SkillLoadOutcome, LoadedSkillRoot, HostSkillRootSnapshot, and the host-merge path becomes unused. (The identically named is_agent_plugin helpers 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.

View original on GitHub ↗