From 1b3f5c92c1dc4d788c1930dc679a9af23f08088c Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 11:19:31 -0700 Subject: [PATCH 1/9] Revert "Merge pull request #13 from Kilo-Org/chore/migration-stub" This reverts commit 5e8eb6d8..., reversing changes made to 30be658c. Restores the audit logic from v0.1.4 as the base for the rename to @kilocode/shell-security. This reverts commit 5e8eb6dec28162aaebed7aa0d65ce32c58c95459, reversing changes made to 30be6584ffbe716583572b94a3ccad8b559524e6. --- CHANGELOG.md | 29 --- README.md | 258 +++++++++++++++++++--- index.ts | 455 ++++++++++++++++++++++++++++++++++----- openclaw.plugin.json | 38 +++- src/audit.ts | 123 +++++++++++ src/auth/device-auth.ts | 138 ++++++++++++ src/auth/token-store.ts | 268 +++++++++++++++++++++++ src/client.ts | 110 ++++++++++ src/platform.ts | 77 +++++++ test/audit.test.ts | 111 ++++++++++ test/device-auth.test.ts | 100 +++++++++ test/platform.test.ts | 127 +++++++++++ test/token-store.test.ts | 130 +++++++++++ 13 files changed, 1847 insertions(+), 117 deletions(-) create mode 100644 src/audit.ts create mode 100644 src/auth/device-auth.ts create mode 100644 src/auth/token-store.ts create mode 100644 src/client.ts create mode 100644 src/platform.ts create mode 100644 test/audit.test.ts create mode 100644 test/device-auth.test.ts create mode 100644 test/platform.test.ts create mode 100644 test/token-store.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e3971..d9f0119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,35 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [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 - Plugin now forwards the active chat surface to the server as `source.channel` on every checkup request. The slash-command path reads `PluginCommandContext.channel` and the tool/natural-language path reads `OpenClawPluginToolContext.messageChannel` (tool registration converted to factory form so the ctx is accessible at tool-instantiation and closed over by `execute()`). Server uses this hint to pick a channel-appropriate format (e.g. collapsible `
` blocks on capable UIs, flat markdown on Telegram/Slack). Backward-compatible with older servers: the field is optional in the client payload and servers that don't declare it in their zod schema silently drop it at parse time (no coordinated release required). diff --git a/README.md b/README.md index 8cf1c8b..3c3fc64 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,250 @@ # @kilocode/openclaw-security-advisor -> **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. +An [OpenClaw](https://openclaw.ai) plugin that runs a security checkup of +your OpenClaw instance and returns an expert analysis report from +KiloCode cloud. -## Migrating to ShellSecurity +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. -Install the new plugin: +--- + +## Install ```bash -openclaw plugins install @kilocode/shell-security -openclaw plugins enable shell-security +openclaw plugins install @kilocode/openclaw-security-advisor +openclaw plugins enable openclaw-security-advisor openclaw gateway restart ``` -Uninstall this old plugin: +On first use, the plugin will walk you through a one-time device auth +flow to connect your KiloCode account. + +### 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 +``` + +### Staying up to date + +New versions ship regularly. To check the latest published stable: + +```bash +npm view @kilocode/openclaw-security-advisor version +``` + +Compare that against the `pluginVersion` line at the end of any security +checkup report. To upgrade: + +```bash +openclaw plugins install @kilocode/openclaw-security-advisor +openclaw gateway restart +``` + +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 plugins uninstall openclaw-security-advisor +openclaw config set plugins.entries.openclaw-security-advisor.config.apiBaseUrl https://your-kilocode.example.com ``` -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. +### 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. + +--- -## Why the rename? +## Contributing -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. +- [`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. -- **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) +--- -## Last real release +## License -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`. +MIT diff --git a/index.ts b/index.ts index 83f678e..128775e 100644 --- a/index.ts +++ b/index.ts @@ -1,43 +1,22 @@ 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; - -/** - * 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}_`; +const DEFAULT_API_BASE = "https://api.kilo.ai"; type ToolResult = { content: Array<{ type: "text"; text: string }>; @@ -47,60 +26,418 @@ type CommandResult = { text: string; }; -type PluginLogger = { - info?: (msg: string) => void; - warn?: (msg: string) => void; - error?: (msg: string) => void; +type ToolRegistration = { + name: string; + description: string; + parameters: Record; + execute: () => Promise; +}; + +/** + * 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. + */ +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; }; /** - * 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. + * 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; logger: PluginLogger; - registerTool: (tool: unknown) => void; - registerCommand: (cmd: unknown) => void; + 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; }; +/** + * 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 | 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 { 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 { + 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 { + // 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 => { + 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 { + 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 (deprecated)", + name: "OpenClaw Security Advisor", description: - "DEPRECATED: this plugin has been renamed to @kilocode/shell-security. Install the new plugin to continue receiving security checkups.", + "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. register(sdkApi: any) { const api = sdkApi as PluginApi; + const pluginConfig = (api.pluginConfig ?? null) as Record< + string, + unknown + > | null; - api.registerTool({ + // 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) => ({ name: "kilocode_security_advisor", description: - "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, " + + "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, " + "summarizing, or reformatting.", parameters: {}, async execute() { - return toolResult(MIGRATION_NOTICE); + const apiBase = resolveApiBase(pluginConfig); + const channel = normalizeChannel(toolCtx.messageChannel); + const markdown = await runFlowSafe(api, apiBase, channel); + return toolResult(markdown); }, - }); + })); + // 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: - "DEPRECATED (migration stub). This plugin has moved to @kilocode/shell-security.", + "Run a KiloCode security checkup of this OpenClaw instance and display the full report.", acceptsArgs: false, - handler: async (): Promise => { - return { text: MIGRATION_NOTICE }; + handler: async (ctx: PluginCommandContext) => { + const apiBase = resolveApiBase(pluginConfig); + const channel = normalizeChannel(ctx.channel); + const markdown = await runFlowSafe(api, apiBase, channel); + return { text: markdown }; }, }); - api.logger.info?.( - "openclaw-security-advisor 0.1.5 migration stub loaded. Plugin has moved to @kilocode/shell-security.", - ); + api.logger.info?.("Registered tool: kilocode_security_advisor"); + api.logger.info?.("Registered command: /security-checkup"); }, }); diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 4e0e2cd..6dcfd5b 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,6 +1,38 @@ { "id": "openclaw-security-advisor", - "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" }] + "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." + } + } + } } diff --git a/src/audit.ts b/src/audit.ts new file mode 100644 index 0000000..e3bfc0b --- /dev/null +++ b/src/audit.ts @@ -0,0 +1,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 { + 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; + } +} diff --git a/src/auth/device-auth.ts b/src/auth/device-auth.ts new file mode 100644 index 0000000..1f31843 --- /dev/null +++ b/src/auth/device-auth.ts @@ -0,0 +1,138 @@ +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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts new file mode 100644 index 0000000..cf58f89 --- /dev/null +++ b/src/auth/token-store.ts @@ -0,0 +1,268 @@ +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; +}; + +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 { + 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..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 { + 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. + const secrets = ( + root.secrets && typeof root.secrets === "object" ? root.secrets : {} + ) as Record; + const providers = ( + secrets.providers && typeof secrets.providers === "object" + ? secrets.providers + : {} + ) as Record; + const nextSecrets = { + ...secrets, + providers: { + ...providers, + [PROVIDER_ID]: { + source: "file", + path: filePath, + mode: "singleValue", + }, + }, + }; + + // Patch plugins.entries..config.authToken with SecretRef + const plugins = ( + root.plugins && typeof root.plugins === "object" ? root.plugins : {} + ) as Record; + const entries = ( + plugins.entries && typeof plugins.entries === "object" + ? plugins.entries + : {} + ) as Record; + const existing = ( + entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object" + ? entries[PLUGIN_ID] + : {} + ) as Record; + const existingConfig = ( + existing.config && typeof existing.config === "object" + ? existing.config + : {} + ) as Record; + + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await unlink(pendingCodeFilePath()); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw err; + } + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..f352147 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,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; + 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 `
` 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 { + 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; +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..3f7a253 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,77 @@ +/** + * 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).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)[key]; + } + return cur; +} diff --git a/test/audit.test.ts b/test/audit.test.ts new file mode 100644 index 0000000..cbd6a23 --- /dev/null +++ b/test/audit.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from "bun:test"; +import { isValidIp, AuditOutputSchema } from "../src/audit"; + +describe("isValidIp", () => { + test("accepts canonical IPv4", () => { + expect(isValidIp("1.2.3.4")).toBe(true); + expect(isValidIp("0.0.0.0")).toBe(true); + expect(isValidIp("255.255.255.255")).toBe(true); + expect(isValidIp("192.168.1.1")).toBe(true); + }); + + test("rejects malformed IPv4", () => { + expect(isValidIp("256.1.1.1")).toBe(false); + expect(isValidIp("1.2.3")).toBe(false); + expect(isValidIp("1.2.3.4.5")).toBe(false); + expect(isValidIp("1.2.3.a")).toBe(false); + expect(isValidIp("")).toBe(false); + }); + + test("accepts canonical IPv6", () => { + expect(isValidIp("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe(true); + }); + + test("accepts compressed IPv6", () => { + expect(isValidIp("2001:db8::1")).toBe(true); + expect(isValidIp("::1")).toBe(true); + expect(isValidIp("fe80::")).toBe(true); + }); + + test("rejects IPv6 with brackets, ports, or trailing junk", () => { + expect(isValidIp("[::1]")).toBe(false); + expect(isValidIp("[2001:db8::1]:8080")).toBe(false); + expect(isValidIp("2001:db8::1 ")).toBe(false); + expect(isValidIp("2001:db8::1\n")).toBe(false); + }); + + test("rejects obvious non-IP input", () => { + expect(isValidIp("example.com")).toBe(false); + expect(isValidIp("localhost")).toBe(false); + expect(isValidIp("not an ip")).toBe(false); + }); +}); + +describe("AuditOutputSchema", () => { + const happyPath = { + ts: 1700000000, + summary: { critical: 1, warn: 2, info: 3 }, + findings: [ + { + checkId: "check1", + severity: "critical", + title: "Title", + detail: "Detail", + }, + ], + }; + + test("accepts a minimal valid audit", () => { + const result = AuditOutputSchema.safeParse(happyPath); + expect(result.success).toBe(true); + }); + + test("accepts audits with optional deep and secretDiagnostics", () => { + const result = AuditOutputSchema.safeParse({ + ...happyPath, + deep: { foo: "bar" }, + secretDiagnostics: [{ kind: "warn" }], + }); + expect(result.success).toBe(true); + }); + + test("accepts findings with nullable remediation", () => { + const result = AuditOutputSchema.safeParse({ + ...happyPath, + findings: [ + { ...happyPath.findings[0], remediation: null }, + { ...happyPath.findings[0], remediation: "fix it" }, + ], + }); + expect(result.success).toBe(true); + }); + + test("rejects audits missing ts", () => { + const { ts: _ts, ...rest } = happyPath; + expect(AuditOutputSchema.safeParse(rest).success).toBe(false); + }); + + test("rejects audits with invalid severity", () => { + const result = AuditOutputSchema.safeParse({ + ...happyPath, + findings: [{ ...happyPath.findings[0], severity: "high" }], + }); + expect(result.success).toBe(false); + }); + + test("rejects audits with non-number summary counts", () => { + const result = AuditOutputSchema.safeParse({ + ...happyPath, + summary: { critical: "1", warn: 2, info: 3 }, + }); + expect(result.success).toBe(false); + }); + + test("accepts an empty findings array", () => { + const result = AuditOutputSchema.safeParse({ + ...happyPath, + findings: [], + }); + expect(result.success).toBe(true); + }); +}); diff --git a/test/device-auth.test.ts b/test/device-auth.test.ts new file mode 100644 index 0000000..82e415a --- /dev/null +++ b/test/device-auth.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { startDeviceAuth } from "../src/auth/device-auth"; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function stubFetch(response: unknown, { ok = true, status = 200 } = {}): void { + const stub: typeof fetch = async () => + new Response(JSON.stringify(response), { + status, + headers: { "Content-Type": "application/json" }, + }); + // ok is derived from status in Response, so we rely on status here. + // Allow callers to force ok=false by passing status>=400. + void ok; + globalThis.fetch = stub; +} + +describe("startDeviceAuth", () => { + test("rewrites the path on the server-provided URL to /openclaw-advisor", async () => { + stubFetch({ + code: "ABCD-1234", + verificationUrl: "https://app.kilo.ai/device-auth?code=ABCD-1234", + expiresIn: 600, + }); + + const result = await startDeviceAuth("https://app.kilo.ai"); + + expect(result.kind).toBe("started"); + expect(result.code).toBe("ABCD-1234"); + expect(result.verificationUrl).toBe( + "https://app.kilo.ai/openclaw-advisor?code=ABCD-1234", + ); + expect(result.expiresIn).toBe(600); + }); + + test("preserves the server-provided origin, not apiBase, when they differ (prod)", async () => { + // Regression: in production, apiBase is the API host (api.kilo.ai) but + // the server builds verificationUrl from APP_URL (app.kilo.ai). Rebuilding + // the link from apiBase would send users to a nonexistent endpoint. + stubFetch({ + code: "QWER-7890", + verificationUrl: "https://app.kilo.ai/device-auth?code=QWER-7890", + expiresIn: 600, + }); + + const result = await startDeviceAuth("https://api.kilo.ai"); + + expect(result.verificationUrl).toBe( + "https://app.kilo.ai/openclaw-advisor?code=QWER-7890", + ); + }); + + test("preserves the dev-loop origin (host.docker.internal / localhost)", async () => { + stubFetch({ + code: "WXYZ-5678", + verificationUrl: + "http://host.docker.internal:3000/device-auth?code=WXYZ-5678", + expiresIn: 600, + }); + + const result = await startDeviceAuth("http://host.docker.internal:3000"); + + expect(result.verificationUrl).toBe( + "http://host.docker.internal:3000/openclaw-advisor?code=WXYZ-5678", + ); + }); + + test("preserves the ?code= query verbatim from the server-provided URL", async () => { + // The server is the source of truth for the query string. We only swap + // the pathname; we never reconstruct the query from the bare `code` field. + stubFetch({ + code: "UVWX-9999", + verificationUrl: + "https://app.kilo.ai/device-auth?code=UVWX-9999&state=extra", + expiresIn: 600, + }); + + const result = await startDeviceAuth("https://api.kilo.ai"); + + expect(result.verificationUrl).toBe( + "https://app.kilo.ai/openclaw-advisor?code=UVWX-9999&state=extra", + ); + }); + + test("throws a descriptive error when the server rejects the request", async () => { + stubFetch({}, { status: 500 }); + + await expect(startDeviceAuth("https://app.kilo.ai")).rejects.toThrow( + /HTTP 500/, + ); + }); +}); diff --git a/test/platform.test.ts b/test/platform.test.ts new file mode 100644 index 0000000..861eafc --- /dev/null +++ b/test/platform.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect } from "bun:test"; +import { detectPlatform } from "../src/platform"; + +const CUSTOMIZER_LOAD_PATH = + "/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer"; + +// Every test passes an explicit `env` so tests never accidentally +// inherit KILOCODE_FEATURE / KILOCLAW_SANDBOX_ID from the developer's +// shell (which would mask an openclaw-classification bug). +const EMPTY_ENV: NodeJS.ProcessEnv = {}; + +describe("detectPlatform", () => { + describe("returns openclaw when no signals hit", () => { + test("null config, empty env", () => { + expect(detectPlatform(null, EMPTY_ENV)).toBe("openclaw"); + }); + + test("undefined config, empty env", () => { + expect(detectPlatform(undefined, EMPTY_ENV)).toBe("openclaw"); + }); + + test("empty object config, empty env", () => { + expect(detectPlatform({}, EMPTY_ENV)).toBe("openclaw"); + }); + + test("unrelated plugin entries, empty env", () => { + const cfg = { + plugins: { + entries: { + brave: { enabled: true }, + openai: { enabled: true }, + }, + load: { paths: ["/some/other/path"] }, + }, + }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + + test("customizer entry present but disabled does not count", () => { + const cfg = { + plugins: { + entries: { + "kiloclaw-customizer": { enabled: false }, + }, + }, + }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + + test("KILOCLAW_SANDBOX_ID set to empty string does not count", () => { + expect(detectPlatform(null, { KILOCLAW_SANDBOX_ID: "" })).toBe( + "openclaw", + ); + }); + + test("KILOCODE_FEATURE set to some other value does not count", () => { + expect(detectPlatform(null, { KILOCODE_FEATURE: "something-else" })).toBe( + "openclaw", + ); + }); + }); + + describe("returns kiloclaw on any single signal hit", () => { + test("signal 2: plugins.entries.kiloclaw-customizer.enabled === true", () => { + const cfg = { + plugins: { entries: { "kiloclaw-customizer": { enabled: true } } }, + }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw"); + }); + + test("signal 3: plugins.load.paths contains the customizer path", () => { + const cfg = { + plugins: { load: { paths: [CUSTOMIZER_LOAD_PATH] } }, + }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw"); + }); + + test("signal 4: KILOCLAW_SANDBOX_ID env", () => { + expect( + detectPlatform(null, { KILOCLAW_SANDBOX_ID: "sandbox-abc123" }), + ).toBe("kiloclaw"); + }); + + test("signal 5: KILOCODE_FEATURE=kiloclaw env", () => { + expect(detectPlatform(null, { KILOCODE_FEATURE: "kiloclaw" })).toBe( + "kiloclaw", + ); + }); + }); + + describe("short-circuits on the first hit", () => { + test("customizer entry hits even if env vars are absent", () => { + const cfg = { + plugins: { entries: { "kiloclaw-customizer": { enabled: true } } }, + }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw"); + }); + + test("env-only hit works when config is absent (older deployments)", () => { + expect(detectPlatform(null, { KILOCODE_FEATURE: "kiloclaw" })).toBe( + "kiloclaw", + ); + }); + }); + + describe("defensive against malformed config", () => { + test("plugins.entries missing is safe", () => { + const cfg = { plugins: {} }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + + test("plugins.entries is a non-object is safe", () => { + const cfg = { plugins: { entries: "not-an-object" } }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + + test("plugins.load.paths is a non-array is safe", () => { + const cfg = { plugins: { load: { paths: "not-an-array" } } }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + + test("deeply nested non-object path is safe", () => { + const cfg = { plugins: { entries: { "kiloclaw-customizer": "scalar" } } }; + expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw"); + }); + }); +}); diff --git a/test/token-store.test.ts b/test/token-store.test.ts new file mode 100644 index 0000000..df70234 --- /dev/null +++ b/test/token-store.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect } from "bun:test"; +import { patchConfig } from "../src/auth/token-store"; + +const TEST_PATH = + "/home/node/.openclaw/secrets/openclaw-security-advisor-auth-token"; + +function getAuthToken(cfg: unknown): unknown { + const root = cfg as Record; + const plugins = root.plugins as Record; + const entries = plugins.entries as Record; + const entry = entries["openclaw-security-advisor"] as Record; + const config = entry.config as Record; + return config.authToken; +} + +function getProvider(cfg: unknown): unknown { + const root = cfg as Record; + const secrets = root.secrets as Record; + const providers = secrets.providers as Record; + return providers.kilocode_security_advisor; +} + +describe("patchConfig", () => { + test("patches an empty config", () => { + const next = patchConfig({}, TEST_PATH); + expect(getProvider(next)).toEqual({ + source: "file", + path: TEST_PATH, + mode: "singleValue", + }); + expect(getAuthToken(next)).toEqual({ + source: "file", + provider: "kilocode_security_advisor", + id: "value", + }); + }); + + test("treats null/undefined config as empty", () => { + expect(() => patchConfig(null, TEST_PATH)).not.toThrow(); + expect(() => patchConfig(undefined, TEST_PATH)).not.toThrow(); + const next = patchConfig(null, TEST_PATH); + expect(getProvider(next)).toBeDefined(); + expect(getAuthToken(next)).toBeDefined(); + }); + + test("preserves unrelated plugin entries", () => { + const cfg = { + plugins: { + entries: { + "some-other-plugin": { config: { key: "value" } }, + }, + }, + }; + const next = patchConfig(cfg, TEST_PATH) as Record; + const plugins = next.plugins as Record; + const entries = plugins.entries as Record; + expect(entries["some-other-plugin"]).toEqual({ + config: { key: "value" }, + }); + expect(entries["openclaw-security-advisor"]).toBeDefined(); + }); + + test("preserves unrelated secret providers", () => { + const cfg = { + secrets: { + providers: { + other_provider: { source: "env", path: "OTHER_TOKEN" }, + }, + }, + }; + const next = patchConfig(cfg, TEST_PATH) as Record; + const secrets = next.secrets as Record; + const providers = secrets.providers as Record; + expect(providers.other_provider).toEqual({ + source: "env", + path: "OTHER_TOKEN", + }); + expect(providers.kilocode_security_advisor).toBeDefined(); + }); + + test("overwrites existing authToken for this plugin", () => { + const cfg = { + plugins: { + entries: { + "openclaw-security-advisor": { + config: { + authToken: "stale-plain-string", + apiBaseUrl: "http://host.docker.internal:3000", + }, + }, + }, + }, + }; + const next = patchConfig(cfg, TEST_PATH); + expect(getAuthToken(next)).toEqual({ + source: "file", + provider: "kilocode_security_advisor", + id: "value", + }); + // apiBaseUrl should survive + const root = next as Record; + const plugins = root.plugins as Record; + const entries = plugins.entries as Record; + const entry = entries["openclaw-security-advisor"] as Record< + string, + unknown + >; + const config = entry.config as Record; + expect(config.apiBaseUrl).toBe("http://host.docker.internal:3000"); + }); + + test("preserves other top-level keys", () => { + const cfg = { + model: "gpt-4o", + theme: "dark", + }; + const next = patchConfig(cfg, TEST_PATH) as Record; + expect(next.model).toBe("gpt-4o"); + expect(next.theme).toBe("dark"); + expect(next.secrets).toBeDefined(); + expect(next.plugins).toBeDefined(); + }); + + test("tolerates corrupt nested shapes (non-object plugins)", () => { + const cfg = { plugins: "not-an-object" }; + expect(() => patchConfig(cfg, TEST_PATH)).not.toThrow(); + const next = patchConfig(cfg, TEST_PATH); + expect(getAuthToken(next)).toBeDefined(); + }); +}); From 84725365db4c981c76659c0f1cd6ede3ceba9fab Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 11:30:58 -0700 Subject: [PATCH 2/9] rename package + plugin id to shell-security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the plugin's identity from OpenClaw Security Advisor / @kilocode/openclaw-security-advisor to ShellSecurity / @kilocode/shell-security, to match the renamed repo (Kilo-Org/openclaw-security-advisor → Kilo-Org/shell-security). - package.json: name → @kilocode/shell-rity; repo URL. - openclaw.plugin.json: id → shell-security; display name → ShellSecurity. - index.ts: plugin id, display name, tool name (kilocode_shell_security), reload noop prefix, log tags, and user-facing config-path references. - src/auth/token-store.ts: PLUGIN_ID, PROVIDER_ID (kilocode_shell_security) → changes install dir, secret file, and pending-code file paths. - src/auth/device-auth.ts: debug log tag. - .github/workflows/publish.yml: repo guard + registry probe + recovery copy point at Kilo-Org/shell-security / @kilocode/shell-security. - script/publish.ts, script/version.ts: NPM_PACKAGE and log copy. - README.md: rename banner, migration block from old plugin, install commands, tool name, config path, package name, doc links. - CHANGELOG.md: [0.2.0] rename entry; [0.1.5] stub entry; retro-dated [0.1.4] for the channel-forwarding changes; updated compare links. - RELEASING.md: bulk replace to new package / repo names. - AGENTS.md: package name banner, @dev install, code-layout section. - test/token-store.test.ts: updated hardcoded secret path + key names. Server API route URL (/api/security-advisor/analyze) is intentionally unchanged so this publish does not require coordinated server changes. /security-checkup slash command name is also unchanged. --- .github/workflows/publish.yml | 14 ++++----- AGENTS.md | 15 +++++----- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++--- README.md | 52 +++++++++++++++++++++------------ RELEASING.md | 34 +++++++++++----------- index.ts | 30 +++++++++---------- 6 files changed, 131 insertions(+), 69 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd6d334..fda2ec0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,7 +42,7 @@ jobs: publish: name: Publish to npm runs-on: ubuntu-24.04 - if: github.repository == 'Kilo-Org/openclaw-security-advisor' + if: github.repository == 'Kilo-Org/shell-security' steps: - uses: actions/checkout@v4 with: @@ -122,10 +122,10 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - echo "Probing registry for @kilocode/openclaw-security-advisor@$VERSION..." + echo "Probing registry for @kilocode/shell-security@$VERSION..." for i in 1 2 3 4 5 6; do STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - "https://registry.npmjs.org/@kilocode/openclaw-security-advisor/$VERSION") + "https://registry.npmjs.org/@kilocode/shell-security/$VERSION") if [ "$STATUS" = "200" ]; then echo "::notice::Verified $VERSION is live on the registry" exit 0 @@ -254,7 +254,7 @@ jobs: PARTIAL PUBLISH STATE ============================================================ - npm publish for @kilocode/openclaw-security-advisor@$VERSION + npm publish for @kilocode/shell-security@$VERSION SUCCEEDED, but the post-publish git/GitHub-release operations FAILED. @@ -265,12 +265,12 @@ jobs: To complete the release manually, run from your local checkout: - cd /path/to/openclaw-security-advisor + cd /path/to/shell-security git fetch origin --tags # First check what already exists: git ls-remote --tags origin "$TAG" - gh release view "$TAG" --repo Kilo-Org/openclaw-security-advisor + gh release view "$TAG" --repo Kilo-Org/shell-security MSG @@ -319,7 +319,7 @@ jobs: # If the GH release is missing, create it: gh release create "$TAG" \\ - --repo Kilo-Org/openclaw-security-advisor \\ + --repo Kilo-Org/shell-security \\ --title "$TAG" \\ --generate-notes${PRERELEASE_FLAG} diff --git a/AGENTS.md b/AGENTS.md index 636422c..8cb117e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ # AGENTS.md -`@kilocode/openclaw-security-advisor` is an OpenClaw plugin that runs a local -`openclaw security audit`, sends it to the KiloCode Security Advisor API, and -renders the returned markdown report inline in chat. +`@kilocode/shell-security` (previously `@kilocode/openclaw-security-advisor`) +is an OpenClaw plugin that runs a local `openclaw security audit`, sends it +to the KiloCode ShellSecurity API, and renders the returned markdown report +inline in chat. - The default branch is `main`. - Releases are gated on manual `workflow_dispatch` — never publish from a push trigger. @@ -53,7 +54,7 @@ Releases are triggered manually from GitHub Actions → `publish` workflow → - **`latest`** — public stable releases (`X.Y.Z`). Default for `npm install`. - **`dev`** — internal dogfood snapshots (`X.Y.Z-dev.N`). Available via - `npm install @kilocode/openclaw-security-advisor@dev`. + `npm install @kilocode/shell-security@dev`. There is no `beta`, `rc`, `next`, or `canary`. Two channels, that's it. @@ -108,18 +109,18 @@ Until then, release commits: ## Code layout - `index.ts` — plugin entry point; registers `/security-checkup` command and - `kilocode_security_advisor` tool; shared `runSecurityAdvisorFlow` handles + `kilocode_shell_security` tool; shared `runShellSecurityFlow` handles all auth paths (env token, saved token, pending device auth, new device auth). - `src/audit.ts` — runs `openclaw security audit --json`, parses + validates output, fetches public IP. -- `src/client.ts` — HTTP client for the Security Advisor API; throws +- `src/client.ts` — HTTP client for the ShellSecurity API; throws `AuthExpiredError` on 401. - `src/platform.ts` — detects `kiloclaw` vs `openclaw`. Kept separate from `audit.ts` so the plugin loader's "env read + network send" security heuristic doesn't flag the combined file. - `src/auth/device-auth.ts` — `startDeviceAuth` + `pollDeviceAuth` helpers. - `src/auth/token-store.ts` — persists auth token to - `~/.openclaw/secrets/openclaw-security-advisor-auth-token` (mode 600) and + `~/.openclaw/secrets/shell-security-auth-token` (mode 600) and patches `openclaw.json` with a `SecretRef`. Also manages the pending device-auth code file. `patchConfig` is covered by unit tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f0119..8c76663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,56 @@ # Changelog -All notable changes to `@kilocode/openclaw-security-advisor` are documented here. +All notable changes to `@kilocode/shell-security` (formerly +`@kilocode/openclaw-security-advisor`) are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.2.0] + +First release under the new `@kilocode/shell-security` name. The plugin +was renamed from `@kilocode/openclaw-security-advisor` to `ShellSecurity` +to reflect a broader mission than any single agent-shell runtime. +Functionally identical to `@kilocode/openclaw-security-advisor@0.1.4`. + +### Changed + +- npm package: `@kilocode/openclaw-security-advisor` → `@kilocode/shell-security`. +- GitHub repo: `Kilo-Org/openclaw-security-advisor` → `Kilo-Org/shell-security` (old URLs redirect). +- OpenClaw plugin id: `openclaw-security-advisor` → `shell-security`. +- Plugin display name: `OpenClaw Security Advisor` → `ShellSecurity`. +- Tool name: `kilocode_security_advisor` → `kilocode_shell_security`. +- Install dir: `~/.openclaw/extensions/openclaw-security-advisor/` → `~/.openclaw/extensions/shell-security/`. +- Secret file: `~/.openclaw/secrets/openclaw-security-advisor-auth-token` → `~/.openclaw/secrets/shell-security-auth-token`. +- `/security-checkup` slash command name unchanged. + +### Migration + +Existing users of `@kilocode/openclaw-security-advisor` should run: + +``` +openclaw plugins install @kilocode/shell-security +openclaw plugins enable shell-security +openclaw gateway restart +openclaw plugins uninstall openclaw-security-advisor +``` + +Device auth runs fresh on first use of the new plugin. The old plugin +remains installable from npm (deprecated) but is no longer receiving +updates. + +## [0.1.5] - 2026-04-22 + +Migration stub. Final release under `@kilocode/openclaw-security-advisor`. + +- Replaced the audit flow with a short migration notice directing users to + `@kilocode/shell-security`. The `/security-checkup` slash command and + the `kilocode_security_advisor` tool both return the notice; no audit + runs, no network call, no auth flow. +- npm package `@kilocode/openclaw-security-advisor` marked deprecated with + the same migration message. + +## [0.1.4] - 2026-04-20 ### Added @@ -54,5 +99,7 @@ Initial dev release. - Audit output validated with a Zod schema at the plugin boundary. - Public IP detection via `ifconfig.me` with IPv4/IPv6 validation. -[Unreleased]: https://github.com/Kilo-Org/openclaw-security-advisor/compare/v0.1.0-dev.1...HEAD -[0.1.0-dev.1]: https://github.com/Kilo-Org/openclaw-security-advisor/releases/tag/v0.1.0-dev.1 +[0.2.0]: https://github.com/Kilo-Org/shell-security/compare/v0.1.5...v0.2.0 +[0.1.5]: https://github.com/Kilo-Org/shell-security/compare/v0.1.4...v0.1.5 +[0.1.4]: https://github.com/Kilo-Org/shell-security/compare/v0.1.0-dev.1...v0.1.4 +[0.1.0-dev.1]: https://github.com/Kilo-Org/shell-security/releases/tag/v0.1.0-dev.1 diff --git a/README.md b/README.md index 3c3fc64..a40608d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -# @kilocode/openclaw-security-advisor +# @kilocode/shell-security + +> **Renamed from `@kilocode/openclaw-security-advisor`.** If you had the +> old plugin installed, see the migration steps below. An [OpenClaw](https://openclaw.ai) plugin that runs a security checkup of your OpenClaw instance and returns an expert analysis report from KiloCode cloud. The plugin takes the output of `openclaw security audit`, sends it to -the KiloCode Security Advisor API for analysis, and returns a detailed +the KiloCode ShellSecurity API for analysis, and returns a detailed markdown report with findings, risks, prioritized recommendations, and concrete remediation guidance, displayed directly in your chat. @@ -14,11 +17,24 @@ concrete remediation guidance, displayed directly in your chat. ## 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 ``` +### Migrating from `@kilocode/openclaw-security-advisor` + +```bash +openclaw plugins install @kilocode/shell-security +openclaw plugins enable shell-security +openclaw gateway restart +openclaw plugins uninstall openclaw-security-advisor +``` + +Device auth runs fresh on the new plugin — you'll be prompted to reconnect +your KiloCode account on first use. Subsequent checkups are identical to +what you got before the rename. + On first use, the plugin will walk you through a one-time device auth flow to connect your KiloCode account. @@ -32,9 +48,9 @@ The plugin ships on two npm dist-tags: stable cuts for early testing. Install with: ```bash - openclaw plugins install @kilocode/openclaw-security-advisor@dev + openclaw plugins install @kilocode/shell-security@dev # or - npm install @kilocode/openclaw-security-advisor@dev + npm install @kilocode/shell-security@dev ``` Dev releases are real npm publishes with the same provenance @@ -43,7 +59,7 @@ The plugin ships on two npm dist-tags: You can also install an exact version directly: ```bash -openclaw plugins install @kilocode/openclaw-security-advisor@0.1.0 +openclaw plugins install @kilocode/shell-security@0.2.0 ``` ### Staying up to date @@ -51,14 +67,14 @@ openclaw plugins install @kilocode/openclaw-security-advisor@0.1.0 New versions ship regularly. To check the latest published stable: ```bash -npm view @kilocode/openclaw-security-advisor version +npm view @kilocode/shell-security version ``` Compare that against the `pluginVersion` line at the end of any security checkup report. To upgrade: ```bash -openclaw plugins install @kilocode/openclaw-security-advisor +openclaw plugins install @kilocode/shell-security openclaw gateway restart ``` @@ -67,7 +83,7 @@ Your security checkup report will occasionally include an inline 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 +`kilocode_shell_security` tool). Security advice improves as the plugin ships new audit signals, so staying current is worthwhile. --- @@ -94,7 +110,7 @@ this for guaranteed verbatim output.** > 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. +> `kilocode_shell_security` tool directly. ### Natural language @@ -106,7 +122,7 @@ You can also just ask the agent: > Audit my OpenClaw config -The agent will call the `kilocode_security_advisor` tool and the report +The agent will call the `kilocode_shell_security` tool and the report will appear in chat. **Heads up:** natural language invocation goes through your configured @@ -153,7 +169,7 @@ is reused automatically. ## What gets sent -The plugin sends the following to the KiloCode Security Advisor API: +The plugin sends the following to the KiloCode ShellSecurity API: - The JSON output of `openclaw security audit` (local config audit results, with no secrets, no file contents, just finding IDs and @@ -177,7 +193,7 @@ HTTPS. ## Configuration The plugin reads its config from `openclaw.json` under -`plugins.entries.openclaw-security-advisor.config`. In most cases, you +`plugins.entries.shell-security.config`. In most cases, you won't need to set anything. The defaults work out of the box. | Field | Default | Purpose | @@ -188,7 +204,7 @@ won't need to set anything. The defaults work out of the box. To override via the OpenClaw CLI: ```bash -openclaw config set plugins.entries.openclaw-security-advisor.config.apiBaseUrl https://your-kilocode.example.com +openclaw config set plugins.entries.shell-security.config.apiBaseUrl https://your-kilocode.example.com ``` ### Environment variables @@ -239,9 +255,9 @@ 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. +- [`AGENTS.md`](https://github.com/Kilo-Org/shell-security/blob/main/AGENTS.md) — build, test, lint, code layout, and contribution rules. +- [`RELEASING.md`](https://github.com/Kilo-Org/shell-security/blob/main/RELEASING.md) — how to cut a release. +- [`CHANGELOG.md`](https://github.com/Kilo-Org/shell-security/blob/main/CHANGELOG.md) — release history. --- diff --git a/RELEASING.md b/RELEASING.md index b138d8e..257f5f7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,4 +1,4 @@ -# Releasing `@kilocode/openclaw-security-advisor` +# Releasing `@kilocode/shell-security` Releases are cut from the `publish` workflow in GitHub Actions. There is no local release script, no automated release on push, and no changesets tool. @@ -17,7 +17,7 @@ refs/heads/main — Changes must be made through a pull request`. > ```bash > git fetch origin --tags > git ls-remote --tags origin "vX.Y.Z" # tag? -> gh release view "vX.Y.Z" --repo Kilo-Org/openclaw-security-advisor # release? +> gh release view "vX.Y.Z" --repo Kilo-Org/shell-security # release? > ``` > > 2. **If the tag exists and only the GitHub release is missing** (the @@ -27,7 +27,7 @@ refs/heads/main — Changes must be made through a pull request`. > > ```bash > gh release create vX.Y.Z \ -> --repo Kilo-Org/openclaw-security-advisor \ +> --repo Kilo-Org/shell-security \ > --title vX.Y.Z \ > --generate-notes \ > --verify-tag @@ -72,25 +72,25 @@ Before clicking "Run workflow", confirm: - [ ] `CHANGELOG.md` has the changes you're about to ship listed under `## [Unreleased]`. - [ ] You know which channel you're targeting and which inputs you'll use (see paths below). - [ ] The tag for the resulting version does **not** already exist on - https://github.com/Kilo-Org/openclaw-security-advisor/releases. + https://github.com/Kilo-Org/shell-security/releases. The workflow fails fast if it does, but check first — it's cheaper to pick a different bump than to recover from a partial publish. ## Cutting a release -1. Open https://github.com/Kilo-Org/openclaw-security-advisor/actions/workflows/publish.yml +1. Open https://github.com/Kilo-Org/shell-security/actions/workflows/publish.yml 2. Click **Run workflow** (top right). 3. Fill in the inputs — see paths below. 4. Click **Run workflow**. 5. Wait for the job to finish (typically 2–3 minutes). -6. Verify on [npm](https://www.npmjs.com/package/@kilocode/openclaw-security-advisor) +6. Verify on [npm](https://www.npmjs.com/package/@kilocode/shell-security) that the new version shipped with the right dist-tag. -7. Verify on the [GitHub releases page](https://github.com/Kilo-Org/openclaw-security-advisor/releases) +7. Verify on the [GitHub releases page](https://github.com/Kilo-Org/shell-security/releases) that the tag and release were created. ### Stable releases (`channel=latest`) -For public releases that go to `npm install @kilocode/openclaw-security-advisor`. +For public releases that go to `npm install @kilocode/shell-security`. **Auto-bump (the common path):** @@ -120,7 +120,7 @@ is rarely what you want for `1.0.0`). For internal dogfood builds. Versions look like `0.1.0-dev.1`, `0.1.0-dev.2`, etc. They publish to the `dev` npm dist-tag, so users get -them with `npm install @kilocode/openclaw-security-advisor@dev`. +them with `npm install @kilocode/shell-security@dev`. **Continue current dev cycle (the common path):** @@ -234,7 +234,7 @@ the runner's resolved registry mirror yet. 1. Wait 1–2 minutes, then verify manually from your machine: ```bash - npm view @kilocode/openclaw-security-advisor@VERSION version + npm view @kilocode/shell-security@VERSION version ``` 2. If the version IS on npm now, the publish was real. Manually create @@ -259,13 +259,13 @@ but the GitHub releases page doesn't list the new version. ```bash # For stable releases: gh release create vX.Y.Z \ - --repo Kilo-Org/openclaw-security-advisor \ + --repo Kilo-Org/shell-security \ --title "vX.Y.Z" \ --generate-notes # For dev releases (note --prerelease): gh release create vX.Y.Z-dev.N \ - --repo Kilo-Org/openclaw-security-advisor \ + --repo Kilo-Org/shell-security \ --title "vX.Y.Z-dev.N" \ --generate-notes \ --prerelease @@ -323,7 +323,7 @@ Recovery steps: ```bash gh release create v1.2.4 \ - --repo Kilo-Org/openclaw-security-advisor \ + --repo Kilo-Org/shell-security \ --title "v1.2.4" \ --generate-notes # Add --prerelease for dev releases. @@ -375,7 +375,7 @@ The explicit version is required because auto-bump from a fresh repo (no prior tags) would resolve to `0.0.1-dev.1`, which doesn't match the intended starting point. -This publishes `@kilocode/openclaw-security-advisor@0.1.0-dev.1` to the +This publishes `@kilocode/shell-security@0.1.0-dev.1` to the `dev` dist-tag, creates the `v0.1.0-dev.1` tag (pointing at an orphan commit), and creates a prerelease GitHub release. `main` history is untouched. @@ -409,12 +409,12 @@ While the package is pre-stable, end users **must** install the dev channel explicitly: ```bash -openclaw plugins install @kilocode/openclaw-security-advisor@dev +openclaw plugins install @kilocode/shell-security@dev # or -npm install @kilocode/openclaw-security-advisor@dev +npm install @kilocode/shell-security@dev ``` -Plain `openclaw plugins install @kilocode/openclaw-security-advisor` +Plain `openclaw plugins install @kilocode/shell-security` (no `@dev`) will resolve to whatever `latest` currently points at, and since `latest` currently points at a prerelease, OpenClaw's prerelease guard will refuse the install with a confusing error. See diff --git a/index.ts b/index.ts index 128775e..770eed2 100644 --- a/index.ts +++ b/index.ts @@ -119,7 +119,7 @@ function toolResult(content: string): ToolResult { } /** - * Top-level wrapper around runSecurityAdvisorFlow. Catches any + * Top-level wrapper around runShellSecurityFlow. 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 @@ -135,10 +135,10 @@ async function runFlowSafe( channel: string | undefined, ): Promise { try { - return await runSecurityAdvisorFlow(api, apiBase, channel); + return await runShellSecurityFlow(api, apiBase, channel); } catch (err) { const message = err instanceof Error ? err.message : String(err); - api.logger.error?.(`security-advisor: unexpected failure: ${message}`); + api.logger.error?.(`shell-security: unexpected failure: ${message}`); return ( `Security checkup failed unexpectedly: ${message}\n\n` + `Check the openclaw gateway logs for details, or try again.` @@ -147,19 +147,19 @@ async function runFlowSafe( } /** - * Shared security-advisor flow used by both the registerTool entry point + * Shared shell-security 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( +async function runShellSecurityFlow( api: PluginApi, apiBase: string, channel: string | undefined, ): Promise { - // Path 0: user explicit config. If `plugins.entries.openclaw-security-advisor.config.authToken` + // Path 0: user explicit config. If `plugins.entries.shell-security.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 @@ -174,7 +174,7 @@ async function runSecurityAdvisorFlow( 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." + "Update `plugins.entries.shell-security.config.authToken` with a fresh KiloCode API key and try again." ); } throw err; @@ -256,7 +256,7 @@ async function runSecurityAdvisorFlow( // 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}`, + `shell-security: failed to persist auth token: ${message}`, ); } @@ -342,13 +342,13 @@ async function doCheckup( } export default definePluginEntry({ - id: "openclaw-security-advisor", - name: "OpenClaw Security Advisor", + id: "shell-security", + name: "ShellSecurity", 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 + // plugins.entries.shell-security.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 @@ -362,9 +362,7 @@ export default definePluginEntry({ // 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", - ], + noopPrefixes: ["plugins.entries.shell-security.config.authToken"], }, // The SDK's OpenClawPluginApi type is large and internal. We narrow // to our own structural PluginApi (declared above) immediately on @@ -391,7 +389,7 @@ export default definePluginEntry({ // so long-running sessions that outlive a channel switch get the // refreshed channel automatically. api.registerTool((toolCtx: PluginToolContext) => ({ - name: "kilocode_security_advisor", + name: "kilocode_shell_security", description: "Run a comprehensive security checkup of this OpenClaw instance. " + "USE THIS TOOL whenever the user asks to: check, audit, scan, review, or " + @@ -437,7 +435,7 @@ export default definePluginEntry({ }, }); - api.logger.info?.("Registered tool: kilocode_security_advisor"); + api.logger.info?.("Registered tool: kilocode_shell_security"); api.logger.info?.("Registered command: /security-checkup"); }, }); From afc2ff1cf2ba9c07642760d6fae23db1b76d901e Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 11:37:45 -0700 Subject: [PATCH 3/9] rename: finish rename + register /shell-security slash command --- CHANGELOG.md | 8 +++++- README.md | 32 +++++++++++++--------- index.ts | 57 +++++++++++++++++++++++++++------------- openclaw.plugin.json | 9 ++++--- package.json | 5 ++-- script/publish.ts | 4 +-- script/version.ts | 4 +-- src/auth/device-auth.ts | 2 +- src/auth/token-store.ts | 4 +-- test/token-store.test.ts | 22 +++++++--------- 10 files changed, 90 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c76663..dd6ca15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,13 @@ Functionally identical to `@kilocode/openclaw-security-advisor@0.1.4`. - Tool name: `kilocode_security_advisor` → `kilocode_shell_security`. - Install dir: `~/.openclaw/extensions/openclaw-security-advisor/` → `~/.openclaw/extensions/shell-security/`. - Secret file: `~/.openclaw/secrets/openclaw-security-advisor-auth-token` → `~/.openclaw/secrets/shell-security-auth-token`. -- `/security-checkup` slash command name unchanged. + +### Added + +- New `/shell-security` slash command, the canonical name matching the + plugin id. The existing `/security-checkup` command is also registered + and works identically, so users migrating from the old plugin can keep + typing the command they're used to. Both are routed to the same handler. ### Migration diff --git a/README.md b/README.md index a40608d..4ecdfe7 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ 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 +`/shell-security` slash command and the natural-language `kilocode_shell_security` tool). Security advice improves as the plugin ships new audit signals, so staying current is worthwhile. @@ -93,24 +93,30 @@ plugin ships new audit signals, so staying current is worthwhile. The plugin exposes two entry points. They do the same thing; pick whichever fits your workflow. -### `/security-checkup` (recommended) +### `/shell-security` (recommended) Type it in chat: ``` -/security-checkup +/shell-security ``` 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_shell_security` tool directly. +> **Legacy alias:** `/security-checkup` is also registered and works +> identically. Existing users migrating from +> `@kilocode/openclaw-security-advisor` can keep typing the command +> they're used to. + +> **Channel compatibility:** `/shell-security` (and its +> `/security-checkup` alias) work in the OpenClaw native control UI +> chat and in Telegram. They do **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_shell_security` tool +> directly. ### Natural language @@ -131,7 +137,7 @@ 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 +`/shell-security` slash command instead** (where supported — see channel compatibility above). It renders the full report regardless of which model is configured. @@ -158,7 +164,7 @@ 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 +connection. Then run `/shell-security` again. The plugin will pick up the approval, persist your auth token, run the checkup, and return the report in the same response. @@ -228,7 +234,7 @@ over the default. **"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. +auth flow on the next invocation. Just run `/shell-security` again. **"Security analysis failed: Rate limit exceeded"** The KiloCode API rate limits security checkups per account. Wait a @@ -236,7 +242,7 @@ 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 +plugin. Use `/shell-security` (the slash command) to bypass the model entirely and render the full report. **Plugin doesn't appear in `/plugins list`** diff --git a/index.ts b/index.ts index 770eed2..21af166 100644 --- a/index.ts +++ b/index.ts @@ -401,11 +401,12 @@ export default definePluginEntry({ "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. " + + "DO NOT suggest the user type `/shell-security` (or the legacy alias " + + "`/security-checkup`) in channels that do not support OpenClaw slash " + + "commands (Kilo Chat and Slack are the known surfaces where slash " + + "commands do not work); invoke this tool directly instead. Slash " + + "commands do work in the OpenClaw native control UI chat and in " + + "Telegram, so suggesting them there is fine. " + "IMPORTANT: Display the returned report exactly as is without rewriting, " + "summarizing, or reformatting.", parameters: {}, @@ -417,25 +418,45 @@ export default definePluginEntry({ }, })); - // 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. + // Entry point 2: slash commands for deterministic invocation that + // bypass the LLM. When the user types /shell-security (or the legacy + // alias /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. + // + // Both names are registered and wired to the same handler. The + // canonical name is `/shell-security` (matches the plugin id); + // `/security-checkup` is kept for users migrating from + // @kilocode/openclaw-security-advisor where the slash command had + // always been called that. Both are declared in + // openclaw.plugin.json's commandAliases so the gateway routes them. + const runSlashCommand = async ( + ctx: PluginCommandContext, + ): Promise => { + const apiBase = resolveApiBase(pluginConfig); + const channel = normalizeChannel(ctx.channel); + const markdown = await runFlowSafe(api, apiBase, channel); + return { text: markdown }; + }; + api.registerCommand({ - name: "security-checkup", + name: "shell-security", description: "Run a KiloCode security checkup of this OpenClaw instance and display the full report.", 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: runSlashCommand, + }); + + api.registerCommand({ + name: "security-checkup", + description: + "Legacy alias for /shell-security. Runs a KiloCode security checkup and displays the full report.", + acceptsArgs: false, + handler: runSlashCommand, }); api.logger.info?.("Registered tool: kilocode_shell_security"); - api.logger.info?.("Registered command: /security-checkup"); + api.logger.info?.("Registered command: /shell-security"); + api.logger.info?.("Registered command: /security-checkup (legacy alias)"); }, }); diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 6dcfd5b..ef36daa 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,8 +1,11 @@ { - "id": "openclaw-security-advisor", - "name": "OpenClaw Security Advisor", + "id": "shell-security", + "name": "ShellSecurity", "description": "Run a security checkup of your OpenClaw instance and get an expert analysis report from KiloCode.", - "commandAliases": [{ "name": "security-checkup", "kind": "runtime-slash" }], + "commandAliases": [ + { "name": "shell-security", "kind": "runtime-slash" }, + { "name": "security-checkup", "kind": "runtime-slash" } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index c89ea95..0a06e69 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@kilocode/openclaw-security-advisor", + "name": "@kilocode/shell-security", "version": "0.1.0", "type": "module", "license": "MIT", @@ -8,6 +8,7 @@ "openclaw", "kiloclaw", "kilocode", + "shell", "security" ], "//": "private: true is intentional — safety net against accidental `npm publish`. The publish script (script/publish.ts) strips this flag before packing and restores it after. Do NOT remove this without also having the publish pipeline in place.", @@ -44,6 +45,6 @@ }, "repository": { "type": "git", - "url": "https://github.com/Kilo-Org/openclaw-security-advisor" + "url": "https://github.com/Kilo-Org/shell-security" } } diff --git a/script/publish.ts b/script/publish.ts index 8de070c..a402f69 100644 --- a/script/publish.ts +++ b/script/publish.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun /** - * Publish script for @kilocode/openclaw-security-advisor. + * Publish script for @kilocode/shell-security. * * Reads the channel from KILO_CHANNEL ("latest" | "dev"); defaults to * "latest". Channel resolution must stay in sync with script/version.ts. @@ -29,7 +29,7 @@ const raw = await Bun.file("package.json").text(); const pkg = JSON.parse(raw); console.log( - `Publishing @kilocode/openclaw-security-advisor@${pkg.version} → channel: ${channel}`, + `Publishing @kilocode/shell-security@${pkg.version} → channel: ${channel}`, ); const original = JSON.stringify(pkg, null, 2) + "\n"; diff --git a/script/version.ts b/script/version.ts index 04cf76f..1184e08 100644 --- a/script/version.ts +++ b/script/version.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun /** - * Version resolution for @kilocode/openclaw-security-advisor. + * Version resolution for @kilocode/shell-security. * * Two channels only: * - `latest` — public stable releases. Versions are plain semver: 1.2.3. @@ -49,7 +49,7 @@ import { $ } from "bun"; -const NPM_PACKAGE = "@kilocode/openclaw-security-advisor"; +const NPM_PACKAGE = "@kilocode/shell-security"; const env = { KILO_CHANNEL: process.env.KILO_CHANNEL, diff --git a/src/auth/device-auth.ts b/src/auth/device-auth.ts index 1f31843..e2750cc 100644 --- a/src/auth/device-auth.ts +++ b/src/auth/device-auth.ts @@ -126,7 +126,7 @@ export async function pollDeviceAuth( // 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}`); + logger?.debug?.(`shell-security: poll transient error: ${message}`); } } diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts index cf58f89..a280649 100644 --- a/src/auth/token-store.ts +++ b/src/auth/token-store.ts @@ -2,8 +2,8 @@ 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"; +const PLUGIN_ID = "shell-security"; +const PROVIDER_ID = "kilocode_shell_security"; /** * Minimal structural type for the parts of the OpenClaw plugin API this diff --git a/test/token-store.test.ts b/test/token-store.test.ts index df70234..ab55be4 100644 --- a/test/token-store.test.ts +++ b/test/token-store.test.ts @@ -1,14 +1,13 @@ import { describe, test, expect } from "bun:test"; import { patchConfig } from "../src/auth/token-store"; -const TEST_PATH = - "/home/node/.openclaw/secrets/openclaw-security-advisor-auth-token"; +const TEST_PATH = "/home/node/.openclaw/secrets/shell-security-auth-token"; function getAuthToken(cfg: unknown): unknown { const root = cfg as Record; const plugins = root.plugins as Record; const entries = plugins.entries as Record; - const entry = entries["openclaw-security-advisor"] as Record; + const entry = entries["shell-security"] as Record; const config = entry.config as Record; return config.authToken; } @@ -17,7 +16,7 @@ function getProvider(cfg: unknown): unknown { const root = cfg as Record; const secrets = root.secrets as Record; const providers = secrets.providers as Record; - return providers.kilocode_security_advisor; + return providers.kilocode_shell_security; } describe("patchConfig", () => { @@ -30,7 +29,7 @@ describe("patchConfig", () => { }); expect(getAuthToken(next)).toEqual({ source: "file", - provider: "kilocode_security_advisor", + provider: "kilocode_shell_security", id: "value", }); }); @@ -57,7 +56,7 @@ describe("patchConfig", () => { expect(entries["some-other-plugin"]).toEqual({ config: { key: "value" }, }); - expect(entries["openclaw-security-advisor"]).toBeDefined(); + expect(entries["shell-security"]).toBeDefined(); }); test("preserves unrelated secret providers", () => { @@ -75,14 +74,14 @@ describe("patchConfig", () => { source: "env", path: "OTHER_TOKEN", }); - expect(providers.kilocode_security_advisor).toBeDefined(); + expect(providers.kilocode_shell_security).toBeDefined(); }); test("overwrites existing authToken for this plugin", () => { const cfg = { plugins: { entries: { - "openclaw-security-advisor": { + "shell-security": { config: { authToken: "stale-plain-string", apiBaseUrl: "http://host.docker.internal:3000", @@ -94,17 +93,14 @@ describe("patchConfig", () => { const next = patchConfig(cfg, TEST_PATH); expect(getAuthToken(next)).toEqual({ source: "file", - provider: "kilocode_security_advisor", + provider: "kilocode_shell_security", id: "value", }); // apiBaseUrl should survive const root = next as Record; const plugins = root.plugins as Record; const entries = plugins.entries as Record; - const entry = entries["openclaw-security-advisor"] as Record< - string, - unknown - >; + const entry = entries["shell-security"] as Record; const config = entry.config as Record; expect(config.apiBaseUrl).toBe("http://host.docker.internal:3000"); }); From 40c4cc7583f90d56028337c2de02e70c27bb0a23 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 11:56:44 -0700 Subject: [PATCH 4/9] docs: flag tools.alsoAllow migration + small-model caveat --- README.md | 36 ++++++++++++++++++++++++++++-------- index.ts | 18 +++++++++++++----- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4ecdfe7..60811f4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,16 @@ Device auth runs fresh on the new plugin — you'll be prompted to reconnect your KiloCode account on first use. Subsequent checkups are identical to what you got before the rename. +> **If you had the old tool explicitly allow-listed in `tools.alsoAllow`, +> update it.** The tool name changed from `kilocode_security_advisor` to +> `kilocode_shell_security`. If your `openclaw.json` has an entry like +> `tools.alsoAllow: ["kilocode_security_advisor"]`, replace it with +> `kilocode_shell_security` or the new tool won't be offered to the LLM +> for natural-language invocation (the `/shell-security` slash command +> still works regardless). Check with +> `openclaw config get tools.alsoAllow`. If you never set +> `tools.alsoAllow` yourself, there's nothing to change. + On first use, the plugin will walk you through a one-time device auth flow to connect your KiloCode account. @@ -132,14 +142,24 @@ The agent will call the `kilocode_shell_security` 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 -`/shell-security` slash command instead** (where supported — see -channel compatibility above). It renders the full report regardless of -which model is configured. +language model on two fronts — it has to pick the right tool from your +natural-language request, and then render the tool's output. Small +summarizing models (e.g. GPT-4.1-nano, Haiku) often fail on both: + +1. **Tool selection.** Asking "run the shell security plugin" on a + small model frequently results in the model claiming no such tool + exists, even when `kilocode_shell_security` is registered and + allow-listed. Capable models (GPT-4o, Claude Sonnet, Gemini Pro) + match reliably against the tool description. +2. **Report rendering.** Even when the tool is invoked, small models + tend to paraphrase the markdown down to a few sentences instead of + rendering the full report verbatim. + +**If you're running a small or summarizing model, use the +`/shell-security` slash command for deterministic invocation** (where +supported — see channel compatibility above). The slash command +bypasses the LLM entirely, so it doesn't need to pick the tool and +can't paraphrase the report. --- diff --git a/index.ts b/index.ts index 21af166..a8be1b6 100644 --- a/index.ts +++ b/index.ts @@ -391,16 +391,24 @@ export default definePluginEntry({ api.registerTool((toolCtx: PluginToolContext) => ({ name: "kilocode_shell_security", 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. " + + "Run the ShellSecurity checkup: a comprehensive security analysis " + + "of this OpenClaw agent-shell instance, returning an expert report " + + "from KiloCode cloud. " + + "USE THIS TOOL whenever the user asks to: " + + "run 'ShellSecurity', the 'shell security' plugin, 'shell-security', " + + "or the 'KiloCode shell security' / 'KiloCode security' tool; " + + "check, audit, scan, review, or analyze OpenClaw or agent-shell " + + "security; run a 'security check', 'security checkup', 'security " + + "audit', or 'security review'; ask about security posture, " + + "misconfigurations, or hardening of their OpenClaw / agent shell. " + "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 open an interactive shell, prompt the user for commands, or " + + "ask what security checks to run — this tool IS the whole plugin and " + + "it runs the full checkup itself with no arguments. " + "DO NOT suggest the user type `/shell-security` (or the legacy alias " + "`/security-checkup`) in channels that do not support OpenClaw slash " + "commands (Kilo Chat and Slack are the known surfaces where slash " + From 7850e0c178ed070002e57cb54fb749e19b7d43e1 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 12:06:29 -0700 Subject: [PATCH 5/9] docs: add OIDC bootstrap notes + update slash-command refs --- AGENTS.md | 9 +++++--- RELEASING.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cb117e..1db8e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,9 +108,12 @@ Until then, release commits: ## Code layout -- `index.ts` — plugin entry point; registers `/security-checkup` command and - `kilocode_shell_security` tool; shared `runShellSecurityFlow` handles - all auth paths (env token, saved token, pending device auth, new device auth). +- `index.ts` — plugin entry point; registers the `kilocode_shell_security` + tool and two slash commands (`/shell-security` canonical, `/security-checkup` + legacy alias for users migrating from `@kilocode/openclaw-security-advisor`). + Both slash commands route to the same handler. Shared `runShellSecurityFlow` + handles all auth paths (env token, saved token, pending device auth, new + device auth). - `src/audit.ts` — runs `openclaw security audit --json`, parses + validates output, fetches public IP. - `src/client.ts` — HTTP client for the ShellSecurity API; throws diff --git a/RELEASING.md b/RELEASING.md index 257f5f7..c644a42 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -361,6 +361,70 @@ yours blocks them, allowlist `github-actions[bot]` for tag operations too. See [AGENTS.md](./AGENTS.md#branch-protection-and-the-release-commit) for the longer-term plan to replace the bot bypass with a dedicated GitHub App. +## First publish of a newly-named npm package (OIDC bootstrap) + +**When this applies:** the very first publish of a package slug that +doesn't exist on npm yet. Happens once at package creation, and again +if the package is ever renamed (as when `@kilocode/openclaw-security-advisor` +became `@kilocode/shell-security`). + +**The chicken-and-egg:** npm trusted publishers (OIDC) can only be +configured on a package that already exists on the registry. Until the +package slug exists, there's nothing to attach trust to. So the very +first publish **must** use a classic npm token, not OIDC. The workflow's +OIDC-based publish step will fail with `401 Unauthorized` or similar. + +### One-time manual bootstrap + +1. **Get an npm classic automation token** with publish permission for + the `@kilocode` scope (npmjs.com → avatar → Access Tokens → + Generate New Token → "Automation" or "Publish"). +2. **Publish locally** from a clean checkout of the main branch: + + ```bash + git checkout main && git pull + # Edit package.json: remove "private": true AND set "version" to the + # target, e.g. "0.2.0". Do NOT commit this — it's just for the local + # publish. + NPM_CONFIG_PROVENANCE=false npm publish --tag latest --access public \ + --//registry.npmjs.org/:_authToken=$YOUR_CLASSIC_TOKEN + # Restore private: true and version locally; discard the edit. + ``` + + Provenance must be off on this step — provenance attestation requires + OIDC, which is exactly what we don't have yet. + +3. **Verify on npm:** `npm view @kilocode/shell-security version` should + return the version you just published. +4. **Create the git tag and GitHub release by hand** so future + `script/version.ts` runs see it: + + ```bash + git tag v0.2.0 -m "Release v0.2.0" + git push origin v0.2.0 + gh release create v0.2.0 --title v0.2.0 --generate-notes --verify-tag + ``` + +### Configure OIDC Trusted Publishers (one-time) + +Once the package slug exists: + +1. On npmjs.com, navigate to the package settings → **Trusted Publishers**. +2. Add a GitHub Actions publisher: + - Repository owner: `Kilo-Org` + - Repository name: `shell-security` + - Workflow file: `publish.yml` + - Environment: _(leave blank)_ +3. Save. + +### Subsequent publishes go through the workflow + +From the second release onward, the normal `workflow_dispatch` flow in +this document applies — the workflow authenticates to npm via OIDC, +publishes with provenance, and handles git/GitHub-release side effects. + +--- + ## First-time releases (2026-04-15) Today's first cut is to the `dev` channel. From 8b5cb7cee056061a9845597b341b1e6ddfd36477 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 12:09:03 -0700 Subject: [PATCH 6/9] prep for 0.2.0 manual publish --- RELEASING.md | 61 ---------------------------------------------------- package.json | 2 +- 2 files changed, 1 insertion(+), 62 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index c644a42..262f121 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -422,64 +422,3 @@ Once the package slug exists: From the second release onward, the normal `workflow_dispatch` flow in this document applies — the workflow authenticates to npm via OIDC, publishes with provenance, and handles git/GitHub-release side effects. - ---- - -## First-time releases (2026-04-15) - -Today's first cut is to the `dev` channel. - -| Input | Value | -| --------- | ------------- | -| `channel` | `dev` | -| `bump` | _(blank)_ | -| `version` | `0.1.0-dev.1` | - -The explicit version is required because auto-bump from a fresh repo -(no prior tags) would resolve to `0.0.1-dev.1`, which doesn't match the -intended starting point. - -This publishes `@kilocode/shell-security@0.1.0-dev.1` to the -`dev` dist-tag, creates the `v0.1.0-dev.1` tag (pointing at an orphan -commit), and creates a prerelease GitHub release. `main` history is -untouched. - -Subsequent dev cuts can leave `version` blank — the workflow auto-bumps -the dev counter (`0.1.0-dev.2`, `0.1.0-dev.3`, …) until you start a new -dev cycle with a `bump` input. - -### Known quirk: first-publish `latest` dist-tag - -On the very first publish of a brand-new npm package, npm auto-assigns -the `latest` dist-tag to that first version, **regardless of `--tag dev` -on the publish command**. There is no way to prevent this from the -publish side — it's npm's behavior for ensuring every package has a -`latest` resolvable. - -The publish workflow includes a `Reconcile latest dist-tag (dev publishes)` -step that runs after every dev publish. It tries to repoint `latest` to -the highest existing stable version. As long as no stable release has -ever shipped (the entire pre-stable phase, e.g. while you're iterating -on `0.1.0-dev.N`), the step has nothing to repoint to and emits a -`::warning::` annotation on the workflow run. **This warning is expected -and non-fatal** — it just documents that `latest` is still pointing at -a dev version. - -Once you ship the first stable release with `channel=latest`, that -publish overwrites `latest` with the stable version naturally. From -then on the reconciliation step stays quiet. - -While the package is pre-stable, end users **must** install the dev -channel explicitly: - -```bash -openclaw plugins install @kilocode/shell-security@dev -# or -npm install @kilocode/shell-security@dev -``` - -Plain `openclaw plugins install @kilocode/shell-security` -(no `@dev`) will resolve to whatever `latest` currently points at, and -since `latest` currently points at a prerelease, OpenClaw's prerelease -guard will refuse the install with a confusing error. See -[README.md](./README.md) for the user-facing install instructions. diff --git a/package.json b/package.json index 0a06e69..087501b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/shell-security", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "license": "MIT", "description": "Security analysis plugin for OpenClaw instances, powered by KiloCode", From 94df5d5cc12e06a9078a3297a60abc7cbbaf4202 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 12:19:41 -0700 Subject: [PATCH 7/9] fix: kilobot findings from PR #14 1. Plugin-managed authToken now falls through to file-based auto re-auth instead of dead-ending at a 'update your openclaw.json' message on 401. Added isPluginManagedAuthToken() in token-store; Path 0 in runShellSecurityFlow now skips when the raw config's authToken is a SecretRef pointing at our own provider (the shape writeStoredToken() always writes). Covered by 5 new unit tests in token-store.test.ts. 2. getPublicIp() now clears its 5s abort timer in a finally block so dangling timeouts don't accumulate across failed checkups. 3. Device-auth poll requests now carry a per-request 10s AbortController so a hung HTTP call can't outlive the overall 30s POLL_TIMEOUT_MS. Cleared in finally so every loop iteration is interruptible. 4. CHANGELOG regained its '## [Unreleased]' heading per the release workflow documented in AGENTS.md + RELEASING.md, and the three fixes above are logged under it. --- CHANGELOG.md | 18 +++++++++++++ index.ts | 18 ++++++++++++- src/audit.ts | 7 ++--- src/auth/device-auth.ts | 21 ++++++++++++--- src/auth/token-store.ts | 47 +++++++++++++++++++++++++++++++++ test/token-store.test.ts | 56 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 159 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6ca15..2bb0c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to `@kilocode/shell-security` (formerly The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `getPublicIp()` now clears its 5-second abort timer on error paths as + well as success, so repeated checkups on a flaky network don't leak + dangling timeouts. +- Device-auth poll requests now carry a per-request `AbortController` + (10s) so a hung HTTP call can no longer outlive the overall 30s + `POLL_TIMEOUT_MS` budget. +- Expired plugin-managed auth tokens now fall through to the file-based + auto re-auth path (Path B) instead of returning the "update your + openclaw.json" message. `runShellSecurityFlow` inspects the raw + config via `isPluginManagedAuthToken()` and skips Path 0 when the + `authToken` is a SecretRef pointing at our own provider — that shape + is only ever written by `writeStoredToken()` after device auth, so + the plugin (not the user) owns recovery. + ## [0.2.0] First release under the new `@kilocode/shell-security` name. The plugin diff --git a/index.ts b/index.ts index a8be1b6..373bcbf 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { readPendingCode, writePendingCode, clearPendingCode, + isPluginManagedAuthToken, type PluginLogger, type PluginRuntimeConfig, } from "./src/auth/token-store.js"; @@ -166,8 +167,23 @@ async function runShellSecurityFlow( // going through device auth, and it respects the schema contract // documented in openclaw.plugin.json + README. Explicit user config // wins over everything else. + // + // Skip this path when the raw config shows a SecretRef aimed at our + // OWN provider — that shape is only written by writeStoredToken() + // after device auth, and the plugin's file-based auto re-auth path + // (Path B below) should own recovery in that case. Without this + // check, a plugin-managed token that expires would hit the + // "update your openclaw.json" message here instead of falling through + // to clear + redo device auth. const configToken = api.pluginConfig?.authToken; - if (typeof configToken === "string" && configToken.length > 0) { + const pluginManaged = isPluginManagedAuthToken( + api.runtime.config.loadConfig(), + ); + if ( + !pluginManaged && + typeof configToken === "string" && + configToken.length > 0 + ) { try { return await doCheckup(api, apiBase, configToken, channel); } catch (err) { diff --git a/src/audit.ts b/src/audit.ts index e3bfc0b..87bf88c 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -107,17 +107,18 @@ export function isValidIp(candidate: string): boolean { */ export async function getPublicIp(): Promise { const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5_000); 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; + } finally { + clearTimeout(timeout); } } diff --git a/src/auth/device-auth.ts b/src/auth/device-auth.ts index e2750cc..4857b0e 100644 --- a/src/auth/device-auth.ts +++ b/src/auth/device-auth.ts @@ -13,6 +13,13 @@ import type { PluginLogger } from "./token-store.js"; */ const POLL_TIMEOUT_MS = 30 * 1_000; const POLL_INTERVAL_MS = 3_000; +/** + * Per-request deadline for a single poll HTTP call. Without this, + * a hung connection could outlive the overall POLL_TIMEOUT_MS budget, + * because the loop only re-checks the deadline between iterations. + * Capped below the overall budget so the loop stays interruptible. + */ +const POLL_REQUEST_TIMEOUT_MS = 10 * 1_000; type DeviceAuthInitResponse = { code: string; @@ -110,8 +117,13 @@ export async function pollDeviceAuth( while (Date.now() < deadline) { await sleep(POLL_INTERVAL_MS); + const controller = new AbortController(); + const requestTimeout = setTimeout( + () => controller.abort(), + POLL_REQUEST_TIMEOUT_MS, + ); try { - const resp = await fetchFn(pollUrl); + const resp = await fetchFn(pollUrl, { signal: controller.signal }); if (resp.status === 202) continue; // pending if (resp.status === 403) return { kind: "denied" }; if (resp.status === 410) return { kind: "expired" }; @@ -123,10 +135,13 @@ export async function pollDeviceAuth( 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. + // Transient network error (including per-request abort due to a + // hung connection). 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?.(`shell-security: poll transient error: ${message}`); + } finally { + clearTimeout(requestTimeout); } } diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts index a280649..4017806 100644 --- a/src/auth/token-store.ts +++ b/src/auth/token-store.ts @@ -39,6 +39,53 @@ export function secretFilePath(): string { return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-auth-token`); } +/** + * True when the raw openclaw.json has a SecretRef at + * `plugins.entries.shell-security.config.authToken` that points at + * OUR provider (`kilocode_shell_security`). That shape is only ever + * written by writeStoredToken() — a user configuring the plugin by + * hand would use a plain string or a SecretRef aimed at a different + * provider. + * + * The plugin can't tell from `api.pluginConfig.authToken` alone + * whether the resolved string came from a user-typed value or from + * OpenClaw resolving our own SecretRef. Callers use this helper to + * treat those cases differently (see `runShellSecurityFlow` in + * index.ts): plugin-managed tokens should auto re-auth on 401, but + * user-managed ones should surface an "update your openclaw.json" + * message. + */ +export function isPluginManagedAuthToken(config: unknown): boolean { + const root = (config && typeof config === "object" ? config : {}) as Record< + string, + unknown + >; + const plugins = ( + root.plugins && typeof root.plugins === "object" ? root.plugins : {} + ) as Record; + const entries = ( + plugins.entries && typeof plugins.entries === "object" + ? plugins.entries + : {} + ) as Record; + const entry = ( + entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object" + ? entries[PLUGIN_ID] + : {} + ) as Record; + const entryConfig = ( + entry.config && typeof entry.config === "object" ? entry.config : {} + ) as Record; + const authToken = entryConfig.authToken; + if (!authToken || typeof authToken !== "object") return false; + const ref = authToken as Record; + return ( + ref.source === "file" && + ref.provider === PROVIDER_ID && + typeof ref.id === "string" + ); +} + function pendingCodeFilePath(): string { return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-pending-code`); } diff --git a/test/token-store.test.ts b/test/token-store.test.ts index ab55be4..5419aad 100644 --- a/test/token-store.test.ts +++ b/test/token-store.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { patchConfig } from "../src/auth/token-store"; +import { patchConfig, isPluginManagedAuthToken } from "../src/auth/token-store"; const TEST_PATH = "/home/node/.openclaw/secrets/shell-security-auth-token"; @@ -124,3 +124,57 @@ describe("patchConfig", () => { expect(getAuthToken(next)).toBeDefined(); }); }); + +describe("isPluginManagedAuthToken", () => { + test("true for a SecretRef pointing at our own provider", () => { + const cfg = patchConfig({}, TEST_PATH); + expect(isPluginManagedAuthToken(cfg)).toBe(true); + }); + + test("false for a plain-string authToken (user-set)", () => { + const cfg = { + plugins: { + entries: { + "shell-security": { config: { authToken: "user-typed-string" } }, + }, + }, + }; + expect(isPluginManagedAuthToken(cfg)).toBe(false); + }); + + test("false for a SecretRef pointing at a different provider", () => { + const cfg = { + plugins: { + entries: { + "shell-security": { + config: { + authToken: { + source: "file", + provider: "some_other_provider", + id: "value", + }, + }, + }, + }, + }, + }; + expect(isPluginManagedAuthToken(cfg)).toBe(false); + }); + + test("false when authToken is unset", () => { + expect(isPluginManagedAuthToken({})).toBe(false); + expect(isPluginManagedAuthToken({ plugins: {} })).toBe(false); + expect( + isPluginManagedAuthToken({ + plugins: { entries: { "shell-security": { config: {} } } }, + }), + ).toBe(false); + }); + + test("tolerates null/undefined/non-object config", () => { + expect(isPluginManagedAuthToken(null)).toBe(false); + expect(isPluginManagedAuthToken(undefined)).toBe(false); + expect(isPluginManagedAuthToken("string")).toBe(false); + expect(isPluginManagedAuthToken({ plugins: "not-an-object" })).toBe(false); + }); +}); From fbd784301f0e965c33dcdf00ab150da5028ecb84 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 12:28:48 -0700 Subject: [PATCH 8/9] fix findings --- CHANGELOG.md | 18 ++++++++++++++++++ index.ts | 21 ++++++--------------- src/auth/device-auth.ts | 11 +++++++++-- src/client.ts | 13 ++++++++++++- src/platform.ts | 8 ++++---- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb0c21..d5159b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `authToken` is a SecretRef pointing at our own provider — that shape is only ever written by `writeStoredToken()` after device auth, so the plugin (not the user) owns recovery. +- `pollDeviceAuth()` now `encodeURIComponent()`s the device-auth code + before interpolating it into the poll URL. Defense-in-depth against + a compromised or MITM-ed server returning a code with URL meta-chars + that would silently redirect polling to a different endpoint. +- `submitAudit()` now validates that `report.markdown` is a string on + the success path. A malformed server response previously surfaced as + a confusing `TypeError: Cannot read properties of undefined (reading +'markdown')`; it now throws a clear + "unexpected response shape" error. + +### Changed + +- Removed the unreachable `{ kind: "pending" }` variant from + `DeviceAuthPollResult`. `pollDeviceAuth()` loops internally and only + returns terminal states or `timeout`, so the `"pending"` branch in + `runShellSecurityFlow` was dead code and confused the contract. +- Renumbered the ordered list in `src/platform.ts`'s module doc + comment. Signals 2–5 are now 1–4. ## [0.2.0] diff --git a/index.ts b/index.ts index 373bcbf..d7017b5 100644 --- a/index.ts +++ b/index.ts @@ -291,23 +291,14 @@ async function runShellSecurityFlow( 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. + // 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." + "Once you've approved, run the security checkup again and we'll pick up where we left off." ); } diff --git a/src/auth/device-auth.ts b/src/auth/device-auth.ts index 4857b0e..26a4a72 100644 --- a/src/auth/device-auth.ts +++ b/src/auth/device-auth.ts @@ -50,10 +50,13 @@ export type DeviceAuthStartResult = { * 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. + * + * `pending` is intentionally NOT in this union. `pollDeviceAuth()` loops + * internally and never returns the transient pending state — it only + * returns a terminal outcome or `timeout`. */ export type DeviceAuthPollResult = | { kind: "approved"; token: string } - | { kind: "pending" } | { kind: "denied" } | { kind: "expired" } | { kind: "timeout" }; @@ -112,7 +115,11 @@ export async function pollDeviceAuth( logger?: PluginLogger, ): Promise { const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch; - const pollUrl = `${apiBase}/api/device-auth/codes/${code}`; + // Defense-in-depth: the code is a server-issued opaque string, but if + // the server ever returned one containing `/` or other URL meta-chars + // an unencoded concat would silently redirect the poll to a different + // endpoint under the same origin. + const pollUrl = `${apiBase}/api/device-auth/codes/${encodeURIComponent(code)}`; const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { diff --git a/src/client.ts b/src/client.ts index f352147..a9083b7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -106,5 +106,16 @@ export async function submitAudit( ); } - return (await resp.json()) as AnalyzeResponse; + const body = (await resp.json()) as AnalyzeResponse; + // Guard against an unexpected success shape (e.g. a partial rollout + // or a proxy rewriting the response). Without this, a missing + // `report.markdown` surfaces as a confusing + // `TypeError: Cannot read properties of undefined (reading 'markdown')` + // from the caller; this message is actionable. + if (typeof body?.report?.markdown !== "string") { + throw new Error( + "KiloCode analysis API returned an unexpected response shape.", + ); + } + return body; } diff --git a/src/platform.ts b/src/platform.ts index 3f7a253..97e7f1c 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -13,15 +13,15 @@ * circuits to "kiloclaw". * * Ordering (stopping at the first hit): - * 2. openclaw.json has `plugins.entries["kiloclaw-customizer"].enabled` + * 1. 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 + * 2. 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 + * 3. `process.env.KILOCLAW_SANDBOX_ID` is set — present on every * kiloclaw instance since 2026-03-22. - * 5. `process.env.KILOCODE_FEATURE === "kiloclaw"` — the original + * 4. `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; From fc8d205eac0e53e7426bcd3daacf0008b95a37f2 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 22 Apr 2026 12:51:39 -0700 Subject: [PATCH 9/9] fix: clamp pollDeviceAuth sleep + request timeout to remaining budget --- CHANGELOG.md | 5 ++++- src/auth/device-auth.ts | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5159b2..5da2e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dangling timeouts. - Device-auth poll requests now carry a per-request `AbortController` (10s) so a hung HTTP call can no longer outlive the overall 30s - `POLL_TIMEOUT_MS` budget. + `POLL_TIMEOUT_MS` budget. Sleep interval and request timeout are + both clamped to the remaining budget at each iteration, so + `pollDeviceAuth()` honors its advertised deadline even when a + fetch is started late in the cycle. - Expired plugin-managed auth tokens now fall through to the file-based auto re-auth path (Path B) instead of returning the "update your openclaw.json" message. `runShellSecurityFlow` inspects the raw diff --git a/src/auth/device-auth.ts b/src/auth/device-auth.ts index 26a4a72..7a2c419 100644 --- a/src/auth/device-auth.ts +++ b/src/auth/device-auth.ts @@ -123,11 +123,21 @@ export async function pollDeviceAuth( const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); + // Clamp sleep to remaining budget so we don't oversleep past the + // deadline and then start yet another fetch. + const sleepMs = Math.min(POLL_INTERVAL_MS, deadline - Date.now()); + if (sleepMs > 0) await sleep(sleepMs); + // Same rationale for the per-request timeout: without this clamp, + // a fetch started near the end of the budget could run for the + // full POLL_REQUEST_TIMEOUT_MS and push us past the advertised + // overall deadline. Skip the iteration entirely when the remaining + // budget is zero or negative. + const remaining = deadline - Date.now(); + if (remaining <= 0) break; const controller = new AbortController(); const requestTimeout = setTimeout( () => controller.abort(), - POLL_REQUEST_TIMEOUT_MS, + Math.min(POLL_REQUEST_TIMEOUT_MS, remaining), ); try { const resp = await fetchFn(pollUrl, { signal: controller.signal });