AGENTS.md told an offline agent to push with SKIP=apm-marketplace-check and asserted that hook was "the only one whose failure mode is 'no network'". Running all 12 pre-push hooks under a network namespace shows two fail, for one shared cause: apm-pack-check-clean resolves the same remote entry. An exact pin does not remove the ls-remote, so both hooks are named now. AGENTS.md also said everything in a plugin root except .apm/ is generated. Plugin roots carry hand-authored README.md, docs/, bin/, sources.md and .mcp.json, so an agent would hunt for an .apm/ source that does not exist or refuse the edit. The rule is positional: immunity belongs to the plugin root, and anything inside a mirrored directory is still rm -rf'd. ADR-0017 said apm strips a hooks field. The real loop is (agents, skills, commands, instructions) -- hooks absent, instructions never mentioned -- and it can never fire, because synthesize_plugin_json_from_apm_yml only emits the eight identity fields. The decision stands; the mechanism was overstated. Its mcpServers amendment is rewritten for the pointer payload and now records the real reason: inlining bypassed apm's credential sanitizer. ADR-0015's owner.email and version-pin passages are corrected against the apm source, and ADR-0016 gains the disallowedTools amendment. agent-audit's allowlist is data, so it gains disallowedTools too -- the ADR and the validator that enforces it had come apart. architecture.md described a root CLAUDE.md that imports two files (it imports one, plus an RTK block) and pointed at an ADR index that does not exist. Seven skill READMEs listed tests/ files the mirror strips, promising installed users files their install lacks; those rows are marked source-only, with the depth-4 template tests explicitly called out as surviving. And plugins/kyberforge/hooks/README.md, deleted during the conversion and preserved nowhere, is restored to a path the mirror does not own -- verified by running a sync against a scratch copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
133 lines
8.7 KiB
Markdown
133 lines
8.7 KiB
Markdown
# Plugin-scope agent-author omits `tools:` and all Claude-only fields from `.apm/agents/*.agent.md`
|
|
|
|
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/<name>.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 a space-separated tool-name string; Copilot CLI expects a
|
|
list drawn from its own alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`). A
|
|
value correct for one harness is wrong for the other.
|
|
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.
|
|
|
|
## Decision
|
|
|
|
At **plugin scope only** (destination package has an `apm.yml` at its root — an APM producer
|
|
package compiled via `apm compile`), `.apm/agents/<name>.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.
|
|
|
|
`agent-audit`, 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 `agent-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.
|
|
`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, so it is honoured for plugin agents. On the Copilot
|
|
side, the same verbatim-copy behaviour that makes `tools:` dangerous makes `disallowedTools`
|
|
harmless: an unrecognised frontmatter key is inert, and unlike a wrong `tools:` value it cannot
|
|
grant or misroute anything — the worst case is that the fence is absent on that harness, which is
|
|
the status quo the decision above already accepted.
|
|
|
|
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.
|
|
|
|
Net position: the allowlist stays dropped for the reason originally given; the specific capability
|
|
that mattered (preventing a read-only orchestrator from writing) is restored by the one mechanism
|
|
that is portable by construction. The consequence below is narrowed accordingly.
|
|
|
|
Enforcement follows the decision: `agent-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`, 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 `<root>/agents/`") no longer applies.
|
|
Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands in
|
|
`<root>/.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; `agent-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 — `agent-audit` reading field
|
|
lists from `references/field-inventory.md` 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 (`name`/`description`/`model`/`source_keys` — the last for provenance
|
|
tracking, validated separately by `validate-provenance.sh` against `sources.md`, not a
|
|
provider-specific field) for plugin-scope agents, while
|
|
continuing to serve its original two-blocklist role for project/user-scope validation.
|