Rules / CFG089
CFG089 Copilot repository settings auto-install third-party plugins
Severity: warn (enabledPlugins, unpinned marketplace source) · info (repository-level autoUpdate, which Copilot currently ignores)
OWASP: LLM04:2026 – Supply Chain
OWASP Agentic: ASI04:2026 – Agentic Supply Chain Vulnerabilities: LLM04:2026 now covers the model and data supply chain and refers the agentic one, "including MCP servers and tool registries", to ASI04
What cfgaudit checks
GitHub Copilot's repository-level .github/copilot/settings.json carries two keys that install and load third-party code:
enabledPlugins— aRecord<string, boolean>keyedPLUGIN-NAME@MARKETPLACE-NAME. Atruevalue auto-installs the plugin, whose hooks, commands and MCP servers then load on session start.extraKnownMarketplaces— named marketplaces plugins may be installed from, sourced from an arbitrary Git host.
This is the threat model CFG055 already covers for Claude Code's identically-named keys, reached through Copilot's file instead.
Why warn and not error
CFG055 escalates to error when a settings file both registers a marketplace and enables a plugin from it. CFG089 does not.
The original reason was that committability was inferred: Copilot's docs called the file repository-level without saying it was meant to be committed. That reason is gone. The CLI configuration reference now states it directly — repository settings "are committed to the repository and shared with collaborators" — and gives both keys as "Merged — repository overrides user for same key", which is stronger than the rule originally assumed: a committed entry wins over a contributor's own value rather than merely adding to it.
The severity stayed at warn anyway, and the new reason is a measurement rather than an inference. Across 97 real .github/copilot/settings.json files, 33 (34%) both register a marketplace and enable a plugin from it. They include dotnet/runtime, dotnet/roslyn, dotnet/aspnetcore and microsoft/testfx.
Registering the marketplace you publish is how a repository ships a first-party plugin. It is the intended usage, not a footgun, and an error on a third of the population would teach people to skip the rule — the same argument #459 made when it dropped CFG095's build-cache warn. CFG055's escalation rests on a population this one does not share.
The self-registered-marketplace case is still called out in the message, at warn.
The source.source nesting is real
{
"extraKnownMarketplaces": {
"our-internal-marketplace": {
"source": { "source": "github", "repo": "acme-corp/copilot-plugins" }
}
}
}
→ warn: the marketplace source carries no immutable pin.
The doubled source key is Copilot's actual schema, not a typo in this document. The documented source types for a marketplace entry are "github" (requires repo, optional ref / path), "git" (requires url, optional ref / path) and "directory" (requires path).
"Unpinned", not "defaults to the default branch"
cfgaudit reports a remote marketplace source as unpinned when it carries neither a full-40-character sha nor a full-SHA ref.
The wording is deliberate. sha is documented as a full 40-character commit pin, "immune to force-pushes or tag/branch moves" — so its presence is a real pin and is not flagged. But what an omitted ref resolves to is undocumented, so cfgaudit does not assert that it tracks the default branch. The finding is the absence of the pin, which is verifiable, not a claim about the fallback, which is not.
A ref naming a branch or tag is not a pin: it moves under whoever controls the upstream.
"directory" sources are on disk and are not flagged — there is no upstream anyone else controls.
Scope
Repository-level file only. A user's own ~/.copilot/settings.json is their own choice, the same exemption CFG055 makes for ~/.claude/settings.json.
Safe alternative
Pin every marketplace source to a full commit SHA, and leave plugin enablement to the developer. A repository that wants to recommend plugins can document them in its README, where installing them stays an explicit decision rather than a side effect of opening a folder.
References
- OWASP LLM04:2026 – Supply Chain
- GitHub Copilot enterprise managed settings reference
- GitHub Copilot CLI configuration directory reference
- CFG055 (the same keys in Claude Code's
settings.json) · CFG074 (unpinned agent-skill sources).
autoUpdate (reported as info at the repository level)
Copilot CLI 1.0.79 added a per-entry switch: "Set "autoUpdate": true on an extraKnownMarketplaces entry in your user settings to auto-update its plugins at session start." The switch names user settings, and GitHub's configuration reference is explicit that the repository level does not honour it. The repository-settings table:
An
autoUpdate: trueon an entry is accepted at the repository level but currently ignored — the auto-update opt-in is only honored when set in the user's own settings or in managed (MDM/server) settings.
cfgaudit scans the repository file (.github/copilot/settings.json), which is the tier the reference says the flag does nothing at, so the finding is info, not warn: a committed repository-scope autoUpdate refreshes nothing today. It is still reported because the key is a declared opt-in a future release could begin honouring, and naming it lets a reader remove a value that reads as a live refresh but is not one.
An earlier version of this rule argued the other way, from the 1.0.80 bundle: repository settings are merged in behind folder trust, "so a committed autoUpdate reaches the same session-start refresh." That inference does not hold. The merge proves the entry is visible to the resolution; the refresh itself is native code (pluginOperationsAutoUpdateDefaultPlugins) the bundle only calls, and whether it reads the per-entry flag of a repository-tier entry is exactly what the reference denies. The vendor's sentence predates the rule (present in github/docs since the 2026-08-06 revision, before the branch was added), so it is the belated correction of a bundle reading preferred over a documented scope (#582). The unpinned-source concern is unaffected: it is a separate warn finding on the same entry, since who controls an unpinned upstream matters regardless of when the refresh fires.
The gitignored twin
The CLI's own settings map is {repo: ".github/copilot/settings.json", local: ".github/copilot/settings.local.json"}, and /settings --repo|--local <key> <value> writes to one or the other. The local file is gitignored by design, so a committed one is worth reporting as its own file: cfgaudit reads it under the project-local scope, the same footing as .claude/settings.local.json, .continue/settings.local.json and .devin/config.local.json. disableAllHooks is global rather than per file, so either file switching hooks off drops the inline tables of both.
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation