Commands: add /plugins chat command#48765
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 Information disclosure via /plugins list and /plugins show on internal (webchat) channel
DescriptionThe new Why this is a problem
Exposure to non-owners
Vulnerable code: const allowInternalReadOnly =
(pluginsCommand.action === "list" || pluginsCommand.action === "show") &&
isInternalMessageChannel(params.command.channel);
const nonOwner = allowInternalReadOnly ? null : rejectNonOwnerCommand(params, "/plugins");
...
text: renderJsonBlock(`🔌 Plugin "${plugin.id}"`, {
plugin,
install,
}),RecommendationTighten authorization and/or redact sensitive fields before returning plugin/install details. Option A (simplest/safer): require owner (or operator.admin) even for internal read-only const allowInternalReadOnly = false; // remove bypass
// OR require admin scope on internal channel for read-only too
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/plugins read",
allowedScopes: ["operator.admin"],
missingText: "❌ /plugins list|show requires operator.admin on internal channel.",
});
if (missingScope) return missingScope;Option B: keep internal read-only but sanitize output
Example: type SafePluginView = Pick<PluginRecord, "id"|"name"|"version"|"enabled"|"status"|"format"|"bundleFormat">;
const safe: SafePluginView = {
id: plugin.id,
name: plugin.name,
version: plugin.version,
enabled: plugin.enabled,
status: plugin.status,
format: plugin.format,
bundleFormat: plugin.bundleFormat,
};
return { text: renderJsonBlock(`🔌 Plugin "${plugin.id}"`, safe) };2. 🔵 Plugin command can hijack new built-in /plugins (/plugin) management command due to handler ordering and missing reservation
DescriptionThe new built-in Impact:
Vulnerable dispatch ordering (plugin commands first): HANDLERS = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
// ...
handleMcpCommand,
handlePluginsCommand,
// ...
];This risk was introduced/expanded by adding a built-in RecommendationPrevent plugins from overriding built-in management commands. Option A (recommended): add // src/plugins/commands.ts
const RESERVED_COMMANDS = new Set([
// ...existing...
"plugin",
"plugins",
]);Option B: ensure built-in command handlers run before plugin command dispatch for reserved names (e.g., special-case Also consider adding a regression test that a plugin cannot register 3. 🔵 Prototype pollution risk when persisting plugin enable/disable state using unvalidated plugin IDs
DescriptionThe
Vulnerable entry point (new command handler): const next = setPluginEnabledInConfig(
structuredClone(loaded.config),
plugin.id,
pluginsCommand.action === "enable",
);Impact depends on runtime/consumers, but prototype-key IDs can:
Because this is triggered by a config write path, the unsafe key can also be persisted to disk on initial config creation scenarios. RecommendationBlock prototype-pollution keys for plugin IDs at all ingestion points, and defensively avoid using plain objects as maps. 1) Validate plugin IDs (manifest load + config keys): import { isBlockedObjectKey } from "../config/prototype-keys.js";
function assertSafePluginId(id: string): void {
if (isBlockedObjectKey(id)) {
throw new Error(`Invalid plugin id: ${id}`);
}
// optionally enforce a conservative charset
if (!/^[a-z0-9][a-z0-9._/-]*$/i.test(id)) {
throw new Error(`Invalid plugin id format: ${id}`);
}
}2) Enforce in assertSafePluginId(plugin.id);3) Use null-prototype maps for dictionaries where arbitrary external keys are stored: const entries = Object.create(null) as Record<string, PluginEntry>;This prevents Analyzed PR: #48765 at commit Last updated on: 2026-03-17T07:32:57Z |
Greptile SummaryThis PR adds Key changes:
Two items worth confirming:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/auto-reply/reply/plugins-commands.ts
Line: 9-11
Comment:
**`/plugin` alias permits write operations**
The PR description and docs both state that `/plugin` (singular) is an alias "for read flows only", but the regex `\/plugins?` and the registry `textAliases: ["/plugins", "/plugin"]` allow it to execute any action including `enable` and `disable`. For example, `/plugin enable foo` will work identically to `/plugins enable foo`.
If the intent is truly to restrict `/plugin` to read operations, the parser should reject write actions when the singular form is used:
```suggestion
export function parsePluginsCommand(raw: string): PluginsCommand | null {
const match = raw.match(/^(\/plugins?)((?:\s+.*)?)$/i);
if (!match) {
return null;
}
const isAlias = match[1].toLowerCase() === "/plugin"; // singular alias
```
Then reject `enable`/`disable` when `isAlias` is `true`. If writes via `/plugin` are intentionally allowed, the docs and PR description should say so to avoid confusion.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/auto-reply/reply/commands-plugins.ts
Line: 79-87
Comment:
**Owner check skipped for malformed commands on internal channels**
When `parsePluginsCommand` returns `{ action: "error" }` (e.g., `/plugins enable` with no plugin name), the `allowInternalReadOnly` condition evaluates to `false` (since `"error"` isn't `"list"` or `"show"`), so `rejectNonOwnerCommand` is called unconditionally. A non-owner on an internal channel sending a malformed `/plugins` command will receive "owner required" rather than the parse-error usage hint.
This mirrors the MCP command's behaviour (which has the same pattern), but it's worth noting: if surfacing parse errors to internal-channel non-owners is desired, the `allowInternalReadOnly` guard could include `"error"`:
```suggestion
const allowInternalReadOnly =
(pluginsCommand.action === "list" ||
pluginsCommand.action === "show" ||
pluginsCommand.action === "error") &&
isInternalMessageChannel(params.command.channel);
```
Otherwise the current behaviour is acceptable from a security standpoint (don't reveal command availability to non-owners).
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: aa85982 |
| const match = raw.match(/^\/plugins?(?:\s+(.*))?$/i); | ||
| if (!match) { | ||
| return null; |
There was a problem hiding this comment.
/plugin alias permits write operations
The PR description and docs both state that /plugin (singular) is an alias "for read flows only", but the regex \/plugins? and the registry textAliases: ["/plugins", "/plugin"] allow it to execute any action including enable and disable. For example, /plugin enable foo will work identically to /plugins enable foo.
If the intent is truly to restrict /plugin to read operations, the parser should reject write actions when the singular form is used:
| const match = raw.match(/^\/plugins?(?:\s+(.*))?$/i); | |
| if (!match) { | |
| return null; | |
| export function parsePluginsCommand(raw: string): PluginsCommand | null { | |
| const match = raw.match(/^(\/plugins?)((?:\s+.*)?)$/i); | |
| if (!match) { | |
| return null; | |
| } | |
| const isAlias = match[1].toLowerCase() === "/plugin"; // singular alias |
Then reject enable/disable when isAlias is true. If writes via /plugin are intentionally allowed, the docs and PR description should say so to avoid confusion.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/plugins-commands.ts
Line: 9-11
Comment:
**`/plugin` alias permits write operations**
The PR description and docs both state that `/plugin` (singular) is an alias "for read flows only", but the regex `\/plugins?` and the registry `textAliases: ["/plugins", "/plugin"]` allow it to execute any action including `enable` and `disable`. For example, `/plugin enable foo` will work identically to `/plugins enable foo`.
If the intent is truly to restrict `/plugin` to read operations, the parser should reject write actions when the singular form is used:
```suggestion
export function parsePluginsCommand(raw: string): PluginsCommand | null {
const match = raw.match(/^(\/plugins?)((?:\s+.*)?)$/i);
if (!match) {
return null;
}
const isAlias = match[1].toLowerCase() === "/plugin"; // singular alias
```
Then reject `enable`/`disable` when `isAlias` is `true`. If writes via `/plugin` are intentionally allowed, the docs and PR description should say so to avoid confusion.
How can I resolve this? If you propose a fix, please make it concise.| path: snapshot.path, | ||
| config, | ||
| report: buildPluginStatusReport({ config, workspaceDir }), | ||
| }; | ||
| } | ||
|
|
||
| export const handlePluginsCommand: CommandHandler = async (params, allowTextCommands) => { | ||
| if (!allowTextCommands) { | ||
| return null; |
There was a problem hiding this comment.
Owner check skipped for malformed commands on internal channels
When parsePluginsCommand returns { action: "error" } (e.g., /plugins enable with no plugin name), the allowInternalReadOnly condition evaluates to false (since "error" isn't "list" or "show"), so rejectNonOwnerCommand is called unconditionally. A non-owner on an internal channel sending a malformed /plugins command will receive "owner required" rather than the parse-error usage hint.
This mirrors the MCP command's behaviour (which has the same pattern), but it's worth noting: if surfacing parse errors to internal-channel non-owners is desired, the allowInternalReadOnly guard could include "error":
| path: snapshot.path, | |
| config, | |
| report: buildPluginStatusReport({ config, workspaceDir }), | |
| }; | |
| } | |
| export const handlePluginsCommand: CommandHandler = async (params, allowTextCommands) => { | |
| if (!allowTextCommands) { | |
| return null; | |
| const allowInternalReadOnly = | |
| (pluginsCommand.action === "list" || | |
| pluginsCommand.action === "show" || | |
| pluginsCommand.action === "error") && | |
| isInternalMessageChannel(params.command.channel); |
Otherwise the current behaviour is acceptable from a security standpoint (don't reveal command availability to non-owners).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/commands-plugins.ts
Line: 79-87
Comment:
**Owner check skipped for malformed commands on internal channels**
When `parsePluginsCommand` returns `{ action: "error" }` (e.g., `/plugins enable` with no plugin name), the `allowInternalReadOnly` condition evaluates to `false` (since `"error"` isn't `"list"` or `"show"`), so `rejectNonOwnerCommand` is called unconditionally. A non-owner on an internal channel sending a malformed `/plugins` command will receive "owner required" rather than the parse-error usage hint.
This mirrors the MCP command's behaviour (which has the same pattern), but it's worth noting: if surfacing parse errors to internal-channel non-owners is desired, the `allowInternalReadOnly` guard could include `"error"`:
```suggestion
const allowInternalReadOnly =
(pluginsCommand.action === "list" ||
pluginsCommand.action === "show" ||
pluginsCommand.action === "error") &&
isInternalMessageChannel(params.command.channel);
```
Otherwise the current behaviour is acceptable from a security standpoint (don't reveal command availability to non-owners).
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa85982e9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ok: true, | ||
| path: snapshot.path, | ||
| config, | ||
| report: buildPluginStatusReport({ config, workspaceDir }), |
There was a problem hiding this comment.
Avoid mutating plugin runtime during /plugins reads
loadPluginCommandState calls buildPluginStatusReport for every /plugins request, but that helper loads plugins with activation enabled by default; in src/plugins/loader.ts this means shouldActivate is true, cached registries are re-activated, and reload paths clear/re-register global plugin commands/handlers. Because this read path runs in the gateway process and uses the command’s workspaceDir, a read-only /plugins list|show can unexpectedly switch active plugin state for the running server (and for other conversations/workspaces), which is a runtime mutation rather than a safe inspection path.
Useful? React with 👍 / 👎.
* Tests: stabilize MCP config merge follow-ups * Commands: add /plugins chat command * Docs: add /plugins slash command guide
* Tests: stabilize MCP config merge follow-ups * Commands: add /plugins chat command * Docs: add /plugins slash command guide
* Tests: stabilize MCP config merge follow-ups * Commands: add /plugins chat command * Docs: add /plugins slash command guide
* Tests: stabilize MCP config merge follow-ups * Commands: add /plugins chat command * Docs: add /plugins slash command guide
* Tests: stabilize MCP config merge follow-ups * Commands: add /plugins chat command * Docs: add /plugins slash command guide
Summary
/mcpmanagement but no equivalent/pluginssurface, so plugin inspection/toggling required dropping to CLI./pluginsand/pluginchat commands forlist,show|get,enable, anddisable, plus config gating viacommands.plugins.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
/pluginschat command family./pluginas a text alias for read flows.commands.plugins=truefeature flag.Security Impact (required)
Yes/No) YesYes/No) NoYes/No) NoYes/No) YesYes/No) NoYes, explain risk + mitigation:enable|disable, still require owner access, and requireoperator.adminon internal gateway surfaces.Repro + Verification
Environment
commands.text=true,commands.plugins=trueSteps
.openclaw/extensions/<id>./plugins listand/plugins show <id>./plugins enable <id>and/plugins disable <id>.Expected
Actual
Evidence
Human Verification (required)
What you personally verified (not just CI), and how:
pnpm test -- src/auto-reply/reply/commands-plugins.test.tspnpm buildpnpm format/pluginalias parsingoperator.adminReview Conversations
Compatibility / Migration
Yes/No) YesYes/No) YesYes/No) NoFailure Recovery (if this breaks)
commands.plugins=falsecommands.pluginsand revert the added command files/pluginsappearing when disabled or bypassing owner/admin gatesRisks and Mitigations
Risk:
/pluginsstatus could drift from config if it read only in-memory config.Mitigation:
readConfigFileSnapshot()+ real plugin discovery.Risk:
Mitigation: