Skill: stanxy/clawguard-skill (version 0.3.0)

--- name: clawwall version: 0.3.0 description: "Outbound DLP for OpenClaw — hard regex blocks secrets & PII from leaving the machine. Domain control, no LLM." author: Stanxy openclaw: requires: bins: - python3 - pip - node - npm envs: [] hooks: - event: gateway:startup handler: hooks/openclaw/dist/handler.js export: onStartup --- # ClawWall — Outbound DLP for OpenClaw **GitHub:** https://github.com/Stanxy/clawguard **PyPI:** https://pypi.org/project/clawwall ClawWall sits between your AI agent and the outside world. Every outbound tool call is intercepted and scanned against 60+ hard-coded patterns before anything leaves the machine. If content matches — it is blocked or redacted. No LLM, no approximation: regex and entropy only. ## Setup One command: ```bash bash setup.sh ``` This installs the Python service, builds the plugin and hook, registers the plugin in your OpenClaw config, sets up a system service, and verifies the health endpoint. The `gateway:startup` hook auto-starts the service whenever OpenClaw boots — no manual `clawwall` command needed. ## Trust & Permissions **What this installs:** | Component | What It Does | |-----------|-------------| | **Python service** (port 8642) | Receives every outbound tool call for scanning | | **OpenClaw plugin** (`before_tool_call`) | Intercepts outbound content and routes to the service | | **Startup hook** (`gateway:startup`) | Auto-starts the service when OpenClaw boots | | **SQLite database** | Stores scan metadata (finding type, severity, action, duration) | | **systemd/launchd service** | Fallback auto-start via OS service manager | **What the database stores:** finding type, severity, position offsets, action taken, and duration. It **never** stores raw content, secrets, or PII values. **What it does NOT do:** no telemetry, no external connections, no data leaves the machine. The service is fully local. ## Verify Installation ```bash # Health check curl -s http://127.0.0.1:8642/api/v1/health # Test scan curl -s -X POST http://127.0.0.1:8642/api/v1/scan \ -H "Content-Type: application/json" \ -d '{"content": "key=[REDACTED_SECRET]"}' # Dashboard open http://127.0.0.1:8642/dashboard ``` ## What ClawWall Detects - **Secrets (52 patterns):** AWS, GCP, Azure, GitHub, GitLab, Stripe, Square, PayPal, Slack, Discord, Telegram, Twilio, SSH/PGP private keys, database URIs, JWT/Bearer tokens, SaaS API keys, and more - **PII (10 patterns):** SSNs, credit cards (Luhn-validated), emails, phone numbers, IP addresses - **Entropy analysis:** high-entropy strings that don't match any known pattern - **Custom patterns:** user-defined regex via policy YAML ## Configuration Environment variables (all prefixed `CLAWGUARD_`): | Variable | Default | Description | |---|---|---| | `CLAWGUARD_HOST` | `0.0.0.0` | Bind address | | `CLAWGUARD_PORT` | `8642` | Port | | `CLAWGUARD_DATABASE_URL` | `sqlite+aiosqlite:///~/.config/clawwall/clawwall.db` | Database path | | `CLAWGUARD_POLICY_PATH` | `~/.config/clawwall/policy.yaml` | Policy file | | `CLAWGUARD_LOG_LEVEL` | `INFO` | Log verbosity | ## Policy Default policy redacts all findings. Edit via the dashboard or `~/.config/clawwall/policy.yaml`: ```yaml default_action: REDACT # BLOCK | REDACT | ALLOW redaction: strategy: mask # mask | hash | remove mask_char: "*" mask_preserve_edges: 4 destination_allowlist: [] # bypass scanning for trusted destinations destination_blocklist: [] # always reject these destinations custom_patterns: [] # add your own regex patterns disabled_patterns: [] # disable specific built-in patterns by name ``` Policy evaluation order: severity overrides > destination allowlist > destination blocklist > destination rules > agent rules > default action. ## Agent Instructions You are operating behind **ClawWall**. All outbound tool calls are scanned before leaving the machine using hard pattern matching — not an LLM. | Result | Meaning | |--------|---------| | `ALLOW` | No sensitive data detected — call went through | | `REDACT` | Sensitive portions were masked before sending | | `BLOCK` | Call rejected — nothing was sent | **Best practices:** - Never include raw API keys, tokens, or PII in outbound calls - Reference credentials by name (`$OPENAI_API_KEY`), not by value - If blocked, check `findings` to see what matched, remove the sensitive data, and retry - False positive? Tell the user to adjust the policy via the dashboard at http://localhost:8642/dashboard ## Troubleshooting | Problem | Solution | |---------|----------| | Service not starting | Check `~/.config/clawwall/clawwall.log` for errors | | Port 8642 in use | Another process is using the port — check with `lsof -i :8642` | | Plugin not intercepting | Verify plugin is registered in `~/.openclaw/openclaw.json` | | False positives | Disable specific patterns via `disabled_patterns` in policy YAML | | Hook not firing | Rebuild hook: `cd hooks/openclaw && npm run build` | | `clawwall` not found | Ensure pip install directory is on PATH, or use `python3 -m clawguard` |

path: hooks/openclaw/HOOK.md
content: ---
event: gateway:startup
name: clawwall-autostart
description: Auto-starts the ClawWall DLP service when the OpenClaw gateway boots.
---

# ClawWall Auto-Start Hook

This hook runs on `gateway:startup` and ensures the ClawWall DLP service is running
before any agent tool calls are processed.

## Behavior

1. Checks if `clawwall` binary is available on PATH
2. Checks if port 8642 is already in use (skips if service already running)
3. Spawns `clawwall` as a detached background process
4. Writes PID to `~/.config/clawwall/clawwall.pid`
5. Waits for health endpoint to respond before returning


path: hooks/openclaw/dist/handler.d.ts
content: /**
 * ClawWall Auto-Start Hook
 *
 * Runs on gateway:startup to ensure the ClawWall DLP service is running
 * before any agent tool calls are processed.
 */
export declare function onStartup(): Promise<void>;


path: hooks/openclaw/dist/handler.js
content: "use strict";
/**
 * ClawWall Auto-Start Hook
 *
 * Runs on gateway:startup to ensure the ClawWall DLP service is running
 * before any agent tool calls are processed.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.onStartup = onStartup;
const child_process_1 = require("child_process");
const fs_1 = require("fs");
const os_1 = require("os");
const path_1 = require("path");
const net_1 = require("net");
const PORT = 8642;
const HEALTH_URL = `http://127.0.0.1:${PORT}/api/v1/health`;
const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), ".config", "clawwall");
const PID_FILE = (0, path_1.join)(CONFIG_DIR, "clawwall.pid");
function log(msg) {
    console.log(`[clawwall-hook] ${msg}`);
}
function isPortInUse(port) {
    return new Promise((resolve) => {
        const socket = (0, net_1.createConnection)({ port, host: "127.0.0.1" });
        socket.setTimeout(1000);
        socket.on("connect", () => {
            socket.destroy();
            resolve(true);
        });
        socket.on("timeout", () => {
            socket.destroy();
            resolve(false);
        });
        socket.on("error", () => {
            resolve(false);
        });
    });
}
function findBinary() {
    try {
        return (0, child_process_1.execSync)("which clawwall", { encoding: "utf-8" }).trim();
    }
    catch {
        return null;
    }
}
async function waitForHealth(maxAttempts = 15) {
    for (let i = 0; i < maxAttempts; i++) {
        try {
            const resp = await fetch(HEALTH_URL);
            if (resp.ok)
                return true;
        }
        catch {
            // service not ready yet
        }
        await new Promise((r) => setTimeout(r, 500));
    }
    return false;
}
async function onStartup() {
    // 1. Check if binary exists
    const bin = findBinary();
    if (!bin) {
        log("clawwall binary not found on PATH — skipping auto-start");
        log("Install with: pip install clawwall");
        return;
    }
    // 2. Check if already running
    const alreadyRunning = await isPortInUse(PORT);
    if (alreadyRunning) {
        log(`Port ${PORT} already in use — ClawWall service appears to be running`);
        return;
    }
    // 3. Ensure config directory exists
    if (!(0, fs_1.existsSync)(CONFIG_DIR)) {
        (0, fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
    }
    // 4. Spawn as detached background process
    log("Starting ClawWall DLP service...");
    const child = (0, child_process_1.spawn)(bin, [], {
        detached: true,
        stdio: "ignore",
        env: { ...process.env },
    });
    child.unref();
    if (child.pid) {
        (0, fs_1.writeFileSync)(PID_FILE, String(child.pid), "utf-8");
        log(`ClawWall started (PID: ${child.pid})`);
    }
    // 5. Wait for health endpoint
    const healthy = await waitForHealth();
    if (healthy) {
        log(`ClawWall ready on http://127.0.0.1:${PORT}`);
        log(`Dashboard: http://127.0.0.1:${PORT}/dashboard`);
    }
    else {
        log("Warning: ClawWall started but health check did not respond in time");
        log("The service may still be initializing — check manually");
    }
}


path: hooks/openclaw/handler.ts
content: /**
 * ClawWall Auto-Start Hook
 *
 * Runs on gateway:startup to ensure the ClawWall DLP service is running
 * before any agent tool calls are processed.
 */

import { execSync, spawn } from "child_process";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { createConnection } from "net";

const PORT = 8642;
const HEALTH_URL = `http://127.0.0.1:${PORT}/api/v1/health`;
const CONFIG_DIR = join(homedir(), ".config", "clawwall");
const PID_FILE = join(CONFIG_DIR, "clawwall.pid");

function log(msg: string): void {
  console.log(`[clawwall-hook] ${msg}`);
}

function isPortInUse(port: number): Promise<boolean> {
  return new Promise((resolve) => {
    const socket = createConnection({ port, host: "127.0.0.1" });
    socket.setTimeout(1000);
    socket.on("connect", () => {
      socket.destroy();
      resolve(true);
    });
    socket.on("timeout", () => {
      socket.destroy();
      resolve(false);
    });
    socket.on("error", () => {
      resolve(false);
    });
  });
}

function findBinary(): string | null {
  try {
    return execSync("which clawwall", { encoding: "utf-8" }).trim();
  } catch {
    return null;
  }
}

async function waitForHealth(maxAttempts: number = 15): Promise<boolean> {
  for (let i = 0; i < maxAttempts; i++) {
    try {
      const resp = await fetch(HEALTH_URL);
      if (resp.ok) return true;
    } catch {
      // service not ready yet
    }
    await new Promise((r) => setTimeout(r, 500));
  }
  return false;
}

export async function onStartup(): Promise<void> {
  // 1. Check if binary exists
  const bin = findBinary();
  if (!bin) {
    log("clawwall binary not found on PATH — skipping auto-start");
    log("Install with: pip install clawwall");
    return;
  }

  // 2. Check if already running
  const alreadyRunning = await isPortInUse(PORT);
  if (alreadyRunning) {
    log(`Port ${PORT} already in use — ClawWall service appears to be running`);
    return;
  }

  // 3. Ensure config directory exists
  if (!existsSync(CONFIG_DIR)) {
    mkdirSync(CONFIG_DIR, { recursive: true });
  }

  // 4. Spawn as detached background process
  log("Starting ClawWall DLP service...");
  const child = spawn(bin, [], {
    detached: true,
    stdio: "ignore",
    env: { ...process.env },
  });

  child.unref();

  if (child.pid) {
    writeFileSync(PID_FILE, String(child.pid), "utf-8");
    log(`ClawWall started (PID: ${child.pid})`);
  }

  // 5. Wait for health endpoint
  const healthy = await waitForHealth();
  if (healthy) {
    log(`ClawWall ready on http://127.0.0.1:${PORT}`);
    log(`Dashboard: http://127.0.0.1:${PORT}/dashboard`);
  } else {
    log("Warning: ClawWall started but health check did not respond in time");
    log("The service may still be initializing — check manually");
  }
}


path: hooks/openclaw/package-lock.json
content: {
  "name": "@clawwall/startup-hook",
  "version": "0.3.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@clawwall/startup-hook",
      "version": "0.3.0",
      "devDependencies": {
        "@types/node": "^20.0.0",
        "typescript": "^5.3.0"
      }
    },
    "node_modules/@types/node": {
      "version": "20.19.33",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    }
  }
}


path: hooks/openclaw/package.json
content: {
  "name": "@clawwall/startup-hook",
  "version": "0.3.0",
  "description": "Auto-start hook for ClawWall DLP service on OpenClaw gateway startup",
  "main": "dist/handler.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {},
  "devDependencies": {
    "typescript": "^5.3.0",
    "@types/node": "^20.0.0"
  }
}


path: hooks/openclaw/tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["handler.ts"],
  "exclude": ["node_modules", "dist"]
}


path: plugin/dist/index.d.ts
content: /**
 * ClawWall Plugin — OpenClaw DLP Bridge
 *
 * Hooks into `before_tool_call` to scan outbound content via the
 * ClawWall Python service. Enforces BLOCK/REDACT decisions.
 */
interface PluginConfig {
    serviceUrl: string;
    blockOnError: boolean;
    timeoutMs: number;
}
interface ToolCallContext {
    toolName: string;
    args: Record<string, unknown>;
    agentId?: string;
    destination?: string;
}
interface HookResult {
    allow: boolean;
    modifiedArgs?: Record<string, unknown>;
    reason?: string;
}
/**
 * Main hook export — called by OpenClaw before each tool call.
 */
export declare function beforeToolCall(context: ToolCallContext, pluginConfig?: Partial<PluginConfig>): Promise<HookResult>;
export {};


path: plugin/dist/index.js
content: "use strict";
/**
 * ClawWall Plugin — OpenClaw DLP Bridge
 *
 * Hooks into `before_tool_call` to scan outbound content via the
 * ClawWall Python service. Enforces BLOCK/REDACT decisions.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.beforeToolCall = beforeToolCall;
const DEFAULT_CONFIG = {
    serviceUrl: "http://127.0.0.1:8642",
    blockOnError: false,
    timeoutMs: 5000,
};
function extractContent(args) {
    const parts = [];
    for (const [key, value] of Object.entries(args)) {
        if (typeof value === "string") {
            parts.push(value);
        }
        else if (value !== null && value !== undefined) {
            parts.push(JSON.stringify(value));
        }
    }
    return parts.join("\n");
}
function inferDestination(toolName, args) {
    // Try common arg names for URLs/hosts
    for (const key of ["url", "endpoint", "host", "destination", "to"]) {
        const val = args[key];
        if (typeof val === "string") {
            try {
                return new URL(val).hostname;
            }
            catch {
                return val;
            }
        }
    }
    return undefined;
}
async function scanContent(content, config, context) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
    try {
        const resp = await fetch(`${config.serviceUrl}/api/v1/scan`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                content,
                destination: context.destination ?? inferDestination(context.toolName, context.args),
                agent_id: context.agentId,
                tool_name: context.toolName,
            }),
            signal: controller.signal,
        });
        if (!resp.ok) {
            throw new Error(`ClawWall returned ${resp.status}`);
        }
        return (await resp.json());
    }
    finally {
        clearTimeout(timeout);
    }
}
async function promptUser(scan) {
    // Fall back to auto-deny if stdin is not a TTY
    if (!process.stdin.isTTY) {
        return false;
    }
    const readline = await Promise.resolve().then(() => __importStar(require("readline")));
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stderr,
    });
    console.error(`\n[ClawWall] ${scan.findings_count} finding(s) detected:`);
    for (const f of scan.findings) {
        console.error(`  - ${f.finding_type} (${f.severity})${f.redacted_snippet ? ": " + f.redacted_snippet : ""}`);
    }
    if (scan.suggested_action) {
        console.error(`  Suggested action: ${scan.suggested_action}`);
    }
    return new Promise((resolve) => {
        rl.question("[ClawWall] Allow this tool call? [y/N] ", (answer) => {
            rl.close();
            resolve(answer.trim().toLowerCase() === "y");
        });
    });
}
/**
 * Main hook export — called by OpenClaw before each tool call.
 */
async function beforeToolCall(context, pluginConfig) {
    const config = { ...DEFAULT_CONFIG, ...pluginConfig };
    const content = extractContent(context.args);
    if (!content.trim()) {
        return { allow: true };
    }
    let scan;
    try {
        scan = await scanContent(content, config, context);
    }
    catch (err) {
        // Service unreachable — fail-open or fail-closed based on config
        if (config.blockOnError) {
            return {
                allow: false,
                reason: `ClawWall service unreachable and blockOnError=true: ${err}`,
            };
        }
        return { allow: true };
    }
    switch (scan.action) {
        case "ALLOW":
            return { allow: true };
        case "BLOCK":
            return {
                allow: false,
                reason: `ClawWall BLOCKED: ${scan.findings_count} finding(s) detected — ${scan.findings.map((f) => f.finding_type).join(", ")}`,
            };
        case "REDACT":
            if (scan.content !== null) {
                // Replace all string args with the redacted content
                // This is a simplified approach — a production version would
                // map redaction offsets back to individual args
                const modifiedArgs = { ...context.args };
                for (const key of Object.keys(modifiedArgs)) {
                    if (typeof modifiedArgs[key] === "string") {
                        modifiedArgs[key] = scan.content;
                        break; // Only replace the first string arg for now
                    }
                }
                return { allow: true, modifiedArgs };
            }
            return { allow: true };
        case "PROMPT": {
            const approved = await promptUser(scan);
            if (!approved) {
                return {
                    allow: false,
                    reason: `ClawWall BLOCKED (user denied): ${scan.findings_count} finding(s) — ${scan.findings.map((f) => f.finding_type).join(", ")}`,
                };
            }
            // User approved — apply redaction if suggested action was REDACT
            if (scan.suggested_action === "REDACT" && scan.content !== null) {
                const modifiedArgs = { ...context.args };
                for (const key of Object.keys(modifiedArgs)) {
                    if (typeof modifiedArgs[key] === "string") {
                        modifiedArgs[key] = scan.content;
                        break;
                    }
                }
                return { allow: true, modifiedArgs };
            }
            return { allow: true };
        }
        default:
            return { allow: true };
    }
}


path: plugin/index.ts
content: /**
 * ClawWall Plugin — OpenClaw DLP Bridge
 *
 * Hooks into `before_tool_call` to scan outbound content via the
 * ClawWall Python service. Enforces BLOCK/REDACT decisions.
 */

interface PluginConfig {
  serviceUrl: string;
  blockOnError: boolean;
  timeoutMs: number;
}

interface ToolCallContext {
  toolName: string;
  args: Record<string, unknown>;
  agentId?: string;
  destination?: string;
}

interface ScanFinding {
  scanner_type: string;
  finding_type: string;
  severity: string;
  start: number;
  end: number;
  redacted_snippet?: string;
}

interface ScanResponse {
  action: "ALLOW" | "BLOCK" | "REDACT" | "PROMPT";
  content: string | null;
  findings: ScanFinding[];
  findings_count: number;
  scan_id: number | null;
  duration_ms: number;
  suggested_action: "ALLOW" | "BLOCK" | "REDACT" | null;
}

interface HookResult {
  allow: boolean;
  modifiedArgs?: Record<string, unknown>;
  reason?: string;
}

const DEFAULT_CONFIG: PluginConfig = {
  serviceUrl: "http://127.0.0.1:8642",
  blockOnError: false,
  timeoutMs: 5000,
};

function extractContent(args: Record<string, unknown>): string {
  const parts: string[] = [];
  for (const [key, value] of Object.entries(args)) {
    if (typeof value === "string") {
      parts.push(value);
    } else if (value !== null && value !== undefined) {
      parts.push(JSON.stringify(value));
    }
  }
  return parts.join("\n");
}

function inferDestination(
  toolName: string,
  args: Record<string, unknown>
): string | undefined {
  // Try common arg names for URLs/hosts
  for (const key of ["url", "endpoint", "host", "destination", "to"]) {
    const val = args[key];
    if (typeof val === "string") {
      try {
        return new URL(val).hostname;
      } catch {
        return val;
      }
    }
  }
  return undefined;
}

async function scanContent(
  content: string,
  config: PluginConfig,
  context: ToolCallContext
): Promise<ScanResponse> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), config.timeoutMs);

  try {
    const resp = await fetch(`${config.serviceUrl}/api/v1/scan`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        content,
        destination:
          context.destination ?? inferDestination(context.toolName, context.args),
        agent_id: context.agentId,
        tool_name: context.toolName,
      }),
      signal: controller.signal,
    });

    if (!resp.ok) {
      throw new Error(`ClawWall returned ${resp.status}`);
    }

    return (await resp.json()) as ScanResponse;
  } finally {
    clearTimeout(timeout);
  }
}

async function promptUser(scan: ScanResponse): Promise<boolean> {
  // Fall back to auto-deny if stdin is not a TTY
  if (!process.stdin.isTTY) {
    return false;
  }

  const readline = await import("readline");
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stderr,
  });

  console.error(
    `\n[ClawWall] ${scan.findings_count} finding(s) detected:`
  );
  for (const f of scan.findings) {
    console.error(
      `  - ${f.finding_type} (${f.severity})${f.redacted_snippet ? ": " + f.redacted_snippet : ""}`
    );
  }
  if (scan.suggested_action) {
    console.error(`  Suggested action: ${scan.suggested_action}`);
  }

  return new Promise<boolean>((resolve) => {
    rl.question("[ClawWall] Allow this tool call? [y/N] ", (answer) => {
      rl.close();
      resolve(answer.trim().toLowerCase() === "y");
    });
  });
}

/**
 * Main hook export — called by OpenClaw before each tool call.
 */
export async function beforeToolCall(
  context: ToolCallContext,
  pluginConfig?: Partial<PluginConfig>
): Promise<HookResult> {
  const config: PluginConfig = { ...DEFAULT_CONFIG, ...pluginConfig };

  const content = extractContent(context.args);
  if (!content.trim()) {
    return { allow: true };
  }

  let scan: ScanResponse;
  try {
    scan = await scanContent(content, config, context);
  } catch (err) {
    // Service unreachable — fail-open or fail-closed based on config
    if (config.blockOnError) {
      return {
        allow: false,
        reason: `ClawWall service unreachable and blockOnError=true: ${err}`,
      };
    }
    return { allow: true };
  }

  switch (scan.action) {
    case "ALLOW":
      return { allow: true };

    case "BLOCK":
      return {
        allow: false,
        reason: `ClawWall BLOCKED: ${scan.findings_count} finding(s) detected — ${scan.findings.map((f) => f.finding_type).join(", ")}`,
      };

    case "REDACT":
      if (scan.content !== null) {
        // Replace all string args with the redacted content
        // This is a simplified approach — a production version would
        // map redaction offsets back to individual args
        const modifiedArgs = { ...context.args };
        for (const key of Object.keys(modifiedArgs)) {
          if (typeof modifiedArgs[key] === "string") {
            modifiedArgs[key] = scan.content;
            break; // Only replace the first string arg for now
          }
        }
        return { allow: true, modifiedArgs };
      }
      return { allow: true };

    case "PROMPT": {
      const approved = await promptUser(scan);
      if (!approved) {
        return {
          allow: false,
          reason: `ClawWall BLOCKED (user denied): ${scan.findings_count} finding(s) — ${scan.findings.map((f) => f.finding_type).join(", ")}`,
        };
      }
      // User approved — apply redaction if suggested action was REDACT
      if (scan.suggested_action === "REDACT" && scan.content !== null) {
        const modifiedArgs = { ...context.args };
        for (const key of Object.keys(modifiedArgs)) {
          if (typeof modifiedArgs[key] === "string") {
            modifiedArgs[key] = scan.content;
            break;
          }
        }
        return { allow: true, modifiedArgs };
      }
      return { allow: true };
    }

    default:
      return { allow: true };
  }
}


path: plugin/openclaw.plugin.json
content: {
  "name": "clawwall",
  "version": "0.3.0",
  "description": "DLP Surveillance Layer — scans outbound content for secrets, PII, and policy violations",
  "hooks": {
    "before_tool_call": {
      "handler": "dist/index.js",
      "export": "beforeToolCall"
    }
  },
  "config": {
    "serviceUrl": {
      "type": "string",
      "default": "http://127.0.0.1:8642",
      "description": "ClawWall service URL"
    },
    "blockOnError": {
      "type": "boolean",
      "default": false,
      "description": "If true, block tool calls when the ClawWall service is unreachable (fail-closed). Default: fail-open."
    },
    "timeoutMs": {
      "type": "number",
      "default": 5000,
      "description": "Timeout for scan requests in milliseconds"
    }
  }
}


path: plugin/package-lock.json
content: {
  "name": "@openclaw/clawwall-plugin",
  "version": "0.3.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@openclaw/clawwall-plugin",
      "version": "0.3.0",
      "devDependencies": {
        "@types/node": "^25.3.0",
        "typescript": "^5.3.0"
      }
    },
    "node_modules/@types/node": {
      "version": "25.3.0",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~7.18.0"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "7.18.2",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    }
  }
}


path: plugin/package.json
content: {
  "name": "@openclaw/clawwall-plugin",
  "version": "0.3.0",
  "description": "DLP plugin bridge — intercepts outbound tool calls and scans via ClawWall service",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "@types/node": "^25.3.0",
    "typescript": "^5.3.0"
  }
}


path: plugin/tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020", "DOM"],
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["index.ts"],
  "exclude": ["node_modules", "dist"]
}


path: setup.sh
content: #!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# ClawWall v0.3.0 — One-Command Installer
#
# Usage:  bash setup.sh
#
# This script installs the ClawWall DLP service, builds the OpenClaw plugin
# and startup hook, registers the plugin, sets up a systemd/launchd service,
# starts the service, and verifies the health endpoint.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail

CLAWWALL_VERSION="0.3.0"
CLAWWALL_PORT=8642
CLAWWALL_CONFIG_DIR="$HOME/.config/clawwall"
OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"

# ── Colors ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

info()  { echo -e "${CYAN}[info]${NC}  $*"; }
ok()    { echo -e "${GREEN}[ok]${NC}    $*"; }
warn()  { echo -e "${YELLOW}[warn]${NC}  $*"; }
fail()  { echo -e "${RED}[fail]${NC}  $*"; exit 1; }

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ── Step 1: Check Prerequisites ─────────────────────────────────────────────
echo ""
echo -e "${BOLD}ClawWall v${CLAWWALL_VERSION} — Installer${NC}"
echo "─────────────────────────────────────────"
echo ""

info "Checking prerequisites..."

# Python 3.10+
if ! command -v python3 &>/dev/null; then
  fail "python3 not found. Install Python 3.10+ and re-run."
fi
PY_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 10 ]; }; then
  fail "Python 3.10+ required (found $PY_VERSION)"
fi
ok "Python $PY_VERSION"

# pip
if ! command -v pip &>/dev/null && ! python3 -m pip --version &>/dev/null 2>&1; then
  fail "pip not found. Install pip and re-run."
fi
ok "pip"

# Node.js
if ! command -v node &>/dev/null; then
  fail "node not found. Install Node.js 18+ and re-run."
fi
ok "node $(node --version)"

# npm
if ! command -v npm &>/dev/null; then
  fail "npm not found. Install npm and re-run."
fi
ok "npm $(npm --version)"

echo ""

# ── Step 2: Install ClawWall Python Service ──────────────────────────────────
info "Installing ClawWall Python service..."

if pip install "clawwall==$CLAWWALL_VERSION" 2>/dev/null; then
  ok "Installed clawwall $CLAWWALL_VERSION from PyPI"
elif pip install clawwall 2>/dev/null; then
  ok "Installed clawwall from PyPI (latest)"
else
  # Fallback: install from local source if we're in the repo
  REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
  if [ -f "$REPO_ROOT/pyproject.toml" ]; then
    info "PyPI install failed — installing from local source..."
    pip install "$REPO_ROOT"
    ok "Installed clawwall from local source"
  else
    fail "Could not install clawwall. Run 'pip install clawwall' manually."
  fi
fi

# Verify binary
if ! command -v clawwall &>/dev/null; then
  warn "clawwall binary not on PATH — it may be in a pip user bin directory"
  warn "Try: python3 -m clawguard"
fi

echo ""

# ── Step 3: Build the OpenClaw Plugin ────────────────────────────────────────
info "Building OpenClaw plugin..."

PLUGIN_DIR="$SCRIPT_DIR/plugin"
if [ -d "$PLUGIN_DIR" ]; then
  cd "$PLUGIN_DIR"
  npm install --silent 2>/dev/null
  npm run build --silent 2>/dev/null
  ok "Plugin built: $PLUGIN_DIR/dist/index.js"
  cd "$SCRIPT_DIR"
else
  warn "Plugin directory not found at $PLUGIN_DIR — skipping"
fi

echo ""

# ── Step 4: Build the Startup Hook ───────────────────────────────────────────
info "Building startup hook..."

HOOK_DIR="$SCRIPT_DIR/hooks/openclaw"
if [ -d "$HOOK_DIR" ]; then
  cd "$HOOK_DIR"
  npm install --silent 2>/dev/null
  npm run build --silent 2>/dev/null
  ok "Hook built: $HOOK_DIR/dist/handler.js"
  cd "$SCRIPT_DIR"
else
  warn "Hook directory not found at $HOOK_DIR — skipping"
fi

echo ""

# ── Step 5: Register Plugin in OpenClaw Config ───────────────────────────────
info "Registering plugin in OpenClaw config..."

PLUGIN_DIST="$PLUGIN_DIR/dist/index.js"
mkdir -p "$(dirname "$OPENCLAW_CONFIG")"

if [ -f "$OPENCLAW_CONFIG" ]; then
  # Check if jq is available for clean JSON manipulation
  if command -v jq &>/dev/null; then
    TEMP_CONFIG=$(mktemp)
    jq --arg path "$PLUGIN_DIST" \
      '.plugins.clawwall = {
        "path": $path,
        "config": {
          "serviceUrl": "http://127.0.0.1:8642",
          "blockOnError": false,
          "timeoutMs": 5000
        }
      }' "$OPENCLAW_CONFIG" > "$TEMP_CONFIG" && mv "$TEMP_CONFIG" "$OPENCLAW_CONFIG"
    ok "Plugin registered in $OPENCLAW_CONFIG (via jq)"
  else
    # Fallback: use node to merge JSON
    node -e "
      const fs = require('fs');
      const config = JSON.parse(fs.readFileSync('$OPENCLAW_CONFIG', 'utf-8'));
      config.plugins = config.plugins || {};
      config.plugins.clawwall = {
        path: '$PLUGIN_DIST',
        config: {
          serviceUrl: 'http://127.0.0.1:8642',
          blockOnError: false,
          timeoutMs: 5000,
        },
      };
      fs.writeFileSync('$OPENCLAW_CONFIG', JSON.stringify(config, null, 2) + '\n');
    "
    ok "Plugin registered in $OPENCLAW_CONFIG (via node)"
  fi
else
  # Create new config file
  cat > "$OPENCLAW_CONFIG" <<JSONEOF
{
  "plugins": {
    "clawwall": {
      "path": "$PLUGIN_DIST",
      "config": {
        "serviceUrl": "http://127.0.0.1:8642",
        "blockOnError": false,
        "timeoutMs": 5000
      }
    }
  }
}
JSONEOF
  ok "Created $OPENCLAW_CONFIG with ClawWall plugin"
fi

echo ""

# ── Step 6: Create System Service ────────────────────────────────────────────
info "Setting up system service for auto-start..."

mkdir -p "$CLAWWALL_CONFIG_DIR"

CLAWWALL_BIN=$(command -v clawwall 2>/dev/null || echo "$(python3 -c 'import sys; print(sys.prefix)')/bin/clawwall")

if [[ "$(uname)" == "Linux" ]] && command -v systemctl &>/dev/null; then
  # ── systemd user service (Linux) ──
  SYSTEMD_DIR="$HOME/.config/systemd/user"
  mkdir -p "$SYSTEMD_DIR"

  cat > "$SYSTEMD_DIR/clawwall.service" <<SVCEOF
[Unit]
Description=ClawWall DLP Service
After=network.target

[Service]
Type=simple
ExecStart=$CLAWWALL_BIN
Restart=on-failure
RestartSec=5
Environment=CLAWGUARD_HOST=0.0.0.0
Environment=CLAWGUARD_PORT=$CLAWWALL_PORT

[Install]
WantedBy=default.target
SVCEOF

  systemctl --user daemon-reload 2>/dev/null || true
  systemctl --user enable clawwall.service 2>/dev/null || true
  ok "Created systemd user service: $SYSTEMD_DIR/clawwall.service"

elif [[ "$(uname)" == "Darwin" ]]; then
  # ── launchd plist (macOS) ──
  PLIST_DIR="$HOME/Library/LaunchAgents"
  PLIST_FILE="$PLIST_DIR/com.clawwall.service.plist"
  mkdir -p "$PLIST_DIR"

  cat > "$PLIST_FILE" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.clawwall.service</string>
  <key>ProgramArguments</key>
  <array>
    <string>$CLAWWALL_BIN</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>EnvironmentVariables</key>
  <dict>
    <key>CLAWGUARD_HOST</key>
    <string>0.0.0.0</string>
    <key>CLAWGUARD_PORT</key>
    <string>$CLAWWALL_PORT</string>
  </dict>
  <key>StandardOutPath</key>
  <string>$CLAWWALL_CONFIG_DIR/clawwall.log</string>
  <key>StandardErrorPath</key>
  <string>$CLAWWALL_CONFIG_DIR/clawwall.err</string>
</dict>
</plist>
PLISTEOF

  ok "Created launchd plist: $PLIST_FILE"
else
  warn "Could not detect systemd or launchd — skipping system service setup"
  warn "The gateway:startup hook will still auto-start the service"
fi

echo ""

# ── Step 7: Start Service & Verify ───────────────────────────────────────────
info "Starting ClawWall service..."

# Check if already running
if curl -sf "http://127.0.0.1:$CLAWWALL_PORT/api/v1/health" &>/dev/null; then
  ok "ClawWall is already running on port $CLAWWALL_PORT"
else
  # Start via systemd/launchd if available, otherwise direct
  if [[ "$(uname)" == "Linux" ]] && command -v systemctl &>/dev/null; then
    systemctl --user start clawwall.service 2>/dev/null || true
  elif [[ "$(uname)" == "Darwin" ]] && [ -f "$PLIST_FILE" ]; then
    launchctl load "$PLIST_FILE" 2>/dev/null || true
  fi

  # Fallback: direct start if service didn't start
  sleep 1
  if ! curl -sf "http://127.0.0.1:$CLAWWALL_PORT/api/v1/health" &>/dev/null; then
    if command -v clawwall &>/dev/null; then
      nohup clawwall > "$CLAWWALL_CONFIG_DIR/clawwall.log" 2>&1 &
      echo $! > "$CLAWWALL_CONFIG_DIR/clawwall.pid"
    else
      nohup python3 -m clawguard > "$CLAWWALL_CONFIG_DIR/clawwall.log" 2>&1 &
      echo $! > "$CLAWWALL_CONFIG_DIR/clawwall.pid"
    fi
  fi

  # Wait for health
  info "Waiting for health endpoint..."
  for i in $(seq 1 15); do
    if curl -sf "http://127.0.0.1:$CLAWWALL_PORT/api/v1/health" &>/dev/null; then
      break
    fi
    sleep 1
  done

  if curl -sf "http://127.0.0.1:$CLAWWALL_PORT/api/v1/health" &>/dev/null; then
    ok "ClawWall is running on port $CLAWWALL_PORT"
  else
    warn "Service started but health check did not respond in time"
    warn "Check logs: $CLAWWALL_CONFIG_DIR/clawwall.log"
  fi
fi

echo ""

# ── Summary ──────────────────────────────────────────────────────────────────
echo -e "${BOLD}──────────────────────────────────────────${NC}"
echo -e "${GREEN}${BOLD}  ClawWall v${CLAWWALL_VERSION} — Setup Complete${NC}"
echo -e "${BOLD}──────────────────────────────────────────${NC}"
echo ""
echo -e "  Service:    http://127.0.0.1:${CLAWWALL_PORT}"
echo -e "  Dashboard:  http://127.0.0.1:${CLAWWALL_PORT}/dashboard"
echo -e "  Health:     http://127.0.0.1:${CLAWWALL_PORT}/api/v1/health"
echo -e "  Config:     ${CLAWWALL_CONFIG_DIR}/"
echo -e "  Plugin:     ${PLUGIN_DIST}"
echo ""
echo -e "  The service auto-starts on OpenClaw gateway boot via the"
echo -e "  ${CYAN}gateway:startup${NC} hook. A systemd/launchd service is also"
echo -e "  installed as a fallback."
echo ""
echo -e "  ${BOLD}Verify:${NC}"
echo -e "    curl http://127.0.0.1:${CLAWWALL_PORT}/api/v1/health"
echo ""


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)