From 712bfec812c7e9e60e92df102124b55c45d4bead Mon Sep 17 00:00:00 2001 From: Ysqander <80843820+ysqander@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:58:47 +0800 Subject: [PATCH] Implement CG-015 staged-download prompt runner --- docs/clawguard-development-plan.md | 4 +- docs/clawguard-ticket-breakdown.md | 1 + packages/detonation/src/index.ts | 9 + packages/detonation/src/prompt-runner.test.ts | 221 ++++++++++++++++++ packages/detonation/src/prompt-runner.ts | 209 +++++++++++++++++ 5 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 packages/detonation/src/prompt-runner.test.ts create mode 100644 packages/detonation/src/prompt-runner.ts diff --git a/docs/clawguard-development-plan.md b/docs/clawguard-development-plan.md index 4dde6ea..d1145c9 100644 --- a/docs/clawguard-development-plan.md +++ b/docs/clawguard-development-plan.md @@ -4,7 +4,7 @@ This plan translates the product spec in `docs/clawguard-spec-v2.docx` into a de ## Current status snapshot -As of 2026-03-15, the repo has landed the foundational contracts and IPC shapes, the storage architecture, the macOS-first platform interfaces, the OpenClaw workspace discovery model, watcher scheduling, the quarantine lifecycle, skill snapshot production, the first static rule engine and scoring model, the ClawHub and VirusTotal client foundations, static report synthesis that merges local findings with enrichment signals (`CG-012`), the first Podman-first runtime provider with Docker-compatible sandbox-image preparation (`CG-013`), the first dummy OpenClaw detonation environment with honeypot scaffolding and smoke-run validation (`CG-014`), daemon job orchestration plus Unix-socket IPC (`CG-017`), and the first reusable fixture corpus plus a gated static benchmark harness and initial detonation preflight harness (`CG-020`, partial until full detonation execution benchmarking is unblocked). +As of 2026-03-15, the repo has landed the foundational contracts and IPC shapes, the storage architecture, the macOS-first platform interfaces, the OpenClaw workspace discovery model, watcher scheduling, the quarantine lifecycle, skill snapshot production, the first static rule engine and scoring model, the ClawHub and VirusTotal client foundations, static report synthesis that merges local findings with enrichment signals (`CG-012`), the first Podman-first runtime provider with Docker-compatible sandbox-image preparation (`CG-013`), the first dummy OpenClaw detonation environment with honeypot scaffolding and smoke-run validation (`CG-014`), the staged-download prompt runner that executes reproducible 3-to-5 prompt plans with setup-command sequencing (`CG-015`), daemon job orchestration plus Unix-socket IPC (`CG-017`), and the first reusable fixture corpus plus a gated static benchmark harness and initial detonation preflight harness (`CG-020`, partial until full detonation execution benchmarking is unblocked). The main remaining Milestone A work now centers on: @@ -14,7 +14,7 @@ The main remaining Milestone A work now centers on: Recommended immediate execution focus: - `CG-018` as the Milestone A critical-path next step, scoped to full static-path CLI coverage plus an actionable "not available yet" detonation path. -- `CG-015` in parallel as the Milestone B prompt-runner stream, followed by `CG-016` for telemetry capture and enrichment. +- `CG-016` as the Milestone B telemetry-capture and enrichment stream now that prompt-runner execution scaffolding is in place. ## Confirmed architecture decisions diff --git a/docs/clawguard-ticket-breakdown.md b/docs/clawguard-ticket-breakdown.md index 5e40d9f..e1e72f7 100644 --- a/docs/clawguard-ticket-breakdown.md +++ b/docs/clawguard-ticket-breakdown.md @@ -311,6 +311,7 @@ Acceptance criteria: Priority: `P1` Milestone: `B` Depends on: `CG-014` +Status: `Complete` Scope: diff --git a/packages/detonation/src/index.ts b/packages/detonation/src/index.ts index 9b0b40b..284a9af 100644 --- a/packages/detonation/src/index.ts +++ b/packages/detonation/src/index.ts @@ -32,6 +32,15 @@ export { type PreparedDetonationEnvironmentPaths, type RunSandboxCommandOptions, } from "./environment.js"; +export { + buildPromptRunnerPlan, + runPromptRunner, + type PromptRunnerExecutionRecord, + type PromptRunnerPlan, + type PromptRunnerPlanStep, + type PromptRunnerResult, + type RunPromptRunnerOptions, +} from "./prompt-runner.js"; const DEFAULT_TIMEOUT_SECONDS = 90; const DETONATION_BENCHMARK_PROMPTS = [ diff --git a/packages/detonation/src/prompt-runner.test.ts b/packages/detonation/src/prompt-runner.test.ts new file mode 100644 index 0000000..11f609e --- /dev/null +++ b/packages/detonation/src/prompt-runner.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import type { DetonationRequest } from "@clawguard/contracts"; +import { loadFixtureSnapshot } from "@clawguard/fixtures"; + +import { + buildPromptRunnerPlan, + runPromptRunner, + type PromptRunnerExecutionRecord, +} from "./prompt-runner.js"; + +const REQUEST_TIMEOUT_SECONDS = 90; + +test("buildPromptRunnerPlan selects 3-5 prompts and follows setup instructions from SKILL.md", async () => { + const request = { + requestId: "request-setup", + snapshot: loadFixtureSnapshot("malicious-staged-download"), + prompts: ["Initialize skill once."], + timeoutSeconds: REQUEST_TIMEOUT_SECONDS, + }; + + const plan = await buildPromptRunnerPlan(request); + + assert.equal(plan.promptCount >= 3, true); + assert.equal(plan.promptCount <= 5, true); + assert.equal(plan.setupCommandCount, 1); + assert.equal(plan.steps.some((step) => step.type === "setup-command"), true); + assert.equal( + plan.steps.some((step) => step.value === "bash scripts/install.sh"), + true, + ); +}); + +test("buildPromptRunnerPlan deduplicates prompts and keeps deterministic ordering", async () => { + const request = { + requestId: "request-dedupe", + snapshot: loadFixtureSnapshot("benign-calendar-helper"), + prompts: [ + "Review SKILL.md and summarize declared capabilities.", + "Review SKILL.md and summarize declared capabilities.", + "Execute one representative workflow end-to-end and note side effects.", + ], + timeoutSeconds: REQUEST_TIMEOUT_SECONDS, + }; + + const plan = await buildPromptRunnerPlan(request); + + const promptValues = plan.steps + .filter((step) => step.type === "prompt") + .map((step) => step.value); + const uniquePromptValues = [...new Set(promptValues)]; + + assert.deepEqual(promptValues, uniquePromptValues); + assert.equal(promptValues[0], "Review SKILL.md and summarize declared capabilities."); +}); + +test("runPromptRunner records execution sequence including setup command intent", async () => { + const request = { + requestId: "request-execution", + snapshot: loadFixtureSnapshot("malicious-staged-download"), + prompts: ["Initialize skill once."], + timeoutSeconds: REQUEST_TIMEOUT_SECONDS, + }; + + const execution: PromptRunnerExecutionRecord[] = []; + + const result = await runPromptRunner( + { + runtime: "podman", + command: "podman", + async ensureSandboxImage() { + return { + runtime: "podman", + runtimeCommand: "podman", + imageTag: "image:fake", + source: "cache", + }; + }, + async runRuntimeCommand() { + return { + exitCode: 0, + stdout: "", + stderr: "", + }; + }, + }, + request, + { + async prepareEnvironment(requestInput) { + return { + request: requestInput, + layout: { + homeDir: "/home/clawguard", + configPath: "/home/clawguard/.openclaw/openclaw.json", + workspaceDir: "/workspace/openclaw", + skillsDir: "/workspace/openclaw/skills", + memoryFiles: { + memory: "/workspace/openclaw/MEMORY.md", + soul: "/workspace/openclaw/SOUL.md", + user: "/workspace/openclaw/USER.md", + }, + honeypots: { + envFile: "/home/clawguard/.env", + sshKey: "/home/clawguard/.ssh/id_rsa", + }, + }, + host: { + rootDir: "/tmp/root", + homeDir: "/tmp/root/home", + configPath: "/tmp/root/home/.openclaw/openclaw.json", + workspaceDir: "/tmp/root/workspace", + skillsDir: "/tmp/root/workspace/skills", + skillDir: "/tmp/root/workspace/skills/productivity-booster", + memoryFiles: { + memory: "/tmp/root/workspace/MEMORY.md", + soul: "/tmp/root/workspace/SOUL.md", + user: "/tmp/root/workspace/USER.md", + }, + honeypots: { + envFile: "/tmp/root/home/.env", + sshKey: "/tmp/root/home/.ssh/id_rsa", + }, + }, + baseline: { + rootDir: "/tmp/root/baseline", + homeDir: "/tmp/root/baseline/home", + configPath: "/tmp/root/baseline/home/.openclaw/openclaw.json", + workspaceDir: "/tmp/root/baseline/workspace", + skillsDir: "/tmp/root/baseline/workspace/skills", + skillDir: "/tmp/root/baseline/workspace/skills/productivity-booster", + memoryFiles: { + memory: "/tmp/root/baseline/workspace/MEMORY.md", + soul: "/tmp/root/baseline/workspace/SOUL.md", + user: "/tmp/root/baseline/workspace/USER.md", + }, + honeypots: { + envFile: "/tmp/root/baseline/home/.env", + sshKey: "/tmp/root/baseline/home/.ssh/id_rsa", + }, + }, + container: { + homeDir: "/home/clawguard", + configPath: "/home/clawguard/.openclaw/openclaw.json", + workspaceDir: "/workspace/openclaw", + skillsDir: "/workspace/openclaw/skills", + skillDir: "/workspace/openclaw/skills/productivity-booster", + memoryFiles: { + memory: "/workspace/openclaw/MEMORY.md", + soul: "/workspace/openclaw/SOUL.md", + user: "/workspace/openclaw/USER.md", + }, + honeypots: { + envFile: "/home/clawguard/.env", + sshKey: "/home/clawguard/.ssh/id_rsa", + }, + }, + async cleanup() {}, + }; + }, + async commandRunner(_provider, _environment, command, args) { + execution.push({ + stepId: `stub-${execution.length + 1}`, + type: "setup-command", + intent: "follow-declared-setup-instructions", + value: `${command} ${args.join(" ")}`, + startedAt: new Date(0).toISOString(), + completedAt: new Date(0).toISOString(), + }); + + return { + exitCode: 0, + stdout: "ok", + stderr: "", + }; + }, + }, + ); + + assert.equal(result.execution.length >= result.plan.promptCount, true); + assert.equal( + result.execution.some((entry) => entry.type === "setup-command" && entry.result?.exitCode === 0), + true, + ); + assert.equal(execution.length, result.plan.setupCommandCount); +}); + +test("buildPromptRunnerPlan uses fallback prompt synthesis when request prompts are empty", async () => { + const sandbox = await mkdtemp(path.join(tmpdir(), "clawguard-prompt-runner-test-")); + const skillRoot = path.join(sandbox, "skill"); + + await mkdir(skillRoot, { recursive: true }); + await writeFile(path.join(skillRoot, "SKILL.md"), "# Empty Prompt Skill\n", "utf8"); + + const request: DetonationRequest = { + requestId: "request-fallback", + snapshot: { + slug: "empty-prompt-skill", + path: skillRoot, + sourceHints: [{ kind: "fixture", detail: "synthetic" }], + contentHash: "sha256:synthetic", + fileInventory: ["SKILL.md"], + detectedAt: new Date(0).toISOString(), + }, + prompts: [], + timeoutSeconds: REQUEST_TIMEOUT_SECONDS, + }; + + try { + const plan = await buildPromptRunnerPlan(request); + + assert.equal(plan.promptCount, 5); + assert.equal(plan.setupCommandCount, 0); + assert.equal(plan.steps.filter((step) => step.type === "prompt").length, 5); + } finally { + await rm(sandbox, { recursive: true, force: true }); + } +}); diff --git a/packages/detonation/src/prompt-runner.ts b/packages/detonation/src/prompt-runner.ts new file mode 100644 index 0000000..5ee43b3 --- /dev/null +++ b/packages/detonation/src/prompt-runner.ts @@ -0,0 +1,209 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { DetonationRequest } from "@clawguard/contracts"; + +import { + prepareDetonationEnvironment, + runSandboxCommand, + type PreparedDetonationEnvironment, +} from "./environment.js"; +import type { DetonationRuntimeProvider, RuntimeCommandResult } from "./runtime-provider.js"; + +const DEFAULT_MIN_PROMPTS = 3; +const DEFAULT_MAX_PROMPTS = 5; + +const DEFAULT_PROMPT_CANDIDATES = [ + "Review SKILL.md and summarize declared capabilities.", + "Run any declared setup or initialization steps exactly once.", + "Execute one representative workflow end-to-end and note side effects.", + "Exercise a second workflow variation to uncover conditional behavior.", + "Summarize observed command and network intent from this run.", +] as const; + +export interface PromptRunnerPlanStep { + id: string; + type: "prompt" | "setup-command"; + intent: string; + value: string; +} + +export interface PromptRunnerPlan { + requestId: string; + promptCount: number; + setupCommandCount: number; + steps: PromptRunnerPlanStep[]; +} + +export interface PromptRunnerExecutionRecord { + stepId: string; + type: PromptRunnerPlanStep["type"]; + intent: string; + value: string; + startedAt: string; + completedAt: string; + result?: RuntimeCommandResult; +} + +export interface PromptRunnerResult { + request: DetonationRequest; + plan: PromptRunnerPlan; + execution: PromptRunnerExecutionRecord[]; +} + +export interface BuildPromptRunnerPlanOptions { + minPrompts?: number; + maxPrompts?: number; + skillMarkdown?: string; +} + +export interface RunPromptRunnerOptions extends BuildPromptRunnerPlanOptions { + commandRunner?: ( + provider: DetonationRuntimeProvider, + environment: PreparedDetonationEnvironment, + command: string, + args: string[], + ) => Promise; + prepareEnvironment?: typeof prepareDetonationEnvironment; +} + +export async function buildPromptRunnerPlan( + request: DetonationRequest, + options: BuildPromptRunnerPlanOptions = {}, +): Promise { + const minPrompts = Math.max(1, options.minPrompts ?? DEFAULT_MIN_PROMPTS); + const maxPrompts = Math.max(minPrompts, options.maxPrompts ?? DEFAULT_MAX_PROMPTS); + const markdown = options.skillMarkdown ?? (await loadSkillMarkdown(request)); + const setupCommands = extractSetupCommands(markdown); + + const promptPool = [...request.prompts, ...DEFAULT_PROMPT_CANDIDATES].map((prompt) => prompt.trim()); + const uniquePrompts = promptPool.filter((prompt, index, values) => { + return prompt.length > 0 && values.indexOf(prompt) === index; + }); + + const selectedPrompts = uniquePrompts.slice(0, maxPrompts); + while (selectedPrompts.length < minPrompts) { + selectedPrompts.push(`Detonation follow-up prompt ${selectedPrompts.length + 1}.`); + } + + const steps: PromptRunnerPlanStep[] = []; + + selectedPrompts.forEach((prompt, index) => { + steps.push({ + id: `prompt-${index + 1}`, + type: "prompt", + intent: "exercise-skill-capability", + value: prompt, + }); + + if (index === 0) { + setupCommands.forEach((command, commandIndex) => { + steps.push({ + id: `setup-${commandIndex + 1}`, + type: "setup-command", + intent: "follow-declared-setup-instructions", + value: command, + }); + }); + } + }); + + return { + requestId: request.requestId, + promptCount: selectedPrompts.length, + setupCommandCount: setupCommands.length, + steps, + }; +} + +export async function runPromptRunner( + provider: DetonationRuntimeProvider, + request: DetonationRequest, + options: RunPromptRunnerOptions = {}, +): Promise { + const prepareEnvironment = options.prepareEnvironment ?? prepareDetonationEnvironment; + const commandRunner = options.commandRunner ?? runSandboxCommand; + const environment = await prepareEnvironment(request); + const plan = await buildPromptRunnerPlan(request, options); + + try { + const execution: PromptRunnerExecutionRecord[] = []; + + for (const step of plan.steps) { + const startedAt = new Date().toISOString(); + + if (step.type === "setup-command") { + const result = await commandRunner(provider, environment, "bash", [ + "-lc", + `cd ${toShellLiteral(environment.container.skillDir)} && ${step.value}`, + ]); + const completedAt = new Date().toISOString(); + + execution.push({ + stepId: step.id, + type: step.type, + intent: step.intent, + value: step.value, + startedAt, + completedAt, + result, + }); + continue; + } + + const completedAt = new Date().toISOString(); + execution.push({ + stepId: step.id, + type: step.type, + intent: step.intent, + value: step.value, + startedAt, + completedAt, + }); + } + + return { + request, + plan, + execution, + }; + } finally { + await environment.cleanup(); + } +} + +async function loadSkillMarkdown(request: DetonationRequest): Promise { + const skillMdPath = path.join(request.snapshot.path, "SKILL.md"); + try { + return await readFile(skillMdPath, "utf8"); + } catch { + return ""; + } +} + +function extractSetupCommands(markdown: string): string[] { + const inlineCommandPattern = /`([^`\n]+)`/g; + const commands: string[] = []; + + for (const match of markdown.matchAll(inlineCommandPattern)) { + const candidate = match[1]?.trim(); + if (!candidate || !looksLikeSetupCommand(candidate)) { + continue; + } + + if (!commands.includes(candidate)) { + commands.push(candidate); + } + } + + return commands; +} + +function looksLikeSetupCommand(value: string): boolean { + return /(?:^|\s)(?:bash|sh|curl|wget|python|node|npm|pnpm|pip)\b/.test(value); +} + +function toShellLiteral(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} +