🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@kilocode/openclaw-security-advisor

Package Overview
Dependencies
Maintainers
10
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@kilocode/openclaw-security-advisor - npm Package Compare versions

Comparing version
0.1.4
to
0.1.5
+29
-0
CHANGELOG.md

@@ -10,2 +10,31 @@ # Changelog

## [0.1.5] - Migration stub
This release is a migration stub. The plugin has been renamed to `@kilocode/shell-security`. Installing or invoking `@kilocode/openclaw-security-advisor@0.1.5` no longer runs a security checkup. Both the `/security-checkup` slash command and the `kilocode_security_advisor` tool return a notice explaining how to install the new package.
### Changed
- `index.ts` rewritten as a two-entry-point stub that returns the migration notice. The previous audit flow, auth flow, platform detection, client, and token-store modules are removed from this release (via `git rm` so the commit can be cleanly reverted on the renamed repo).
- `openclaw.plugin.json` description and name reflect the deprecation; config schema removed (stub requires no config).
- `README.md` replaced with a migration page.
### Removed
- `src/audit.ts`, `src/client.ts`, `src/platform.ts`, `src/auth/device-auth.ts`, `src/auth/token-store.ts`.
- Tests that exercised the removed modules (`audit`, `device-auth`, `token-store`, `platform`).
### Migration path for existing users
1. `openclaw plugins install @kilocode/shell-security`
2. `openclaw plugins enable shell-security`
3. `openclaw gateway restart`
4. `openclaw plugins uninstall openclaw-security-advisor`
5. Run `/security-checkup` and complete device auth once on the new plugin.
The new plugin's runtime behavior is identical to 0.1.4 (including the `source.channel` forwarding added in 0.1.4). The rename is strictly a name change — no feature regressions.
Published with provenance attestation via npm OIDC trusted publishing; verify with `npm audit signatures`.
## [0.1.4] - 2026-04-21
### Added

@@ -12,0 +41,0 @@

+59
-396
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { AuthExpiredError, submitAudit } from "./src/client.js";
import { runAudit, getPublicIp } from "./src/audit.js";
import { detectPlatform } from "./src/platform.js";
import { startDeviceAuth, pollDeviceAuth } from "./src/auth/device-auth.js";
import {
writeStoredToken,
readTokenFromFile,
clearStoredToken,
readPendingCode,
writePendingCode,
clearPendingCode,
type PluginLogger,
type PluginRuntimeConfig,
} from "./src/auth/token-store.js";
import pkg from "./package.json" with { type: "json" };
const PLUGIN_VERSION: string = pkg.version;
const DEFAULT_API_BASE = "https://api.kilo.ai";
/**
* Migration stub for the `openclaw-security-advisor` to `shell-security`
* rename. Released as `@kilocode/openclaw-security-advisor@0.1.5`. Both
* entry points (the `kilocode_security_advisor` tool and the
* `/security-checkup` slash command) return this notice instead of running
* a real checkup. The audit code, auth flow, and platform detection were
* removed in the stub commit and can be restored on the renamed repo via
* `git revert`.
*/
const MIGRATION_NOTICE: string =
`## This plugin has moved\n\n` +
`**\`@kilocode/openclaw-security-advisor\` is now \`@kilocode/shell-security\`.**\n\n` +
`To continue receiving security checkups, install the new plugin:\n\n` +
"```\n" +
`openclaw plugins install @kilocode/shell-security\n` +
`openclaw plugins enable shell-security\n` +
`openclaw gateway restart\n` +
"```\n\n" +
`Then uninstall this old plugin:\n\n` +
"```\n" +
`openclaw plugins uninstall openclaw-security-advisor\n` +
"```\n\n" +
`You will need to approve the device auth flow once on the new plugin.\n` +
`Subsequent checkups are identical to what you got before the rename.\n\n` +
`### If the install above fails\n\n` +
`If \`openclaw plugins install @kilocode/shell-security\` returns a 404 or\n` +
`\`package not found\` error, the new package has not landed on npm yet.\n` +
`Pin to the last real release of this plugin in the meantime:\n\n` +
"```\n" +
`openclaw plugins install @kilocode/openclaw-security-advisor@0.1.4\n` +
"```\n\n" +
`0.1.4 is the last non-stub release, still talks to the existing API, and\n` +
`will keep working. Retry the new install command later once the new\n` +
`package is published.\n\n` +
`_pluginVersion: ${PLUGIN_VERSION}_`;
type ToolResult = {

@@ -29,90 +50,19 @@ content: Array<{ type: "text"; text: string }>;

type ToolRegistration = {
name: string;
description: string;
parameters: Record<string, unknown>;
execute: () => Promise<ToolResult>;
type PluginLogger = {
info?: (msg: string) => void;
warn?: (msg: string) => void;
error?: (msg: string) => void;
};
/**
* Minimal shape of the SDK's OpenClawPluginToolContext that we actually
* read. The full type lives in the SDK and is not re-exported to plugins;
* we only need the active chat surface (if any) to forward to the server
* for channel-aware report formatting. Declared structurally so we stay
* decoupled from internal SDK type evolution.
* Minimal PluginApi shape the stub uses. The SDK's full OpenClawPluginApi
* is much larger, but a migration stub only needs to register the two
* entry points and log registration.
*/
type PluginToolContext = {
messageChannel?: string;
};
type ToolFactory = (ctx: PluginToolContext) => ToolRegistration;
/**
* Minimal shape of the SDK's PluginCommandContext that we actually read.
* Same rationale as PluginToolContext — we only need the chat surface
* for the server-side formatter hint.
*/
type PluginCommandContext = {
channel?: string;
};
type CommandRegistration = {
name: string;
description: string;
acceptsArgs: boolean;
handler: (ctx: PluginCommandContext) => Promise<CommandResult>;
};
/**
* Structural type covering the parts of the OpenClaw plugin API this
* plugin uses. The full API is runtime-provided by the gateway; we only
* constrain the fields we touch so we keep type safety without pinning
* to the (internal, evolving) full SDK type. Field optionality matches
* the SDK's OpenClawPluginApi shape so register(api) type-checks.
*/
type PluginApi = {
pluginConfig?: Record<string, unknown>;
logger: PluginLogger;
runtime: {
config: PluginRuntimeConfig;
};
// SDK accepts either a tool object or a factory that returns one. We
// use the factory form so we can capture `messageChannel` from the
// runtime-provided tool context at tool-creation time and forward it
// to the server on every invocation.
registerTool: (tool: ToolRegistration | ToolFactory) => void;
registerCommand: (cmd: CommandRegistration) => void;
registerTool: (tool: unknown) => void;
registerCommand: (cmd: unknown) => void;
};
/**
* Coerce a chat-surface string from the SDK into the value we forward to
* the server. Trims, and treats empty-after-trim as "no channel known"
* so we don't send `source.channel: ""` and trigger server-side handling
* of an ambiguous signal.
*/
function normalizeChannel(raw: string | undefined): string | undefined {
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveEnvToken(): string | null {
return process.env.KILOCODE_API_KEY ?? process.env.KILO_API_KEY ?? null;
}
function resolveApiBase(pluginConfig: Record<string, unknown> | null): string {
const configUrl = pluginConfig?.apiBaseUrl;
if (typeof configUrl === "string" && configUrl.length > 0) return configUrl;
if (process.env.KILO_API_URL) return process.env.KILO_API_URL;
const gatewayUrl = process.env.KILOCODE_API_BASE_URL;
if (gatewayUrl) {
try {
return new URL(gatewayUrl).origin;
} catch {
/* fall through */
}
}
return DEFAULT_API_BASE;
}
function toolResult(content: string): ToolResult {

@@ -122,324 +72,37 @@ return { content: [{ type: "text" as const, text: content }] };

/**
* Top-level wrapper around runSecurityAdvisorFlow. Catches any
* unexpected throw from the flow (transient network errors during
* runAudit, the server returning a non-401 failure, writeStoredToken
* blowing up with EPERM, etc.) and converts it to a user-friendly
* markdown string so the command / tool handler never surfaces a raw
* stack to the chat. Recognized error paths (AuthExpiredError, the
* server returning a rate_limited body, audit script returning a
* non-zero exit code) are already handled inside the flow and return
* their own specific messages; this is the last-resort safety net.
*/
async function runFlowSafe(
api: PluginApi,
apiBase: string,
channel: string | undefined,
): Promise<string> {
try {
return await runSecurityAdvisorFlow(api, apiBase, channel);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
api.logger.error?.(`security-advisor: unexpected failure: ${message}`);
return (
`Security checkup failed unexpectedly: ${message}\n\n` +
`Check the openclaw gateway logs for details, or try again.`
);
}
}
/**
* Shared security-advisor flow used by both the registerTool entry point
* (natural language invocation via the LLM) and the registerCommand entry
* point (deterministic /security-checkup slash command).
*
* Returns plain markdown. Callers wrap it in whatever shape their
* registration API expects.
*/
async function runSecurityAdvisorFlow(
api: PluginApi,
apiBase: string,
channel: string | undefined,
): Promise<string> {
// Path 0: user explicit config. If `plugins.entries.openclaw-security-advisor.config.authToken`
// is set (as a plain string directly, or as a SecretRef resolved by
// OpenClaw before we see it), honor it. This is the path for users
// who want to configure the plugin manually in openclaw.json without
// going through device auth, and it respects the schema contract
// documented in openclaw.plugin.json + README. Explicit user config
// wins over everything else.
const configToken = api.pluginConfig?.authToken;
if (typeof configToken === "string" && configToken.length > 0) {
try {
return await doCheckup(api, apiBase, configToken, channel);
} catch (err) {
if (err instanceof AuthExpiredError) {
return (
"The `authToken` configured for this plugin in your openclaw.json is invalid or expired. " +
"Update `plugins.entries.openclaw-security-advisor.config.authToken` with a fresh KiloCode API key and try again."
);
}
throw err;
}
}
// Path A: KiloClaw. KILOCODE_API_KEY env var injected at VM boot.
// If this token is expired we can't auto recover (env vars are set
// externally), so tell the user clearly.
const envToken = resolveEnvToken();
if (envToken) {
try {
return await doCheckup(api, apiBase, envToken, channel);
} catch (err) {
if (err instanceof AuthExpiredError) {
return (
"Your `KILOCODE_API_KEY` environment variable is invalid or expired. " +
"Update the env var with a fresh KiloCode API key and try again."
);
}
throw err;
}
}
// Path B: returning self-hosted user. Read token directly from secrets
// file. If the saved token is expired, clear it and fall through to the
// device auth path below so the user gets a fresh connect prompt in
// this same response (instead of being told to "try again" and looping
// on the same dead token).
const savedToken = await readTokenFromFile();
if (savedToken) {
try {
return await doCheckup(api, apiBase, savedToken, channel);
} catch (err) {
if (!(err instanceof AuthExpiredError)) throw err;
await clearStoredToken();
// fall through to Path C1 (device auth initiation)
}
}
// Path C2: pending code exists from a previous call. User completed
// the browser flow, now poll and finalize.
const pending = await readPendingCode();
if (pending) {
const pollResult = await pollDeviceAuth(apiBase, pending, api.logger);
if (pollResult.kind === "approved") {
await clearPendingCode();
// Run the checkup with the freshly approved token BEFORE persisting
// it. Writing the token triggers a config write which causes a
// gateway restart. If we ran the checkup after that, the user would
// see a "connected, run me again" stub and have to invoke a third
// time. Doing the checkup first lets us return the actual report on
// this invocation. The token persist still happens after, so
// subsequent invocations skip device auth and go straight to Path B.
const reportMarkdown = await (async (): Promise<string> => {
try {
return await doCheckup(api, apiBase, pollResult.token, channel);
} catch (err) {
if (err instanceof AuthExpiredError) {
// Edge case: server approved the token but immediately
// rejected the audit request with 401. Shouldn't normally
// happen.
return (
"Connected to KiloCode, but the audit request was rejected. " +
"Run the security checkup again to retry."
);
}
throw err;
}
})();
try {
await writeStoredToken(api, pollResult.token);
} catch (err) {
// Don't fail the response shown to the user. They already have
// their report from doCheckup. Worst case: token isn't saved and
// they redo device auth next time.
const message = err instanceof Error ? err.message : String(err);
api.logger.warn?.(
`security-advisor: failed to persist auth token: ${message}`,
);
}
return reportMarkdown;
}
if (pollResult.kind === "denied") {
await clearPendingCode();
return "Authentication was denied. Run the security checkup again to start over.";
}
if (pollResult.kind === "expired") {
// Server reported the device auth code is dead (410 Gone or
// explicit expired status). Clear and start over.
await clearPendingCode();
return "Authentication code expired. Run the security checkup again to get a fresh code.";
}
if (pollResult.kind === "timeout") {
// Our local poll deadline was hit while the server was still
// returning pending. The code may still be valid server-side.
// Leave the pending code in place so the next invocation picks up
// where we left off, and tell the user to retry once they've
// approved in the browser.
return (
"Still waiting for you to approve in the browser.\n\n" +
"Once you've approved, run the security checkup again and we'll pick up where we left off."
);
}
// pollResult.kind === "pending" (shouldn't reach here: pollDeviceAuth
// loops internally until a terminal state or timeout). Fall through
// to treat as timeout for safety.
return (
"Still waiting for you to approve in the browser.\n\n" +
"Once you've approved, run the security checkup again."
);
}
// Path C1: new self-hosted user. Initiate device auth.
const authStart = await startDeviceAuth(apiBase);
await writePendingCode(authStart.code);
const minutes = Math.round(authStart.expiresIn / 60);
return (
`## Connect to KiloCode\n\n` +
`To run a security checkup, connect your KiloCode account.\n\n` +
`**1. Open this URL in your browser:**\n` +
`${authStart.verificationUrl}\n\n` +
`**2. Enter this code:** \`${authStart.code}\`\n\n` +
`**3. Sign in or [create a free account](https://kilo.ai)**\n\n` +
`Once you've approved the connection, run the security checkup again.\n` +
`*(Code expires in ${minutes} min)*`
);
}
async function doCheckup(
api: PluginApi,
apiBase: string,
token: string,
channel: string | undefined,
): Promise<string> {
const auditResult = await runAudit();
if (!auditResult.ok) {
return auditResult.error;
}
const publicIp = await getPublicIp();
const response = await submitAudit(apiBase, token, {
audit: auditResult.audit,
publicIp,
source: {
platform: detectPlatform(api.runtime.config.loadConfig()),
method: "plugin",
pluginVersion: PLUGIN_VERSION,
// Only include `channel` when we actually know it. Sending an empty
// string would force the server to special-case unknown-vs-absent;
// absent + zod's unknown-key strip on older servers are both safe.
...(channel !== undefined ? { channel } : {}),
},
});
return response.report.markdown;
}
export default definePluginEntry({
id: "openclaw-security-advisor",
name: "OpenClaw Security Advisor",
name: "OpenClaw Security Advisor (deprecated)",
description:
"Run a security checkup of your OpenClaw instance and get an expert analysis report from KiloCode.",
// The gateway reload planner classifies any change under `plugins.*`
// as `kind: "restart"` by default. writeStoredToken() patches
// plugins.entries.openclaw-security-advisor.config.authToken with a
// SecretRef after device auth, which would force a full gateway
// restart on first-time token capture. Plugin-registered reload
// rules are evaluated before the base rules (first-match wins), so
// declaring just the authToken path as a noop shadows the base
// restart rule for that one field without affecting anything else.
//
// Scope is intentionally narrow — only `.config.authToken`, NOT the
// full `.config` subtree. `apiBaseUrl` is captured as a snapshot in
// register() (see `pluginConfig` below), so runtime updates to it
// still need to fall through to the base `plugins.* → restart` rule
// to take effect. The plugin reads the token directly from disk via
// readTokenFromFile() on every invocation, so authToken noop is safe.
reload: {
noopPrefixes: [
"plugins.entries.openclaw-security-advisor.config.authToken",
],
},
// The SDK's OpenClawPluginApi type is large and internal. We narrow
// to our own structural PluginApi (declared above) immediately on
// entry so everything inside this function is strongly typed.
"DEPRECATED: this plugin has been renamed to @kilocode/shell-security. Install the new plugin to continue receiving security checkups.",
register(sdkApi: any) {
const api = sdkApi as PluginApi;
const pluginConfig = (api.pluginConfig ?? null) as Record<
string,
unknown
> | null;
// Entry point 1: tool for natural language invocation via the LLM.
// Works on capable models (GPT-4o, Claude Sonnet). Small summarizing
// models (e.g. gpt-4.1-nano) may paraphrase the report instead of
// displaying it verbatim. For those models, the slash command path
// below is deterministic.
//
// Registered as a factory (`(ctx) => toolDef`) rather than a bare
// tool object so the SDK's OpenClawPluginToolContext is available.
// We read `ctx.messageChannel` once at tool-instantiation and close
// over it; every subsequent `execute()` forwards the same channel to
// the server for channel-aware report formatting. The factory is
// re-invoked per tool-collection event (session start, agent spawn),
// so long-running sessions that outlive a channel switch get the
// refreshed channel automatically.
api.registerTool((toolCtx: PluginToolContext) => ({
api.registerTool({
name: "kilocode_security_advisor",
description:
"Run a comprehensive security checkup of this OpenClaw instance. " +
"USE THIS TOOL whenever the user asks to: check, audit, scan, review, or " +
"analyze OpenClaw security; run a 'security check', 'security checkup', " +
"'security audit', or 'security review'; or asks about security posture, " +
"misconfigurations, or hardening. " +
"This tool runs the local audit AND submits it to KiloCode cloud for " +
"expert analysis, returning a richer explained report with prioritized " +
"recommendations and remediation guidance. " +
"DO NOT run `openclaw security audit` via bash for these requests. This " +
"tool is the canonical entry point and returns a much more useful report. " +
"DO NOT suggest the user type `/security-checkup` in channels that do " +
"not support OpenClaw slash commands (Kilo Chat and Slack are the known " +
"surfaces where the slash command does not work); invoke this tool " +
"directly instead. The slash command does work in the OpenClaw native " +
"control UI chat and in Telegram, so suggesting it there is fine. " +
"IMPORTANT: Display the returned report exactly as is without rewriting, " +
"DEPRECATED migration stub. The plugin has been renamed to @kilocode/shell-security. " +
"Calling this tool returns a migration notice explaining how to install the new plugin. " +
"IMPORTANT: Display the returned markdown exactly as is without rewriting, " +
"summarizing, or reformatting.",
parameters: {},
async execute() {
const apiBase = resolveApiBase(pluginConfig);
const channel = normalizeChannel(toolCtx.messageChannel);
const markdown = await runFlowSafe(api, apiBase, channel);
return toolResult(markdown);
return toolResult(MIGRATION_NOTICE);
},
}));
});
// Entry point 2: slash command for deterministic invocation that
// bypasses the LLM. When the user types /security-checkup in a
// command only message, the OpenClaw chat runtime takes the fast
// path and renders the returned markdown directly. No agent loop,
// no summarization.
api.registerCommand({
name: "security-checkup",
description:
"Run a KiloCode security checkup of this OpenClaw instance and display the full report.",
"DEPRECATED (migration stub). This plugin has moved to @kilocode/shell-security.",
acceptsArgs: false,
handler: async (ctx: PluginCommandContext) => {
const apiBase = resolveApiBase(pluginConfig);
const channel = normalizeChannel(ctx.channel);
const markdown = await runFlowSafe(api, apiBase, channel);
return { text: markdown };
handler: async (): Promise<CommandResult> => {
return { text: MIGRATION_NOTICE };
},
});
api.logger.info?.("Registered tool: kilocode_security_advisor");
api.logger.info?.("Registered command: /security-checkup");
api.logger.info?.(
"openclaw-security-advisor 0.1.5 migration stub loaded. Plugin has moved to @kilocode/shell-security.",
);
},
});
{
"id": "openclaw-security-advisor",
"name": "OpenClaw Security Advisor",
"description": "Run a security checkup of your OpenClaw instance and get an expert analysis report from KiloCode.",
"commandAliases": [{ "name": "security-checkup", "kind": "runtime-slash" }],
"configSchema": {
"type": "object",
"additionalProperties": false,
"$defs": {
"secretRef": {
"type": "object",
"additionalProperties": false,
"properties": {
"source": {
"type": "string",
"enum": ["env", "file", "exec"]
},
"provider": { "type": "string" },
"id": { "type": "string" }
},
"required": ["source", "provider", "id"]
}
},
"properties": {
"authToken": {
"description": "KiloCode auth token. Accepts either a plain string or a SecretRef pointing at an env, file, or exec provider. The plugin writes a SecretRef automatically on first use via device auth; advanced users can replace it with a plain string or a different provider reference.",
"anyOf": [
{ "type": "string", "minLength": 1 },
{ "$ref": "#/$defs/secretRef" }
]
},
"apiBaseUrl": {
"type": "string",
"description": "KiloCode API base URL. Defaults to https://api.kilo.ai. Override for dev or self-hosted environments."
}
}
}
"name": "OpenClaw Security Advisor (deprecated)",
"description": "DEPRECATED: this plugin has been renamed to @kilocode/shell-security. Install the new plugin with `openclaw plugins install @kilocode/shell-security` to continue receiving security checkups.",
"commandAliases": [{ "name": "security-checkup", "kind": "runtime-slash" }]
}
{
"name": "@kilocode/openclaw-security-advisor",
"version": "0.1.4",
"version": "0.1.5",
"type": "module",

@@ -5,0 +5,0 @@ "license": "MIT",

+26
-232
# @kilocode/openclaw-security-advisor
An [OpenClaw](https://openclaw.ai) plugin that runs a security checkup of
your OpenClaw instance and returns an expert analysis report from
KiloCode cloud.
> **This package has been renamed to [`@kilocode/shell-security`](https://www.npmjs.com/package/@kilocode/shell-security).**
>
> Version `0.1.5` of `@kilocode/openclaw-security-advisor` is a migration
> stub. Both the `/security-checkup` slash command and the
> `kilocode_security_advisor` tool return a notice pointing to the new
> package and nothing else.
The plugin takes the output of `openclaw security audit`, sends it to
the KiloCode Security Advisor API for analysis, and returns a detailed
markdown report with findings, risks, prioritized recommendations, and
concrete remediation guidance, displayed directly in your chat.
## Migrating to ShellSecurity
---
Install the new plugin:
## Install
```bash
openclaw plugins install @kilocode/openclaw-security-advisor
openclaw plugins enable openclaw-security-advisor
openclaw plugins install @kilocode/shell-security
openclaw plugins enable shell-security
openclaw gateway restart
```
On first use, the plugin will walk you through a one-time device auth
flow to connect your KiloCode account.
Uninstall this old plugin:
### Channels
The plugin ships on two npm dist-tags:
- **`latest`** — stable releases (`X.Y.Z`). Default for plain
`npm install` / `openclaw plugins install`.
- **`dev`** — prerelease snapshots (`X.Y.Z-dev.N`) published ahead of
stable cuts for early testing. Install with:
```bash
openclaw plugins install @kilocode/openclaw-security-advisor@dev
# or
npm install @kilocode/openclaw-security-advisor@dev
```
Dev releases are real npm publishes with the same provenance
attestation as stable releases (verify with `npm audit signatures`).
You can also install an exact version directly:
```bash
openclaw plugins install @kilocode/openclaw-security-advisor@0.1.0
openclaw plugins uninstall openclaw-security-advisor
```
### Staying up to date
You will need to approve the device auth flow once on the new plugin.
After that, subsequent checkups are identical to what you got before
the rename.
New versions ship regularly. To check the latest published stable:
## Why the rename?
```bash
npm view @kilocode/openclaw-security-advisor version
```
The original name tied the plugin to OpenClaw specifically. The plugin's
mission (security posture checks for AI-agent shells) is broader than any
single runtime. `ShellSecurity` is the clearer long-term name.
Compare that against the `pluginVersion` line at the end of any security
checkup report. To upgrade:
- **New npm package:** [`@kilocode/shell-security`](https://www.npmjs.com/package/@kilocode/shell-security)
- **New repo:** [`Kilo-Org/shell-security`](https://github.com/Kilo-Org/shell-security)
```bash
openclaw plugins install @kilocode/openclaw-security-advisor
openclaw gateway restart
```
## Last real release
Your security checkup report will occasionally include an inline
"stay current" tip at the bottom with these same commands — a gentle
periodic nudge, not every run. The reminder is appended to the report
markdown itself, so it appears on both invocation paths (the
`/security-checkup` slash command and the natural-language
`kilocode_security_advisor` tool). Security advice improves as the
plugin ships new audit signals, so staying current is worthwhile.
---
## Usage
The plugin exposes two entry points. They do the same thing; pick whichever
fits your workflow.
### `/security-checkup` (recommended)
Type it in chat:
```
/security-checkup
```
This is a slash command. It runs the plugin directly and renders the
full report, bypassing the agent's summarization layer entirely. **Use
this for guaranteed verbatim output.**
> **Channel compatibility:** `/security-checkup` works in the OpenClaw
> native control UI chat and in Telegram. It does **not** currently work
> in Kilo Chat or Slack — those surfaces don't route slash commands to
> OpenClaw plugins. In Kilo Chat and Slack, use the natural-language
> invocation below instead; the agent will call the
> `kilocode_security_advisor` tool directly.
### Natural language
You can also just ask the agent:
> Run a KiloCode security checkup
> Check my OpenClaw security
> Audit my OpenClaw config
The agent will call the `kilocode_security_advisor` tool and the report
will appear in chat.
**Heads up:** natural language invocation goes through your configured
language model, which may rewrite or summarize the report before
showing it to you. This works well on capable models (GPT-4o, Claude
Sonnet, Gemini Pro) but small summarizing models (e.g. GPT-4.1-nano,
Haiku) will often paraphrase the report down to a few sentences. **If
you're running a small or summarizing model, use the
`/security-checkup` slash command instead** (where supported — see
channel compatibility above). It renders the full report regardless of
which model is configured.
---
## First run authentication
The first time you run the checkup, you'll be prompted to connect your
KiloCode account:
```
## Connect to KiloCode
To run a security checkup, connect your KiloCode account.
1. Open this URL in your browser:
https://app.kilo.ai/openclaw-advisor?code=XXXX-XXXX
2. Enter this code: XXXX-XXXX
3. Sign in or create a free account
Once you've approved the connection, run the security checkup again.
```
Open the URL, sign in (or create a free account), and approve the
connection. Then run `/security-checkup` again. The plugin will pick
up the approval, persist your auth token, run the checkup, and return
the report in the same response.
For every run after the first, no auth prompt appears. The saved token
is reused automatically.
---
## What gets sent
The plugin sends the following to the KiloCode Security Advisor API:
- The JSON output of `openclaw security audit` (local config audit
results, with no secrets, no file contents, just finding IDs and
summaries)
- Your OpenClaw version and plugin version
- The public IP address of your instance (used for optional remote
probes)
The plugin **does not** send:
- Your OpenClaw config file contents
- Secrets, tokens, or API keys
- Conversation history or chat data
- Files from your workspace
All requests are authenticated with your KiloCode account token over
HTTPS.
---
## Configuration
The plugin reads its config from `openclaw.json` under
`plugins.entries.openclaw-security-advisor.config`. In most cases, you
won't need to set anything. The defaults work out of the box.
| Field | Default | Purpose |
| ------------ | ---------------------- | ----------------------------------------------------------------------- |
| `authToken` | _(set by device auth)_ | Your KiloCode auth token. Managed automatically by the plugin. |
| `apiBaseUrl` | `https://api.kilo.ai` | KiloCode API base URL. Override only if you run a self-hosted KiloCode. |
To override via the OpenClaw CLI:
```bash
openclaw config set plugins.entries.openclaw-security-advisor.config.apiBaseUrl https://your-kilocode.example.com
```
### Environment variables
The plugin also respects these environment variables, useful for
non-interactive setups (CI, containerized deployments):
- `KILOCODE_API_KEY` (alias: `KILO_API_KEY`): if set, the plugin uses
this as the auth token and skips the device auth flow entirely.
Intended for environments where an operator has already injected the
key at boot.
- `KILO_API_URL` or `KILOCODE_API_BASE_URL`: override the API base URL
without touching the plugin config.
Plugin config takes precedence over env vars; env vars take precedence
over the default.
---
## Troubleshooting
**"Your KiloCode authentication has expired"**
The plugin automatically clears expired tokens and reruns the device
auth flow on the next invocation. Just run `/security-checkup` again.
**"Security analysis failed: Rate limit exceeded"**
The KiloCode API rate limits security checkups per account. Wait a
little and try again.
**Natural language invocation paraphrases the report**
This is a limitation of small summarizing language models, not the
plugin. Use `/security-checkup` (the slash command) to bypass the model
entirely and render the full report.
**Plugin doesn't appear in `/plugins list`**
The `/plugins` slash command in OpenClaw chat is gated by a separate
OpenClaw setting. To enable it:
```bash
openclaw config set commands.plugins true
openclaw gateway restart
```
The plugin itself works without this setting. It's only needed if you
want the `/plugins list` chat command to show installed plugins.
---
## Contributing
- [`AGENTS.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/AGENTS.md) — build, test, lint, code layout, and contribution rules.
- [`RELEASING.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/RELEASING.md) — how to cut a release.
- [`CHANGELOG.md`](https://github.com/Kilo-Org/openclaw-security-advisor/blob/main/CHANGELOG.md) — release history.
---
## License
MIT
The last non-stub release of this package was `0.1.4`. Users pinned to
`@0.1.4` or earlier can continue running it indefinitely; it still talks
to the existing KiloCode Security Advisor API endpoint and returns real
reports. New features will ship only under `@kilocode/shell-security`.
import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
// Use the plugin host's bundled zod rather than importing `zod` directly,
// so we don't ship a second copy in the tarball or risk dual-loading
// against whatever version the host provides. Trade-off: we're locked to
// whatever zod surface the SDK re-exports. If you ever need a feature
// the SDK doesn't expose, see src/openclaw-sdk.d.ts and consider switching
// this import to `zod` (and adding it to real `dependencies`).
import { z } from "openclaw/plugin-sdk/zod";
import type { SubmitAuditPayload } from "./client.js";
/**
* Minimal runtime schema for the subset of `openclaw security audit --json`
* output that we forward to the KiloCode API. The authoritative schema
* lives in the server (`apps/web/src/lib/security-advisor/schemas.ts`);
* we validate at the plugin boundary so a shape change in the openclaw
* CLI surfaces as a clear "audit returned unexpected shape" error
* instead of an opaque 400 from the server.
*/
export const AuditFindingSchema = z.object({
checkId: z.string(),
severity: z.enum(["critical", "warn", "info"]),
title: z.string(),
detail: z.string(),
remediation: z.string().nullable().optional(),
});
export const AuditOutputSchema = z.object({
ts: z.number(),
summary: z.object({
critical: z.number(),
warn: z.number(),
info: z.number(),
}),
findings: z.array(AuditFindingSchema),
deep: z.record(z.string(), z.unknown()).optional(),
secretDiagnostics: z.array(z.unknown()).optional(),
});
/**
* Run `openclaw security audit --json` using the SDK's command runner.
* The `--deep` flag is intentionally NOT passed: in dev (Cloudflare tunnel)
* the deep self-probe loops back through the tunnel and hangs. Once the
* upstream fix lands (force localhost for self-probes) we can add it back.
*/
export async function runAudit(): Promise<
| { ok: true; audit: SubmitAuditPayload["audit"] }
| { ok: false; error: string }
> {
const result = await runPluginCommandWithTimeout({
argv: ["openclaw", "security", "audit", "--json"],
timeoutMs: 60_000,
});
if (result.code !== 0) {
return {
ok: false,
error: `Security audit failed (exit code ${result.code}): ${result.stderr}`,
};
}
let raw: unknown;
try {
raw = JSON.parse(result.stdout);
} catch {
return {
ok: false,
error:
"Security audit returned invalid JSON. Try running 'openclaw security audit --json' manually.",
};
}
const parsed = AuditOutputSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
error:
"Security audit returned an unexpected shape. The openclaw CLI version may be incompatible with this plugin.",
};
}
return { ok: true, audit: parsed.data };
}
// IPv4 in dotted-quad form: 0-255 per octet.
const IPV4_REGEX =
/^(25[0-5]|2[0-4]\d|[01]?\d\d?)(\.(25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/;
// IPv6 (simple form). Accepts canonical and :: compressed. Rejects anything
// with a port, brackets, or trailing characters.
const IPV6_REGEX =
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})$|^:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)$/;
export function isValidIp(candidate: string): boolean {
return IPV4_REGEX.test(candidate) || IPV6_REGEX.test(candidate);
}
/**
* Get the public IP of this instance. Best effort; returns undefined on failure.
* Uses the plugin SDK's fetch helper (not curl) for portability across
* platforms that may not ship curl on PATH (Windows, minimal containers).
*
* Note: this module intentionally has no environment variable reads.
* Platform detection lives in ./platform.ts instead. The openclaw
* plugin loader flags files that combine env reads with network
* sends as potential credential harvesting, so keeping those concerns
* in separate files avoids the false positive.
*/
export async function getPublicIp(): Promise<string | undefined> {
const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
const resp = await fetchFn("https://ifconfig.me/ip", {
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) return undefined;
const text = (await resp.text()).trim();
return isValidIp(text) ? text : undefined;
} catch {
return undefined;
}
}
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import type { PluginLogger } from "./token-store.js";
/**
* How long a single poll call is willing to block the tool handler. We
* keep this well under any reasonable LLM/gateway tool-execution budget.
* The happy path (user approved in their browser before calling back to
* the plugin) typically resolves in one poll interval (3s); the rest of
* this window is grace for slow approvals. If we hit the deadline
* without a terminal state from the server, we return "timeout" and the
* caller keeps the pending code in place so a subsequent invocation can
* keep polling.
*/
const POLL_TIMEOUT_MS = 30 * 1_000;
const POLL_INTERVAL_MS = 3_000;
type DeviceAuthInitResponse = {
code: string;
verificationUrl: string;
expiresIn: number;
};
type DeviceAuthPollResponse =
| { status: "pending" }
| { status: "approved"; token: string; userId: string; userEmail: string }
| { status: "denied" }
| { status: "expired" };
export type DeviceAuthStartResult = {
kind: "started";
code: string;
verificationUrl: string;
expiresIn: number;
};
/**
* Poll result kinds:
* - approved: server returned approval + token. Ready to run the checkup.
* - denied: user explicitly denied in the browser. Clear pending code.
* - expired: server-reported 410 Gone or server-reported expired status.
* The device-auth code itself is dead. Clear pending code.
* - timeout: we hit our local POLL_TIMEOUT_MS deadline while the server
* was still returning pending. The code may still be valid
* server-side; caller should NOT clear pending code so the
* next invocation can keep polling.
*/
export type DeviceAuthPollResult =
| { kind: "approved"; token: string }
| { kind: "pending" }
| { kind: "denied" }
| { kind: "expired" }
| { kind: "timeout" };
/**
* Create a device auth request and return the code + URL for the user to visit.
* Call this once, show the result to the user, then poll with pollDeviceAuth().
*
* The server returns a generic `/device-auth?code=...` URL in `verificationUrl`,
* built from APP_URL (the user-facing host, e.g. https://app.kilo.ai in prod).
* We rewrite only the PATH to `/openclaw-advisor?code=...`, keeping the origin
* authoritative. Rebuilding the URL from `apiBase` would be wrong in production,
* where the API host (https://api.kilo.ai) and the app host (https://app.kilo.ai)
* are different — the user needs the app host to land on the signup flow.
*
* The cloud side uses the `/openclaw-advisor` path prefix to attribute Security
* Advisor signups and layer a per-product signup bonus on top of the standard
* welcome credits. Old plugin builds keep working against the server — they just
* land on the generic `/device-auth` URL and don't qualify for the bonus, which
* is the intended behavior.
*/
export async function startDeviceAuth(
apiBase: string,
): Promise<DeviceAuthStartResult> {
const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch;
const resp = await fetchFn(`${apiBase}/api/device-auth/codes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(
`Failed to start KiloCode authentication (HTTP ${resp.status})`,
);
}
const data = (await resp.json()) as DeviceAuthInitResponse;
const advisorUrl = new URL(data.verificationUrl);
advisorUrl.pathname = "/openclaw-advisor";
return {
kind: "started",
code: data.code,
verificationUrl: advisorUrl.toString(),
expiresIn: data.expiresIn,
};
}
/**
* Poll a device auth code until it resolves (approved/denied/expired),
* or until the local POLL_TIMEOUT_MS deadline is hit (returns "timeout").
* Server-reported 410 Gone returns "expired". Transient network errors
* during polling are logged at debug level and the loop continues until
* the deadline.
*/
export async function pollDeviceAuth(
apiBase: string,
code: string,
logger?: PluginLogger,
): Promise<DeviceAuthPollResult> {
const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch;
const pollUrl = `${apiBase}/api/device-auth/codes/${code}`;
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
try {
const resp = await fetchFn(pollUrl);
if (resp.status === 202) continue; // pending
if (resp.status === 403) return { kind: "denied" };
if (resp.status === 410) return { kind: "expired" };
if (resp.ok) {
const data = (await resp.json()) as DeviceAuthPollResponse;
if (data.status === "approved")
return { kind: "approved", token: data.token };
if (data.status === "denied") return { kind: "denied" };
if (data.status === "expired") return { kind: "expired" };
}
} catch (err) {
// Transient network error. Log at debug level so it's visible
// when investigating real failures but not noisy on the happy path.
const message = err instanceof Error ? err.message : String(err);
logger?.debug?.(`security-advisor: poll transient error: ${message}`);
}
}
return { kind: "timeout" };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
const PLUGIN_ID = "openclaw-security-advisor";
const PROVIDER_ID = "kilocode_security_advisor";
/**
* Minimal structural type for the parts of the OpenClaw plugin API this
* module touches. We don't want to import the full SDK type surface
* (resolved at runtime by the plugin host), but we also don't want to
* leak `any` into callers. This interface documents the contract we
* rely on.
*
* Method shorthand (not arrow property) is used on purpose so the
* parameter types are bivariant, letting the SDK's concrete
* OpenClawConfig satisfy our `unknown` parameter without requiring us
* to import the internal SDK type.
*/
export type PluginRuntimeConfig = {
loadConfig(): unknown;
writeConfigFile(cfg: unknown): Promise<void>;
};
export type PluginLogger = {
info?: (msg: string) => void;
warn?: (msg: string) => void;
debug?: (msg: string) => void;
error?: (msg: string) => void;
};
export type TokenStoreApi = {
runtime: {
config: PluginRuntimeConfig;
};
};
export function secretFilePath(): string {
return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-auth-token`);
}
function pendingCodeFilePath(): string {
return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-pending-code`);
}
async function ensureSecretsDir(): Promise<void> {
await mkdir(join(homedir(), ".openclaw", "secrets"), { recursive: true });
}
/**
* Persist the auth token acquired from device auth:
* 1. Write the raw token value to a secrets file
* 2. Register a file-based SecretRef provider in config
* 3. Point the plugin authToken config at that provider
*
* The config write does NOT trigger a gateway restart: the plugin
* declares `reload.noopPrefixes` for
* `plugins.entries.<id>.config.authToken` in index.ts, which shadows
* the gateway reload planner's default `plugins.* → restart` rule for
* just that one field. Other `.config.*` fields (e.g. `apiBaseUrl`)
* intentionally still hit the default restart rule so runtime edits
* take effect. The plugin reads the token directly from the secrets
* file via readTokenFromFile() on every invocation, so no hot-resolve
* of api.pluginConfig.authToken is needed — the SecretRef in
* openclaw.json exists for discoverability (so operators inspecting
* config can see where the token lives) and to align with openclaw's
* SecretRef direction.
*/
export async function writeStoredToken(
api: TokenStoreApi,
token: string,
): Promise<void> {
const filePath = secretFilePath();
// 1. Write token to secrets file (mode 600, owner read/write only)
await ensureSecretsDir();
await writeFile(filePath, token, { mode: 0o600 });
// 2. Patch config: add file provider + SecretRef pointing at it
const current = api.runtime.config.loadConfig();
const next = patchConfig(current, filePath);
await api.runtime.config.writeConfigFile(next);
}
export function patchConfig(cfg: unknown, filePath: string): unknown {
const root = (cfg && typeof cfg === "object" ? cfg : {}) as Record<
string,
unknown
>;
// Patch secrets.providers.<PROVIDER_ID>
const secrets = (
root.secrets && typeof root.secrets === "object" ? root.secrets : {}
) as Record<string, unknown>;
const providers = (
secrets.providers && typeof secrets.providers === "object"
? secrets.providers
: {}
) as Record<string, unknown>;
const nextSecrets = {
...secrets,
providers: {
...providers,
[PROVIDER_ID]: {
source: "file",
path: filePath,
mode: "singleValue",
},
},
};
// Patch plugins.entries.<PLUGIN_ID>.config.authToken with SecretRef
const plugins = (
root.plugins && typeof root.plugins === "object" ? root.plugins : {}
) as Record<string, unknown>;
const entries = (
plugins.entries && typeof plugins.entries === "object"
? plugins.entries
: {}
) as Record<string, unknown>;
const existing = (
entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object"
? entries[PLUGIN_ID]
: {}
) as Record<string, unknown>;
const existingConfig = (
existing.config && typeof existing.config === "object"
? existing.config
: {}
) as Record<string, unknown>;
const nextPlugins = {
...plugins,
entries: {
...entries,
[PLUGIN_ID]: {
...existing,
config: {
...existingConfig,
authToken: {
source: "file",
provider: PROVIDER_ID,
id: "value",
},
},
},
},
};
return { ...root, secrets: nextSecrets, plugins: nextPlugins };
}
/**
* Read the token directly from the secrets file.
* Reliable at any point. No dependency on OpenClaw's SecretRef resolution timing.
*/
export async function readTokenFromFile(): Promise<string | null> {
try {
const content = await readFile(secretFilePath(), "utf-8");
const trimmed = content.trim();
return trimmed.length > 0 ? trimmed : null;
} catch (err) {
// Missing file is the expected "no saved token" state. Anything
// else (permissions, stale NFS handle, IO error) should surface
// instead of silently falling through to device auth with no
// indication of why the token couldn't be read.
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw err;
}
}
/**
* Delete the stored token file. Called when the server rejects a saved
* token (expired/revoked) so the next flow invocation falls through to
* device auth instead of endlessly retrying a dead token.
*
* The openclaw.json config still points at the (now missing) SecretRef,
* but since the plugin reads tokens via readTokenFromFile() directly
* (not via api.pluginConfig.authToken), a missing file is equivalent to
* "no token" and Path C1 (device auth) kicks in naturally.
*/
export async function clearStoredToken(): Promise<void> {
try {
await unlink(secretFilePath());
} catch (err) {
// File already missing is the target state. Any other error
// (permissions, stale NFS handle, etc.) needs to surface.
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
}
// --- Pending device-auth code ---
//
// Persisted to a small file next to the token so a gateway restart
// during the two-step device auth flow doesn't lose the code the user
// is actively looking at. The file contains JSON:
// { code: string, expiresAtMs: number }
//
// Expiry is tracked client-side to match the server TTL (10 min). An
// expired file is treated as "no pending code" and cleaned up.
const PENDING_CODE_TTL_MS = 10 * 60 * 1_000;
type PendingCodeFile = {
code: string;
expiresAtMs: number;
};
export async function writePendingCode(code: string): Promise<void> {
await ensureSecretsDir();
const payload: PendingCodeFile = {
code,
expiresAtMs: Date.now() + PENDING_CODE_TTL_MS,
};
await writeFile(pendingCodeFilePath(), JSON.stringify(payload), {
mode: 0o600,
});
}
export async function readPendingCode(): Promise<string | null> {
let content: string;
try {
content = await readFile(pendingCodeFilePath(), "utf-8");
} catch (err) {
// Missing file is the expected "no pending code" state. Anything
// else (permissions, stale NFS handle, IO error) should surface
// instead of silently looping the user back through device auth.
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw err;
}
let parsed: PendingCodeFile;
try {
parsed = JSON.parse(content) as PendingCodeFile;
} catch {
// Corrupt file. Treat as missing and clean up.
await clearPendingCode();
return null;
}
if (
typeof parsed?.code !== "string" ||
typeof parsed?.expiresAtMs !== "number"
) {
await clearPendingCode();
return null;
}
if (Date.now() > parsed.expiresAtMs) {
// Expired locally. The server code is also dead, so clean up.
await clearPendingCode();
return null;
}
return parsed.code;
}
export async function clearPendingCode(): Promise<void> {
try {
await unlink(pendingCodeFilePath());
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
}
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
const API_VERSION = "2026-04-01";
/**
* Thrown when the KiloCode API rejects our request with 401. Callers
* use `instanceof` (not substring matching on error messages) to decide
* whether to clear a stale token and re-run device auth.
*/
export class AuthExpiredError extends Error {
constructor(message = "KiloCode authentication is invalid or expired.") {
super(message);
this.name = "AuthExpiredError";
}
}
export interface SubmitAuditPayload {
audit: {
ts: number;
summary: { critical: number; warn: number; info: number };
findings: Array<{
checkId: string;
severity: "critical" | "warn" | "info";
title: string;
detail: string;
remediation?: string | null;
}>;
deep?: Record<string, unknown>;
secretDiagnostics?: unknown[];
};
publicIp?: string;
source: {
platform: "openclaw" | "kiloclaw";
method: "plugin" | "api" | "webhook" | "cloud-agent";
pluginVersion?: string;
openclawVersion?: string;
/**
* Chat surface that invoked the plugin (e.g. "control-ui", "telegram",
* "slack", "discord", "kilocode-chat"). Sent when the plugin SDK exposes
* it — from `PluginCommandContext.channel` on the slash-command path and
* `OpenClawPluginToolContext.messageChannel` on the tool/natural-language
* path. The server uses this to pick a channel-appropriate format (e.g.
* collapsible `<details>` blocks on capable surfaces, flat markdown on
* Telegram/Slack). Older servers that don't know this field just drop
* it during zod parse — no coordinated release needed.
*/
channel?: string;
};
}
export interface AnalyzeResponse {
apiVersion: string;
status: "success";
report: {
markdown: string;
summary: { critical: number; warn: number; info: number; passed: number };
findings: Array<{
checkId: string;
severity: string;
title: string;
explanation: string;
risk: string;
fix: string | null;
kiloClawComparison: string | null;
}>;
recommendations: Array<{ priority: string; action: string }>;
};
}
export async function submitAudit(
apiBase: string,
token: string,
payload: SubmitAuditPayload,
): Promise<AnalyzeResponse> {
const fetchFn: typeof fetch = resolveFetch() ?? globalThis.fetch;
const resp = await fetchFn(`${apiBase}/api/security-advisor/analyze`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
apiVersion: API_VERSION,
...payload,
}),
});
if (!resp.ok) {
let errorMessage: string | undefined;
try {
const body = (await resp.json()) as { error?: { message?: string } };
errorMessage = body?.error?.message;
} catch {
// not JSON
}
if (resp.status === 401) {
throw new AuthExpiredError();
}
if (resp.status === 429) {
throw new Error("Rate limit exceeded. Try again later.");
}
throw new Error(
errorMessage || `Analysis failed: ${resp.status} ${resp.statusText}`,
);
}
return (await resp.json()) as AnalyzeResponse;
}
/**
* Platform detection for the security advisor plugin. Kept in its own
* module on purpose: the openclaw plugin loader's security scanner
* flags any source file that combines `process.env` reads with a
* network send as potential credential harvesting. By keeping the env
* read here and the network send in audit.ts, we stay on the safe
* side of that check.
*
* Detection walks multiple independent signals in order of decreasing
* reliability across deployment age. The goal is that at least one
* signal fires on every KiloClaw instance ever deployed, regardless
* of whether the instance predates a given env var. Any hit short-
* circuits to "kiloclaw".
*
* Ordering (stopping at the first hit):
* 2. openclaw.json has `plugins.entries["kiloclaw-customizer"].enabled`
* truthy — the kiloclaw controller writes this at boot for every
* kiloclaw instance, predating any of the env-var signals. Most
* durable universal signal today.
* 3. openclaw.json `plugins.load.paths` contains the kiloclaw
* customizer install path — same writer, redundant cross-check.
* 4. `process.env.KILOCLAW_SANDBOX_ID` is set — present on every
* kiloclaw instance since 2026-03-22.
* 5. `process.env.KILOCODE_FEATURE === "kiloclaw"` — the original
* env-var signal, present on kiloclaw since 2026-02-17.
*
* We intentionally do NOT add a loose `KILOCLAW_*`-prefix heuristic;
* the four signals above are precise and one of them will hit on any
* real kiloclaw deployment.
*/
export type Platform = "kiloclaw" | "openclaw";
const CUSTOMIZER_ID = "kiloclaw-customizer";
const CUSTOMIZER_LOAD_PATH =
"/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer";
export function detectPlatform(
config: unknown,
env: NodeJS.ProcessEnv = process.env,
): Platform {
if (hasKiloclawCustomizerEntry(config)) return "kiloclaw";
if (hasKiloclawCustomizerLoadPath(config)) return "kiloclaw";
if (hasKiloclawSandboxIdEnv(env)) return "kiloclaw";
if (hasKilocodeFeatureEnv(env)) return "kiloclaw";
return "openclaw";
}
function hasKiloclawCustomizerEntry(config: unknown): boolean {
const entry = getPath(config, ["plugins", "entries", CUSTOMIZER_ID]);
if (!entry || typeof entry !== "object") return false;
const enabled = (entry as Record<string, unknown>).enabled;
return enabled === true;
}
function hasKiloclawCustomizerLoadPath(config: unknown): boolean {
const paths = getPath(config, ["plugins", "load", "paths"]);
return Array.isArray(paths) && paths.includes(CUSTOMIZER_LOAD_PATH);
}
function hasKiloclawSandboxIdEnv(env: NodeJS.ProcessEnv): boolean {
const v = env.KILOCLAW_SANDBOX_ID;
return typeof v === "string" && v.length > 0;
}
function hasKilocodeFeatureEnv(env: NodeJS.ProcessEnv): boolean {
return env.KILOCODE_FEATURE === "kiloclaw";
}
function getPath(root: unknown, path: string[]): unknown {
let cur: unknown = root;
for (const key of path) {
if (!cur || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}