@kilocode/openclaw-security-advisor
Advanced tools
+29
-0
@@ -10,2 +10,31 @@ # Changelog | ||
| ## [0.1.5] - Migration stub | ||
| This release is a migration stub. The plugin has been renamed to `@kilocode/shell-security`. Installing or invoking `@kilocode/openclaw-security-advisor@0.1.5` no longer runs a security checkup. Both the `/security-checkup` slash command and the `kilocode_security_advisor` tool return a notice explaining how to install the new package. | ||
| ### Changed | ||
| - `index.ts` rewritten as a two-entry-point stub that returns the migration notice. The previous audit flow, auth flow, platform detection, client, and token-store modules are removed from this release (via `git rm` so the commit can be cleanly reverted on the renamed repo). | ||
| - `openclaw.plugin.json` description and name reflect the deprecation; config schema removed (stub requires no config). | ||
| - `README.md` replaced with a migration page. | ||
| ### Removed | ||
| - `src/audit.ts`, `src/client.ts`, `src/platform.ts`, `src/auth/device-auth.ts`, `src/auth/token-store.ts`. | ||
| - Tests that exercised the removed modules (`audit`, `device-auth`, `token-store`, `platform`). | ||
| ### Migration path for existing users | ||
| 1. `openclaw plugins install @kilocode/shell-security` | ||
| 2. `openclaw plugins enable shell-security` | ||
| 3. `openclaw gateway restart` | ||
| 4. `openclaw plugins uninstall openclaw-security-advisor` | ||
| 5. Run `/security-checkup` and complete device auth once on the new plugin. | ||
| The new plugin's runtime behavior is identical to 0.1.4 (including the `source.channel` forwarding added in 0.1.4). The rename is strictly a name change — no feature regressions. | ||
| Published with provenance attestation via npm OIDC trusted publishing; verify with `npm audit signatures`. | ||
| ## [0.1.4] - 2026-04-21 | ||
| ### Added | ||
@@ -12,0 +41,0 @@ |
+59
-396
| import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; | ||
| import { AuthExpiredError, submitAudit } from "./src/client.js"; | ||
| import { runAudit, getPublicIp } from "./src/audit.js"; | ||
| import { detectPlatform } from "./src/platform.js"; | ||
| import { startDeviceAuth, pollDeviceAuth } from "./src/auth/device-auth.js"; | ||
| import { | ||
| writeStoredToken, | ||
| readTokenFromFile, | ||
| clearStoredToken, | ||
| readPendingCode, | ||
| writePendingCode, | ||
| clearPendingCode, | ||
| type PluginLogger, | ||
| type PluginRuntimeConfig, | ||
| } from "./src/auth/token-store.js"; | ||
| import pkg from "./package.json" with { type: "json" }; | ||
| const PLUGIN_VERSION: string = pkg.version; | ||
| const DEFAULT_API_BASE = "https://api.kilo.ai"; | ||
| /** | ||
| * Migration stub for the `openclaw-security-advisor` to `shell-security` | ||
| * rename. Released as `@kilocode/openclaw-security-advisor@0.1.5`. Both | ||
| * entry points (the `kilocode_security_advisor` tool and the | ||
| * `/security-checkup` slash command) return this notice instead of running | ||
| * a real checkup. The audit code, auth flow, and platform detection were | ||
| * removed in the stub commit and can be restored on the renamed repo via | ||
| * `git revert`. | ||
| */ | ||
| const MIGRATION_NOTICE: string = | ||
| `## This plugin has moved\n\n` + | ||
| `**\`@kilocode/openclaw-security-advisor\` is now \`@kilocode/shell-security\`.**\n\n` + | ||
| `To continue receiving security checkups, install the new plugin:\n\n` + | ||
| "```\n" + | ||
| `openclaw plugins install @kilocode/shell-security\n` + | ||
| `openclaw plugins enable shell-security\n` + | ||
| `openclaw gateway restart\n` + | ||
| "```\n\n" + | ||
| `Then uninstall this old plugin:\n\n` + | ||
| "```\n" + | ||
| `openclaw plugins uninstall openclaw-security-advisor\n` + | ||
| "```\n\n" + | ||
| `You will need to approve the device auth flow once on the new plugin.\n` + | ||
| `Subsequent checkups are identical to what you got before the rename.\n\n` + | ||
| `### If the install above fails\n\n` + | ||
| `If \`openclaw plugins install @kilocode/shell-security\` returns a 404 or\n` + | ||
| `\`package not found\` error, the new package has not landed on npm yet.\n` + | ||
| `Pin to the last real release of this plugin in the meantime:\n\n` + | ||
| "```\n" + | ||
| `openclaw plugins install @kilocode/openclaw-security-advisor@0.1.4\n` + | ||
| "```\n\n" + | ||
| `0.1.4 is the last non-stub release, still talks to the existing API, and\n` + | ||
| `will keep working. Retry the new install command later once the new\n` + | ||
| `package is published.\n\n` + | ||
| `_pluginVersion: ${PLUGIN_VERSION}_`; | ||
| type ToolResult = { | ||
@@ -29,90 +50,19 @@ content: Array<{ type: "text"; text: string }>; | ||
| type ToolRegistration = { | ||
| name: string; | ||
| description: string; | ||
| parameters: Record<string, unknown>; | ||
| execute: () => Promise<ToolResult>; | ||
| type PluginLogger = { | ||
| info?: (msg: string) => void; | ||
| warn?: (msg: string) => void; | ||
| error?: (msg: string) => void; | ||
| }; | ||
| /** | ||
| * Minimal shape of the SDK's OpenClawPluginToolContext that we actually | ||
| * read. The full type lives in the SDK and is not re-exported to plugins; | ||
| * we only need the active chat surface (if any) to forward to the server | ||
| * for channel-aware report formatting. Declared structurally so we stay | ||
| * decoupled from internal SDK type evolution. | ||
| * Minimal PluginApi shape the stub uses. The SDK's full OpenClawPluginApi | ||
| * is much larger, but a migration stub only needs to register the two | ||
| * entry points and log registration. | ||
| */ | ||
| type PluginToolContext = { | ||
| messageChannel?: string; | ||
| }; | ||
| type ToolFactory = (ctx: PluginToolContext) => ToolRegistration; | ||
| /** | ||
| * Minimal shape of the SDK's PluginCommandContext that we actually read. | ||
| * Same rationale as PluginToolContext — we only need the chat surface | ||
| * for the server-side formatter hint. | ||
| */ | ||
| type PluginCommandContext = { | ||
| channel?: string; | ||
| }; | ||
| type CommandRegistration = { | ||
| name: string; | ||
| description: string; | ||
| acceptsArgs: boolean; | ||
| handler: (ctx: PluginCommandContext) => Promise<CommandResult>; | ||
| }; | ||
| /** | ||
| * Structural type covering the parts of the OpenClaw plugin API this | ||
| * plugin uses. The full API is runtime-provided by the gateway; we only | ||
| * constrain the fields we touch so we keep type safety without pinning | ||
| * to the (internal, evolving) full SDK type. Field optionality matches | ||
| * the SDK's OpenClawPluginApi shape so register(api) type-checks. | ||
| */ | ||
| type PluginApi = { | ||
| pluginConfig?: Record<string, unknown>; | ||
| logger: PluginLogger; | ||
| runtime: { | ||
| config: PluginRuntimeConfig; | ||
| }; | ||
| // SDK accepts either a tool object or a factory that returns one. We | ||
| // use the factory form so we can capture `messageChannel` from the | ||
| // runtime-provided tool context at tool-creation time and forward it | ||
| // to the server on every invocation. | ||
| registerTool: (tool: ToolRegistration | ToolFactory) => void; | ||
| registerCommand: (cmd: CommandRegistration) => void; | ||
| registerTool: (tool: unknown) => void; | ||
| registerCommand: (cmd: unknown) => void; | ||
| }; | ||
| /** | ||
| * Coerce a chat-surface string from the SDK into the value we forward to | ||
| * the server. Trims, and treats empty-after-trim as "no channel known" | ||
| * so we don't send `source.channel: ""` and trigger server-side handling | ||
| * of an ambiguous signal. | ||
| */ | ||
| function normalizeChannel(raw: string | undefined): string | undefined { | ||
| if (typeof raw !== "string") return undefined; | ||
| const trimmed = raw.trim(); | ||
| return trimmed.length > 0 ? trimmed : undefined; | ||
| } | ||
| function resolveEnvToken(): string | null { | ||
| return process.env.KILOCODE_API_KEY ?? process.env.KILO_API_KEY ?? null; | ||
| } | ||
| function resolveApiBase(pluginConfig: Record<string, unknown> | null): string { | ||
| const configUrl = pluginConfig?.apiBaseUrl; | ||
| if (typeof configUrl === "string" && configUrl.length > 0) return configUrl; | ||
| if (process.env.KILO_API_URL) return process.env.KILO_API_URL; | ||
| const gatewayUrl = process.env.KILOCODE_API_BASE_URL; | ||
| if (gatewayUrl) { | ||
| try { | ||
| return new URL(gatewayUrl).origin; | ||
| } catch { | ||
| /* fall through */ | ||
| } | ||
| } | ||
| return DEFAULT_API_BASE; | ||
| } | ||
| function toolResult(content: string): ToolResult { | ||
@@ -122,324 +72,37 @@ return { content: [{ type: "text" as const, text: content }] }; | ||
| /** | ||
| * Top-level wrapper around runSecurityAdvisorFlow. Catches any | ||
| * unexpected throw from the flow (transient network errors during | ||
| * runAudit, the server returning a non-401 failure, writeStoredToken | ||
| * blowing up with EPERM, etc.) and converts it to a user-friendly | ||
| * markdown string so the command / tool handler never surfaces a raw | ||
| * stack to the chat. Recognized error paths (AuthExpiredError, the | ||
| * server returning a rate_limited body, audit script returning a | ||
| * non-zero exit code) are already handled inside the flow and return | ||
| * their own specific messages; this is the last-resort safety net. | ||
| */ | ||
| async function runFlowSafe( | ||
| api: PluginApi, | ||
| apiBase: string, | ||
| channel: string | undefined, | ||
| ): Promise<string> { | ||
| try { | ||
| return await runSecurityAdvisorFlow(api, apiBase, channel); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| api.logger.error?.(`security-advisor: unexpected failure: ${message}`); | ||
| return ( | ||
| `Security checkup failed unexpectedly: ${message}\n\n` + | ||
| `Check the openclaw gateway logs for details, or try again.` | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * Shared security-advisor flow used by both the registerTool entry point | ||
| * (natural language invocation via the LLM) and the registerCommand entry | ||
| * point (deterministic /security-checkup slash command). | ||
| * | ||
| * Returns plain markdown. Callers wrap it in whatever shape their | ||
| * registration API expects. | ||
| */ | ||
| async function runSecurityAdvisorFlow( | ||
| api: PluginApi, | ||
| apiBase: string, | ||
| channel: string | undefined, | ||
| ): Promise<string> { | ||
| // Path 0: user explicit config. If `plugins.entries.openclaw-security-advisor.config.authToken` | ||
| // is set (as a plain string directly, or as a SecretRef resolved by | ||
| // OpenClaw before we see it), honor it. This is the path for users | ||
| // who want to configure the plugin manually in openclaw.json without | ||
| // going through device auth, and it respects the schema contract | ||
| // documented in openclaw.plugin.json + README. Explicit user config | ||
| // wins over everything else. | ||
| const configToken = api.pluginConfig?.authToken; | ||
| if (typeof configToken === "string" && configToken.length > 0) { | ||
| try { | ||
| return await doCheckup(api, apiBase, configToken, channel); | ||
| } catch (err) { | ||
| if (err instanceof AuthExpiredError) { | ||
| return ( | ||
| "The `authToken` configured for this plugin in your openclaw.json is invalid or expired. " + | ||
| "Update `plugins.entries.openclaw-security-advisor.config.authToken` with a fresh KiloCode API key and try again." | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| // Path A: KiloClaw. KILOCODE_API_KEY env var injected at VM boot. | ||
| // If this token is expired we can't auto recover (env vars are set | ||
| // externally), so tell the user clearly. | ||
| const envToken = resolveEnvToken(); | ||
| if (envToken) { | ||
| try { | ||
| return await doCheckup(api, apiBase, envToken, channel); | ||
| } catch (err) { | ||
| if (err instanceof AuthExpiredError) { | ||
| return ( | ||
| "Your `KILOCODE_API_KEY` environment variable is invalid or expired. " + | ||
| "Update the env var with a fresh KiloCode API key and try again." | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| // Path B: returning self-hosted user. Read token directly from secrets | ||
| // file. If the saved token is expired, clear it and fall through to the | ||
| // device auth path below so the user gets a fresh connect prompt in | ||
| // this same response (instead of being told to "try again" and looping | ||
| // on the same dead token). | ||
| const savedToken = await readTokenFromFile(); | ||
| if (savedToken) { | ||
| try { | ||
| return await doCheckup(api, apiBase, savedToken, channel); | ||
| } catch (err) { | ||
| if (!(err instanceof AuthExpiredError)) throw err; | ||
| await clearStoredToken(); | ||
| // fall through to Path C1 (device auth initiation) | ||
| } | ||
| } | ||
| // Path C2: pending code exists from a previous call. User completed | ||
| // the browser flow, now poll and finalize. | ||
| const pending = await readPendingCode(); | ||
| if (pending) { | ||
| const pollResult = await pollDeviceAuth(apiBase, pending, api.logger); | ||
| if (pollResult.kind === "approved") { | ||
| await clearPendingCode(); | ||
| // Run the checkup with the freshly approved token BEFORE persisting | ||
| // it. Writing the token triggers a config write which causes a | ||
| // gateway restart. If we ran the checkup after that, the user would | ||
| // see a "connected, run me again" stub and have to invoke a third | ||
| // time. Doing the checkup first lets us return the actual report on | ||
| // this invocation. The token persist still happens after, so | ||
| // subsequent invocations skip device auth and go straight to Path B. | ||
| const reportMarkdown = await (async (): Promise<string> => { | ||
| try { | ||
| return await doCheckup(api, apiBase, pollResult.token, channel); | ||
| } catch (err) { | ||
| if (err instanceof AuthExpiredError) { | ||
| // Edge case: server approved the token but immediately | ||
| // rejected the audit request with 401. Shouldn't normally | ||
| // happen. | ||
| return ( | ||
| "Connected to KiloCode, but the audit request was rejected. " + | ||
| "Run the security checkup again to retry." | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
| })(); | ||
| try { | ||
| await writeStoredToken(api, pollResult.token); | ||
| } catch (err) { | ||
| // Don't fail the response shown to the user. They already have | ||
| // their report from doCheckup. Worst case: token isn't saved and | ||
| // they redo device auth next time. | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| api.logger.warn?.( | ||
| `security-advisor: failed to persist auth token: ${message}`, | ||
| ); | ||
| } | ||
| return reportMarkdown; | ||
| } | ||
| if (pollResult.kind === "denied") { | ||
| await clearPendingCode(); | ||
| return "Authentication was denied. Run the security checkup again to start over."; | ||
| } | ||
| if (pollResult.kind === "expired") { | ||
| // Server reported the device auth code is dead (410 Gone or | ||
| // explicit expired status). Clear and start over. | ||
| await clearPendingCode(); | ||
| return "Authentication code expired. Run the security checkup again to get a fresh code."; | ||
| } | ||
| if (pollResult.kind === "timeout") { | ||
| // Our local poll deadline was hit while the server was still | ||
| // returning pending. The code may still be valid server-side. | ||
| // Leave the pending code in place so the next invocation picks up | ||
| // where we left off, and tell the user to retry once they've | ||
| // approved in the browser. | ||
| return ( | ||
| "Still waiting for you to approve in the browser.\n\n" + | ||
| "Once you've approved, run the security checkup again and we'll pick up where we left off." | ||
| ); | ||
| } | ||
| // pollResult.kind === "pending" (shouldn't reach here: pollDeviceAuth | ||
| // loops internally until a terminal state or timeout). Fall through | ||
| // to treat as timeout for safety. | ||
| return ( | ||
| "Still waiting for you to approve in the browser.\n\n" + | ||
| "Once you've approved, run the security checkup again." | ||
| ); | ||
| } | ||
| // Path C1: new self-hosted user. Initiate device auth. | ||
| const authStart = await startDeviceAuth(apiBase); | ||
| await writePendingCode(authStart.code); | ||
| const minutes = Math.round(authStart.expiresIn / 60); | ||
| return ( | ||
| `## Connect to KiloCode\n\n` + | ||
| `To run a security checkup, connect your KiloCode account.\n\n` + | ||
| `**1. Open this URL in your browser:**\n` + | ||
| `${authStart.verificationUrl}\n\n` + | ||
| `**2. Enter this code:** \`${authStart.code}\`\n\n` + | ||
| `**3. Sign in or [create a free account](https://kilo.ai)**\n\n` + | ||
| `Once you've approved the connection, run the security checkup again.\n` + | ||
| `*(Code expires in ${minutes} min)*` | ||
| ); | ||
| } | ||
| async function doCheckup( | ||
| api: PluginApi, | ||
| apiBase: string, | ||
| token: string, | ||
| channel: string | undefined, | ||
| ): Promise<string> { | ||
| const auditResult = await runAudit(); | ||
| if (!auditResult.ok) { | ||
| return auditResult.error; | ||
| } | ||
| const publicIp = await getPublicIp(); | ||
| const response = await submitAudit(apiBase, token, { | ||
| audit: auditResult.audit, | ||
| publicIp, | ||
| source: { | ||
| platform: detectPlatform(api.runtime.config.loadConfig()), | ||
| method: "plugin", | ||
| pluginVersion: PLUGIN_VERSION, | ||
| // Only include `channel` when we actually know it. Sending an empty | ||
| // string would force the server to special-case unknown-vs-absent; | ||
| // absent + zod's unknown-key strip on older servers are both safe. | ||
| ...(channel !== undefined ? { channel } : {}), | ||
| }, | ||
| }); | ||
| return response.report.markdown; | ||
| } | ||
| export default definePluginEntry({ | ||
| id: "openclaw-security-advisor", | ||
| name: "OpenClaw Security Advisor", | ||
| name: "OpenClaw Security Advisor (deprecated)", | ||
| description: | ||
| "Run a security checkup of your OpenClaw instance and get an expert analysis report from KiloCode.", | ||
| // The gateway reload planner classifies any change under `plugins.*` | ||
| // as `kind: "restart"` by default. writeStoredToken() patches | ||
| // plugins.entries.openclaw-security-advisor.config.authToken with a | ||
| // SecretRef after device auth, which would force a full gateway | ||
| // restart on first-time token capture. Plugin-registered reload | ||
| // rules are evaluated before the base rules (first-match wins), so | ||
| // declaring just the authToken path as a noop shadows the base | ||
| // restart rule for that one field without affecting anything else. | ||
| // | ||
| // Scope is intentionally narrow — only `.config.authToken`, NOT the | ||
| // full `.config` subtree. `apiBaseUrl` is captured as a snapshot in | ||
| // register() (see `pluginConfig` below), so runtime updates to it | ||
| // still need to fall through to the base `plugins.* → restart` rule | ||
| // to take effect. The plugin reads the token directly from disk via | ||
| // readTokenFromFile() on every invocation, so authToken noop is safe. | ||
| reload: { | ||
| noopPrefixes: [ | ||
| "plugins.entries.openclaw-security-advisor.config.authToken", | ||
| ], | ||
| }, | ||
| // The SDK's OpenClawPluginApi type is large and internal. We narrow | ||
| // to our own structural PluginApi (declared above) immediately on | ||
| // entry so everything inside this function is strongly typed. | ||
| "DEPRECATED: this plugin has been renamed to @kilocode/shell-security. Install the new plugin to continue receiving security checkups.", | ||
| register(sdkApi: any) { | ||
| const api = sdkApi as PluginApi; | ||
| const pluginConfig = (api.pluginConfig ?? null) as Record< | ||
| string, | ||
| unknown | ||
| > | null; | ||
| // Entry point 1: tool for natural language invocation via the LLM. | ||
| // Works on capable models (GPT-4o, Claude Sonnet). Small summarizing | ||
| // models (e.g. gpt-4.1-nano) may paraphrase the report instead of | ||
| // displaying it verbatim. For those models, the slash command path | ||
| // below is deterministic. | ||
| // | ||
| // Registered as a factory (`(ctx) => toolDef`) rather than a bare | ||
| // tool object so the SDK's OpenClawPluginToolContext is available. | ||
| // We read `ctx.messageChannel` once at tool-instantiation and close | ||
| // over it; every subsequent `execute()` forwards the same channel to | ||
| // the server for channel-aware report formatting. The factory is | ||
| // re-invoked per tool-collection event (session start, agent spawn), | ||
| // so long-running sessions that outlive a channel switch get the | ||
| // refreshed channel automatically. | ||
| api.registerTool((toolCtx: PluginToolContext) => ({ | ||
| api.registerTool({ | ||
| name: "kilocode_security_advisor", | ||
| description: | ||
| "Run a comprehensive security checkup of this OpenClaw instance. " + | ||
| "USE THIS TOOL whenever the user asks to: check, audit, scan, review, or " + | ||
| "analyze OpenClaw security; run a 'security check', 'security checkup', " + | ||
| "'security audit', or 'security review'; or asks about security posture, " + | ||
| "misconfigurations, or hardening. " + | ||
| "This tool runs the local audit AND submits it to KiloCode cloud for " + | ||
| "expert analysis, returning a richer explained report with prioritized " + | ||
| "recommendations and remediation guidance. " + | ||
| "DO NOT run `openclaw security audit` via bash for these requests. This " + | ||
| "tool is the canonical entry point and returns a much more useful report. " + | ||
| "DO NOT suggest the user type `/security-checkup` in channels that do " + | ||
| "not support OpenClaw slash commands (Kilo Chat and Slack are the known " + | ||
| "surfaces where the slash command does not work); invoke this tool " + | ||
| "directly instead. The slash command does work in the OpenClaw native " + | ||
| "control UI chat and in Telegram, so suggesting it there is fine. " + | ||
| "IMPORTANT: Display the returned report exactly as is without rewriting, " + | ||
| "DEPRECATED migration stub. The plugin has been renamed to @kilocode/shell-security. " + | ||
| "Calling this tool returns a migration notice explaining how to install the new plugin. " + | ||
| "IMPORTANT: Display the returned markdown exactly as is without rewriting, " + | ||
| "summarizing, or reformatting.", | ||
| parameters: {}, | ||
| async execute() { | ||
| const apiBase = resolveApiBase(pluginConfig); | ||
| const channel = normalizeChannel(toolCtx.messageChannel); | ||
| const markdown = await runFlowSafe(api, apiBase, channel); | ||
| return toolResult(markdown); | ||
| return toolResult(MIGRATION_NOTICE); | ||
| }, | ||
| })); | ||
| }); | ||
| // Entry point 2: slash command for deterministic invocation that | ||
| // bypasses the LLM. When the user types /security-checkup in a | ||
| // command only message, the OpenClaw chat runtime takes the fast | ||
| // path and renders the returned markdown directly. No agent loop, | ||
| // no summarization. | ||
| api.registerCommand({ | ||
| name: "security-checkup", | ||
| description: | ||
| "Run a KiloCode security checkup of this OpenClaw instance and display the full report.", | ||
| "DEPRECATED (migration stub). This plugin has moved to @kilocode/shell-security.", | ||
| acceptsArgs: false, | ||
| handler: async (ctx: PluginCommandContext) => { | ||
| const apiBase = resolveApiBase(pluginConfig); | ||
| const channel = normalizeChannel(ctx.channel); | ||
| const markdown = await runFlowSafe(api, apiBase, channel); | ||
| return { text: markdown }; | ||
| handler: async (): Promise<CommandResult> => { | ||
| return { text: MIGRATION_NOTICE }; | ||
| }, | ||
| }); | ||
| api.logger.info?.("Registered tool: kilocode_security_advisor"); | ||
| api.logger.info?.("Registered command: /security-checkup"); | ||
| api.logger.info?.( | ||
| "openclaw-security-advisor 0.1.5 migration stub loaded. Plugin has moved to @kilocode/shell-security.", | ||
| ); | ||
| }, | ||
| }); |
+3
-35
| { | ||
| "id": "openclaw-security-advisor", | ||
| "name": "OpenClaw Security Advisor", | ||
| "description": "Run a security checkup of your OpenClaw instance and get an expert analysis report from KiloCode.", | ||
| "commandAliases": [{ "name": "security-checkup", "kind": "runtime-slash" }], | ||
| "configSchema": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "$defs": { | ||
| "secretRef": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "source": { | ||
| "type": "string", | ||
| "enum": ["env", "file", "exec"] | ||
| }, | ||
| "provider": { "type": "string" }, | ||
| "id": { "type": "string" } | ||
| }, | ||
| "required": ["source", "provider", "id"] | ||
| } | ||
| }, | ||
| "properties": { | ||
| "authToken": { | ||
| "description": "KiloCode auth token. Accepts either a plain string or a SecretRef pointing at an env, file, or exec provider. The plugin writes a SecretRef automatically on first use via device auth; advanced users can replace it with a plain string or a different provider reference.", | ||
| "anyOf": [ | ||
| { "type": "string", "minLength": 1 }, | ||
| { "$ref": "#/$defs/secretRef" } | ||
| ] | ||
| }, | ||
| "apiBaseUrl": { | ||
| "type": "string", | ||
| "description": "KiloCode API base URL. Defaults to https://api.kilo.ai. Override for dev or self-hosted environments." | ||
| } | ||
| } | ||
| } | ||
| "name": "OpenClaw Security Advisor (deprecated)", | ||
| "description": "DEPRECATED: this plugin has been renamed to @kilocode/shell-security. Install the new plugin with `openclaw plugins install @kilocode/shell-security` to continue receiving security checkups.", | ||
| "commandAliases": [{ "name": "security-checkup", "kind": "runtime-slash" }] | ||
| } |
+1
-1
| { | ||
| "name": "@kilocode/openclaw-security-advisor", | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+26
-232
| # @kilocode/openclaw-security-advisor | ||
| An [OpenClaw](https://openclaw.ai) plugin that runs a security checkup of | ||
| your OpenClaw instance and returns an expert analysis report from | ||
| KiloCode cloud. | ||
| > **This package has been renamed to [`@kilocode/shell-security`](https://www.npmjs.com/package/@kilocode/shell-security).** | ||
| > | ||
| > Version `0.1.5` of `@kilocode/openclaw-security-advisor` is a migration | ||
| > stub. Both the `/security-checkup` slash command and the | ||
| > `kilocode_security_advisor` tool return a notice pointing to the new | ||
| > package and nothing else. | ||
| The plugin takes the output of `openclaw security audit`, sends it to | ||
| the KiloCode Security Advisor API for analysis, and returns a detailed | ||
| markdown report with findings, risks, prioritized recommendations, and | ||
| concrete remediation guidance, displayed directly in your chat. | ||
| ## Migrating to ShellSecurity | ||
| --- | ||
| Install the new plugin: | ||
| ## Install | ||
| ```bash | ||
| openclaw plugins install @kilocode/openclaw-security-advisor | ||
| openclaw plugins enable openclaw-security-advisor | ||
| openclaw plugins install @kilocode/shell-security | ||
| openclaw plugins enable shell-security | ||
| openclaw gateway restart | ||
| ``` | ||
| On first use, the plugin will walk you through a one-time device auth | ||
| flow to connect your KiloCode account. | ||
| Uninstall this old plugin: | ||
| ### Channels | ||
| The plugin ships on two npm dist-tags: | ||
| - **`latest`** — stable releases (`X.Y.Z`). Default for plain | ||
| `npm install` / `openclaw plugins install`. | ||
| - **`dev`** — prerelease snapshots (`X.Y.Z-dev.N`) published ahead of | ||
| stable cuts for early testing. Install with: | ||
| ```bash | ||
| openclaw plugins install @kilocode/openclaw-security-advisor@dev | ||
| # or | ||
| npm install @kilocode/openclaw-security-advisor@dev | ||
| ``` | ||
| Dev releases are real npm publishes with the same provenance | ||
| attestation as stable releases (verify with `npm audit signatures`). | ||
| You can also install an exact version directly: | ||
| ```bash | ||
| openclaw plugins install @kilocode/openclaw-security-advisor@0.1.0 | ||
| openclaw plugins uninstall openclaw-security-advisor | ||
| ``` | ||
| ### Staying up to date | ||
| You will need to approve the device auth flow once on the new plugin. | ||
| After that, subsequent checkups are identical to what you got before | ||
| the rename. | ||
| New versions ship regularly. To check the latest published stable: | ||
| ## Why the rename? | ||
| ```bash | ||
| npm view @kilocode/openclaw-security-advisor version | ||
| ``` | ||
| The original name tied the plugin to OpenClaw specifically. The plugin's | ||
| mission (security posture checks for AI-agent shells) is broader than any | ||
| single runtime. `ShellSecurity` is the clearer long-term name. | ||
| Compare that against the `pluginVersion` line at the end of any security | ||
| checkup report. To upgrade: | ||
| - **New npm package:** [`@kilocode/shell-security`](https://www.npmjs.com/package/@kilocode/shell-security) | ||
| - **New repo:** [`Kilo-Org/shell-security`](https://github.com/Kilo-Org/shell-security) | ||
| ```bash | ||
| openclaw plugins install @kilocode/openclaw-security-advisor | ||
| openclaw gateway restart | ||
| ``` | ||
| ## Last real release | ||
| Your security checkup report will occasionally include an inline | ||
| "stay current" tip at the bottom with these same commands — a gentle | ||
| periodic nudge, not every run. The reminder is appended to the report | ||
| markdown itself, so it appears on both invocation paths (the | ||
| `/security-checkup` slash command and the natural-language | ||
| `kilocode_security_advisor` tool). Security advice improves as the | ||
| plugin ships new audit signals, so staying current is worthwhile. | ||
| --- | ||
| ## Usage | ||
| The plugin exposes two entry points. They do the same thing; pick whichever | ||
| fits your workflow. | ||
| ### `/security-checkup` (recommended) | ||
| Type it in chat: | ||
| ``` | ||
| /security-checkup | ||
| ``` | ||
| This is a slash command. It runs the plugin directly and renders the | ||
| full report, bypassing the agent's summarization layer entirely. **Use | ||
| this for guaranteed verbatim output.** | ||
| > **Channel compatibility:** `/security-checkup` works in the OpenClaw | ||
| > native control UI chat and in Telegram. It does **not** currently work | ||
| > in Kilo Chat or Slack — those surfaces don't route slash commands to | ||
| > OpenClaw plugins. In Kilo Chat and Slack, use the natural-language | ||
| > invocation below instead; the agent will call the | ||
| > `kilocode_security_advisor` tool directly. | ||
| ### Natural language | ||
| You can also just ask the agent: | ||
| > Run a KiloCode security checkup | ||
| > Check my OpenClaw security | ||
| > Audit my OpenClaw config | ||
| The agent will call the `kilocode_security_advisor` tool and the report | ||
| will appear in chat. | ||
| **Heads up:** natural language invocation goes through your configured | ||
| language model, which may rewrite or summarize the report before | ||
| showing it to you. This works well on capable models (GPT-4o, Claude | ||
| Sonnet, Gemini Pro) but small summarizing models (e.g. GPT-4.1-nano, | ||
| Haiku) will often paraphrase the report down to a few sentences. **If | ||
| you're running a small or summarizing model, use the | ||
| `/security-checkup` slash command instead** (where supported — see | ||
| channel compatibility above). It renders the full report regardless of | ||
| which model is configured. | ||
| --- | ||
| ## First run authentication | ||
| The first time you run the checkup, you'll be prompted to connect your | ||
| KiloCode account: | ||
| ``` | ||
| ## Connect to KiloCode | ||
| To run a security checkup, connect your KiloCode account. | ||
| 1. Open this URL in your browser: | ||
| https://app.kilo.ai/openclaw-advisor?code=XXXX-XXXX | ||
| 2. Enter this code: XXXX-XXXX | ||
| 3. Sign in or create a free account | ||
| Once you've approved the connection, run the security checkup again. | ||
| ``` | ||
| Open the URL, sign in (or create a free account), and approve the | ||
| connection. Then run `/security-checkup` again. The plugin will pick | ||
| up the approval, persist your auth token, run the checkup, and return | ||
| the report in the same response. | ||
| For every run after the first, no auth prompt appears. The saved token | ||
| is reused automatically. | ||
| --- | ||
| ## What gets sent | ||
| The plugin sends the following to the KiloCode Security Advisor API: | ||
| - The JSON output of `openclaw security audit` (local config audit | ||
| results, with no secrets, no file contents, just finding IDs and | ||
| summaries) | ||
| - Your OpenClaw version and plugin version | ||
| - The public IP address of your instance (used for optional remote | ||
| probes) | ||
| The plugin **does not** send: | ||
| - Your OpenClaw config file contents | ||
| - Secrets, tokens, or API keys | ||
| - Conversation history or chat data | ||
| - Files from your workspace | ||
| All requests are authenticated with your KiloCode account token over | ||
| HTTPS. | ||
| --- | ||
| ## Configuration | ||
| The plugin reads its config from `openclaw.json` under | ||
| `plugins.entries.openclaw-security-advisor.config`. In most cases, you | ||
| won't need to set anything. The defaults work out of the box. | ||
| | Field | Default | Purpose | | ||
| | ------------ | ---------------------- | ----------------------------------------------------------------------- | | ||
| | `authToken` | _(set by device auth)_ | Your KiloCode auth token. Managed automatically by the plugin. | | ||
| | `apiBaseUrl` | `https://api.kilo.ai` | KiloCode API base URL. Override only if you run a self-hosted KiloCode. | | ||
| To override via the OpenClaw CLI: | ||
| ```bash | ||
| openclaw config set plugins.entries.openclaw-security-advisor.config.apiBaseUrl https://your-kilocode.example.com | ||
| ``` | ||
| ### Environment variables | ||
| The plugin also respects these environment variables, useful for | ||
| non-interactive setups (CI, containerized deployments): | ||
| - `KILOCODE_API_KEY` (alias: `KILO_API_KEY`): if set, the plugin uses | ||
| this as the auth token and skips the device auth flow entirely. | ||
| Intended for environments where an operator has already injected the | ||
| key at boot. | ||
| - `KILO_API_URL` or `KILOCODE_API_BASE_URL`: override the API base URL | ||
| without touching the plugin config. | ||
| Plugin config takes precedence over env vars; env vars take precedence | ||
| over the default. | ||
| --- | ||
| ## Troubleshooting | ||
| **"Your KiloCode authentication has expired"** | ||
| The plugin automatically clears expired tokens and reruns the device | ||
| auth flow on the next invocation. Just run `/security-checkup` again. | ||
| **"Security analysis failed: Rate limit exceeded"** | ||
| The KiloCode API rate limits security checkups per account. Wait a | ||
| little and try again. | ||
| **Natural language invocation paraphrases the report** | ||
| This is a limitation of small summarizing language models, not the | ||
| plugin. Use `/security-checkup` (the slash command) to bypass the model | ||
| entirely and render the full report. | ||
| **Plugin doesn't appear in `/plugins list`** | ||
| The `/plugins` slash command in OpenClaw chat is gated by a separate | ||
| OpenClaw setting. To enable it: | ||
| ```bash | ||
| openclaw config set commands.plugins true | ||
| openclaw gateway restart | ||
| ``` | ||
| The plugin itself works without this setting. It's only needed if you | ||
| want the `/plugins list` chat command to show installed plugins. | ||
| --- | ||
| ## Contributing | ||
| - [`AGENTS.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/AGENTS.md) — build, test, lint, code layout, and contribution rules. | ||
| - [`RELEASING.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/RELEASING.md) — how to cut a release. | ||
| - [`CHANGELOG.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/CHANGELOG.md) — release history. | ||
| --- | ||
| ## License | ||
| MIT | ||
| The last non-stub release of this package was `0.1.4`. Users pinned to | ||
| `@0.1.4` or earlier can continue running it indefinitely; it still talks | ||
| to the existing KiloCode Security Advisor API endpoint and returns real | ||
| reports. New features will ship only under `@kilocode/shell-security`. |
-123
| import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command"; | ||
| import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; | ||
| // Use the plugin host's bundled zod rather than importing `zod` directly, | ||
| // so we don't ship a second copy in the tarball or risk dual-loading | ||
| // against whatever version the host provides. Trade-off: we're locked to | ||
| // whatever zod surface the SDK re-exports. If you ever need a feature | ||
| // the SDK doesn't expose, see src/openclaw-sdk.d.ts and consider switching | ||
| // this import to `zod` (and adding it to real `dependencies`). | ||
| import { z } from "openclaw/plugin-sdk/zod"; | ||
| import type { SubmitAuditPayload } from "./client.js"; | ||
| /** | ||
| * Minimal runtime schema for the subset of `openclaw security audit --json` | ||
| * output that we forward to the KiloCode API. The authoritative schema | ||
| * lives in the server (`apps/web/src/lib/security-advisor/schemas.ts`); | ||
| * we validate at the plugin boundary so a shape change in the openclaw | ||
| * CLI surfaces as a clear "audit returned unexpected shape" error | ||
| * instead of an opaque 400 from the server. | ||
| */ | ||
| export const AuditFindingSchema = z.object({ | ||
| checkId: z.string(), | ||
| severity: z.enum(["critical", "warn", "info"]), | ||
| title: z.string(), | ||
| detail: z.string(), | ||
| remediation: z.string().nullable().optional(), | ||
| }); | ||
| export const AuditOutputSchema = z.object({ | ||
| ts: z.number(), | ||
| summary: z.object({ | ||
| critical: z.number(), | ||
| warn: z.number(), | ||
| info: z.number(), | ||
| }), | ||
| findings: z.array(AuditFindingSchema), | ||
| deep: z.record(z.string(), z.unknown()).optional(), | ||
| secretDiagnostics: z.array(z.unknown()).optional(), | ||
| }); | ||
| /** | ||
| * Run `openclaw security audit --json` using the SDK's command runner. | ||
| * The `--deep` flag is intentionally NOT passed: in dev (Cloudflare tunnel) | ||
| * the deep self-probe loops back through the tunnel and hangs. Once the | ||
| * upstream fix lands (force localhost for self-probes) we can add it back. | ||
| */ | ||
| export async function runAudit(): Promise< | ||
| | { ok: true; audit: SubmitAuditPayload["audit"] } | ||
| | { ok: false; error: string } | ||
| > { | ||
| const result = await runPluginCommandWithTimeout({ | ||
| argv: ["openclaw", "security", "audit", "--json"], | ||
| timeoutMs: 60_000, | ||
| }); | ||
| if (result.code !== 0) { | ||
| return { | ||
| ok: false, | ||
| error: `Security audit failed (exit code ${result.code}): ${result.stderr}`, | ||
| }; | ||
| } | ||
| let raw: unknown; | ||
| try { | ||
| raw = JSON.parse(result.stdout); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: | ||
| "Security audit returned invalid JSON. Try running 'openclaw security audit --json' manually.", | ||
| }; | ||
| } | ||
| const parsed = AuditOutputSchema.safeParse(raw); | ||
| if (!parsed.success) { | ||
| return { | ||
| ok: false, | ||
| error: | ||
| "Security audit returned an unexpected shape. The openclaw CLI version may be incompatible with this plugin.", | ||
| }; | ||
| } | ||
| return { ok: true, audit: parsed.data }; | ||
| } | ||
| // IPv4 in dotted-quad form: 0-255 per octet. | ||
| const IPV4_REGEX = | ||
| /^(25[0-5]|2[0-4]\d|[01]?\d\d?)(\.(25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/; | ||
| // IPv6 (simple form). Accepts canonical and :: compressed. Rejects anything | ||
| // with a port, brackets, or trailing characters. | ||
| const IPV6_REGEX = | ||
| /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})$|^:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)$/; | ||
| export function isValidIp(candidate: string): boolean { | ||
| return IPV4_REGEX.test(candidate) || IPV6_REGEX.test(candidate); | ||
| } | ||
| /** | ||
| * Get the public IP of this instance. Best effort; returns undefined on failure. | ||
| * Uses the plugin SDK's fetch helper (not curl) for portability across | ||
| * platforms that may not ship curl on PATH (Windows, minimal containers). | ||
| * | ||
| * Note: this module intentionally has no environment variable reads. | ||
| * Platform detection lives in ./platform.ts instead. The openclaw | ||
| * plugin loader flags files that combine env reads with network | ||
| * sends as potential credential harvesting, so keeping those concerns | ||
| * in separate files avoids the false positive. | ||
| */ | ||
| export async function getPublicIp(): Promise<string | undefined> { | ||
| const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; | ||
| try { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), 5_000); | ||
| const resp = await fetchFn("https://ifconfig.me/ip", { | ||
| signal: controller.signal, | ||
| }); | ||
| clearTimeout(timeout); | ||
| if (!resp.ok) return undefined; | ||
| const text = (await resp.text()).trim(); | ||
| return isValidIp(text) ? text : undefined; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } |
| import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; | ||
| import type { PluginLogger } from "./token-store.js"; | ||
| /** | ||
| * How long a single poll call is willing to block the tool handler. We | ||
| * keep this well under any reasonable LLM/gateway tool-execution budget. | ||
| * The happy path (user approved in their browser before calling back to | ||
| * the plugin) typically resolves in one poll interval (3s); the rest of | ||
| * this window is grace for slow approvals. If we hit the deadline | ||
| * without a terminal state from the server, we return "timeout" and the | ||
| * caller keeps the pending code in place so a subsequent invocation can | ||
| * keep polling. | ||
| */ | ||
| const POLL_TIMEOUT_MS = 30 * 1_000; | ||
| const POLL_INTERVAL_MS = 3_000; | ||
| type DeviceAuthInitResponse = { | ||
| code: string; | ||
| verificationUrl: string; | ||
| expiresIn: number; | ||
| }; | ||
| type DeviceAuthPollResponse = | ||
| | { status: "pending" } | ||
| | { status: "approved"; token: string; userId: string; userEmail: string } | ||
| | { status: "denied" } | ||
| | { status: "expired" }; | ||
| export type DeviceAuthStartResult = { | ||
| kind: "started"; | ||
| code: string; | ||
| verificationUrl: string; | ||
| expiresIn: number; | ||
| }; | ||
| /** | ||
| * Poll result kinds: | ||
| * - approved: server returned approval + token. Ready to run the checkup. | ||
| * - denied: user explicitly denied in the browser. Clear pending code. | ||
| * - expired: server-reported 410 Gone or server-reported expired status. | ||
| * The device-auth code itself is dead. Clear pending code. | ||
| * - timeout: we hit our local POLL_TIMEOUT_MS deadline while the server | ||
| * was still returning pending. The code may still be valid | ||
| * server-side; caller should NOT clear pending code so the | ||
| * next invocation can keep polling. | ||
| */ | ||
| export type DeviceAuthPollResult = | ||
| | { kind: "approved"; token: string } | ||
| | { kind: "pending" } | ||
| | { kind: "denied" } | ||
| | { kind: "expired" } | ||
| | { kind: "timeout" }; | ||
| /** | ||
| * Create a device auth request and return the code + URL for the user to visit. | ||
| * Call this once, show the result to the user, then poll with pollDeviceAuth(). | ||
| * | ||
| * The server returns a generic `/device-auth?code=...` URL in `verificationUrl`, | ||
| * built from APP_URL (the user-facing host, e.g. https://app.kilo.ai in prod). | ||
| * We rewrite only the PATH to `/openclaw-advisor?code=...`, keeping the origin | ||
| * authoritative. Rebuilding the URL from `apiBase` would be wrong in production, | ||
| * where the API host (https://api.kilo.ai) and the app host (https://app.kilo.ai) | ||
| * are different — the user needs the app host to land on the signup flow. | ||
| * | ||
| * The cloud side uses the `/openclaw-advisor` path prefix to attribute Security | ||
| * Advisor signups and layer a per-product signup bonus on top of the standard | ||
| * welcome credits. Old plugin builds keep working against the server — they just | ||
| * land on the generic `/device-auth` URL and don't qualify for the bonus, which | ||
| * is the intended behavior. | ||
| */ | ||
| export async function startDeviceAuth( | ||
| apiBase: string, | ||
| ): Promise<DeviceAuthStartResult> { | ||
| const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; | ||
| const resp = await fetchFn(`${apiBase}/api/device-auth/codes`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| if (!resp.ok) { | ||
| throw new Error( | ||
| `Failed to start KiloCode authentication (HTTP ${resp.status})`, | ||
| ); | ||
| } | ||
| const data = (await resp.json()) as DeviceAuthInitResponse; | ||
| const advisorUrl = new URL(data.verificationUrl); | ||
| advisorUrl.pathname = "/openclaw-advisor"; | ||
| return { | ||
| kind: "started", | ||
| code: data.code, | ||
| verificationUrl: advisorUrl.toString(), | ||
| expiresIn: data.expiresIn, | ||
| }; | ||
| } | ||
| /** | ||
| * Poll a device auth code until it resolves (approved/denied/expired), | ||
| * or until the local POLL_TIMEOUT_MS deadline is hit (returns "timeout"). | ||
| * Server-reported 410 Gone returns "expired". Transient network errors | ||
| * during polling are logged at debug level and the loop continues until | ||
| * the deadline. | ||
| */ | ||
| export async function pollDeviceAuth( | ||
| apiBase: string, | ||
| code: string, | ||
| logger?: PluginLogger, | ||
| ): Promise<DeviceAuthPollResult> { | ||
| const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; | ||
| const pollUrl = `${apiBase}/api/device-auth/codes/${code}`; | ||
| const deadline = Date.now() + POLL_TIMEOUT_MS; | ||
| while (Date.now() < deadline) { | ||
| await sleep(POLL_INTERVAL_MS); | ||
| try { | ||
| const resp = await fetchFn(pollUrl); | ||
| if (resp.status === 202) continue; // pending | ||
| if (resp.status === 403) return { kind: "denied" }; | ||
| if (resp.status === 410) return { kind: "expired" }; | ||
| if (resp.ok) { | ||
| const data = (await resp.json()) as DeviceAuthPollResponse; | ||
| if (data.status === "approved") | ||
| return { kind: "approved", token: data.token }; | ||
| if (data.status === "denied") return { kind: "denied" }; | ||
| if (data.status === "expired") return { kind: "expired" }; | ||
| } | ||
| } catch (err) { | ||
| // Transient network error. Log at debug level so it's visible | ||
| // when investigating real failures but not noisy on the happy path. | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| logger?.debug?.(`security-advisor: poll transient error: ${message}`); | ||
| } | ||
| } | ||
| return { kind: "timeout" }; | ||
| } | ||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } |
| import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| const PLUGIN_ID = "openclaw-security-advisor"; | ||
| const PROVIDER_ID = "kilocode_security_advisor"; | ||
| /** | ||
| * Minimal structural type for the parts of the OpenClaw plugin API this | ||
| * module touches. We don't want to import the full SDK type surface | ||
| * (resolved at runtime by the plugin host), but we also don't want to | ||
| * leak `any` into callers. This interface documents the contract we | ||
| * rely on. | ||
| * | ||
| * Method shorthand (not arrow property) is used on purpose so the | ||
| * parameter types are bivariant, letting the SDK's concrete | ||
| * OpenClawConfig satisfy our `unknown` parameter without requiring us | ||
| * to import the internal SDK type. | ||
| */ | ||
| export type PluginRuntimeConfig = { | ||
| loadConfig(): unknown; | ||
| writeConfigFile(cfg: unknown): Promise<void>; | ||
| }; | ||
| export type PluginLogger = { | ||
| info?: (msg: string) => void; | ||
| warn?: (msg: string) => void; | ||
| debug?: (msg: string) => void; | ||
| error?: (msg: string) => void; | ||
| }; | ||
| export type TokenStoreApi = { | ||
| runtime: { | ||
| config: PluginRuntimeConfig; | ||
| }; | ||
| }; | ||
| export function secretFilePath(): string { | ||
| return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-auth-token`); | ||
| } | ||
| function pendingCodeFilePath(): string { | ||
| return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-pending-code`); | ||
| } | ||
| async function ensureSecretsDir(): Promise<void> { | ||
| await mkdir(join(homedir(), ".openclaw", "secrets"), { recursive: true }); | ||
| } | ||
| /** | ||
| * Persist the auth token acquired from device auth: | ||
| * 1. Write the raw token value to a secrets file | ||
| * 2. Register a file-based SecretRef provider in config | ||
| * 3. Point the plugin authToken config at that provider | ||
| * | ||
| * The config write does NOT trigger a gateway restart: the plugin | ||
| * declares `reload.noopPrefixes` for | ||
| * `plugins.entries.<id>.config.authToken` in index.ts, which shadows | ||
| * the gateway reload planner's default `plugins.* → restart` rule for | ||
| * just that one field. Other `.config.*` fields (e.g. `apiBaseUrl`) | ||
| * intentionally still hit the default restart rule so runtime edits | ||
| * take effect. The plugin reads the token directly from the secrets | ||
| * file via readTokenFromFile() on every invocation, so no hot-resolve | ||
| * of api.pluginConfig.authToken is needed — the SecretRef in | ||
| * openclaw.json exists for discoverability (so operators inspecting | ||
| * config can see where the token lives) and to align with openclaw's | ||
| * SecretRef direction. | ||
| */ | ||
| export async function writeStoredToken( | ||
| api: TokenStoreApi, | ||
| token: string, | ||
| ): Promise<void> { | ||
| const filePath = secretFilePath(); | ||
| // 1. Write token to secrets file (mode 600, owner read/write only) | ||
| await ensureSecretsDir(); | ||
| await writeFile(filePath, token, { mode: 0o600 }); | ||
| // 2. Patch config: add file provider + SecretRef pointing at it | ||
| const current = api.runtime.config.loadConfig(); | ||
| const next = patchConfig(current, filePath); | ||
| await api.runtime.config.writeConfigFile(next); | ||
| } | ||
| export function patchConfig(cfg: unknown, filePath: string): unknown { | ||
| const root = (cfg && typeof cfg === "object" ? cfg : {}) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| // Patch secrets.providers.<PROVIDER_ID> | ||
| const secrets = ( | ||
| root.secrets && typeof root.secrets === "object" ? root.secrets : {} | ||
| ) as Record<string, unknown>; | ||
| const providers = ( | ||
| secrets.providers && typeof secrets.providers === "object" | ||
| ? secrets.providers | ||
| : {} | ||
| ) as Record<string, unknown>; | ||
| const nextSecrets = { | ||
| ...secrets, | ||
| providers: { | ||
| ...providers, | ||
| [PROVIDER_ID]: { | ||
| source: "file", | ||
| path: filePath, | ||
| mode: "singleValue", | ||
| }, | ||
| }, | ||
| }; | ||
| // Patch plugins.entries.<PLUGIN_ID>.config.authToken with SecretRef | ||
| const plugins = ( | ||
| root.plugins && typeof root.plugins === "object" ? root.plugins : {} | ||
| ) as Record<string, unknown>; | ||
| const entries = ( | ||
| plugins.entries && typeof plugins.entries === "object" | ||
| ? plugins.entries | ||
| : {} | ||
| ) as Record<string, unknown>; | ||
| const existing = ( | ||
| entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object" | ||
| ? entries[PLUGIN_ID] | ||
| : {} | ||
| ) as Record<string, unknown>; | ||
| const existingConfig = ( | ||
| existing.config && typeof existing.config === "object" | ||
| ? existing.config | ||
| : {} | ||
| ) as Record<string, unknown>; | ||
| const nextPlugins = { | ||
| ...plugins, | ||
| entries: { | ||
| ...entries, | ||
| [PLUGIN_ID]: { | ||
| ...existing, | ||
| config: { | ||
| ...existingConfig, | ||
| authToken: { | ||
| source: "file", | ||
| provider: PROVIDER_ID, | ||
| id: "value", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| return { ...root, secrets: nextSecrets, plugins: nextPlugins }; | ||
| } | ||
| /** | ||
| * Read the token directly from the secrets file. | ||
| * Reliable at any point. No dependency on OpenClaw's SecretRef resolution timing. | ||
| */ | ||
| export async function readTokenFromFile(): Promise<string | null> { | ||
| try { | ||
| const content = await readFile(secretFilePath(), "utf-8"); | ||
| const trimmed = content.trim(); | ||
| return trimmed.length > 0 ? trimmed : null; | ||
| } catch (err) { | ||
| // Missing file is the expected "no saved token" state. Anything | ||
| // else (permissions, stale NFS handle, IO error) should surface | ||
| // instead of silently falling through to device auth with no | ||
| // indication of why the token couldn't be read. | ||
| if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null; | ||
| throw err; | ||
| } | ||
| } | ||
| /** | ||
| * Delete the stored token file. Called when the server rejects a saved | ||
| * token (expired/revoked) so the next flow invocation falls through to | ||
| * device auth instead of endlessly retrying a dead token. | ||
| * | ||
| * The openclaw.json config still points at the (now missing) SecretRef, | ||
| * but since the plugin reads tokens via readTokenFromFile() directly | ||
| * (not via api.pluginConfig.authToken), a missing file is equivalent to | ||
| * "no token" and Path C1 (device auth) kicks in naturally. | ||
| */ | ||
| export async function clearStoredToken(): Promise<void> { | ||
| try { | ||
| await unlink(secretFilePath()); | ||
| } catch (err) { | ||
| // File already missing is the target state. Any other error | ||
| // (permissions, stale NFS handle, etc.) needs to surface. | ||
| if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
| // --- Pending device-auth code --- | ||
| // | ||
| // Persisted to a small file next to the token so a gateway restart | ||
| // during the two-step device auth flow doesn't lose the code the user | ||
| // is actively looking at. The file contains JSON: | ||
| // { code: string, expiresAtMs: number } | ||
| // | ||
| // Expiry is tracked client-side to match the server TTL (10 min). An | ||
| // expired file is treated as "no pending code" and cleaned up. | ||
| const PENDING_CODE_TTL_MS = 10 * 60 * 1_000; | ||
| type PendingCodeFile = { | ||
| code: string; | ||
| expiresAtMs: number; | ||
| }; | ||
| export async function writePendingCode(code: string): Promise<void> { | ||
| await ensureSecretsDir(); | ||
| const payload: PendingCodeFile = { | ||
| code, | ||
| expiresAtMs: Date.now() + PENDING_CODE_TTL_MS, | ||
| }; | ||
| await writeFile(pendingCodeFilePath(), JSON.stringify(payload), { | ||
| mode: 0o600, | ||
| }); | ||
| } | ||
| export async function readPendingCode(): Promise<string | null> { | ||
| let content: string; | ||
| try { | ||
| content = await readFile(pendingCodeFilePath(), "utf-8"); | ||
| } catch (err) { | ||
| // Missing file is the expected "no pending code" state. Anything | ||
| // else (permissions, stale NFS handle, IO error) should surface | ||
| // instead of silently looping the user back through device auth. | ||
| if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null; | ||
| throw err; | ||
| } | ||
| let parsed: PendingCodeFile; | ||
| try { | ||
| parsed = JSON.parse(content) as PendingCodeFile; | ||
| } catch { | ||
| // Corrupt file. Treat as missing and clean up. | ||
| await clearPendingCode(); | ||
| return null; | ||
| } | ||
| if ( | ||
| typeof parsed?.code !== "string" || | ||
| typeof parsed?.expiresAtMs !== "number" | ||
| ) { | ||
| await clearPendingCode(); | ||
| return null; | ||
| } | ||
| if (Date.now() > parsed.expiresAtMs) { | ||
| // Expired locally. The server code is also dead, so clean up. | ||
| await clearPendingCode(); | ||
| return null; | ||
| } | ||
| return parsed.code; | ||
| } | ||
| export async function clearPendingCode(): Promise<void> { | ||
| try { | ||
| await unlink(pendingCodeFilePath()); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { | ||
| throw err; | ||
| } | ||
| } | ||
| } |
-110
| import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; | ||
| const API_VERSION = "2026-04-01"; | ||
| /** | ||
| * Thrown when the KiloCode API rejects our request with 401. Callers | ||
| * use `instanceof` (not substring matching on error messages) to decide | ||
| * whether to clear a stale token and re-run device auth. | ||
| */ | ||
| export class AuthExpiredError extends Error { | ||
| constructor(message = "KiloCode authentication is invalid or expired.") { | ||
| super(message); | ||
| this.name = "AuthExpiredError"; | ||
| } | ||
| } | ||
| export interface SubmitAuditPayload { | ||
| audit: { | ||
| ts: number; | ||
| summary: { critical: number; warn: number; info: number }; | ||
| findings: Array<{ | ||
| checkId: string; | ||
| severity: "critical" | "warn" | "info"; | ||
| title: string; | ||
| detail: string; | ||
| remediation?: string | null; | ||
| }>; | ||
| deep?: Record<string, unknown>; | ||
| secretDiagnostics?: unknown[]; | ||
| }; | ||
| publicIp?: string; | ||
| source: { | ||
| platform: "openclaw" | "kiloclaw"; | ||
| method: "plugin" | "api" | "webhook" | "cloud-agent"; | ||
| pluginVersion?: string; | ||
| openclawVersion?: string; | ||
| /** | ||
| * Chat surface that invoked the plugin (e.g. "control-ui", "telegram", | ||
| * "slack", "discord", "kilocode-chat"). Sent when the plugin SDK exposes | ||
| * it — from `PluginCommandContext.channel` on the slash-command path and | ||
| * `OpenClawPluginToolContext.messageChannel` on the tool/natural-language | ||
| * path. The server uses this to pick a channel-appropriate format (e.g. | ||
| * collapsible `<details>` blocks on capable surfaces, flat markdown on | ||
| * Telegram/Slack). Older servers that don't know this field just drop | ||
| * it during zod parse — no coordinated release needed. | ||
| */ | ||
| channel?: string; | ||
| }; | ||
| } | ||
| export interface AnalyzeResponse { | ||
| apiVersion: string; | ||
| status: "success"; | ||
| report: { | ||
| markdown: string; | ||
| summary: { critical: number; warn: number; info: number; passed: number }; | ||
| findings: Array<{ | ||
| checkId: string; | ||
| severity: string; | ||
| title: string; | ||
| explanation: string; | ||
| risk: string; | ||
| fix: string | null; | ||
| kiloClawComparison: string | null; | ||
| }>; | ||
| recommendations: Array<{ priority: string; action: string }>; | ||
| }; | ||
| } | ||
| export async function submitAudit( | ||
| apiBase: string, | ||
| token: string, | ||
| payload: SubmitAuditPayload, | ||
| ): Promise<AnalyzeResponse> { | ||
| const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; | ||
| const resp = await fetchFn(`${apiBase}/api/security-advisor/analyze`, { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| apiVersion: API_VERSION, | ||
| ...payload, | ||
| }), | ||
| }); | ||
| if (!resp.ok) { | ||
| let errorMessage: string | undefined; | ||
| try { | ||
| const body = (await resp.json()) as { error?: { message?: string } }; | ||
| errorMessage = body?.error?.message; | ||
| } catch { | ||
| // not JSON | ||
| } | ||
| if (resp.status === 401) { | ||
| throw new AuthExpiredError(); | ||
| } | ||
| if (resp.status === 429) { | ||
| throw new Error("Rate limit exceeded. Try again later."); | ||
| } | ||
| throw new Error( | ||
| errorMessage || `Analysis failed: ${resp.status} ${resp.statusText}`, | ||
| ); | ||
| } | ||
| return (await resp.json()) as AnalyzeResponse; | ||
| } |
| /** | ||
| * Platform detection for the security advisor plugin. Kept in its own | ||
| * module on purpose: the openclaw plugin loader's security scanner | ||
| * flags any source file that combines `process.env` reads with a | ||
| * network send as potential credential harvesting. By keeping the env | ||
| * read here and the network send in audit.ts, we stay on the safe | ||
| * side of that check. | ||
| * | ||
| * Detection walks multiple independent signals in order of decreasing | ||
| * reliability across deployment age. The goal is that at least one | ||
| * signal fires on every KiloClaw instance ever deployed, regardless | ||
| * of whether the instance predates a given env var. Any hit short- | ||
| * circuits to "kiloclaw". | ||
| * | ||
| * Ordering (stopping at the first hit): | ||
| * 2. openclaw.json has `plugins.entries["kiloclaw-customizer"].enabled` | ||
| * truthy — the kiloclaw controller writes this at boot for every | ||
| * kiloclaw instance, predating any of the env-var signals. Most | ||
| * durable universal signal today. | ||
| * 3. openclaw.json `plugins.load.paths` contains the kiloclaw | ||
| * customizer install path — same writer, redundant cross-check. | ||
| * 4. `process.env.KILOCLAW_SANDBOX_ID` is set — present on every | ||
| * kiloclaw instance since 2026-03-22. | ||
| * 5. `process.env.KILOCODE_FEATURE === "kiloclaw"` — the original | ||
| * env-var signal, present on kiloclaw since 2026-02-17. | ||
| * | ||
| * We intentionally do NOT add a loose `KILOCLAW_*`-prefix heuristic; | ||
| * the four signals above are precise and one of them will hit on any | ||
| * real kiloclaw deployment. | ||
| */ | ||
| export type Platform = "kiloclaw" | "openclaw"; | ||
| const CUSTOMIZER_ID = "kiloclaw-customizer"; | ||
| const CUSTOMIZER_LOAD_PATH = | ||
| "/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer"; | ||
| export function detectPlatform( | ||
| config: unknown, | ||
| env: NodeJS.ProcessEnv = process.env, | ||
| ): Platform { | ||
| if (hasKiloclawCustomizerEntry(config)) return "kiloclaw"; | ||
| if (hasKiloclawCustomizerLoadPath(config)) return "kiloclaw"; | ||
| if (hasKiloclawSandboxIdEnv(env)) return "kiloclaw"; | ||
| if (hasKilocodeFeatureEnv(env)) return "kiloclaw"; | ||
| return "openclaw"; | ||
| } | ||
| function hasKiloclawCustomizerEntry(config: unknown): boolean { | ||
| const entry = getPath(config, ["plugins", "entries", CUSTOMIZER_ID]); | ||
| if (!entry || typeof entry !== "object") return false; | ||
| const enabled = (entry as Record<string, unknown>).enabled; | ||
| return enabled === true; | ||
| } | ||
| function hasKiloclawCustomizerLoadPath(config: unknown): boolean { | ||
| const paths = getPath(config, ["plugins", "load", "paths"]); | ||
| return Array.isArray(paths) && paths.includes(CUSTOMIZER_LOAD_PATH); | ||
| } | ||
| function hasKiloclawSandboxIdEnv(env: NodeJS.ProcessEnv): boolean { | ||
| const v = env.KILOCLAW_SANDBOX_ID; | ||
| return typeof v === "string" && v.length > 0; | ||
| } | ||
| function hasKilocodeFeatureEnv(env: NodeJS.ProcessEnv): boolean { | ||
| return env.KILOCODE_FEATURE === "kiloclaw"; | ||
| } | ||
| function getPath(root: unknown, path: string[]): unknown { | ||
| let cur: unknown = root; | ||
| for (const key of path) { | ||
| if (!cur || typeof cur !== "object") return undefined; | ||
| cur = (cur as Record<string, unknown>)[key]; | ||
| } | ||
| return cur; | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
0
-100%2
-77.78%20477
-68.49%7
-41.67%172
-85.31%45
-82.07%