Skip to content

Commands: add /plugins chat command#48765

Merged
vincentkoc merged 4 commits intoopenclaw:mainfrom
vincentkoc:vincentkoc-code/plugins-chat-command
Mar 17, 2026
Merged

Commands: add /plugins chat command#48765
vincentkoc merged 4 commits intoopenclaw:mainfrom
vincentkoc:vincentkoc-code/plugins-chat-command

Conversation

@vincentkoc
Copy link
Copy Markdown
Member

Summary

  • Problem: chat had /mcp management but no equivalent /plugins surface, so plugin inspection/toggling required dropping to CLI.
  • Why it matters: operators managing OpenClaw from chat could inspect MCP state but not plugin state, which is an awkward gap for runtime management.
  • What changed: added /plugins and /plugin chat commands for list, show|get, enable, and disable, plus config gating via commands.plugins.
  • What did NOT change (scope boundary): no plugin install/update/uninstall from chat; CLI remains the path for those higher-risk flows.

Change Type (select all)

  • Feature

Scope (select all touched areas)

  • Skills / tool execution
  • UI / DX

Linked Issue/PR

User-visible / Behavior Changes

  • Added /plugins chat command family.
  • Added /plugin as a text alias for read flows.
  • Added commands.plugins=true feature flag.

Security Impact (required)

  • New permissions/capabilities? (Yes/No) Yes
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) Yes
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:
    • This adds a new owner/admin-gated chat command for plugin state management. Reads are limited to plugin discovery/status. Writes are limited to enable|disable, still require owner access, and require operator.admin on internal gateway surfaces.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local dev tree
  • Model/provider: n/a
  • Integration/channel (if any): text chat command harness
  • Relevant config (redacted): commands.text=true, commands.plugins=true

Steps

  1. Create a workspace plugin bundle under .openclaw/extensions/<id>.
  2. Run /plugins list and /plugins show <id>.
  3. Run /plugins enable <id> and /plugins disable <id>.

Expected

  • The plugin is listed from real discovery state.
  • Show returns plugin metadata.
  • Enable/disable updates config and changes loaded status on the next read.

Actual

  • Matches expected in the added chat-command tests.

Evidence

  • Failing test/log before + passing after

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios:
    • pnpm test -- src/auto-reply/reply/commands-plugins.test.ts
    • pnpm build
    • pnpm format
  • Edge cases checked:
    • /plugin alias parsing
    • internal write rejection without operator.admin
    • real workspace plugin discovery for list/show
  • What you did not verify:
    • native slash-command registration on live provider surfaces
    • broader registry suite still has two unrelated existing failures

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) Yes
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: set commands.plugins=false
  • Files/config to restore: remove commands.plugins and revert the added command files
  • Known bad symptoms reviewers should watch for: /plugins appearing when disabled or bypassing owner/admin gates

Risks and Mitigations

  • Risk:

    • /plugins status could drift from config if it read only in-memory config.
  • Mitigation:

    • Reads use readConfigFileSnapshot() + real plugin discovery.
  • Risk:

    • Chat command scope could overreach into install/update flows.
  • Mitigation:

    • Scope is intentionally limited to list/show/enable/disable.

@vincentkoc vincentkoc self-assigned this Mar 17, 2026
@openclaw-barnacle openclaw-barnacle Bot added cli CLI command changes agents Agent runtime and tooling size: L maintainer Maintainer-authored PR labels Mar 17, 2026
@openclaw-barnacle openclaw-barnacle Bot added the docs Improvements or additions to documentation label Mar 17, 2026
@vincentkoc vincentkoc marked this pull request as ready for review March 17, 2026 05:57
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 17, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Information disclosure via /plugins list and /plugins show on internal (webchat) channel
2 🔵 Low Plugin command can hijack new built-in /plugins (/plugin) management command due to handler ordering and missing reservation
3 🔵 Low Prototype pollution risk when persisting plugin enable/disable state using unvalidated plugin IDs

1. 🟡 Information disclosure via /plugins list and /plugins show on internal (webchat) channel

Property Value
Severity Medium
CWE CWE-200
Location src/auto-reply/reply/commands-plugins.ts:97-155

Description

The new /plugins command exposes internal plugin and install metadata to non-owner senders on the internal webchat channel.

Why this is a problem

  • allowInternalReadOnly explicitly bypasses the owner gate for /plugins list and /plugins show when params.command.channel is internal (webchat).
  • renderJsonBlock() uses JSON.stringify() on the full plugin record and config.plugins.installs[plugin.id] (the install record) with no redaction.
  • The exposed objects include multiple fields that can disclose sensitive environment details:
    • PluginRecord: source, rootDir, workspaceDir, and error (often contains file paths / stack traces / environment details).
    • PluginInstallRecord: sourcePath, installPath (local filesystem paths), and spec / resolvedSpec (may contain private registry URLs or credential-bearing URLs), plus integrity/shasum metadata.

Exposure to non-owners

  • For internal gateway chat requests, the server sets CommandAuthorized: true (see gateway/server-methods/chat.ts), meaning any internal client may be treated as command-authorized.
  • Because /plugins list|show bypasses rejectNonOwnerCommand() on internal channels, a non-owner webchat user can retrieve these details.

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,
}),

Recommendation

Tighten 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

  • For /plugins list, output only id, name, version, enabled, status.
  • For /plugins show, do not include source, rootDir, workspaceDir, error, installPath, sourcePath, spec, resolvedSpec, integrity, shasum.

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

Property Value
Severity Low
CWE CWE-284
Location src/auto-reply/reply/commands-core.ts:175-207

Description

The new built-in /plugins management command can be intercepted by a plugin-registered command with the same name, because plugin commands are dispatched before built-in handlers and plugins/plugin are not reserved command names.

Impact:

  • A plugin can register a command named plugins or plugin, causing handlePluginCommand to execute instead of the built-in handlePluginsCommand.
  • Since plugin commands can set requireAuth: false, a malicious/buggy plugin could make /plugins ... invocations run without the owner/admin checks implemented in handlePluginsCommand.
  • Even when requireAuth is left at the default (true), this collision can still undermine expected authorization semantics by bypassing built-in gating (e.g., commands.plugins flag, owner-only write paths).

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 /plugins command (with "/plugin" as an alias) without preventing plugins from registering those names.

Recommendation

Prevent plugins from overriding built-in management commands.

Option A (recommended): add plugin and plugins to the reserved command list used by plugin command registration.

// 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 /plugins and /plugin in handlePluginCommand to return null so built-ins can handle them).

Also consider adding a regression test that a plugin cannot register plugins/plugin as a command name.


3. 🔵 Prototype pollution risk when persisting plugin enable/disable state using unvalidated plugin IDs

Property Value
Severity Low
CWE CWE-1321
Location src/auto-reply/reply/commands-plugins.ts:176-192

Description

The /plugins enable|disable command writes plugin state into config.plugins.entries using plugin.id as an object key without blocking prototype-pollution keys.

  • plugin.id ultimately comes from untrusted plugin manifests on disk (openclaw.plugin.json) and is only validated as a non-empty string.
  • The handler passes this ID directly into setPluginEnabledInConfig(...), which uses computed object keys ([resolvedId]) to build the next config.
  • If a plugin declares an ID like __proto__, constructor, or prototype, this can lead to prototype-pollution style object corruption in downstream code that treats plugins.entries as a dictionary (e.g., code that assigns into a plain {} using obj[pluginId] = ...).

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:

  • Corrupt in-memory config-derived dictionaries (logic bypass/DoS)
  • In some build/transpile environments (e.g., object spread lowered to Object.assign), potentially become classic prototype pollution.

Because this is triggered by a config write path, the unsafe key can also be persisted to disk on initial config creation scenarios.

Recommendation

Block 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 /plugins write path (belt-and-suspenders):

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 __proto__/constructor from having special behavior even if they slip through.


Analyzed PR: #48765 at commit aa85982

Last updated on: 2026-03-17T07:32:57Z

@vincentkoc vincentkoc merged commit cc88b4a into openclaw:main Mar 17, 2026
7 checks passed
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 17, 2026

Greptile Summary

This PR adds /plugins and /plugin chat commands for plugin inspection and enablement toggling, filling a gap between the existing /mcp management surface and the CLI-only plugin management path. The implementation closely mirrors the existing /mcp command in structure — using the same auth-gate ordering (rejectUnauthorizedCommand → owner/internal-bypass → feature-flag), reading fresh state from disk via readConfigFileSnapshot(), and validating config before writes.

Key changes:

  • New commands-plugins.ts handler and plugins-commands.ts parser for list, show/get, enable, disable
  • commands.plugins config flag (default false) gates the feature
  • /plugin (singular) registered as a text alias alongside /plugins
  • operator.admin scope required for write operations on internal gateway channels
  • Tests cover list/show, enable/disable lifecycle, and the admin-scope rejection

Two items worth confirming:

  • The /plugin singular alias is documented as "for read flows" but the parser and registry allow it for enable/disable writes as well — if this should be read-only, enforcement needs to be added to the parser; if writes are intentional, the docs should reflect that.
  • When a malformed command (e.g. /plugins enable with no name) is sent on an internal channel by a non-owner, the handler returns "owner required" rather than the parse-error hint, since allowInternalReadOnly excludes the "error" action. This matches the /mcp pattern so is likely intentional.

Confidence Score: 4/5

  • Safe to merge with minor follow-up on the /plugin alias write-access question.
  • The implementation is well-structured, correctly gated behind commands.plugins: false by default, and the write path (enable/disable) enforces owner + operator.admin scope. The logic mirrors the established /mcp pattern and has meaningful test coverage. The main open question is whether /plugin (singular) should truly be restricted to reads as documented — if yes, a one-line parser guard is needed; if no, the docs need updating. No security regressions or data-loss risks are evident.
  • src/auto-reply/reply/plugins-commands.ts — confirm intent for the /plugin alias allowing write operations.
Prompt To Fix All 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.

---

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

Comment on lines +9 to +11
const match = raw.match(/^\/plugins?(?:\s+(.*))?$/i);
if (!match) {
return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 /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:

Suggested change
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.

Comment on lines +79 to +87
path: snapshot.path,
config,
report: buildPluginStatusReport({ config, workspaceDir }),
};
}

export const handlePluginsCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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":

Suggested change
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!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

nikolaisid pushed a commit to nikolaisid/openclaw that referenced this pull request Mar 18, 2026
* Tests: stabilize MCP config merge follow-ups

* Commands: add /plugins chat command

* Docs: add /plugins slash command guide
sbezludny pushed a commit to sbezludny/openclaw that referenced this pull request Mar 27, 2026
* Tests: stabilize MCP config merge follow-ups

* Commands: add /plugins chat command

* Docs: add /plugins slash command guide
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
* Tests: stabilize MCP config merge follow-ups

* Commands: add /plugins chat command

* Docs: add /plugins slash command guide
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* Tests: stabilize MCP config merge follow-ups

* Commands: add /plugins chat command

* Docs: add /plugins slash command guide
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* Tests: stabilize MCP config merge follow-ups

* Commands: add /plugins chat command

* Docs: add /plugins slash command guide
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling cli CLI command changes docs Improvements or additions to documentation maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant