A skill's grants outlive the skill. uninstallSkill deletes the row in skills and nothing else (server/src/plugins/store.ts:2423), and a grant is a row in plugin_grants whose ref is plain text with no foreign key (server/src/db/schema/plugins.ts:281). Every grant that put the skill on a Bot stays behind after the skill is gone, and it stays harmless only until somebody reuses the name.
How the name comes back
Slugs are shared across the deployment, and a free one is anybody's. skillRefusal treats a slug with no row as a new skill and lets any signed-in person write it (server/src/plugins/routes.ts:131). listForAgent then resolves a Bot's skill grants by slug alone, without asking whose skill answers to that slug now (server/src/plugins/store.ts:2585), so the Bot is offered whatever the new author wrote.
That reaches the model in two places. The / menu in a channel is built from GET /api/plugins/for/:agentId (app/src/lib/plugins/skill-commands.ts:9), and on send the chosen chip's instructions are put in front of the run (app/src/components/channels/channel-chat.tsx:851). When tool selection runs for that Bot, the skill's title and summary also go into the selection prompt (server/src/plugins/selection.ts:98).
What it gets around
Asked for directly, the grant route refuses this outcome: a person may put only their own skill, and only on a Bot they own, never on a Bot the deployment shares (server/src/plugins/routes.ts:780). The schema says a person's skill is "theirs alone" (server/src/db/schema/plugins.ts:197). The tenant package already guards against this on its own path, granting a package skill only when the package actually wrote it (server/src/tenant-package.ts:914). Uninstalling from the Skills page is the path without that guard.
Reproduction
Driven through createPluginRoutes as non-administrators against a migrated test database, on main at a96d88c:
- Alice writes
standup, grants it to her own Bot, and uninstalls it. All three answer 200.
- Bob writes a skill named
standup with his own instructions. 200.
- Bob asks to grant
standup to Alice's Bot. 403, as it should be.
GET /for/<alice's bot> lists standup with Bob's instructions.
The deployment-wide version is worse. An administrator puts a deployment skill on a shared Bot and uninstalls it. Alice writes a skill under the same name, and her direct grant to the shared Bot is refused with 403. The shared Bot then lists Alice's instructions for everybody who uses it. Afterwards all three original grants are still in plugin_grants.
What a fix probably has to do
Remove a skill's grants in the same transaction that removes the skill, and delete the rows that earlier uninstalls already left, because deployments that have uninstalled a granted skill are exposed today. A grant naming no skill is never listed or offered, so deleting it changes nothing a person can see. Only skill grants should go: mcp and bot grants share the table.
Filtering at read time instead would also hide skills an administrator deliberately put on a Bot the skill's author does not own, which the grant route allows (server/src/plugins/routes.ts:765).
Severity
Medium. It needs a skill to have been uninstalled while still granted, and the new author has to choose that slug, which is easy for a deployment skill because everybody can see those names. Past that point, any signed-in person can put instructions of their choosing on a Bot other people use, around the ownership rule, and the trail shows no grant because none was made. It does not widen what a Bot can do: a skill can only ask for tools the Bot already holds, and every call is still decided and audited.
A skill's grants outlive the skill.
uninstallSkilldeletes the row inskillsand nothing else (server/src/plugins/store.ts:2423), and a grant is a row inplugin_grantswhoserefis plain text with no foreign key (server/src/db/schema/plugins.ts:281). Every grant that put the skill on a Bot stays behind after the skill is gone, and it stays harmless only until somebody reuses the name.How the name comes back
Slugs are shared across the deployment, and a free one is anybody's.
skillRefusaltreats a slug with no row as a new skill and lets any signed-in person write it (server/src/plugins/routes.ts:131).listForAgentthen resolves a Bot's skill grants by slug alone, without asking whose skill answers to that slug now (server/src/plugins/store.ts:2585), so the Bot is offered whatever the new author wrote.That reaches the model in two places. The
/menu in a channel is built fromGET /api/plugins/for/:agentId(app/src/lib/plugins/skill-commands.ts:9), and on send the chosen chip's instructions are put in front of the run (app/src/components/channels/channel-chat.tsx:851). When tool selection runs for that Bot, the skill's title and summary also go into the selection prompt (server/src/plugins/selection.ts:98).What it gets around
Asked for directly, the grant route refuses this outcome: a person may put only their own skill, and only on a Bot they own, never on a Bot the deployment shares (
server/src/plugins/routes.ts:780). The schema says a person's skill is "theirs alone" (server/src/db/schema/plugins.ts:197). The tenant package already guards against this on its own path, granting a package skill only when the package actually wrote it (server/src/tenant-package.ts:914). Uninstalling from the Skills page is the path without that guard.Reproduction
Driven through
createPluginRoutesas non-administrators against a migrated test database, onmainata96d88c:standup, grants it to her own Bot, and uninstalls it. All three answer 200.standupwith his own instructions. 200.standupto Alice's Bot. 403, as it should be.GET /for/<alice's bot>listsstandupwith Bob's instructions.The deployment-wide version is worse. An administrator puts a deployment skill on a shared Bot and uninstalls it. Alice writes a skill under the same name, and her direct grant to the shared Bot is refused with 403. The shared Bot then lists Alice's instructions for everybody who uses it. Afterwards all three original grants are still in
plugin_grants.What a fix probably has to do
Remove a skill's grants in the same transaction that removes the skill, and delete the rows that earlier uninstalls already left, because deployments that have uninstalled a granted skill are exposed today. A grant naming no skill is never listed or offered, so deleting it changes nothing a person can see. Only
skillgrants should go:mcpandbotgrants share the table.Filtering at read time instead would also hide skills an administrator deliberately put on a Bot the skill's author does not own, which the grant route allows (
server/src/plugins/routes.ts:765).Severity
Medium. It needs a skill to have been uninstalled while still granted, and the new author has to choose that slug, which is easy for a deployment skill because everybody can see those names. Past that point, any signed-in person can put instructions of their choosing on a Bot other people use, around the ownership rule, and the trail shows no grant because none was made. It does not widen what a Bot can do: a skill can only ask for tools the Bot already holds, and every call is still decided and audited.