diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index dc502b931..8e1b3b31e 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -8,6 +8,7 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { registerSecurityWrapCli } from "./security-wrap-cli.js"; type SecurityAuditOptions = { json?: boolean; @@ -30,13 +31,16 @@ function formatSummary(summary: { critical: number; warn: number; info: number } export function registerSecurityCli(program: Command) { const security = program .command("security") - .description("Security tools (audit)") + .description("Security tools (audit, wrap)") .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`, ); + // Register subcommands + registerSecurityWrapCli(security); + security .command("audit") .description("Audit config + local state for common security foot-guns") diff --git a/src/cli/security-wrap-cli.ts b/src/cli/security-wrap-cli.ts new file mode 100644 index 000000000..080140b4d --- /dev/null +++ b/src/cli/security-wrap-cli.ts @@ -0,0 +1,124 @@ +import type { Command } from "commander"; +import { createInterface } from "readline"; + +import { + detectSuspiciousPatterns, + wrapExternalContent, + type ExternalContentSource, +} from "../security/external-content.js"; +import { defaultRuntime } from "../runtime.js"; +import { isRich, theme } from "../terminal/theme.js"; + +type WrapOptions = { + source?: string; + url?: string; + stdin?: boolean; + json?: boolean; + noWarning?: boolean; +}; + +async function readStdin(): Promise { + return new Promise((resolve) => { + const lines: string[] = []; + const rl = createInterface({ input: process.stdin }); + rl.on("line", (line) => lines.push(line)); + rl.on("close", () => resolve(lines.join("\n"))); + }); +} + +async function fetchUrl(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + return response.text(); +} + +export function registerSecurityWrapCli(securityCmd: Command) { + securityCmd + .command("wrap") + .description("Wrap external content with security boundaries for safe LLM processing") + .option("--source ", "Content source label (email, webhook, api, or custom)", "api") + .option("--url ", "Fetch content from URL") + .option("--stdin", "Read content from stdin", false) + .option("--no-warning", "Omit security warning header", false) + .option("--json", "Output as JSON with metadata", false) + .addHelpText( + "after", + ` +Examples: + echo '{"data": "test"}' | moltbot security wrap --stdin --source api + moltbot security wrap --url https://api.example.com/data + curl -s https://api.example.com | moltbot security wrap --stdin --source "external-api" + +Use this when fetching external APIs in skills to protect against prompt injection. +`, + ) + .action(async (opts: WrapOptions) => { + const rich = isRich(); + + // Determine content source + let content: string; + let sourceLabel = opts.source || "api"; + + if (opts.url) { + try { + content = await fetchUrl(opts.url); + sourceLabel = opts.source || opts.url; + } catch (err) { + defaultRuntime.error(`Error fetching URL: ${err}`); + process.exit(1); + } + } else if (opts.stdin || !process.stdin.isTTY) { + content = await readStdin(); + } else { + defaultRuntime.error("No input provided. Use --url or --stdin, or pipe content."); + process.exit(1); + } + + // Detect suspicious patterns + const suspiciousPatterns = detectSuspiciousPatterns(content); + if (suspiciousPatterns.length > 0) { + const warn = rich ? theme.warn("⚠️ SUSPICIOUS PATTERNS DETECTED:") : "WARNING: SUSPICIOUS PATTERNS DETECTED:"; + defaultRuntime.error(warn); + for (const pattern of suspiciousPatterns) { + defaultRuntime.error(` - ${pattern}`); + } + defaultRuntime.error(""); + } + + // Map source to type + const sourceType: ExternalContentSource = + sourceLabel === "email" + ? "email" + : sourceLabel === "webhook" + ? "webhook" + : sourceLabel === "api" + ? "api" + : "unknown"; + + // Wrap content + const wrapped = wrapExternalContent(content, { + source: sourceType, + sender: sourceLabel !== sourceType ? sourceLabel : undefined, + includeWarning: opts.noWarning !== true, + }); + + if (opts.json) { + const output = { + wrapped, + metadata: { + source: sourceLabel, + sourceType, + timestamp: new Date().toISOString(), + suspiciousPatterns: suspiciousPatterns.length > 0 ? suspiciousPatterns : undefined, + contentLength: content.length, + wrappedLength: wrapped.length, + }, + }; + defaultRuntime.log(JSON.stringify(output, null, 2)); + } else { + defaultRuntime.log(wrapped); + } + }); +}