From 9485753a5de7af0d1984507c38f76235ef2458b5 Mon Sep 17 00:00:00 2001 From: Ysqander <80843820+ysqander@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:52:52 +0800 Subject: [PATCH] Implement CG-013 detonation runtime provider and sandbox image prep --- docs/clawguard-development-plan.md | 6 +- docs/clawguard-ticket-breakdown.md | 14 +- packages/detonation/sandbox/Containerfile | 15 +++ packages/detonation/src/index.test.ts | 96 +++++++++++++- packages/detonation/src/index.ts | 16 ++- packages/detonation/src/runtime-provider.ts | 139 ++++++++++++++++++++ 6 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 packages/detonation/sandbox/Containerfile create mode 100644 packages/detonation/src/runtime-provider.ts diff --git a/docs/clawguard-development-plan.md b/docs/clawguard-development-plan.md index dd0e1c8..1e92640 100644 --- a/docs/clawguard-development-plan.md +++ b/docs/clawguard-development-plan.md @@ -4,12 +4,16 @@ This plan translates the product spec in `docs/clawguard-spec-v2.docx` into a de ## Current status snapshot -As of 2026-03-13, 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, and the first reusable fixture corpus plus a gated static benchmark harness and initial detonation preflight harness. +As of 2026-03-13, 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, the first reusable fixture corpus plus a gated static benchmark harness and initial detonation preflight harness, and the first Podman-first runtime provider with Docker-compatible sandbox-image preparation. The main remaining Milestone A work now centers on: - daemon orchestration, IPC, and CLI flows needed to turn the implemented packages into the end-to-end static interception path +The main remaining Milestone B detonation work now centers on: + +- dummy OpenClaw detonation environment setup, staged-download prompt execution, and telemetry capture on top of the runtime provider foundation. + ## Confirmed architecture decisions - Monorepo from day one. diff --git a/docs/clawguard-ticket-breakdown.md b/docs/clawguard-ticket-breakdown.md index 1f45783..3cb72b2 100644 --- a/docs/clawguard-ticket-breakdown.md +++ b/docs/clawguard-ticket-breakdown.md @@ -10,16 +10,17 @@ This ticket plan converts the high-level implementation plan into deliverable wo ## Current snapshot -As of 2026-03-13, the repo has landed the main code and documentation for `CG-001` through `CG-012`. +As of 2026-03-13, the repo has landed the main code and documentation for `CG-001` through `CG-013`. -`CG-020` now covers the reusable fixture corpus, gated static benchmark harness, and detonation preflight harness, but full detonation execution benchmarking remains blocked on `CG-013` through `CG-016`. +`CG-020` now covers the reusable fixture corpus, gated static benchmark harness, and detonation preflight harness, but full detonation execution benchmarking remains blocked on `CG-014` through `CG-016`. -The next unfinished Milestone A tickets now start with: +The next unfinished Milestone B tickets now start with: -- `CG-017`: daemon job orchestration and IPC -- `CG-018`: CLI commands and output formatting +- `CG-014`: dummy OpenClaw detonation environment and honeypots +- `CG-015`: staged-download prompt runner +- `CG-016`: telemetry capture and VT enrichment -`CG-020` remains partially complete until detonation execution benchmarking can land on top of `CG-013` through `CG-016`. +`CG-020` remains partially complete until detonation execution benchmarking can land on top of `CG-014` through `CG-016`. ## Epic A: Monorepo Foundation @@ -269,6 +270,7 @@ Acceptance criteria: Priority: `P1` Milestone: `B` Depends on: `CG-002`, `CG-004` +Status: `Complete` Scope: diff --git a/packages/detonation/sandbox/Containerfile b/packages/detonation/sandbox/Containerfile new file mode 100644 index 0000000..42bd479 --- /dev/null +++ b/packages/detonation/sandbox/Containerfile @@ -0,0 +1,15 @@ +FROM node:22-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", "--version"] diff --git a/packages/detonation/src/index.test.ts b/packages/detonation/src/index.test.ts index 96c3ba2..d8acefa 100644 --- a/packages/detonation/src/index.test.ts +++ b/packages/detonation/src/index.test.ts @@ -6,17 +6,30 @@ import type { ContainerRuntimeDetector, DetectedContainerRuntime } from "@clawgu import { buildDetonationBenchmarkRequest, + createDetonationRuntimeProvider, runDetonationPreflightBenchmark, runDetonationPreflightBenchmarkCli, } from "./index.js"; -function createRuntimeDetector(runtime?: DetectedContainerRuntime): ContainerRuntimeDetector { +function createRuntimeDetector( + runtime?: DetectedContainerRuntime, + available: DetectedContainerRuntime[] = runtime ? [runtime] : [], +): ContainerRuntimeDetector { return { async detectAvailableRuntimes() { - return runtime ? [runtime] : []; + return available; }, - async getPreferredRuntime() { - return runtime; + async getPreferredRuntime(preferredRuntime) { + if (preferredRuntime !== undefined) { + const preferredMatch = available.find( + (candidate) => candidate.runtime === preferredRuntime, + ); + if (preferredMatch !== undefined) { + return preferredMatch; + } + } + + return available[0]; }, }; } @@ -72,3 +85,78 @@ test("runDetonationPreflightBenchmarkCli reports runtime-unavailable without fai assert.ok(result.summary.rows.every((row) => row.status === "runtime-unavailable")); assert.ok(result.summary.rows.every((row) => row.timeoutSeconds === 120)); }); + +test("createDetonationRuntimeProvider prefers Podman when both runtimes are available", async () => { + const runtimeDetector = createRuntimeDetector(undefined, [ + { + runtime: "docker", + command: "docker", + }, + { + runtime: "podman", + command: "podman", + }, + ]); + + const commandLog: string[] = []; + const provider = await createDetonationRuntimeProvider({ + runtimeDetector, + commandExecutor: { + async run(command, args) { + commandLog.push(`${command} ${args.join(" ")}`); + return { + exitCode: 1, + stdout: "", + stderr: "missing", + }; + }, + }, + }); + + assert.equal(provider.runtime, "podman"); + assert.equal(provider.command, "podman"); + assert.deepEqual(commandLog, []); +}); + +test("runtime providers share image-cache semantics across podman and docker", async () => { + for (const runtime of ["podman", "docker"] as const) { + const commandCalls: Array<{ command: string; args: string[] }> = []; + const runtimeDetector = createRuntimeDetector({ runtime, command: runtime }); + + const provider = await createDetonationRuntimeProvider({ + runtimeDetector, + commandExecutor: { + async run(command, args) { + commandCalls.push({ command, args }); + + const isImageCheck = + runtime === "podman" + ? args.join(" ") === "image exists ghcr.io/clawguard/detonation-sandbox:0.1.0" + : args.join(" ") === "image inspect ghcr.io/clawguard/detonation-sandbox:0.1.0"; + + if (isImageCheck) { + return { + exitCode: 1, + stdout: "", + stderr: "not found", + }; + } + + return { + exitCode: 0, + stdout: "ok", + stderr: "", + }; + }, + }, + }); + + const result = await provider.ensureSandboxImage(); + + assert.equal(result.runtime, runtime); + assert.equal(result.runtimeCommand, runtime); + assert.equal(result.source, "built"); + assert.equal(commandCalls[0]?.command, runtime); + assert.ok(commandCalls[1]?.args.includes("build")); + } +}); diff --git a/packages/detonation/src/index.ts b/packages/detonation/src/index.ts index c646971..0e59e82 100644 --- a/packages/detonation/src/index.ts +++ b/packages/detonation/src/index.ts @@ -9,9 +9,17 @@ import { } from "@clawguard/fixtures"; import { createPlatformAdapter, type ContainerRuntimeDetector } from "@clawguard/platform"; -export type DetonationRuntime = "podman" | "docker"; - -export const defaultDetonationRuntime: DetonationRuntime = "podman"; +export { + createDetonationRuntimeProvider, + defaultDetonationRuntime, + defaultSandboxImageTag, + type CreateDetonationRuntimeProviderOptions, + type DetonationRuntimeProvider, + type EnsureSandboxImageOptions, + type EnsureSandboxImageResult, + type RuntimeCommandExecutor, + type RuntimeCommandResult, +} from "./runtime-provider.js"; const DEFAULT_TIMEOUT_SECONDS = 90; const DETONATION_BENCHMARK_PROMPTS = [ @@ -74,7 +82,7 @@ export async function runDetonationPreflightBenchmark( } const runtimeStart = performance.now(); - const runtime = await runtimeDetector.getPreferredRuntime(defaultDetonationRuntime); + const runtime = await runtimeDetector.getPreferredRuntime("podman"); const runtimeDetectionMs = performance.now() - runtimeStart; const rows: DetonationPreflightBenchmarkRow[] = fixtures.map((fixture) => { diff --git a/packages/detonation/src/runtime-provider.ts b/packages/detonation/src/runtime-provider.ts new file mode 100644 index 0000000..f683b98 --- /dev/null +++ b/packages/detonation/src/runtime-provider.ts @@ -0,0 +1,139 @@ +import path from "node:path"; + +import type { DetonationRuntimeKind } from "@clawguard/contracts"; +import { createPlatformAdapter, type ContainerRuntimeDetector } from "@clawguard/platform"; + +export const defaultDetonationRuntime: DetonationRuntimeKind = "podman"; +export const defaultSandboxImageTag = "ghcr.io/clawguard/detonation-sandbox:0.1.0"; + +const SANDBOX_CONTAINERFILE_PATH = path.resolve(import.meta.dirname, "../sandbox/Containerfile"); +const SANDBOX_CONTEXT_DIR = path.resolve(import.meta.dirname, "../sandbox"); + +export interface RuntimeCommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface RuntimeCommandExecutor { + run(command: string, args: string[]): Promise; +} + +export interface EnsureSandboxImageOptions { + imageTag?: string; + strategy?: "build" | "pull"; + containerfilePath?: string; + contextDirectory?: string; +} + +export interface EnsureSandboxImageResult { + runtime: DetonationRuntimeKind; + runtimeCommand: string; + imageTag: string; + source: "cache" | "built" | "pulled"; +} + +export interface DetonationRuntimeProvider { + readonly runtime: DetonationRuntimeKind; + readonly command: string; + ensureSandboxImage(options?: EnsureSandboxImageOptions): Promise; +} + +export interface CreateDetonationRuntimeProviderOptions { + preferredRuntime?: DetonationRuntimeKind; + runtimeDetector?: ContainerRuntimeDetector; + commandExecutor?: RuntimeCommandExecutor; +} + +export async function createDetonationRuntimeProvider( + options: CreateDetonationRuntimeProviderOptions = {}, +): Promise { + const runtimeDetector = options.runtimeDetector ?? createPlatformAdapter().containerRuntimes; + const detectedRuntime = await runtimeDetector.getPreferredRuntime( + options.preferredRuntime ?? defaultDetonationRuntime, + ); + + if (detectedRuntime === undefined) { + throw new Error( + "No supported container runtime is available. Install Podman (preferred) or Docker.", + ); + } + + const commandExecutor = options.commandExecutor; + if (commandExecutor === undefined) { + throw new Error("A runtime command executor is required for detonation runtime operations."); + } + + return new ContainerRuntimeProvider( + detectedRuntime.runtime, + detectedRuntime.command, + commandExecutor, + ); +} + +class ContainerRuntimeProvider implements DetonationRuntimeProvider { + public constructor( + public readonly runtime: DetonationRuntimeKind, + public readonly command: string, + private readonly commandExecutor: RuntimeCommandExecutor, + ) {} + + public async ensureSandboxImage( + options: EnsureSandboxImageOptions = {}, + ): Promise { + const imageTag = options.imageTag ?? defaultSandboxImageTag; + + if (await this.imageExists(imageTag)) { + return { + runtime: this.runtime, + runtimeCommand: this.command, + imageTag, + source: "cache", + }; + } + + const strategy = options.strategy ?? "build"; + if (strategy === "pull") { + await this.runOrThrow(["pull", imageTag], `Unable to pull sandbox image ${imageTag}.`); + return { + runtime: this.runtime, + runtimeCommand: this.command, + imageTag, + source: "pulled", + }; + } + + const containerfilePath = options.containerfilePath ?? SANDBOX_CONTAINERFILE_PATH; + const contextDirectory = options.contextDirectory ?? SANDBOX_CONTEXT_DIR; + + await this.runOrThrow( + ["build", "--file", containerfilePath, "--tag", imageTag, contextDirectory], + `Unable to build sandbox image ${imageTag}.`, + ); + + return { + runtime: this.runtime, + runtimeCommand: this.command, + imageTag, + source: "built", + }; + } + + private async imageExists(imageTag: string): Promise { + if (this.runtime === "podman") { + const result = await this.commandExecutor.run(this.command, ["image", "exists", imageTag]); + return result.exitCode === 0; + } + + const result = await this.commandExecutor.run(this.command, ["image", "inspect", imageTag]); + return result.exitCode === 0; + } + + private async runOrThrow(args: string[], message: string): Promise { + const result = await this.commandExecutor.run(this.command, args); + + if (result.exitCode !== 0) { + throw new Error(`${message} ${result.stderr || result.stdout}`.trim()); + } + } +}