# Plugin-scope agent-author omits `tools:` and all Claude-only fields from `.apm/agents/*.agent.md` **Amended by ADR-0025 (2026-09-15).** `agent-audit` was removed and its flow merged with `skill-audit`'s into `factory-audit`, which dispatches to a skill flow and an agent flow at Step 0. Read `agent-audit` below as `factory-audit`'s agent flow, and `validate.sh` as that flow's validator. The decision is unchanged — plugin-scope `.apm/agents/*.agent.md` still carries only the allowlisted fields, and the allowlist is still read as data from a reference file, now `factory-audit/references/agent-field-inventory.md`. The present-tense skill names below are updated accordingly. This ADR is a narrower, downstream consequence discovered while designing issue #89's implementation under ADR-0015's broader direction (Microsoft APM replaces hand-authored plugin/marketplace authoring). It does not restate ADR-0015's rationale — see that ADR for the parent decision. ## Context APM's agent primitive (`.apm/agents/.agent.md`) has no per-target integrator in `apm compile` — confirmed via APM's own Python source (`integration/targets.py` and related files, cited in `plugins/kyberforge/docs/research/docs/microsoft-apm/agent-primitive-schema.md`). Compilation does a naive verbatim copy of the whole frontmatter and body to both the Claude Code and Copilot CLI targets. This is unlike: - The **skill** primitive, which is also a straight copy (confirmed in the same research doc) but has no field semantics to conflict — `SKILL.md`'s content is target-agnostic already. - The **prompt**, **instructions**, and **hooks** primitives, which each get real per-target reconstruction through a dedicated integrator (field allowlisting, key renaming, dropped-field warnings). Because the agent primitive ships the same frontmatter unchanged to both harnesses, two concrete incompatibilities surface: 1. **`tools:`** — Claude Code expects tool names drawn from its own vocabulary, as a comma-separated string or a YAML list (`agent-definition.md:37`); Copilot CLI expects a list drawn from a different alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`). The incompatibility is the vocabulary, not the punctuation: a value correct for one harness names tools the other does not have. 2. **Claude-only knobs with no Copilot equivalent** — `isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`. Writing any of these means Copilot's copy carries frontmatter keys it doesn't recognize at all. Whether Copilot's agent loader ignores unknown keys or errors on them is unconfirmed by research. *(Still unconfirmed as of the 2026-08-14 amendment below, which admits `disallowedTools` as an explicitly accepted risk rather than by resolving this question.)* ## Decision At **plugin scope only** (destination package has an `apm.yml` at its root — an APM producer package compiled via `apm compile`), `.apm/agents/.agent.md` carries only `name`, `description`, `model`, and the prose body. No `tools:` field, no Claude-only fields, at all. *(Narrowed by the 2026-08-14 amendment below: `disallowedTools` is admitted as a fifth allowed field. `tools:` and every other Claude-only knob remain excluded on the reasoning given here.)* Absent `tools:` means inherit-all-tools on both harnesses — the one value that is never wrong on either target, unlike a present, harness-specific value that is guaranteed wrong on at least one of them. `factory-audit`'s agent flow, at plugin scope, is intended to flag — as a **SUGGESTION**, not a FAIL, since this is an upstream schema limitation rather than an authoring mistake — any agent whose description or body implies a need for tool restriction or a Claude-only behavior the frontmatter can no longer express. This would give visibility into the gap without pretending the schema can do something it can't. **Not yet implemented**: `check_apm_agent_file()` in `validate.sh` currently validates only the field allowlist, `name`, `description`, and body-emptiness/length — it has no heuristic for this case. Tracked as follow-up work. ### Scope boundary This decision applies to **plugin-scope `agent-author` only**. Project scope (`.claude/agents/` + `.github/agents/`) and user scope (`~/.claude/agents/` + `~/.copilot/agents/`) are not APM packages — neither goes through `apm compile` — so both keep today's dual-file Claude+Copilot pair model exactly as ADR-0005 and ADR-0008 already describe. Those two ADRs remain fully authoritative for project and user scope; only their plugin-scope clauses are affected by this ADR (see the update notes appended to each). ## Considered options **Pick one harness's vocabulary and accept breakage on the other (rejected).** E.g. always write Claude's space-separated `tools:` string. Rejected because it ships a value that is silently wrong (or possibly a hard error) on Copilot, and which harness "wins" would be an arbitrary, undocumented asymmetry. **Same as above, but `factory-audit` flags the cross-harness breakage as a tracked finding (rejected).** Rejected for the same core reason — it still ships a wrong value to a real harness. Tracking the breakage doesn't prevent it, and the chosen decision already gets equivalent visibility (a SUGGESTION finding) without ever shipping the wrong value in the first place. ## Amendment (2026-08-14): the write fence comes back as a denylist The decision above generalised from `tools:` to "no tool restriction at all". That over-reached. The unportability argument is specific to the **allowlist**: Claude Code reads `tools:` as a delimited string of its own tool names, Copilot CLI reads it as a list drawn from its alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`), so one value is wrong on one harness. That reasoning stands, and `tools:` stays out of every plugin-scope agent. A **denylist** has no such conflict. The evidence for that splits three ways, and this amendment states which part is which rather than asserting the whole as settled. **Confirmed — Claude Code honours it for plugin subagents.** `plugins/kyberforge/docs/research/docs/claude-code-plugins/agent-definition.md:39` documents `disallowedTools` as a "Denylist applied before `tools`… Takes precedence over `tools`", and — the part that matters here — it is **not** in that document's plugin-subagent ignore list. Line 99 names exactly three fields plugin agents silently ignore: `hooks`, `mcpServers`, `permissionMode`. `disallowedTools` is absent from that list. Claude Code is also the harness where the fence is actually wanted, so the field earns its place on this evidence alone. **Inferred — the field is very likely inert on Copilot CLI, but by analogy, not by documentation.** `plugins/kyberforge/docs/research/docs/github-copilot-plugins/troubleshooting.md:50` and `:53` record Copilot *silently ignoring* two agent frontmatter fields it does not process (`mcp-servers` and `metadata` outside the cloud runtime) rather than erroring on them. That is a documented tolerance for *known-but-unprocessed* keys, which is adjacent to, not identical to, tolerance for an *unknown* key. No stronger evidence exists: a sweep of the vendored Copilot corpus (`agent-definition.md`, `api-reference.md`, `troubleshooting.md`, `configuration.md`) documents unknown-key handling nowhere. **Unverified — Copilot's loader behaviour on an unrecognised key.** Context item 2 above says this is unconfirmed by research and that remains true; nothing found since changes it. An earlier revision of this amendment claimed "an unrecognised frontmatter key is inert" as settled fact and attributed it to apm's verbatim-copy behaviour. That attribution was a non-sequitur — verbatim copy describes what *apm* does at compile time and says nothing about what *Copilot* does at load time — and the claim contradicted this ADR's own Context section. **So this is an accepted risk, stated as one.** Blast radius if the inference is wrong and Copilot errors on the key: the three affected plugin-scope agents fail to load under Copilot CLI. It is loud, not silent; it is confined to three agents in three plugins; no other primitive and no Claude Code path is affected; and the remedy is a one-line frontmatter deletion. What the denylist shape *does* rule out categorically — independent of loader behaviour — is the failure mode that motivated dropping `tools:` in the first place: a denied name the other harness does not recognise denies nothing, so a mis-shaped value can never grant or misroute a capability. The risk is a load failure, never a silent over-grant. That asymmetry is why the same verbatim copy that makes `tools:` unshippable makes `disallowedTools` worth shipping. So the read-only orchestrator agents regain their write fence: `gitea-orchestrate`, `apm-orchestrate` and `lint-runner` each carry `disallowedTools: Edit, Write, NotebookEdit` plus explicit prose in the body stating the agent does not edit files. `git-orchestrate` is deliberately excluded — it legitimately declared `edit` before the conversion and still needs to write. **Residual — the fence is partial, and the prose is doing more of the work than the field is.** `disallowedTools: Edit, Write, NotebookEdit` denies exactly those three tools. It does not deny `Bash`, and at plugin scope these agents carry no `tools:` and therefore inherit it, so `bash -c 'echo … > f'` remains unfenced by frontmatter. Only the body prose covers that path. This is not a regression introduced here — the pre-conversion `tools:` allowlists also granted `Bash`, so the shell route was open then too — but the ADR should not credit the mechanism with more than it delivers. Closing it would need a `disallowedTools` entry for `Bash`, which these agents cannot take because they legitimately shell out. Net position: the allowlist stays dropped for the reason originally given, and the denylist is admitted as the portable-by-construction half of what was lost. It restores a real, Claude-Code- confirmed write fence against the tool-call path, not a complete write sandbox. The consequence below is narrowed accordingly. Enforcement follows the decision: `factory-audit`'s plugin-scope validator reads its allowlist as data from the `apm-agent-allowlist` section of `plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md` (now `factory-audit/references/agent-field-inventory.md`, see ADR-0025), and that line now reads `name description model source_keys disallowedTools`. `disallowedTools` also stays in that file's `claude-code-only-fields` list, which is not a contradiction — that list governs whether a field may cross the CC/Copilot boundary in a real project/user-scope *pair*, a different question from whether a field is safe under verbatim copy in a single vendor-neutral file. ## Consequences - Every plugin-scope APM agent loses per-agent tool *allowlisting* and any Claude-only capability (isolation, maxTurns, effort, memory, permissionMode) until APM ships a real per-target integrator for the agent primitive. This is a known, accepted regression, not an oversight. Tool **denial** is not part of that loss — see the 2026-08-14 amendment above. - **ADR-0005 is partially superseded** — its plugin-scope clause ("directory containing `plugin.json` is plugin scope → both files land in `/agents/`") no longer applies. Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands in `/.apm/agents/`." Project and user scope, and the rest of ADR-0005, are unaffected. - **ADR-0008 is partially superseded** — its counterpart-derivation/pair-validation mechanism no longer applies at plugin scope; `factory-audit` takes the single file directly there. Project and user scope, where a real pair still exists, are unaffected. - **ADR-0009 is not superseded.** The mechanism it established — `factory-audit` reading field lists from `references/field-inventory.md` (now `factory-audit/references/agent-field-inventory.md`, see ADR-0025) rather than hardcoding them, with a `source_keys` provenance chain — survives and is reused. Only the *content shape* changes for plugin scope: `field-inventory.md` shifts from two side-by-side CC-only/Copilot-only blocklists to one vendor-neutral allowlist for plugin-scope agents, while continuing to serve its original two-blocklist role for project/user-scope validation. That file's `apm-agent-allowlist` section is the authoritative list and is read as data by `validate.sh`; as amended on 2026-08-14 it holds `name`/`description`/`model`/`source_keys`/`disallowedTools` — `source_keys` for provenance tracking, validated separately by `validate-provenance.sh` against `sources.md` rather than being a provider-specific field, and `disallowedTools` per the amendment above.