Skip to content

fix: centralize plugin command auth requirements#55395

Open
jalehman wants to merge 4 commits intomainfrom
codex/plugin-command-scope-auth
Open

fix: centralize plugin command auth requirements#55395
jalehman wants to merge 4 commits intomainfrom
codex/plugin-command-scope-auth

Conversation

@jalehman
Copy link
Copy Markdown
Contributor

Summary

  • Problem: plugin commands only received a lossy auth view, so privileged internal checks had to be enforced ad hoc inside individual plugins.
  • Why it matters: that makes command authorization easier to bypass or forget when a plugin mixes low-risk and privileged actions.
  • What changed: plugin commands now receive richer auth context and can declare owner and internal gateway-scope requirements that the shared dispatcher enforces.
  • What did NOT change (scope boundary): this does not change public command syntax, plugin install flow, or non-internal chat command behavior outside the new centralized checks.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #
  • This PR fixes a bug or regression

Root Cause / Regression History (if applicable)

  • Root cause: the plugin command pipeline reduced authorization to isAuthorizedSender, which was not rich enough to express owner-only or internal scope-based requirements in the shared dispatcher.
  • Missing detection / guardrail: there was no declarative plugin command auth model for internal gateway scopes, so privileged checks lived inline in plugin handlers instead of in one enforcement path.
  • Prior context (git blame, prior PR, issue, or refactor if known): the immediate device-pair fix added scope plumbing and an inline /pair approve check, but left the broader plugin command model unchanged.
  • Why this regressed now: new internal command capabilities made the lossy boolean context more problematic because some plugin actions are materially more sensitive than others.
  • If unknown, what was ruled out: N/A

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/plugins/commands.test.ts, extensions/device-pair/index.test.ts
  • Scenario the test should lock in: internal callers without required scopes are rejected centrally, owner-only requirements are enforced centrally, and mixed-sensitivity commands can require scope only for privileged sub-actions.
  • Why this is the smallest reliable guardrail: the bug lives at the plugin command dispatch seam, and that seam can be tested directly without a full end-to-end environment.
  • Existing test that already covers this (if any): the device-pair tests covered the inline handler check; this PR moves the guardrail into dispatcher-level coverage.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

Internal callers now get centralized authorization failures for plugin commands that declare required owner or gateway scopes.
/pair approve still requires the same privileged access, but that requirement is now enforced by shared plugin command auth instead of an inline plugin-only check.

Diagram (if applicable)

Before:
[internal plugin command] -> [dispatcher checks auth boolean] -> [plugin handler does ad hoc privileged check]

After:
[internal plugin command] -> [dispatcher sees owner/scopes/surface] -> [dispatcher enforces command requirements] -> [plugin handler runs]

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (Yes)
  • Data access scope changed? (Yes)
  • If any Yes, explain risk + mitigation:
    • The shared plugin command dispatcher now enforces richer authorization requirements before handlers run. That changes command execution behavior for internal callers, but it narrows access rather than expanding it. Mitigations are centralized enforcement, explicit command declarations, admin-scope compatibility, and regression coverage around the dispatcher seam.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22 + pnpm workspace
  • Model/provider: N/A
  • Integration/channel (if any): internal gateway command path and plugin command path
  • Relevant config (redacted): default local test config

Steps

  1. Register a plugin command that requires internal gateway scope or owner-only access.
  2. Execute it through the shared plugin command dispatcher with and without the required auth context.
  3. Execute /pair approve and non-privileged /pair actions through the plugin command path.

Expected

  • Commands with declared requirements are rejected centrally when auth context is insufficient.
  • Non-privileged /pair actions continue to work without requiring pairing scope.
  • Authorized callers still succeed.

Actual

  • Matches expected behavior.
  • Full typecheck, repo checks, build, and full test suite pass.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: explicit pnpm tsgo, pnpm build, pnpm check, and full pnpm test; focused regression tests for plugin command dispatcher auth and device-pair auth behavior.
  • Edge cases checked: admin scope satisfies narrower operator scope requirements; gateway scope enforcement only applies to internal callers; mixed-sensitivity /pair actions do not over-restrict non-privileged subcommands.
  • What you did not verify: manual runtime testing against a live gateway or real channel surface.

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)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Risks and Mitigations

  • Risk: a plugin command could declare the wrong required scope and accidentally over- or under-restrict an internal action.
    • Mitigation: command definition validation, centralized enforcement, and targeted regression tests around the dispatcher seam.
  • Risk: richer auth context could diverge from upstream command context over time.
    • Mitigation: the dispatcher now receives the same owner/surface/scope context already computed upstream, rather than reconstructing it inside plugins.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 26, 2026

Greptile Summary

This PR centralizes plugin command authorization by moving owner and gateway-scope enforcement out of individual plugin handlers and into the shared executePluginCommand dispatcher. The PluginCommandContext is enriched with surface and senderIsOwner, and two new command-definition fields (requiredGatewayScopes, resolveRequiredGatewayScopes) let commands declare their auth requirements declaratively. The device-pair approve path is the concrete migration: its inline scope check is removed and replaced by resolveRequiredGatewayScopes, with test coverage moved to the dispatcher seam.

Key observations:

  • The core scope-enforcement logic is correct: admin scope satisfies any narrower requirement (per-scope OR condition), external callers bypass gateway enforcement, and dynamic scope resolution composes cleanly with static declarations.
  • resolveRequiredGatewayScopes is spread inline without a try/catch; a throwing callback bypasses the handler-level error boundary and propagates unhandled to the caller.
  • GatewayClientScopes is cast to OperatorScope[] in commands-plugin.ts rather than filtered, silently hiding any future mismatch between gateway-issued scope strings and the OperatorScope union.
  • The "admin scope bypasses narrower requirements" edge case is called out in the PR as manually verified but has no automated regression test in commands.test.ts.

Confidence Score: 4/5

Safe to merge; the security hardening is correct and narrows access, with no regressions in existing test coverage.

The core authorization logic is sound, the PR correctly removes ad-hoc checks and centralizes enforcement, and the new tests cover the primary dispatcher paths. The three remaining items are all P2: an unguarded throw from resolveRequiredGatewayScopes, an unsafe type cast for gatewayClientScopes, and a missing automated test for the admin-scope bypass that the PR explicitly calls out as a manually verified edge case.

src/plugins/commands.ts (unguarded resolveRequiredGatewayScopes throw), src/auto-reply/reply/commands-plugin.ts (type cast), src/plugins/commands.test.ts (missing admin-scope bypass test)

Important Files Changed

Filename Overview
src/plugins/commands.ts Adds centralized owner and gateway scope enforcement to executePluginCommand; logic is correct (admin scope bypass works per-scope, external callers skip scope check); resolveRequiredGatewayScopes callback not wrapped in try/catch before the handler's existing error boundary.
src/plugins/types.ts Adds surface, senderIsOwner, typed OperatorScope[] for gatewayClientScopes, PluginCommandAuthorizationContext, and new command definition fields; all additions are additive and backward-compatible.
src/plugins/command-registration.ts Validates requiredGatewayScopes against the known OperatorScope set; resolveRequiredGatewayScopes is type-checked but its runtime return values are not validated at registration time.
src/auto-reply/reply/commands-plugin.ts Now threads surface and senderIsOwner into executePluginCommand; uses an unsafe as-cast for gatewayClientScopes rather than a narrowing filter.
src/plugins/commands.test.ts Good seam-level coverage for new dispatcher auth paths; missing an automated test for the admin-scope bypass explicitly called out as manually verified.
extensions/device-pair/index.ts Inline scope check removed and replaced with declarative resolveRequiredGatewayScopes; the approve-action scope requirement is preserved correctly.
extensions/device-pair/index.test.ts Approve-path tests updated to exercise executePluginCommand directly, correctly locking in centralized enforcement behavior.
extensions/phone-control/index.test.ts Minimal update to add surface and senderIsOwner to the test command context factory; no functional changes.
extensions/talk-voice/index.test.ts Tightens gatewayClientScopes parameter type from string[] to OperatorScope[]; adds surface and senderIsOwner to command context; no behavioral changes.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/commands.ts
Line: 265

Comment:
**`resolveRequiredGatewayScopes` throw not caught**

`resolveRequiredGatewayScopes?.(authContext)` is spread inline without any error handling. If a plugin's callback throws, the exception propagates out of `executePluginCommand` before it can be caught by the try/catch that wraps `command.handler` (lines 343–356). The caller in `commands-plugin.ts` does not have its own try/catch either, so an unexpected throw here would bubble up unhandled and could crash the request entirely rather than returning a safe error reply.

Consider wrapping the dynamic resolution in a try/catch:

```
let dynamicScopes: readonly OperatorScope[] = [];
try {
  dynamicScopes = command.resolveRequiredGatewayScopes?.(authContext) ?? [];
} catch (err) {
  logVerbose(`Plugin command /${command.name} resolveRequiredGatewayScopes threw: ${(err as Error).message}`);
  return { text: "⚠️ Command failed. Please try again later." };
}
const requiredGatewayScopes = Array.from(
  new Set([...(command.requiredGatewayScopes ?? []), ...dynamicScopes]),
);
```

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-plugin.ts
Line: 42-44

Comment:
**Unsafe `as` cast for `gatewayClientScopes`**

`params.ctx.GatewayClientScopes` is cast directly to `OperatorScope[] | undefined` without any filtering or validation. If the upstream gateway ever populates this field with a scope string that is not a member of `OperatorScope` (e.g., a future scope added to the gateway but not yet to `method-scopes.ts`), the cast silently hides the mismatch. The runtime effect is safe-fail (the unknown scope simply won't match any requirement), but it makes the mismatch invisible in both compile-time output and logs, which can make debugging difficult.

A narrowing filter would make the intent explicit and self-documenting:

```
const KNOWN_OPERATOR_SCOPES = new Set<string>([
  ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE,
]);

gatewayClientScopes: (params.ctx.GatewayClientScopes ?? []).filter(
  (s): s is OperatorScope => KNOWN_OPERATOR_SCOPES.has(s),
),
```

(Or extract a shared `isOperatorScope` type-guard into `method-scopes.ts` so it can be reused.)

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/plugins/commands.test.ts
Line: 342-462

Comment:
**Missing test for `operator.admin` scope bypass**

The PR description and the human-verification section both explicitly call out that "admin scope satisfies narrower operator scope requirements" as an edge case that was verified manually. However, there is no automated test that locks this in at the dispatcher seam. Given that this bypass is the mechanism by which CLI/admin callers retain access after the centralized auth change, a missing regression test here is a notable gap — especially since the test file already has dedicated coverage for the analogous "external callers bypass scope requirements" case.

Consider adding a case alongside the existing gateway scope tests:

```ts
it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
  const handler = vi.fn(async () => ({ text: "ok" }));

  const result = await executePluginCommand({
    command: {
      name: "pairing",
      description: "Pairing command",
      requiredGatewayScopes: ["operator.pairing"],
      handler,
      pluginId: "demo-plugin",
    },
    surface: "webchat",
    channel: "webchat",
    senderId: "admin-1",
    isAuthorizedSender: true,
    gatewayClientScopes: ["operator.admin"],
    commandBody: "/pairing",
    config: {} as never,
  });

  expect(handler).toHaveBeenCalledTimes(1);
  expect(result).toEqual({ text: "ok" });
});
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Plugins: centralize plugin command auth ..." | Re-trigger Greptile

Comment thread src/plugins/commands.ts Outdated
Comment thread src/auto-reply/reply/commands-plugin.ts Outdated
Comment on lines +342 to +462
config: {} as never,
});

expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ This command requires owner authorization." });
});

it("blocks internal callers missing required gateway scopes", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));

const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pairing",
config: {} as never,
});

expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});

it("allows external callers to bypass gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));

const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "telegram",
channel: "telegram",
senderId: "123",
isAuthorizedSender: true,
commandBody: "/pairing",
config: {} as never,
});

expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});

it("supports context-sensitive gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const command: Parameters<typeof executePluginCommand>[0]["command"] = {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler,
pluginId: "demo-plugin",
};

const denied = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});

const allowed = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "qr",
commandBody: "/pair qr",
config: {} as never,
});

expect(handler).toHaveBeenCalledTimes(1);
expect(denied).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
expect(allowed).toEqual({ text: "ok" });
});
});
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 Missing test for operator.admin scope bypass

The PR description and the human-verification section both explicitly call out that "admin scope satisfies narrower operator scope requirements" as an edge case that was verified manually. However, there is no automated test that locks this in at the dispatcher seam. Given that this bypass is the mechanism by which CLI/admin callers retain access after the centralized auth change, a missing regression test here is a notable gap — especially since the test file already has dedicated coverage for the analogous "external callers bypass scope requirements" case.

Consider adding a case alongside the existing gateway scope tests:

it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
  const handler = vi.fn(async () => ({ text: "ok" }));

  const result = await executePluginCommand({
    command: {
      name: "pairing",
      description: "Pairing command",
      requiredGatewayScopes: ["operator.pairing"],
      handler,
      pluginId: "demo-plugin",
    },
    surface: "webchat",
    channel: "webchat",
    senderId: "admin-1",
    isAuthorizedSender: true,
    gatewayClientScopes: ["operator.admin"],
    commandBody: "/pairing",
    config: {} as never,
  });

  expect(handler).toHaveBeenCalledTimes(1);
  expect(result).toEqual({ text: "ok" });
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/commands.test.ts
Line: 342-462

Comment:
**Missing test for `operator.admin` scope bypass**

The PR description and the human-verification section both explicitly call out that "admin scope satisfies narrower operator scope requirements" as an edge case that was verified manually. However, there is no automated test that locks this in at the dispatcher seam. Given that this bypass is the mechanism by which CLI/admin callers retain access after the centralized auth change, a missing regression test here is a notable gap — especially since the test file already has dedicated coverage for the analogous "external callers bypass scope requirements" case.

Consider adding a case alongside the existing gateway scope tests:

```ts
it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
  const handler = vi.fn(async () => ({ text: "ok" }));

  const result = await executePluginCommand({
    command: {
      name: "pairing",
      description: "Pairing command",
      requiredGatewayScopes: ["operator.pairing"],
      handler,
      pluginId: "demo-plugin",
    },
    surface: "webchat",
    channel: "webchat",
    senderId: "admin-1",
    isAuthorizedSender: true,
    gatewayClientScopes: ["operator.admin"],
    commandBody: "/pairing",
    config: {} as never,
  });

  expect(handler).toHaveBeenCalledTimes(1);
  expect(result).toEqual({ text: "ok" });
});
```

How can I resolve this? If you propose a fix, please make it concise.

@openclaw-barnacle openclaw-barnacle Bot added the docs Improvements or additions to documentation label Mar 26, 2026
@jalehman jalehman force-pushed the codex/plugin-command-scope-auth branch from ac56bc7 to fbe3942 Compare March 26, 2026 22:41
Move plugin command authorization toward the GHSA's long-term model by
preserving richer auth context, supporting declarative owner and gateway
scope requirements, and enforcing them in the shared dispatcher. Convert
`/pair approve` to use the centralized requirement path and add regression
coverage for dispatcher-level auth behavior.

Regeneration-Prompt: |
  This follow-up hardening is for the plugin command auth gap described in
  GHSA-9gwp-pxfh-w6r5. The immediate exploit path was already fixed by
  plumbing gateway scopes into the device-pair plugin and checking `/pair
  approve` inline, but the longer-term goal is to stop relying on lossy,
  plugin-specific auth checks.

  Preserve the existing plugin command flow and keep the change additive.
  Carry richer authorization context into plugin execution, including owner
  status and command surface, and let commands declare owner or internal
  gateway-scope requirements that the central dispatcher enforces. Internal
  callers should fail closed when required scopes are missing, with admin
  scope still satisfying narrower operator requirements, while non-internal
  chat surfaces should keep their current auth behavior.

  Because `/pair` mixes low-risk actions like `qr` and `status` with the
  privileged `approve` action, use a context-sensitive requirement instead
  of making the whole command require pairing scope. Add focused regression
  tests around dispatcher enforcement and update any command-context test
  helpers that now need the richer fields.
Update the generated Plugin SDK API baseline files after extending plugin
command types for centralized owner and gateway-scope authorization.

Regeneration-Prompt: |
  The prior commit intentionally changed exported plugin SDK types in
  `src/plugins/types.ts` by adding richer plugin command auth context and
  declarative command requirement fields. CI reported plugin SDK API drift,
  which means the generated baseline files under `docs/.generated/` no
  longer matched the exported surface.

  Regenerate only the plugin SDK API baseline artifacts with the repo's
  standard generator, verify `pnpm plugin-sdk:api:check` passes, and keep
  this follow-up scoped to those generated files. Do not fold in unrelated
  failing tests from untouched surfaces.
Catch dynamic gateway-scope resolver failures in the dispatcher, narrow
forwarded gateway scope strings with an explicit operator-scope guard, add
regression coverage for admin bypass and resolver-throw behavior, and
refresh bundled plugin metadata after main-branch drift.

Regeneration-Prompt: |
  Follow up on review feedback for the centralized plugin command auth
  change. Keep the scope tightly limited to the three review items:
  catch exceptions from `resolveRequiredGatewayScopes`, replace the raw
  `GatewayClientScopes` cast with explicit operator-scope narrowing, and
  add dispatcher-level tests for the `operator.admin` bypass plus the safe
  failure path when dynamic scope resolution throws.

  While landing that patch, the repo hook may report stale bundled plugin
  metadata generated files because main advanced. Regenerate those standard
  outputs with the repo generator so the branch is consistent enough to
  rebase, but do not chase unrelated CI or Discord test failures here.
@jalehman jalehman force-pushed the codex/plugin-command-scope-auth branch from fbe3942 to c94f10b Compare March 26, 2026 23:11
@openclaw-barnacle openclaw-barnacle Bot added the gateway Gateway runtime label Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation extensions: device-pair extensions: phone-control extensions: talk-voice gateway Gateway runtime maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant