Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/clawguard-development-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions docs/clawguard-ticket-breakdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -269,6 +270,7 @@ Acceptance criteria:
Priority: `P1`
Milestone: `B`
Depends on: `CG-002`, `CG-004`
Status: `Complete`

Scope:

Expand Down
15 changes: 15 additions & 0 deletions packages/detonation/sandbox/Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
96 changes: 92 additions & 4 deletions packages/detonation/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
},
};
}
Expand Down Expand Up @@ -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"));
}
});
16 changes: 12 additions & 4 deletions packages/detonation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) => {
Expand Down
139 changes: 139 additions & 0 deletions packages/detonation/src/runtime-provider.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeCommandResult>;
}

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<EnsureSandboxImageResult>;
}

export interface CreateDetonationRuntimeProviderOptions {
preferredRuntime?: DetonationRuntimeKind;
runtimeDetector?: ContainerRuntimeDetector;
commandExecutor?: RuntimeCommandExecutor;
}

export async function createDetonationRuntimeProvider(
options: CreateDetonationRuntimeProviderOptions = {},
): Promise<DetonationRuntimeProvider> {
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<EnsureSandboxImageResult> {
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<boolean> {
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<void> {
const result = await this.commandExecutor.run(this.command, args);

if (result.exitCode !== 0) {
throw new Error(`${message} ${result.stderr || result.stdout}`.trim());
}
}
}