Skill: fatcatmaofei/openclaw-guardian (version 1.0.1)

--- name: openclaw-guardian description: > A security layer plugin for OpenClaw that intercepts dangerous tool calls (exec, write, edit) through two-tier regex blacklist rules and LLM-based intent verification. Critical operations require 3/3 unanimous LLM votes, warning-level operations require 1 LLM confirmation. 99% of normal operations pass instantly with zero overhead. Includes bypass/pipe-attack detection, path canonicalization, SHA-256 hash-chain audit logging, and auto-discovers a cheap model from your existing provider config. --- # OpenClaw Guardian > The missing safety layer for AI agents. ## Why? OpenClaw gives agents direct access to shell, files, email, browser, and more. 99% of that is harmless. Guardian catches the 1% that isn't — without slowing down the rest. ## How It Works ``` Tool Call → Blacklist Matcher (regex rules, 0ms) ↓ No match → Pass instantly (99% of calls) Warning hit → 1 LLM vote ("did the user ask for this?") Critical hit → 3 LLM votes (all must confirm user intent) ``` ### Two Blacklist Levels | Level | LLM Votes | Latency | Examples | |-------|-----------|---------|---------| | No match | 0 | ~0ms | Reading files, git, normal ops | | Warning | 1 | ~1-2s | `rm -rf /tmp/cache`, `chmod 777`, `sudo apt` | | Critical | 3 (unanimous) | ~2-4s | `rm -rf ~/`, `mkfs`, `dd of=/dev/`, `shutdown` | ### What Gets Checked Only three tool types are inspected: - `exec` → command string matched against exec blacklist - `write` / `edit` → file path canonicalized and matched against path blacklist - Everything else passes through instantly ### LLM Intent Verification When a blacklist rule matches, Guardian asks a lightweight LLM: "Did the user explicitly request this?" It reads recent conversation context to prevent false positives. - Warning: 1 LLM call. Confirmed → proceed. - Critical: 3 parallel LLM calls. All 3 must confirm. Any "no" → block. Auto-discovers a cheap/fast model from your existing OpenClaw provider config (prefers Haiku). No separate API key needed. ### LLM Fallback - Critical + LLM down → blocked (fail-safe) - Warning + LLM down → asks user for manual confirmation ## Blacklist Rules ### Critical (exec) - `rm -rf` on system paths (excludes `/tmp/` and workspace) - `mkfs`, `dd` to block devices, redirects to `/dev/sd*` - Writes to `/etc/passwd`, `/etc/shadow`, `/etc/sudoers` - `shutdown`, `reboot`, disable SSH - Bypass: `eval`, absolute-path rm, interpreter-based (`python -c`, `node -e`) - Pipe attacks: `curl | sh`, `wget | bash`, `base64 -d | sh` - Chain attacks: download + `chmod +x` + execute ### Warning (exec) - `rm -rf` on safe paths, `sudo`, `chmod 777`, `chown root` - Package install/remove, service management - Crontab mods, SSH/SCP, Docker ops, `kill`/`killall` ### Path Rules (write/edit) - Critical: system auth files, SSH keys, systemd units - Warning: dotfiles, `/etc/` configs, `.env` files, `authorized_keys` ## Audit Log Every blacklist hit logged to `~/.openclaw/guardian-audit.jsonl` with SHA-256 hash chain — tamper-evident, each entry covers full content + previous hash. ## Installation ```bash openclaw plugins install openclaw-guardian ``` Or manually: ```bash cd ~/.openclaw/workspace git clone https://github.com/fatcatMaoFei/openclaw-guardian.git ``` ## Token Cost | Scenario | % of Ops | Extra Cost | |----------|----------|------------| | No match | ~99% | 0 | | Warning | ~0.5-1% | ~500 tokens | | Critical | <0.5% | ~1500 tokens | Prefers cheap models (Haiku, GPT-4o-mini, Gemini Flash). ## File Structure ``` extensions/guardian/ ├── index.ts # Entry — registers before_tool_call hook ├── src/ │ ├── blacklist.ts # Two-tier regex rules (critical/warning) │ ├── llm-voter.ts # LLM intent verification │ └── audit-log.ts # SHA-256 hash-chain audit logger ├── test/ │ └── blacklist.test.ts # Blacklist rule tests ├── openclaw.plugin.json # Plugin manifest └── default-policies.json # Enable/disable toggle ``` ## License MIT

path: references/README.md
content: # openclaw-guardian

> **The missing safety layer for AI agents.**

## Why This Exists

OpenClaw is powerful — it gives AI agents direct access to shell commands, file operations, email, browser automation, and more. That power is exactly what makes it useful, but it's also what makes people nervous.

The community has been vocal: *"security nightmare"*, *"what if the AI deletes my files?"*, *"I don't trust it with my credentials"*. OpenClaw's existing safety (sandbox + allowlist + manual confirmation) only covers `exec`, and it's all-or-nothing — either you trust the agent completely, or you block everything.

**openclaw-guardian** fills that gap. It sits between the AI's decision and the actual execution, automatically assessing risk and routing dangerous operations through independent Guardian Agents for voting-based approval. Think of it as a board of directors that reviews high-stakes decisions before they happen — except it takes 2 seconds, not 2 weeks.

The key insight: **95% of what an AI agent does is harmless** (reading files, fetching URLs, writing notes). Only ~5% is potentially dangerous (deleting files, running destructive commands, accessing secrets). Guardian only intervenes on that 5%, so you get safety without sacrificing speed.

## How It Works

```
AI Agent wants to run a tool (e.g., exec "rm -rf /tmp/data")
                    ↓
        ┌───────────────────────┐
        │   Risk Assessor       │  ← Keyword rules, 0ms, no model call
        │   Scores 0-100        │
        └───────────┬───────────┘
                    ↓
    ┌───────────────┼───────────────┐
    ↓               ↓               ↓
  0-30            31-70           71-100
 Fast Lane     Light Review     Full Vote
 (just go)    (1 Guardian)    (3 Guardians)
    ↓               ↓               ↓
 Execute      Single review    Parallel vote
              all angles       different angles
                    ↓               ↓
              approve/reject   majority rules
                                    ↓
                            approve → execute
                            reject → block + tell user why
```

### The Three Lanes

| Lane | Risk Score | Guardians | Latency | When |
|------|-----------|-----------|---------|------|
| Fast | 0-30 | 0 | ~0ms | Reading files, fetching URLs, normal operations |
| Light | 31-70 | 1 (comprehensive) | ~1-2s | Writing config files, sending messages |
| Full | 71-100 | 3 (specialized) | ~2-4s | `rm -rf`, `sudo`, accessing `.ssh`, `.env` |

### Guardian Perspectives

Each Guardian in Full Vote mode reviews from a **different angle** — this isn't redundant voting, it's multi-perspective analysis:

| Guardian | What It Checks |
|----------|---------------|
| **Safety** | Will this break something? Is it destructive? Can the system recover? |
| **Privacy** | Does this touch credentials, API keys, personal data, or secrets? |
| **Permission** | Is this within the scope of what the user authorized? |
| **Reversibility** | Can this be undone? What's the blast radius if it goes wrong? |
| **Comprehensive** | All of the above (used in Light Review when only 1 Guardian is needed) |

### Why Not Just Use LLMs for Everything?

Guardian's Risk Assessor uses **zero-cost keyword rules** — no model calls for scoring. Pattern matching like `rm -rf` → +40 points, `sudo` → +25 points is instant and deterministic. LLM-based Guardian evaluation is optional and only triggered for the ~5% of operations that actually need review.

## Quick Start (One Command)

### 1. Clone into your OpenClaw workspace

```bash
cd ~/.openclaw/workspace
git clone https://github.com/fatcatMaoFei/openclaw-guardian.git
```

### 2. Register the plugin

Add to your `openclaw.json`:

```json
{
  "plugins": {
    "load": {
      "paths": ["./openclaw-guardian"]
    },
    "entries": {
      "openclaw-guardian": {
        "enabled": true
      }
    }
  }
}
```

### 3. Restart

```bash
openclaw gateway restart
```

That's it. Guardian is now active. Every tool call goes through risk assessment automatically.

## Customization

### Adjust Risk Policies

Edit `default-policies.json` to match your comfort level:

```json
{
  "thresholds": {
    "low": 30,
    "high": 70
  },
  "policies": [
    {
      "tool": "exec",
      "baseScore": 50,
      "keywords": {
        "rm -rf": 40,
        "sudo ": 25,
        "kill ": 20,
        "reboot": 30,
        "shutdown": 30,
        "mkfs": 50,
        "dd if=": 40,
        "> /dev/": 50
      }
    },
    {
      "tool": "write",
      "baseScore": 15,
      "keywords": {
        ".env": 45,
        "passwd": 50,
        ".ssh": 50,
        "config.json": 30,
        "openclaw.json": 40
      }
    },
    {
      "tool": "file_delete",
      "baseScore": 40
    },
    {
      "tool": "message_send",
      "baseScore": 20
    },
    {
      "tool": "read",
      "baseScore": 5,
      "keywords": {
        ".env": 55,
        ".ssh/id_": 75,
        "passwd": 50
      }
    },
    {
      "tool": "web_fetch",
      "baseScore": 5
    }
  ]
}
```

**Want stricter security?** Lower the thresholds:
```json
{ "thresholds": { "low": 15, "high": 50 } }
```

**Want less friction?** Raise them:
```json
{ "thresholds": { "low": 50, "high": 85 } }
```

**Want to add your own dangerous keywords?** Just add them to the tool's `keywords` object with a score boost.

### Adjust Voting Rules

```json
{
  "voting": {
    "lightReview": { "guardians": 1, "threshold": 1 },
    "fullVote": { "guardians": 3, "threshold": 2 }
  }
}
```

Want 5 Guardians with 4/5 majority? Change it:
```json
{ "fullVote": { "guardians": 5, "threshold": 4 } }
```

### Trust Budget

After N consecutive approvals, Guardian auto-downgrades the review tier:

```json
{
  "trustBudget": {
    "enabled": true,
    "autoDowngradeAfter": 10
  }
}
```

Any rejection resets the counter. Disable with `"enabled": false`.

## Audit Trail

Every decision is logged to `~/.openclaw/guardian-audit.jsonl` with SHA-256 hash chaining:

```json
{
  "timestamp": "2026-02-24T09:30:00.000Z",
  "toolName": "exec",
  "riskScore": 90,
  "tier": "full",
  "votes": [
    { "guardian": "safety", "approve": false, "reason": "rm -rf on system path" },
    { "guardian": "privacy", "approve": true },
    { "guardian": "permission", "approve": false, "reason": "not explicitly authorized" }
  ],
  "approved": false,
  "hash": "a1b2c3...",
  "prevHash": "d4e5f6..."
}
```

Tamper-evident: each entry's hash includes the previous entry's hash. Break one link and the whole chain fails verification.

## Architecture

```
openclaw-guardian/
├── openclaw.plugin.json    # Plugin manifest
├── index.ts                # Entry — registers before_tool_call / after_tool_call hooks
├── src/
│   ├── risk-assessor.ts    # Keyword rule engine (0ms, no model calls)
│   ├── guardian-voter.ts   # Tiered voting with parallel execution + early termination
│   └── audit-log.ts        # SHA-256 hash-chain audit logger
├── guardians/              # Guardian system prompts (each a different review angle)
│   ├── safety.md
│   ├── privacy.md
│   ├── permission.md
│   ├── reversibility.md
│   └── comprehensive.md
└── default-policies.json   # Default risk policies (user-customizable)
```

### How It Hooks Into OpenClaw

OpenClaw's agent loop: `Model → tool_call → Tool Executor → result → Model`

Guardian registers a `before_tool_call` plugin hook. This hook fires **after** the model decides to call a tool but **before** the tool actually executes. If Guardian returns `{ block: true }`, the tool is stopped and the model receives a rejection message instead.

This is the same hook interface OpenClaw uses internally for loop detection — battle-tested, async-safe, and zero modifications to core code.

## Token Cost

| Tier | % of Operations | Extra Cost |
|------|----------------|------------|
| Fast Lane | 85-95% | 0 (rule-based only) |
| Light Review | 5-10% | ~500 tokens per review |
| Full Vote | 1-3% | ~1500 tokens per review |

**Average overhead: ~15-35% of total token usage.** Most operations cost nothing extra.

## Status

🚧 Under active development — contributions welcome.

## License

MIT


path: scripts/audit-log.ts
content: /**
 * Audit Log — SHA-256 hash-chain append-only trail.
 * Only logs blacklist-matched operations (not every tool call).
 */

import { createHash } from "node:crypto";
import { appendFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { BlacklistMatch } from "./blacklist.js";

export type AuditEntry = {
  timestamp: string;
  toolName: string;
  blacklistLevel: string;
  blacklistReason: string;
  pattern: string;
  userConfirmed: boolean;
  finalReason: string;
  hash: string;
  prevHash: string;
};

let lastHash = "";
let logPath = "";

function getLogPath(): string {
  if (logPath) return logPath;
  logPath = join(homedir(), ".openclaw", "guardian-audit.jsonl");
  return logPath;
}

function computeHash(data: string): string {
  return createHash("sha256").update(data).digest("hex");
}

function recoverLastHash(path: string): string {
  try {
    if (!existsSync(path)) return "";
    const content = readFileSync(path, "utf-8").trim();
    if (!content) return "";
    const lines = content.split("\n");
    const lastLine = lines[lines.length - 1];
    const entry = JSON.parse(lastLine) as AuditEntry;
    return entry.hash ?? "";
  } catch {
    return "";
  }
}

export function initAuditLog(): void {
  const path = getLogPath();
  const dir = dirname(path);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  lastHash = recoverLastHash(path);
}

export function writeAuditEntry(
  toolName: string,
  params: Record<string, unknown>,
  match: BlacklistMatch,
  userConfirmed: boolean,
  reason: string,
): void {
  const entry: Omit<AuditEntry, "hash"> & { hash?: string } = {
    timestamp: new Date().toISOString(),
    toolName,
    blacklistLevel: match.level,
    blacklistReason: match.reason,
    pattern: match.pattern,
    userConfirmed,
    finalReason: reason,
    prevHash: lastHash,
  };

  const hashInput = JSON.stringify(entry);
  entry.hash = computeHash(hashInput);
  lastHash = entry.hash;

  try {
    appendFileSync(getLogPath(), JSON.stringify(entry) + "\n", "utf-8");
  } catch (err) {
    console.error(`[guardian] audit write failed: ${err}`);
  }
}


path: scripts/blacklist.ts
content: /**
 * Blacklist rules — pure pattern matching, no LLM involved.
 * Two levels: "critical" (needs 3/3 LLM votes) and "warning" (needs 1/1).
 *
 * IMPORTANT: These patterns are checked against:
 *   - exec: the command string
 *   - write/edit: the file path
 * The caller (index.ts) decides what text to pass in.
 */

export type BlacklistMatch = {
  level: "critical" | "warning";
  pattern: string;
  reason: string;
};

interface Rule {
  pattern: RegExp;
  reason: string;
}

// ── CRITICAL: irreversible destruction or system compromise ────────
// Needs 3/3 LLM votes confirming user intent to pass

const CRITICAL_EXEC: Rule[] = [
  // Filesystem destruction — only recursive rm on system paths
  {
    pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)\/(?!tmp\/|home\/clawdbot\/)/,
    reason: "rm -rf on root-level system path",
  },
  {
    pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)~\//,
    reason: "rm -rf on home directory",
  },
  { pattern: /mkfs\b/, reason: "filesystem format (mkfs)" },
  { pattern: /dd\s+if=.*of=\/dev\//, reason: "raw disk write (dd)" },
  { pattern: />\s*\/dev\/sd/, reason: "redirect to block device" },
  // System auth files (write/modify, not read)
  {
    pattern: /(?:tee|>>?)\s*\/etc\/(?:passwd|shadow|sudoers)/,
    reason: "write to system auth file",
  },
  {
    pattern: /sed\s+-i.*\/etc\/(?:passwd|shadow|sudoers)/,
    reason: "in-place edit of system auth file",
  },
  // System shutdown
  { pattern: /\b(?:shutdown|reboot)\b/, reason: "system shutdown/reboot" },
  { pattern: /\binit\s+[06]\b/, reason: "system halt/reboot (init)" },
  // Kill SSH (locks out remote access)
  { pattern: /systemctl\s+(?:stop|disable)\s+sshd/, reason: "disable SSH (remote lockout)" },
  // === BYPASS PREVENTION ===
  // Absolute path to rm
  { pattern: /\/bin\/rm\s+(-[a-zA-Z]*r[a-zA-Z]*)\s+/, reason: "rm via absolute path" },
  { pattern: /\/usr\/bin\/rm\s+(-[a-zA-Z]*r[a-zA-Z]*)\s+/, reason: "rm via absolute path" },
  // eval with dangerous content
  { pattern: /\beval\s+/, reason: "eval execution (arbitrary code)" },

  // ── Interpreter inline code — dangerous operations ──

  // Node: child_process, exec/spawn
  {
    pattern:
      /\bnode\s+(-e|--eval)\s+.*\b(child_process|\.exec\s*\(|\.spawn\s*\(|\.execSync\s*\(|\.spawnSync\s*\()/,
    reason: "node -e with subprocess execution",
  },
  // Node: dangerous fs ops on system paths
  {
    pattern:
      /\bnode\s+(-e|--eval)\s+.*\b(unlinkSync|rmdirSync|rmSync|writeFileSync)\s*\(\s*['"]\/(?!tmp\/)/,
    reason: "node -e with dangerous fs op on system path",
  },
  // Node: network server creation
  {
    pattern:
      /\bnode\s+(-e|--eval)\s+.*(net\.createServer|http\.createServer|https\.createServer|dgram\.createSocket|tls\.createServer|require\s*\(\s*['"](?:net|http|https|dgram|tls)['"]\s*\)\.create|\.createServer\s*\(|\.createSocket\s*\()/,
    reason: "node -e with network server creation",
  },
  // Node: vm sandbox escape / eval+require
  {
    pattern: /\bnode\s+(-e|--eval)\s+.*\b(vm\.runInNewContext|vm\.runInThisContext)\b/,
    reason: "node -e with VM sandbox escape",
  },
  {
    pattern: /\bnode\s+(-e|--eval)\s+.*\beval\s*\(.*\brequire\b/,
    reason: "node -e with eval+require (code injection)",
  },

  // Python: os.system, subprocess, shutil.rmtree, dangerous file ops
  {
    pattern:
      /\bpython[23]?\s+(-c|--command)\s+.*\b(os\.system|subprocess|shutil\.rmtree|os\.remove|os\.unlink)\b/,
    reason: "python -c with dangerous system call",
  },
  {
    pattern: /\bpython[23]?\s+(-c|--command)\s+.*\bopen\s*\(\s*['"]\/etc\//,
    reason: "python -c writing to system config",
  },
  // Python: network server creation
  {
    pattern: /\bpython[23]?\s+(-c|--command)\s+.*\b(socket\.socket|http\.server|socketserver)\b/,
    reason: "python -c with network server/socket",
  },
  // Python: __import__('os').system() / exec()/eval() with dangerous ops
  {
    pattern: /\bpython[23]?\s+(-c|--command)\s+.*__import__\s*\(\s*['"]os['"]\s*\)/,
    reason: "python -c with __import__('os') (stealth import)",
  },
  {
    pattern:
      /\bpython[23]?\s+(-c|--command)\s+.*\b(exec|eval)\s*\(.*\b(os\.|subprocess|shutil|socket)\b/,
    reason: "python -c with exec/eval containing dangerous module",
  },

  // Perl: system(), exec(), unlink on system paths
  {
    pattern: /\bperl\s+(-e|--eval)\s+.*\b(system\s*\(|exec\s*\(|unlink\s+['"]\/(?!tmp\/))/,
    reason: "perl -e with dangerous system call",
  },
  // Perl: IO::Socket
  {
    pattern: /\bperl\s+(-e|--eval)\s+.*\bIO::Socket\b/,
    reason: "perl -e with network socket (IO::Socket)",
  },

  // Ruby: system(), exec(), File.delete on system paths
  {
    pattern: /\bruby\s+(-e|--eval)\s+.*\b(system\s*\(|exec\s*\(|File\.delete|FileUtils\.rm_rf)/,
    reason: "ruby -e with dangerous system call",
  },
  // Ruby: TCPServer / Socket
  {
    pattern: /\bruby\s+(-e|--eval)\s+.*\b(TCPServer|TCPSocket|Socket\.new|UDPSocket|UNIXServer)\b/,
    reason: "ruby -e with network socket/server",
  },

  // ── Reverse shell patterns ──
  {
    pattern: /bash\s+-i\s+>&?\s*\/dev\/tcp\//,
    reason: "bash reverse shell via /dev/tcp",
  },
  {
    pattern: /\bnc\s+.*-e\s+/,
    reason: "netcat reverse shell (nc -e)",
  },
  {
    pattern: /\bncat\s+.*--(?:exec|sh-exec)\b/,
    reason: "ncat reverse shell (--exec/--sh-exec)",
  },
  {
    pattern: /\bsocat\b.*\bexec\b/i,
    reason: "socat exec (reverse shell / command relay)",
  },

  // ── Process injection / debugging ──
  {
    pattern: /\bgdb\s+.*-p\s+\d+/,
    reason: "gdb process attach (process injection)",
  },
  {
    pattern: /\bstrace\s+.*-p\s+\d+/,
    reason: "strace process attach (process inspection)",
  },
  {
    pattern: /\bptrace\b/,
    reason: "ptrace (process injection/tracing)",
  },

  // ── Kernel module manipulation ──
  {
    pattern: /\b(?:insmod|modprobe|rmmod)\s+/,
    reason: "kernel module manipulation",
  },

  // ── Download and execute pattern (handled in CHAIN_ATTACKS above) ──

  // xargs with dangerous commands
  { pattern: /xargs\s+.*\brm\b/, reason: "xargs rm (indirect deletion)" },
  { pattern: /xargs\s+.*\bchmod\b/, reason: "xargs chmod (indirect permission change)" },
  // find -exec with dangerous commands
  { pattern: /find\s+.*-exec\s+.*\brm\b/, reason: "find -exec rm (indirect deletion)" },
  { pattern: /find\s+.*-delete\b/, reason: "find -delete (bulk deletion)" },
];

const CRITICAL_PATH: Rule[] = [
  { pattern: /^\/etc\/(?:passwd|shadow|sudoers)$/, reason: "write to system auth file" },
  { pattern: /^\/boot\//, reason: "write to boot partition" },
];

// ── WARNING: risky but possibly intentional ────────────────────────
// Needs 1/1 LLM vote confirming user intent to pass

const WARNING_EXEC: Rule[] = [
  // Recursive delete (non-system paths — CRITICAL already catches system paths)
  { pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*)\s+/, reason: "recursive file deletion" },
  // Privilege escalation
  { pattern: /\bsudo\s+/, reason: "privilege escalation (sudo)" },
  // Dangerous permissions on system files
  {
    pattern: /chmod\s+[47]77\s+\/(?:etc|bin|sbin|usr|var|boot|lib)\b/,
    reason: "world-writable permission on system path",
  },
  { pattern: /chmod\s+[47]77\b/, reason: "world-writable permission (chmod 777)" },
  { pattern: /chmod\s+-R\s+/, reason: "recursive permission change" },
  {
    pattern: /chown\s+.*\/(?:etc|bin|sbin|usr|var|boot|lib)\b/,
    reason: "chown on system path",
  },
  { pattern: /chown\s+-R\s+/, reason: "recursive ownership change" },
  // setuid/setgid
  { pattern: /chmod\s+[ug]\+s\b/, reason: "setuid/setgid bit (privilege escalation)" },
  { pattern: /chmod\s+[2-7][0-7]{3}\b/, reason: "special permission bits (setuid/setgid/sticky)" },
  // Force kill
  { pattern: /kill\s+-9\s+/, reason: "force kill process (SIGKILL)" },
  { pattern: /\bkillall\s+/, reason: "killall processes" },
  { pattern: /\bpkill\s+/, reason: "pkill processes" },
  // Service management
  { pattern: /systemctl\s+(?:stop|disable|restart)\s+/, reason: "systemctl service operation" },
  // Database destruction
  { pattern: /DROP\s+(?:DATABASE|TABLE)\b/i, reason: "DROP DATABASE/TABLE" },
  { pattern: /TRUNCATE\s+/i, reason: "TRUNCATE table" },
  // Network/firewall changes
  { pattern: /\biptables\s+/, reason: "firewall rule change (iptables)" },
  { pattern: /\bufw\s+(?:allow|deny|delete|disable)\b/, reason: "firewall rule change (ufw)" },
  // Crontab modification
  { pattern: /\bcrontab\s+(-r|-e|-)\s*$/, reason: "crontab modification" },
  { pattern: /\bcrontab\s+-/, reason: "crontab modification" },
  // Disk operations
  { pattern: /\bfdisk\s+/, reason: "disk partition operation" },
  { pattern: /\bparted\s+/, reason: "disk partition operation" },
  { pattern: /\bmount\s+/, reason: "filesystem mount operation" },
  { pattern: /\bumount\s+/, reason: "filesystem unmount operation" },
  // SSH key operations
  { pattern: /ssh-keygen\s+/, reason: "SSH key generation/modification" },
  // Environment variable manipulation that could affect security
  {
    pattern: /export\s+(?:PATH|LD_PRELOAD|LD_LIBRARY_PATH)=/,
    reason: "security-sensitive environment variable change",
  },
];

const WARNING_PATH: Rule[] = [
  { pattern: /^\/etc\//, reason: "write to /etc/ system config" },
  { pattern: /^\/root\//, reason: "write to /root/ directory" },
];

// ── Safe Command Patterns (whitelist, checked before blacklist) ─────

const SAFE_EXEC: RegExp[] = [
  // git rm --cached only removes from index, not filesystem
  /^git\s+rm\s+.*--cached/,
  // git operations are generally safe
  /^git\s+(?:add|commit|push|pull|fetch|log|status|diff|branch|checkout|merge|rebase|stash|tag|remote|clone)\b/,
  // echo/printf — ONLY safe if not piped to shell (pipe splits into separate segments)
  /^(?:echo|printf)\s+/,
  // read-only commands — exclude find (can be used with -exec/-delete)
  /^(?:cat|head|tail|less|more|grep|ls|stat|file|wc|du|df|which|whereis|type|id|whoami|hostname|uname|date|uptime)\s*/,
  // package info (not install)
  /^(?:apt|dpkg|pip|npm)\s+(?:list|show|info|search)\b/,
  // node -p is print-only (safe)
  /^node\s+-p\s+/,
];

// ── Quote/Comment Detection ────────────────────────────────────────

function isQuotedOrCommented(text: string, matchIndex: number): boolean {
  const before = text.slice(0, matchIndex);

  // Inside double quotes?
  const doubleQuotes = (before.match(/"/g) || []).length;
  if (doubleQuotes % 2 === 1) return true;

  // Inside single quotes?
  const singleQuotes = (before.match(/'/g) || []).length;
  if (singleQuotes % 2 === 1) return true;

  // After a comment character on the same line?
  const lastNewline = before.lastIndexOf("\n");
  const currentLine = before.slice(lastNewline + 1);
  if (currentLine.includes("#")) return true;

  return false;
}

// ── Matching ───────────────────────────────────────────────────────

function matchRules(
  text: string,
  rules: Rule[],
  level: "critical" | "warning",
): BlacklistMatch | null {
  for (const rule of rules) {
    const m = rule.pattern.exec(text);
    if (m && !isQuotedOrCommented(text, m.index)) {
      return { level, pattern: rule.pattern.source, reason: rule.reason };
    }
  }
  return null;
}

// ── Command Segmentation ───────────────────────────────────────────

function splitCommand(cmd: string): string[] {
  // Split on shell operators, but not inside quotes
  const segments: string[] = [];
  let current = "";
  let inSingle = false;
  let inDouble = false;

  for (let i = 0; i < cmd.length; i++) {
    const ch = cmd[i];
    if (ch === "'" && !inDouble) {
      inSingle = !inSingle;
      current += ch;
      continue;
    }
    if (ch === '"' && !inSingle) {
      inDouble = !inDouble;
      current += ch;
      continue;
    }
    if (!inSingle && !inDouble) {
      // Check double-char operators first: && and ||
      if ((ch === "&" && cmd[i + 1] === "&") || (ch === "|" && cmd[i + 1] === "|")) {
        segments.push(current.trim());
        current = "";
        i++; // skip second char
        continue;
      }
      // Single-char separators: ; | \n
      if (ch === ";" || ch === "|" || ch === "\n") {
        segments.push(current.trim());
        current = "";
        continue;
      }
    }
    current += ch;
  }
  if (current.trim()) segments.push(current.trim());
  return segments.filter(Boolean);
}

// ── Public API ─────────────────────────────────────────────────────

/**
 * Check a command (exec) against blacklist.
 * Splits on shell operators and checks each segment.
 * Returns null if no match (99% of calls).
 */
export function checkExecBlacklist(command: string): BlacklistMatch | null {
  if (!command) return null;

  // Phase 1: Check the FULL command string for pipe-based attacks
  // These patterns span across pipe boundaries and must be checked before splitting
  const PIPE_ATTACKS: Rule[] = [
    {
      pattern: /base64\s+(-d|--decode).*\|\s*(?:bash|sh|zsh|dash)/,
      reason: "base64 decoded pipe to shell",
    },
    {
      pattern: /\bcurl\b.*\|\s*(?:bash|sh|zsh|dash|python|perl|ruby)/,
      reason: "curl pipe to shell (remote code execution)",
    },
    {
      pattern: /\bwget\b.*\|\s*(?:bash|sh|zsh|dash|python|perl|ruby)/,
      reason: "wget pipe to shell (remote code execution)",
    },
    { pattern: /\becho\b.*\|\s*(?:bash|sh|zsh|dash)\b/, reason: "echo pipe to shell" },
    { pattern: /\bprintf\b.*\|\s*(?:bash|sh|zsh|dash)\b/, reason: "printf pipe to shell" },
    { pattern: /\|\s*(?:bash|sh|zsh|dash)\s*$/, reason: "pipe to shell interpreter" },
    { pattern: /\|\s*(?:bash|sh|zsh|dash)\s*[;&|]/, reason: "pipe to shell interpreter" },
    // Encoding bypass: base64 decode piped to shell
    {
      pattern: /\bbase64\b.*\|\s*(?:bash|sh|zsh|dash)/,
      reason: "base64 pipe to shell (encoding bypass)",
    },
    // crontab stdin injection: echo ... | crontab -
    {
      pattern: /\|.*\bcrontab\s+-\s*$/,
      reason: "pipe to crontab stdin (crontab injection)",
    },
    {
      pattern: /\becho\b.*\|\s*crontab\b/,
      reason: "echo pipe to crontab (crontab injection)",
    },
  ];
  // Download + execute chain (must check full command before split)
  const CHAIN_ATTACKS: Rule[] = [
    {
      pattern: /\b(?:curl|wget)\b.*&&.*chmod\s+\+x\b/,
      reason: "download + chmod +x chain (download and execute)",
    },
    {
      pattern: /\b(?:curl|wget)\b.*&&.*\bsh\b/,
      reason: "download + shell execute chain",
    },
    {
      pattern: /\b(?:curl|wget)\b.*&&.*\bbash\b/,
      reason: "download + bash execute chain",
    },
  ];
  const fullMatch =
    matchRules(command, PIPE_ATTACKS, "critical") ?? matchRules(command, CHAIN_ATTACKS, "critical");
  if (fullMatch) return fullMatch;

  // Phase 2: Split on shell operators and check each segment
  const segments = splitCommand(command);
  for (const seg of segments) {
    // Whitelist check: safe commands skip blacklist entirely
    if (SAFE_EXEC.some((re) => re.test(seg))) continue;

    const m =
      matchRules(seg, CRITICAL_EXEC, "critical") ?? matchRules(seg, WARNING_EXEC, "warning");
    if (m) return m;
  }
  return null;
}

/**
 * Check a file path (write/edit) against blacklist.
 * Returns null if no match.
 */
export function checkPathBlacklist(filePath: string): BlacklistMatch | null {
  if (!filePath) return null;
  return (
    matchRules(filePath, CRITICAL_PATH, "critical") ?? matchRules(filePath, WARNING_PATH, "warning")
  );
}


path: scripts/default-policies.json
content: enabled: yes

path: scripts/index.ts
content: /**
 * OpenClaw Guardian v2 — Blacklist + LLM Intent Verification
 *
 * Flow:
 *   tool call → only check exec/write/edit
 *     → blacklist match? no → pass (99%)
 *     → yes, critical → 3 LLM votes (all must confirm user intent)
 *     → yes, warning  → 1 LLM vote (confirm user intent)
 *     → LLM down → critical: block, warning: ask user
 */

import { readFileSync } from "node:fs";
import { join, dirname, resolve, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";

function canonicalizePath(raw: string): string {
  if (!raw) return raw;
  // Expand ~ to home dir
  if (raw.startsWith("~/")) raw = raw.replace("~", process.env.HOME ?? "/root");
  // Resolve to absolute + normalize (removes ../ etc)
  return normalize(resolve(raw));
}
import { initAuditLog, writeAuditEntry } from "./src/audit-log.js";
import { checkExecBlacklist, checkPathBlacklist } from "./src/blacklist.js";
import { initLlm, singleVote, multiVote } from "./src/llm-voter.js";

function loadEnabled(): boolean {
  try {
    const dir = dirname(fileURLToPath(import.meta.url));
    const raw = readFileSync(join(dir, "default-policies.json"), "utf-8");
    return JSON.parse(raw).enabled !== false;
  } catch {
    return true; // default on if config missing
  }
}

export default function setup(api: OpenClawPluginApi): void {
  if (!loadEnabled()) {
    api.logger.info("[guardian] Disabled by policy");
    return;
  }

  initAuditLog();
  initLlm(api.config);
  const log = api.logger;
  log.info("[guardian] v2 active — blacklist + LLM intent verification");

  api.on("before_tool_call", async (event, ctx) => {
    const { toolName, params } = event;

    // Only check exec, write, edit — everything else passes instantly
    let match = null;

    if (toolName === "exec") {
      match = checkExecBlacklist((params?.command ?? "") as string);
    } else if (toolName === "write" || toolName === "edit") {
      const rawPath = (params?.file_path ?? params?.path ?? "") as string;
      const safePath = canonicalizePath(rawPath);
      match = checkPathBlacklist(safePath);
    }

    if (!match) return; // 99% of calls end here

    const detail =
      toolName === "exec"
        ? (params?.command ?? "").toString().slice(0, 120)
        : (params?.file_path ?? params?.path ?? "").toString().slice(0, 120);

    log.warn(
      `[guardian] ⚠️ Blacklist hit: ${match.level.toUpperCase()} | tool=${toolName} | ${detail} | rule=${match.reason}`,
    );

    // Blacklist hit — verify user intent via LLM
    const sessionKey = ctx?.sessionKey as string | undefined;

    if (match.level === "critical") {
      const result = await multiVote(toolName, params ?? {}, sessionKey, 3, 3);
      writeAuditEntry(toolName, params ?? {}, match, result.confirmed, result.reason);

      if (!result.confirmed) {
        log.error(
          `[guardian] 🛑 BLOCKED CRITICAL | tool=${toolName} | ${detail} | votes=${result.reason}`,
        );
        return {
          block: true,
          blockReason: `🛡️ Guardian: 危险操作被拦截 — ${match.reason}。${result.reason}`,
        };
      }
      log.info(`[guardian] ✅ CRITICAL passed (3/3 confirmed) | tool=${toolName} | ${detail}`);
      return;
    }

    // Warning level: 1 vote
    const result = await singleVote(toolName, params ?? {}, sessionKey);
    writeAuditEntry(toolName, params ?? {}, match, result.confirmed, result.reason);

    if (!result.confirmed) {
      log.warn(
        `[guardian] 🚫 BLOCKED WARNING | tool=${toolName} | ${detail} | reason=${result.reason}`,
      );
      return {
        block: true,
        blockReason: `🛡️ Guardian: 此操作需要用户确认 — ${match.reason}。请先询问用户是否要执行此操作。`,
      };
    }
    log.info(`[guardian] ✅ WARNING passed (user confirmed) | tool=${toolName} | ${detail}`);
    return;
  });
}


path: scripts/llm-voter.ts
content: /**
 * LLM voter — calls a lightweight model to check if user explicitly requested/confirmed
 * the flagged operation. Single job: "Did the user ask for this?"
 *
 * LLM config is read from OpenClaw's own model provider config (api.config.models.providers).
 * No separate API key needed — uses whatever the user already configured.
 */

import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";

export type Vote = { voter: number; confirmed: boolean; reason: string };
export type VoteResult = { confirmed: boolean; reason: string; votes?: Vote[] };

// ── LLM Config (resolved at init from OpenClaw config) ─────────────

let llmUrl = "";
let llmApiKey = "";
let llmModel = "";
let llmApi: string = "anthropic-messages";
let llmHeaders: Record<string, string> = {};
let llmReady = false;

const LLM_TIMEOUT_MS = 5000;
const LLM_MAX_TOKENS = 200;

// Preferred models for guardian voting (cheap + fast)
const PREFERRED_MODELS = [
  "claude-haiku-4-5-20251001",
  "claude-3-5-haiku",
  "claude-3-haiku",
  "gpt-4o-mini",
  "gemini-2.0-flash",
];

/**
 * Initialize LLM config from OpenClaw's provider config.
 * Called once at plugin setup.
 */
export function initLlm(config: Record<string, unknown>): void {
  const providers = (config as Record<string, any>)?.models?.providers;
  if (!providers || typeof providers !== "object") {
    console.error("[guardian] No model providers found in config");
    return;
  }

  // Strategy: find a provider with a cheap/fast model
  for (const preferred of PREFERRED_MODELS) {
    for (const [, provider] of Object.entries(providers) as [string, any][]) {
      if (!provider.baseUrl || !provider.apiKey) continue;
      const models = provider.models ?? [];
      const found = models.find(
        (m: any) => m.id === preferred || m.id?.includes(preferred) || m.name?.includes(preferred),
      );
      if (found) {
        llmUrl = provider.baseUrl.replace(/\/$/, "");
        llmApiKey = provider.apiKey;
        llmModel = found.id;
        llmApi = found.api ?? provider.api ?? "anthropic-messages";
        llmHeaders = { ...provider.headers, ...found.headers };
        llmReady = true;
        console.log(`[guardian] LLM ready: ${llmModel} via ${llmUrl}`);
        return;
      }
    }
  }

  // Fallback: use the first provider with any model
  for (const [, provider] of Object.entries(providers) as [string, any][]) {
    if (!provider.baseUrl || !provider.apiKey) continue;
    const models = provider.models ?? [];
    if (models.length > 0) {
      llmUrl = provider.baseUrl.replace(/\/$/, "");
      llmApiKey = provider.apiKey;
      llmModel = models[0].id;
      llmApi = models[0].api ?? provider.api ?? "anthropic-messages";
      llmHeaders = { ...provider.headers, ...models[0].headers };
      llmReady = true;
      console.log(`[guardian] LLM fallback: ${llmModel} via ${llmUrl}`);
      return;
    }
  }

  console.error("[guardian] No usable LLM provider found");
}

// ── System Prompt ──────────────────────────────────────────────────

const SYSTEM_PROMPT = `You are a security confirmation checker for an AI agent.

Your ONLY job is to determine: Did the user explicitly request or confirm this operation?

You will receive:
1. A tool call that was flagged as potentially dangerous
2. Recent conversation messages for context

Rules:
- If the user clearly asked for this operation (e.g., "delete that folder", "remove the old files", "restart the service"), answer YES.
- If the user confirmed after being asked (e.g., "yes", "do it", "confirmed", "go ahead"), answer YES.
- If there is no clear user intent or confirmation for this specific operation, answer NO.
- When in doubt, answer NO.
- Do NOT evaluate whether the operation is dangerous — that's already been determined. You are ONLY checking user intent.

Examples:

User said: "帮我删掉 /data/old-backup 这个文件夹"
Tool: exec, Command: rm -rf /data/old-backup
→ {"confirmed": true, "reason": "User explicitly asked to delete /data/old-backup"}

User said: "看看磁盘空间"
Tool: exec, Command: rm -rf /var/log/old
→ {"confirmed": false, "reason": "User asked about disk space, not file deletion"}

User said: "好的，执行吧"
Tool: exec, Command: sudo systemctl restart nginx
→ {"confirmed": true, "reason": "User said go ahead, confirming the operation"}

User said: "我要重启服务器"
Tool: exec, Command: reboot
→ {"confirmed": true, "reason": "User explicitly requested server reboot"}

User said: "检查一下 nginx 状态"
Tool: exec, Command: systemctl stop nginx
→ {"confirmed": false, "reason": "User asked to check status, not stop the service"}

User said: "yes"
Tool: exec, Command: rm -rf /home/user/project
→ {"confirmed": true, "reason": "User confirmed with yes"}

User said: (no recent messages)
Tool: exec, Command: rm -rf /tmp/cache
→ {"confirmed": false, "reason": "No user messages found to confirm this operation"}

User said: "echo 'rm -rf /' 这个命令很危险"
Tool: exec, Command: rm -rf /
→ {"confirmed": false, "reason": "User was discussing the command as dangerous, not requesting execution"}

Respond with EXACTLY one JSON object:
{"confirmed": true/false, "reason": "brief explanation"}`;

// ── Context Reader ─────────────────────────────────────────────────

function resolveSessionsDir(): string {
  // Try common paths
  const candidates = [
    join(process.env.HOME ?? "/root", ".openclaw/agents/main/sessions"),
    "/root/.openclaw/agents/main/sessions",
    "/home/clawdbot/.openclaw/agents/main/sessions",
  ];
  for (const dir of candidates) {
    try {
      readdirSync(dir);
      return dir;
    } catch {
      /* try next */
    }
  }
  return candidates[0]; // fallback
}

export function readRecentContext(_sessionKey?: string): string {
  try {
    const sessDir = resolveSessionsDir();
    const files = readdirSync(sessDir)
      .filter((f: string) => f.endsWith(".jsonl"))
      .map((f: string) => ({ name: f, mtime: statSync(join(sessDir, f)).mtimeMs }))
      .sort((a, b) => b.mtime - a.mtime);

    if (files.length === 0) return "(no session context available)";

    const latest = join(sessDir, files[0].name);
    const raw = readFileSync(latest, "utf-8");
    const lines = raw.split("\n").slice(-50).join("\n");

    const userMessages: string[] = [];
    for (const line of lines.split("\n")) {
      if (!line.trim()) continue;
      try {
        const entry = JSON.parse(line);
        const msg = entry.message ?? entry;
        if (msg.role === "user") {
          const text =
            typeof msg.content === "string"
              ? msg.content
              : Array.isArray(msg.content)
                ? msg.content
                    .filter((b: any) => b.type === "text")
                    .map((b: any) => b.text)
                    .join(" ")
                : "";
          if (text.trim()) userMessages.push(text.trim().slice(0, 500));
        }
      } catch {
        /* skip malformed lines */
      }
    }

    return userMessages.slice(-3).join("\n---\n") || "(no user messages found)";
  } catch {
    return "(failed to read session context)";
  }
}

// ── LLM Call (supports anthropic-messages and openai-completions) ──

async function callLLM(userPrompt: string): Promise<{ confirmed: boolean; reason: string }> {
  if (!llmReady) throw new Error("LLM not initialized");

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);

  try {
    let resp: Response;

    if (llmApi === "anthropic-messages") {
      const endpoint = llmUrl.endsWith("/messages") ? llmUrl : `${llmUrl}/v1/messages`;
      resp = await fetch(endpoint, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": llmApiKey,
          "anthropic-version": "2023-06-01",
          ...llmHeaders,
        },
        body: JSON.stringify({
          model: llmModel,
          max_tokens: LLM_MAX_TOKENS,
          temperature: 0,
          system: SYSTEM_PROMPT,
          messages: [{ role: "user", content: userPrompt }],
        }),
        signal: controller.signal,
      });
    } else {
      // OpenAI-compatible (openai-completions, ollama, etc.)
      const endpoint = llmUrl.endsWith("/chat/completions")
        ? llmUrl
        : `${llmUrl}/v1/chat/completions`;
      resp = await fetch(endpoint, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${llmApiKey}`,
          ...llmHeaders,
        },
        body: JSON.stringify({
          model: llmModel,
          max_tokens: LLM_MAX_TOKENS,
          temperature: 0,
          messages: [
            { role: "system", content: SYSTEM_PROMPT },
            { role: "user", content: userPrompt },
          ],
        }),
        signal: controller.signal,
      });
    }

    if (!resp.ok) throw new Error(`LLM HTTP ${resp.status}`);
    const data = (await resp.json()) as any;

    // Extract text from either Anthropic or OpenAI response format
    const text = data.content?.[0]?.text ?? data.choices?.[0]?.message?.content ?? "";

    const jsonMatch = text.match(/\{[\s\S]*?\}/);
    if (!jsonMatch) throw new Error("No JSON in LLM response");
    const parsed = JSON.parse(jsonMatch[0]);
    return { confirmed: !!parsed.confirmed, reason: parsed.reason ?? "" };
  } finally {
    clearTimeout(timer);
  }
}

// ── Prompt Builder ─────────────────────────────────────────────────

function buildPrompt(toolName: string, params: Record<string, any>, context: string): string {
  const detail =
    toolName === "exec"
      ? `Command: ${params.command ?? "(empty)"}`
      : `File path: ${params.file_path ?? params.path ?? "(empty)"}`;
  return `Flagged tool call:\n- Tool: ${toolName}\n- ${detail}\n\nRecent user messages:\n${context}`;
}

// ── Public API ─────────────────────────────────────────────────────

export async function singleVote(
  toolName: string,
  params: Record<string, any>,
  sessionKey?: string,
): Promise<VoteResult> {
  const context = readRecentContext(sessionKey);
  const prompt = buildPrompt(toolName, params, context);
  try {
    return await callLLM(prompt);
  } catch (e: any) {
    return { confirmed: false, reason: `LLM unavailable: ${e.message}` };
  }
}

export async function multiVote(
  toolName: string,
  params: Record<string, any>,
  sessionKey?: string,
  count = 3,
  threshold = 3,
): Promise<VoteResult & { votes: Vote[] }> {
  const context = readRecentContext(sessionKey);
  const prompt = buildPrompt(toolName, params, context);

  const promises = Array.from({ length: count }, (_, i) =>
    callLLM(prompt)
      .then((r): Vote => ({ voter: i + 1, confirmed: r.confirmed, reason: r.reason }))
      .catch(
        (e: any): Vote => ({ voter: i + 1, confirmed: false, reason: `LLM error: ${e.message}` }),
      ),
  );

  const votes = await Promise.all(promises);
  const yesCount = votes.filter((v) => v.confirmed).length;
  const confirmed = yesCount >= threshold;
  const reason = confirmed
    ? `${yesCount}/${count} voters confirmed user intent`
    : `Only ${yesCount}/${count} confirmed (need ${threshold})`;

  return { confirmed, reason, votes };
}


path: scripts/openclaw.plugin.json
content: {
  "id": "openclaw-guardian",
  "name": "OpenClaw Guardian",
  "description": "Security gate plugin — whitelist + LLM judge + rule fallback. 99% of operations pass via whitelist with 0ms latency.",
  "version": "2.0.0",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "enabled": { "type": "boolean", "default": true }
    }
  }
}


path: scripts/package.json
content: {
  "name": "@openclaw/guardian",
  "version": "0.1.0",
  "private": true,
  "description": "Tiered safety voting plugin for OpenClaw",
  "license": "MIT",
  "type": "module",
  "devDependencies": {
    "openclaw": "workspace:*"
  },
  "peerDependencies": {
    "openclaw": ">=2026.1.26"
  },
  "openclaw": {
    "extensions": [
      "./index.ts"
    ]
  }
}


path: scripts/tsconfig.json
content: 404: Not Found

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