From e40977d8bc274f47ab7556f2ff4bc575127149b9 Mon Sep 17 00:00:00 2001 From: anikitenko Date: Tue, 7 Apr 2026 00:06:04 +0300 Subject: [PATCH] feat(capabilities): add declared capability preflight diagnostics for plugins --- README.md | 2 + docs/API_STABILITY.md | 2 + docs/SAFE_PLUGIN_AUTHORING.md | 17 ++++++ examples/README.md | 1 + .../operator-kubernetes-plugin.fixture.ts | 48 +++++++++++++++++ .../operator-terraform-plugin.fixture.ts | 48 +++++++++++++++++ src/FDOInterface.ts | 2 + src/PluginRegistry.ts | 54 ++++++++++++++++++- src/types.ts | 5 ++ tests/PluginRegistry.test.ts | 37 +++++++++++++ 10 files changed, 215 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6bf6dc..4c42d84 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Recommended backend model for these plugins: - operational actions go through host-mediated privileged contracts - process execution uses `system.process.exec` plus a narrow scope such as `system.process.scope.docker-cli` - filesystem mutations use `system.fs.mutate` plus a narrow scope such as `system.fs.scope.` +- plugins should declare expected capabilities in code via `declareCapabilities()` so host preflight checks can run before rare action paths are triggered Do not model these plugins around generic unrestricted shell access. @@ -229,6 +230,7 @@ Preferred pattern: - audit every privileged request with plugin identity and correlation id - use `requestScopedWorkflow(...)` when a plugin needs a typed multi-step preview/apply or inspect/act flow instead of plugin-private orchestration - expect workflow step results to expose typed process outcome data (`command`, `args`, `exitCode`, `stdout`, `stderr`, `durationMs`) rather than opaque blobs +- implement `declareCapabilities()` for operator plugins so declared vs granted capabilities can be compared during preflight and diagnostics Examples of suitable process scopes: diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md index 7ae6c58..b3acde6 100644 --- a/docs/API_STABILITY.md +++ b/docs/API_STABILITY.md @@ -27,6 +27,8 @@ This document defines which SDK surfaces are stable contracts versus internal im - `renderOnLoad()` - `serializeRender()` - `serializeRenderOnLoad()` +- Optional declared capability surface: + - `declareCapabilities()` - Public plugin contracts: - `FDOInterface` - `PluginMetadata` diff --git a/docs/SAFE_PLUGIN_AUTHORING.md b/docs/SAFE_PLUGIN_AUTHORING.md index fd53353..71cd6cc 100644 --- a/docs/SAFE_PLUGIN_AUTHORING.md +++ b/docs/SAFE_PLUGIN_AUTHORING.md @@ -84,6 +84,13 @@ Privileged SDK features are capability-gated. The host should grant capabilities Without capability grants, these operations throw explicit permission errors by design. +Best practice for operator-style and privileged plugins: + +- declare expected capabilities in code via `declareCapabilities()` +- treat that declaration as an early intent manifest for host preflight and diagnostics +- do not treat declared capabilities as actual grants; the host remains authoritative +- keep runtime `requireCapability(...)` and scoped helper enforcement for real authorization + For system-level changes such as `/etc/hosts`, use a host-mediated action contract with strict payload validation and host-side confirmation/auditing. For Docker-style plugins, prefer host-mediated `system.process.exec` with a narrow scope such as `system.process.scope.docker-cli` and a host allowlist for exact command paths and argument patterns. @@ -149,6 +156,16 @@ This lets plugin authors and AI tooling distinguish: This is the model AI tooling should recommend when a plugin needs to behave like Docker Desktop, a Kubernetes console, or another operational dashboard. +For production-grade UX, operator plugins should also expose: + +```ts +declareCapabilities() { + return createOperatorToolCapabilityPreset("kubectl"); +} +``` + +This allows hosts to compare declared capabilities with granted capabilities during preflight, before a user reaches a rare or deep action path. + ## Error-Path Safety - Keep render error fallbacks simple and runtime-safe diff --git a/examples/README.md b/examples/README.md index b926153..ecd4aa0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,7 @@ Primary authoring entry points: Use the operator fixtures for production-oriented DevOps/SRE/plugin authoring work. Use the non-operator fixtures for lifecycle, error-handling, storage, and UI composition baselines. +For privileged/operator fixtures, prefer declaring expected capabilities in code via `declareCapabilities()` so host preflight checks can compare declared vs granted capabilities early. ## Getting Started diff --git a/examples/fixtures/operator-kubernetes-plugin.fixture.ts b/examples/fixtures/operator-kubernetes-plugin.fixture.ts index d29dba2..13deecf 100644 --- a/examples/fixtures/operator-kubernetes-plugin.fixture.ts +++ b/examples/fixtures/operator-kubernetes-plugin.fixture.ts @@ -2,6 +2,7 @@ import { FDOInterface, FDO_SDK, PluginMetadata, + PluginRegistry, createOperatorToolCapabilityPreset, getOperatorToolPreset, createScopedWorkflowRequest, @@ -26,12 +27,20 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements return this._metadata; } + declareCapabilities() { + return createOperatorToolCapabilityPreset("kubectl"); + } + init(): void { const preset = getOperatorToolPreset("kubectl"); this.info("Kubernetes operator fixture initialized", { preset, + declaredCapabilities: this.declareCapabilities(), requestedCapabilities: createOperatorToolCapabilityPreset("kubectl"), }); + + PluginRegistry.registerHandler("kubectl.previewClusterObjects", async () => this.previewClusterObjects()); + PluginRegistry.registerHandler("kubectl.inspectAndRestartWorkflow", async () => this.inspectAndRestartWorkflow()); } render(): string { @@ -41,10 +50,49 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements

Recommended host capability bundle: ${JSON.stringify(createOperatorToolCapabilityPreset("kubectl"))}

Preferred request helper: requestOperatorTool("kubectl", ...)

For inspect/act flows, prefer requestScopedWorkflow(...) over plugin-private orchestration.

+
+ + +
+

       
     `;
   }
 
+  renderOnLoad(): string {
+    return `
+      () => {
+        const previewObjectsButton = document.getElementById("kubectl-preview-objects");
+        const inspectRestartWorkflowButton = document.getElementById("kubectl-inspect-restart-workflow");
+        const output = document.getElementById("kubectl-workflow-result");
+        if (!previewObjectsButton || !inspectRestartWorkflowButton || !output) return;
+
+        const runHandler = async (handler) => {
+          output.textContent = "Running...";
+          try {
+            const result = await window.createBackendReq("UI_MESSAGE", {
+              handler,
+              content: {},
+            });
+            output.textContent = JSON.stringify(result, null, 2);
+          } catch (error) {
+            output.textContent = JSON.stringify({
+              error: error instanceof Error ? error.message : String(error),
+            }, null, 2);
+          }
+        };
+
+        previewObjectsButton.addEventListener("click", () => {
+          void runHandler("kubectl.previewClusterObjects");
+        });
+
+        inspectRestartWorkflowButton.addEventListener("click", () => {
+          void runHandler("kubectl.inspectAndRestartWorkflow");
+        });
+      }
+    `;
+  }
+
   async previewClusterObjects(): Promise {
     return requestOperatorTool("kubectl", {
       command: "/usr/local/bin/kubectl",
diff --git a/examples/fixtures/operator-terraform-plugin.fixture.ts b/examples/fixtures/operator-terraform-plugin.fixture.ts
index d4c1109..a6344b6 100644
--- a/examples/fixtures/operator-terraform-plugin.fixture.ts
+++ b/examples/fixtures/operator-terraform-plugin.fixture.ts
@@ -2,6 +2,7 @@ import {
   FDOInterface,
   FDO_SDK,
   PluginMetadata,
+  PluginRegistry,
   createOperatorToolActionRequest,
   createOperatorToolCapabilityPreset,
   createScopedWorkflowRequest,
@@ -26,6 +27,10 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
     return this._metadata;
   }
 
+  declareCapabilities() {
+    return createOperatorToolCapabilityPreset("terraform");
+  }
+
   init(): void {
     const request = createOperatorToolActionRequest("terraform", {
       command: "/usr/local/bin/terraform",
@@ -36,9 +41,13 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
     });
 
     this.info("Terraform operator fixture initialized", {
+      declaredCapabilities: this.declareCapabilities(),
       requestedCapabilities: createOperatorToolCapabilityPreset("terraform"),
       request,
     });
+
+    PluginRegistry.registerHandler("terraform.previewPlan", async () => this.previewPlan());
+    PluginRegistry.registerHandler("terraform.previewApplyWorkflow", async () => this.previewAndApplyWorkflow());
   }
 
   render(): string {
@@ -47,10 +56,49 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F
         

${this._metadata.name}

Use curated capability and request helpers for known tool families.

For multi-step preview/apply flows, prefer requestScopedWorkflow(...) over plugin-private orchestration.

+
+ + +
+

       
     `;
   }
 
+  renderOnLoad(): string {
+    return `
+      () => {
+        const previewPlanButton = document.getElementById("terraform-preview-plan");
+        const previewApplyWorkflowButton = document.getElementById("terraform-preview-apply-workflow");
+        const output = document.getElementById("terraform-workflow-result");
+        if (!previewPlanButton || !previewApplyWorkflowButton || !output) return;
+
+        const runHandler = async (handler) => {
+          output.textContent = "Running...";
+          try {
+            const result = await window.createBackendReq("UI_MESSAGE", {
+              handler,
+              content: {},
+            });
+            output.textContent = JSON.stringify(result, null, 2);
+          } catch (error) {
+            output.textContent = JSON.stringify({
+              error: error instanceof Error ? error.message : String(error),
+            }, null, 2);
+          }
+        };
+
+        previewPlanButton.addEventListener("click", () => {
+          void runHandler("terraform.previewPlan");
+        });
+
+        previewApplyWorkflowButton.addEventListener("click", () => {
+          void runHandler("terraform.previewApplyWorkflow");
+        });
+      }
+    `;
+  }
+
   async previewPlan(): Promise {
     return requestOperatorTool("terraform", {
       command: "/usr/local/bin/terraform",
diff --git a/src/FDOInterface.ts b/src/FDOInterface.ts
index 221439c..cdbb701 100644
--- a/src/FDOInterface.ts
+++ b/src/FDOInterface.ts
@@ -1,4 +1,5 @@
 import {FDO_SDK} from "./index";
+import { PluginCapability } from "./types";
 
 /**
  * Public plugin contract for FDO SDK plugins.
@@ -7,6 +8,7 @@ import {FDO_SDK} from "./index";
  * The SDK serializes those strings for host transport separately.
  */
 export interface FDOInterface {
+    declareCapabilities?(): PluginCapability[];
     init(): void;
     render(): string;
     renderOnLoad?(): string;
diff --git a/src/PluginRegistry.ts b/src/PluginRegistry.ts
index bcba1a1..330415d 100644
--- a/src/PluginRegistry.ts
+++ b/src/PluginRegistry.ts
@@ -21,12 +21,13 @@ import { createDefaultStore } from "./StoreDefault";
 import { createJsonStore } from "./StoreJson";
 import { validatePluginMetadata, validateSerializedRenderPayload } from "./utils/contracts";
 import { NotificationManager } from "./utils/NotificationManager";
-import { configureCapabilities, getCapabilityDiagnostics, requireCapability } from "./utils/capabilities";
+import { configureCapabilities, createCapabilityBundle, getCapabilityDiagnostics, requireCapability } from "./utils/capabilities";
 import { emitDeprecationWarning } from "./utils/deprecation";
 
 type PluginWithMetadata = FDO_SDK & { metadata: PluginMetadata };
 type PluginWithQuickActions = FDO_SDK & { defineQuickActions: () => QuickAction[] };
 type PluginWithSidePanel = FDO_SDK & { defineSidePanel: () => SidePanelConfig };
+type PluginWithDeclaredCapabilities = FDO_SDK & { declareCapabilities: () => CapabilityConfiguration["granted"] };
 
 export class PluginRegistry {
     public static readonly DIAGNOSTICS_HANDLER = "__sdk.getDiagnostics";
@@ -151,6 +152,7 @@ export class PluginRegistry {
         this.refreshLoggerContext();
         this._logger.event("plugin.init.start");
         try {
+            this.logDeclaredCapabilityPreflight();
             this.pluginInstance?.init(); // Safe call without `!`
             this.refreshLoggerContext();
             this.diagnosticsState.initCount += 1;
@@ -240,6 +242,11 @@ export class PluginRegistry {
         const quickActionsCount = this.getQuickActions().length;
         const sidePanelConfig = this.getSidePanelConfig();
         const permissions = getCapabilityDiagnostics();
+        const declaredCapabilities = this.getDeclaredCapabilities();
+        const grantedSet = new Set(permissions.granted);
+        const declaredSet = new Set(declaredCapabilities);
+        const missingDeclared = declaredCapabilities.filter((capability) => !grantedSet.has(capability));
+        const undeclaredGranted = permissions.granted.filter((capability) => !declaredSet.has(capability));
 
         return {
             apiVersion: FDO_SDK.API_VERSION,
@@ -269,6 +276,11 @@ export class PluginRegistry {
                     capabilities: store.capabilities,
                     version: store.getVersion?.(),
                 })),
+                declaration: {
+                    declared: declaredCapabilities,
+                    missing: missingDeclared,
+                    undeclaredGranted,
+                },
                 permissions,
             },
             notifications: {
@@ -457,6 +469,46 @@ export class PluginRegistry {
     private static hasSidePanel(plugin: FDO_SDK): plugin is PluginWithSidePanel {
         return "defineSidePanel" in plugin;
     }
+
+    private static hasDeclaredCapabilities(plugin: FDO_SDK): plugin is PluginWithDeclaredCapabilities {
+        return "declareCapabilities" in plugin && typeof (plugin as PluginWithDeclaredCapabilities).declareCapabilities === "function";
+    }
+
+    private static getDeclaredCapabilities(): CapabilityConfiguration["granted"] {
+        if (!this.pluginInstance || !this.hasDeclaredCapabilities(this.pluginInstance)) {
+            return [];
+        }
+
+        const declared = this.pluginInstance.declareCapabilities();
+        if (!Array.isArray(declared) || declared.some((capability) => typeof capability !== "string")) {
+            throw new Error("Plugin declareCapabilities() must return an array of capability strings.");
+        }
+
+        return createCapabilityBundle([...declared]);
+    }
+
+    private static logDeclaredCapabilityPreflight(): void {
+        const declaredCapabilities = this.getDeclaredCapabilities();
+        if (declaredCapabilities.length === 0) {
+            return;
+        }
+
+        const grantedCapabilities = getCapabilityDiagnostics().granted;
+        const grantedSet = new Set(grantedCapabilities);
+        const missingCapabilities = declaredCapabilities.filter((capability) => !grantedSet.has(capability));
+
+        this._logger.event("plugin.capabilities.declared", {
+            declaredCapabilities,
+            grantedCapabilities,
+            missingCapabilities,
+        });
+
+        if (missingCapabilities.length > 0) {
+            this._logger.warn(
+                `Plugin declared capabilities that are not currently granted: ${missingCapabilities.join(", ")}.`
+            );
+        }
+    }
 }
 
 export default PluginRegistry;
diff --git a/src/types.ts b/src/types.ts
index 09c2eea..09015db 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -383,6 +383,11 @@ export type PluginDiagnostics = {
         quickActionsCount: number;
         hasSidePanel: boolean;
         stores: PluginStoreDiagnostic[];
+        declaration: {
+            declared: PluginCapability[];
+            missing: PluginCapability[];
+            undeclaredGranted: PluginCapability[];
+        };
         permissions: {
             granted: PluginCapability[];
             usageCount: Record;
diff --git a/tests/PluginRegistry.test.ts b/tests/PluginRegistry.test.ts
index 4c5bd9a..8483f51 100644
--- a/tests/PluginRegistry.test.ts
+++ b/tests/PluginRegistry.test.ts
@@ -146,6 +146,43 @@ describe("PluginRegistry", () => {
         expect(output).toEqual({ render: undefined, onLoad: undefined });
     });
 
+    test("includes declared capability diagnostics and missing preflight information", () => {
+        class DeclaredCapabilitiesPlugin extends FDO_SDK {
+            get metadata() {
+                return {
+                    id: "declared-capabilities-plugin",
+                    name: "Declared Capabilities Plugin",
+                    version: "1.0.0",
+                    author: "Test",
+                    description: "Declares expected capabilities",
+                    icon: "cog",
+                };
+            }
+
+            declareCapabilities() {
+                return ["system.process.exec", "system.process.scope.terraform"] as const;
+            }
+
+            init() {}
+            render(): any { return "
ok
"; } + } + + const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => {}); + PluginRegistry.configureCapabilities({ granted: ["system.process.exec"] }); + PluginRegistry.registerPlugin(new DeclaredCapabilitiesPlugin()); + PluginRegistry.callInit(); + + const diagnostics = PluginRegistry.getDiagnostics(); + expect(diagnostics.capabilities.declaration).toEqual({ + declared: ["system.process.exec", "system.process.scope.terraform"], + missing: ["system.process.scope.terraform"], + undeclaredGranted: [], + }); + expect(warnSpy).toHaveBeenCalledWith( + 'Plugin declared capabilities that are not currently granted: system.process.scope.terraform.' + ); + }); + test("callInit and callRenderer with plugin instance", () => { const mockPlugin = { init: vi.fn(),