From ac14face388a025903353fd2217733f7c10e4282 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 18:41:16 -0800 Subject: [PATCH 1/6] rebranding + default policies for plug-n-play --- README.md | 131 +++++++-------- policies/README.md | 233 ++++++++++++++++++++++++++ policies/audit-only.json | 153 +++++++++++++++++ policies/read-only-local.json | 283 ++++++++++++++++++++++++++++++++ policies/strict-web-only.json | 191 ++++++++++++++++++++++ policies/strict.json | 300 ++++++++++++++++++++++++++++++++++ 6 files changed, 1221 insertions(+), 70 deletions(-) create mode 100644 policies/README.md create mode 100644 policies/audit-only.json create mode 100644 policies/read-only-local.json create mode 100644 policies/strict-web-only.json create mode 100644 policies/strict.json diff --git a/README.md b/README.md index e0dea8667948..5cd5dbbe6d15 100644 --- a/README.md +++ b/README.md @@ -10,120 +10,111 @@

Pre-authorization. Post-verification. Zero-trust AI agent security.
- See how it works → + Read the Security Architecture →

- CI status - GitHub release - npm version + CI status + GitHub release + npm version MIT License

-**SecureClaw** is a _personal AI assistant_ you run on your own devices. -It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. +## Authorization ≠ Intent -If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. +Identity and token authorization are solved problems in traditional software engineering. But in the era of autonomous AI agents, standard RBAC is not enough. **Just because an agent holds a valid API token to execute a command doesn't mean its current _intent_ is safe.** -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +If an agent hallucinates, experiences context compaction, or falls victim to prompt injection, your standard authorization system blindly complies with the malicious intent. -Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. -The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. -Works with npm, pnpm, or bun. -New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) +**SecureClaw** is an enterprise-hardened fork of the popular OpenClaw framework. It replaces probabilistic "LLM-as-a-judge" safety measures with a deterministic, Zero-Trust execution harness. -## Sponsors - -| OpenAI | Blacksmith | -| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | +### The Security Harness -**Subscriptions (OAuth):** +SecureClaw wraps the standard OpenClaw execution loop with an embedded Rust sidecar (`predicate-authorityd`) that enforces safety in two places: -- **[OpenAI](https://openai.com/)** (ChatGPT/Codex) +1. **Pre-Execution Gate (The Gate):** Before the orchestrator executes an action (e.g., a bash command or browser click), it is intercepted and evaluated against a local, fail-closed JSON policy. It blocks rogue intents in <1ms before the OS even sees them. +2. **Post-Execution Verification (The Math):** SecureClaw uses deterministic math based on state changes (e.g., `url_contains('example.com')`) to instantly evaluate if an action succeeded, completely eliminating hallucination risks in the validation step. -Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). +[Website](https://predicatesystems.com) · [Security Docs](https://docs.predicatesystems.com) · [Policies Directory](policies/) · [Discord](https://discord.gg/predicatesystems) -## Models (selection + auth) +--- -- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models) -- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover) +## Install -## Install (recommended) - -Runtime: **Node ≥22**. +SecureClaw requires **Node ≥22** and is distributed under the official Predicate Systems NPM scope to guarantee supply-chain integrity. ```bash -npm install -g openclaw@latest -# or: pnpm add -g openclaw@latest +# Install via the official scoped package +npm install -g @predicatesystems/secureclaw@latest +# or: pnpm add -g @predicatesystems/secureclaw@latest -openclaw onboard --install-daemon +# Initialize with strict security defaults +secureclaw onboard --install-daemon --strict-mode ``` -The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running. - -## Quick start (TL;DR) - -Runtime: **Node ≥22**. +## Quick Start (Secure Loop) -Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started) +By default, SecureClaw runs in a walled garden. You must define explicit permissions in your local policy files. ```bash -openclaw onboard --install-daemon +# Start the Gateway with the Rust interceptor enabled +secureclaw gateway --port 18789 --policy-file policies/strict.json --verbose -openclaw gateway --port 18789 --verbose +# Send an intent to the agent +secureclaw agent --message "Read the ssh config" --thinking high -# Send a message -openclaw message send --to +1234567890 --message "Hello from SecureClaw" - -# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat) -openclaw agent --message "Ship checklist" --thinking high +# If the policy does not explicitly allow fs.read on ~/.ssh, it will be hard-blocked in <1ms. ``` -Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`). - -## Development channels +--- -- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-`), npm dist-tag `latest`. -- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing). -- **dev**: moving head of `main`, npm dist-tag `dev` (when published). +## Plug-and-Play Policies -Switch channels (git + npm): `openclaw update --channel stable|beta|dev`. -Details: [Development channels](https://docs.openclaw.ai/install/development-channels). +SecureClaw ships with four ready-to-use policy templates in the [`policies/`](policies/) directory. Pick the one that matches your security posture: -## From source (development) +| Policy | Use Case | What It Does | +| ----------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| **[`strict.json`](policies/strict.json)** | Production default | Workspace-isolated writes, blocks sensitive files (`.env`, `.ssh/`), allows safe shell commands and HTTPS. **Start here.** | +| **[`strict-web-only.json`](policies/strict-web-only.json)** | Browser automation | Zero local access. Blocks ALL filesystem and shell. Only allows HTTPS navigation to allowlisted domains. | +| **[`read-only-local.json`](policies/read-only-local.json)** | Code review agents | Read anywhere, write nowhere. Allows `cat`, `grep`, `git status` but blocks `rm`, `git push`, writes. | +| **[`audit-only.json`](policies/audit-only.json)** | Agent profiling | Allows everything (except catastrophic commands) with full logging. **Use to learn what policies you need.** | -Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. +### Example: Deploying a Read-Only Code Reviewer ```bash -git clone https://github.com/openclaw/openclaw.git -cd openclaw - -pnpm install -pnpm ui:build # auto-installs UI deps on first run -pnpm build +secureclaw gateway --port 18789 --policy-file policies/read-only-local.json +``` -pnpm openclaw onboard --install-daemon +### Example: Locked-Down Browser Bot -# Dev loop (auto-reload on TS changes) -pnpm gateway:watch +```bash +secureclaw gateway --port 18789 --policy-file policies/strict-web-only.json ``` -Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary. +### Creating Custom Policies -## Security defaults (DM access) +1. Start with `audit-only.json` to observe what your agent actually does +2. Review the authorization logs to see requested actions +3. Copy the closest template and customize the rules +4. See [`policies/README.md`](policies/README.md) for the full schema reference -SecureClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. +--- -Full security guide: [Security](https://docs.openclaw.ai/gateway/security) +## Upstream OpenClaw Integrations -Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack: +SecureClaw inherits the massive, highly flexible integration ecosystem of upstream OpenClaw, allowing you to deploy secure agents across any surface. -- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message. -- Approve with: `openclaw pairing approve ` (then the sender is added to a local allowlist store). -- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`). +- **Multi-channel Inbox:** Connect to WhatsApp, Telegram, Slack, Discord, Microsoft Teams, Signal, and Matrix. +- **First-Class Tools:** Native browser control (via Sentience DOM pruning), system cron, macOS Canvas, and iOS/Android nodes. +- **Remote Gateway Operations:** Run your agent safely on headless Linux servers while maintaining remote access via Tailscale Serve/Funnel or SSH tunnels. -Run `openclaw doctor` to surface risky/misconfigured DM policies. +_(For full channel setup instructions, see the [Upstream Integration Guides](https://docs.openclaw.ai/start/getting-started))_ + +## Sponsors + +| OpenAI | Blacksmith | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | ## Highlights diff --git a/policies/README.md b/policies/README.md new file mode 100644 index 000000000000..2acfdf162cee --- /dev/null +++ b/policies/README.md @@ -0,0 +1,233 @@ +# SecureClaw Policy Templates + +This directory contains policy templates for the SecureClaw authorization harness. Policies define what actions your AI agent is allowed to perform, enforced by the `predicate-authorityd` Rust sidecar. + +> **Supported Formats:** The Python SDK (`predicate_authority`) supports both **YAML** and **JSON**. The file extension (`.yaml`, `.yml`, or `.json`) determines the parser used. YAML is recommended for human editing. + +## Quick Start + +```bash +# Start SecureClaw with a policy +secureclaw gateway --port 18789 --policy-file policies/strict.json + +# Or set via environment variable +export SECURECLAW_POLICY_FILE=policies/strict-web-only.json +secureclaw gateway --port 18789 +``` + +## Available Templates + +| Policy | Use Case | Filesystem | Shell | Network | +| ------------------------------------------------ | -------------------------------- | ---------------- | ------------------ | -------------------- | +| **[strict.json](strict.json)** | Production default | Workspace only | Safe commands | HTTPS only | +| **[strict-web-only.json](strict-web-only.json)** | Browser automation, web scraping | BLOCKED | BLOCKED | HTTPS allowlist only | +| **[read-only-local.json](read-only-local.json)** | Code review, documentation | READ only | Safe commands only | HTTPS GET only | +| **[audit-only.json](audit-only.json)** | Agent profiling, development | ALLOWED (logged) | ALLOWED (logged) | ALLOWED (logged) | + +--- + +## Policy Templates + +### 1. Strict (`strict.json`) - Recommended Default + +**Purpose:** Balanced production policy with workspace isolation. + +**Allows:** + +- Filesystem read everywhere, write only in workspace directories +- Safe shell commands (ls, cat, grep, git, npm, etc.) +- HTTPS requests to any domain +- Full browser interactions + +**Blocks:** + +- Access to sensitive files (`.env`, `.ssh/`, credentials) +- Writes outside workspace directories +- Dangerous shell commands (`rm -rf`, `sudo`, etc.) +- Non-HTTPS protocols + +**Best for:** General-purpose secure agent deployment. + +```bash +secureclaw gateway --policy-file policies/strict.json +``` + +### 2. Strict Web-Only (`strict-web-only.json`) + +**Purpose:** Lock down agents to browser-only operations with no local access. + +**Allows:** + +- Browser navigation to allowlisted HTTPS domains +- Browser interactions (click, type, read) with snapshot verification +- Screenshot and DOM extraction + +**Blocks:** + +- ALL filesystem operations (`fs.*`) +- ALL shell/system execution (`bash`, `exec`, `os.*`) +- Non-HTTPS protocols (`http://`, `file://`, `data:`) +- Sensitive form field interactions (password, credit card) + +**Best for:** Web scraping bots, form-filling agents, UI testing automation. + +```bash +secureclaw gateway --policy-file policies/strict-web-only.json +``` + +### 3. Read-Only Local (`read-only-local.json`) + +**Purpose:** Allow context gathering without modification capabilities. + +**Allows:** + +- Filesystem read operations (`fs.read`, `fs.list`, `fs.stat`) +- Safe shell commands (`cat`, `grep`, `find`, `git status`, `git log`) +- HTTPS GET requests +- Browser read operations (screenshot, extract) + +**Blocks:** + +- Filesystem writes (`fs.write`, `fs.create`, `fs.delete`) +- Dangerous shell commands (`rm`, `sudo`, `chmod`, `git push`) +- HTTP mutation methods (POST, PUT, PATCH, DELETE) +- Access to credential files (`.env`, `.ssh/`, `.aws/`) + +**Best for:** Code review agents, documentation generators, codebase analysis. + +```bash +secureclaw gateway --policy-file policies/read-only-local.json +``` + +### 4. Audit Only (`audit-only.json`) + +**Purpose:** Profile agent behavior before writing restrictive policies. + +**Allows:** + +- ALL actions (requires `audit_enabled` label to confirm logging is active) + +**Blocks:** + +- Only catastrophic commands (`rm -rf /`, fork bombs) + +**Best for:** Initial agent onboarding, understanding action patterns, development. + +```bash +# Start in audit mode +secureclaw gateway --policy-file policies/audit-only.json + +# Review authorization logs +tail -f /var/log/secureclaw/audit.jsonl +``` + +> ⚠️ **WARNING:** Audit-only provides NO security protection. Use only in development/staging or with fully trusted agents. + +--- + +## Policy Schema (JSON) + +Each policy file contains a `rules` array evaluated in order. Rules are matched using glob patterns. + +```json +{ + "version": "1.0", + "rules": [ + { + "name": "rule-identifier", + "effect": "allow", + "principals": ["agent:*", "agent:my-browser-bot"], + "actions": ["fs.read", "browser.*"], + "resources": ["https://example.com*", "/home/*/projects/*"], + "required_labels": ["mfa_verified", "snapshot_captured"] + } + ] +} +``` + +### Required Fields + +| Field | Type | Description | +| ------------ | --------------------- | ------------------------------------------ | +| `name` | string | Unique identifier for logging/debugging | +| `effect` | `"allow"` or `"deny"` | Action to take when rule matches | +| `principals` | string[] | WHO can perform the action (glob patterns) | +| `actions` | string[] | WHAT actions are covered (glob patterns) | +| `resources` | string[] | ON WHAT resources (glob patterns) | + +### Optional Fields + +| Field | Type | Description | +| ---------------------- | -------- | -------------------------------------------------------- | +| `required_labels` | string[] | Verification labels that must be present (default: `[]`) | +| `max_delegation_depth` | number | Max delegation chain depth | + +## Evaluation Order + +1. **DENY rules are evaluated first** - Any matching DENY immediately blocks the action +2. **ALLOW rules are checked** - Must match AND have all required_labels present +3. **Default DENY** - If no rules match, action is blocked (fail-closed) + +## Pattern Matching + +Patterns use glob-style matching: + +| Pattern | Matches | +| ------------------------ | ---------------------------------------- | +| `*` | Anything | +| `agent:*` | Any agent identity | +| `fs.*` | `fs.read`, `fs.write`, `fs.delete`, etc. | +| `https://*.example.com*` | Any subdomain of example.com | +| `/home/*/projects/**` | Any file under any user's projects dir | +| `element:button[*` | Button elements with any attributes | + +## Creating Custom Policies + +1. **Start with audit-only** to understand your agent's behavior: + + ```bash + secureclaw gateway --policy-file policies/audit-only.json + # Run your agent workflows + # Review logs to see what actions are requested + ``` + +2. **Copy the closest template** as a starting point: + + ```bash + cp policies/read-only-local.json policies/my-agent.json + ``` + +3. **Customize the rules** based on observed behavior: + - Add domains to navigation allowlists + - Allow specific shell commands your agent needs + - Block sensitive file paths + +4. **Test with your agent** before deploying: + ```bash + secureclaw gateway --policy-file policies/my-agent.json --verbose + ``` + +## Environment Variables + +| Variable | Description | +| ------------------------ | ------------------------------------------------------------ | +| `SECURECLAW_POLICY_FILE` | Path to policy JSON file | +| `SECURECLAW_FAIL_CLOSED` | Set `true` to block if sidecar unavailable (default: `true`) | +| `SECURECLAW_SIDECAR_URL` | Sidecar endpoint (default: `http://127.0.0.1:8787`) | + +## Hot Reload + +Policies can be reloaded without restarting the gateway: + +```bash +# Use the API endpoint +curl -X POST http://127.0.0.1:8787/policy/reload \ + -H "Content-Type: application/json" \ + -d @policies/strict.json +``` + +## Additional Resources + +- [Security Architecture](../docs/secureclaw-architecture.md) - How the authorization harness works +- [Predicate Authority Docs](https://docs.predicatesystems.com) - Full sidecar documentation +- [Policy Examples](examples/) - More specialized policy templates diff --git a/policies/audit-only.json b/policies/audit-only.json new file mode 100644 index 000000000000..0b94e55ea426 --- /dev/null +++ b/policies/audit-only.json @@ -0,0 +1,153 @@ +{ + "version": "1.0", + "metadata": { + "name": "audit-only", + "description": "Permissive policy for agent profiling - logs all actions", + "environment": "development", + "warning": "NOT FOR PRODUCTION USE" + }, + "rules": [ + { + "name": "block-catastrophic-commands", + "effect": "deny", + "principals": ["*"], + "actions": ["shell.exec", "bash", "exec", "run"], + "resources": [ + "rm -rf /*", + "rm -rf /", + ":(){ :|:& };:", + "dd if=/dev/zero*", + "mkfs*", + "fdisk*", + "curl*|base64*", + "wget*|base64*", + "cat*|nc*", + "cat*|curl*" + ], + "required_labels": [] + }, + { + "name": "allow-all-filesystem-read-audit", + "effect": "allow", + "principals": ["*"], + "actions": [ + "fs.read", + "fs.read_file", + "fs.list", + "fs.readdir", + "fs.stat", + "fs.exists", + "fs.access", + "fs.glob", + "fs.find", + "fs.search", + "file.read", + "read", + "glob", + "grep" + ], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "allow-all-filesystem-write-audit", + "effect": "allow", + "principals": ["*"], + "actions": [ + "fs.write", + "fs.write_file", + "fs.create", + "fs.append", + "fs.delete", + "fs.remove", + "fs.rename", + "fs.move", + "fs.copy", + "fs.mkdir", + "fs.rmdir", + "fs.chmod", + "fs.chown", + "file.write", + "file.delete", + "write", + "edit", + "delete" + ], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "allow-all-shell-execution-audit", + "effect": "allow", + "principals": ["*"], + "actions": [ + "shell.exec", + "bash", + "bash.exec", + "os.exec", + "os.spawn", + "os.run", + "process.spawn", + "process.exec", + "system.run", + "system.exec", + "exec", + "run" + ], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "allow-all-http-requests-audit", + "effect": "allow", + "principals": ["*"], + "actions": [ + "http.request", + "http.get", + "http.post", + "http.put", + "http.patch", + "http.delete", + "fetch" + ], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "allow-all-browser-actions-audit", + "effect": "allow", + "principals": ["*"], + "actions": [ + "browser.navigate", + "browser.goto", + "browser.snapshot", + "browser.screenshot", + "browser.click", + "browser.type", + "browser.fill", + "browser.scroll", + "browser.wait", + "browser.read", + "browser.extract" + ], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "allow-all-other-actions-audit", + "effect": "allow", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"], + "required_labels": ["audit_enabled"] + }, + { + "name": "deny-if-audit-not-enabled", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"], + "required_labels": [] + } + ] +} diff --git a/policies/read-only-local.json b/policies/read-only-local.json new file mode 100644 index 000000000000..09a492ce5d5c --- /dev/null +++ b/policies/read-only-local.json @@ -0,0 +1,283 @@ +{ + "version": "1.0", + "metadata": { + "name": "read-only-local", + "description": "Allows filesystem read for context gathering, blocks all writes" + }, + "rules": [ + { + "name": "block-filesystem-write", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.write", + "fs.write_file", + "fs.create", + "fs.append", + "file.write", + "write", + "edit" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-filesystem-delete", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.delete", + "fs.remove", + "fs.unlink", + "fs.rmdir", + "fs.rm", + "file.delete", + "delete" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-filesystem-modify", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.rename", + "fs.move", + "fs.copy", + "fs.mkdir", + "fs.chmod", + "fs.chown", + "fs.truncate", + "file.rename", + "file.move" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-sensitive-file-read", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.read_file", "file.read", "read"], + "resources": [ + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "/etc/ssh/*", + "*/.ssh/id_*", + "*/.ssh/known_hosts", + "*/.ssh/authorized_keys", + "*/.aws/credentials", + "*/.aws/config", + "*/.config/gcloud/*", + "*/.azure/*", + "*/.kube/config", + "*/.npmrc", + "*/.pypirc", + "*/.netrc", + "*/.env", + "*/.env.*", + "**/.env", + "**/.env.*", + "**/credentials*", + "**/secrets*", + "**/*.pem", + "**/*.key", + "**/id_rsa*", + "**/id_ed25519*" + ], + "required_labels": [] + }, + { + "name": "block-dangerous-shell-commands", + "effect": "deny", + "principals": ["*"], + "actions": ["shell.exec", "bash", "exec", "run"], + "resources": [ + "rm *", + "rm -rf *", + "rmdir *", + "unlink *", + "shred *", + "mv *", + "cp *", + "mkdir *", + "touch *", + "chmod *", + "chown *", + "chgrp *", + "sudo *", + "su *", + "doas *", + "pkexec *", + "apt *", + "apt-get *", + "yum *", + "dnf *", + "pacman *", + "brew *", + "npm *", + "pip *", + "pip3 *", + "curl * | bash*", + "curl * | sh*", + "wget * | bash*", + "wget * | sh*", + "git push*", + "git commit*", + "git reset*", + "git checkout*", + "git branch -d*", + "git branch -D*", + "vi *", + "vim *", + "nano *", + "emacs *", + "sed -i*", + "awk -i*" + ], + "required_labels": [] + }, + { + "name": "block-http-mutations", + "effect": "deny", + "principals": ["*"], + "actions": ["http.post", "http.put", "http.patch", "http.delete"], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-browser-mutations", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.click", "browser.type", "browser.fill", "browser.submit"], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "allow-filesystem-read", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "fs.read", + "fs.read_file", + "fs.list", + "fs.readdir", + "fs.stat", + "fs.exists", + "fs.access", + "file.read", + "read" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "allow-filesystem-search", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["fs.glob", "fs.find", "fs.search", "glob", "grep"], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "allow-readonly-shell-commands", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["shell.exec", "bash", "exec", "run"], + "resources": [ + "cat *", + "head *", + "tail *", + "less *", + "more *", + "bat *", + "ls *", + "ls", + "ll *", + "tree *", + "exa *", + "find *", + "locate *", + "which *", + "whereis *", + "type *", + "grep *", + "rg *", + "ag *", + "ack *", + "wc *", + "sort *", + "uniq *", + "cut *", + "awk *", + "sed *", + "jq *", + "yq *", + "pwd", + "whoami", + "hostname", + "uname *", + "date", + "uptime", + "df *", + "du *", + "free *", + "top -b*", + "ps *", + "env", + "printenv*", + "git status*", + "git log*", + "git diff*", + "git show*", + "git branch*", + "git remote*", + "git rev-parse*", + "git ls-files*", + "git blame*", + "wc -l*", + "cloc *", + "tokei *", + "file *", + "stat *" + ], + "required_labels": [] + }, + { + "name": "allow-https-get-requests", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.get", "http.request", "fetch", "browser.navigate", "browser.goto"], + "resources": ["https://*"], + "required_labels": [] + }, + { + "name": "allow-browser-read-operations", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "browser.snapshot", + "browser.screenshot", + "browser.read", + "browser.extract", + "browser.get_text", + "browser.get_html", + "browser.get_state", + "browser.scroll", + "browser.wait" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "default-deny", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"], + "required_labels": [] + } + ] +} diff --git a/policies/strict-web-only.json b/policies/strict-web-only.json new file mode 100644 index 000000000000..1345968cb14a --- /dev/null +++ b/policies/strict-web-only.json @@ -0,0 +1,191 @@ +{ + "version": "1.0", + "metadata": { + "name": "strict-web-only", + "description": "Browser automation only - blocks all filesystem and shell access" + }, + "rules": [ + { + "name": "block-all-filesystem-read", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.read", + "fs.read_file", + "fs.list", + "fs.stat", + "fs.exists", + "file.read", + "read" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-all-filesystem-write", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.write", + "fs.write_file", + "fs.create", + "fs.delete", + "fs.remove", + "fs.mkdir", + "fs.rmdir", + "fs.rename", + "fs.copy", + "fs.move", + "file.write", + "write", + "edit" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-all-system-execution", + "effect": "deny", + "principals": ["*"], + "actions": [ + "os.exec", + "os.spawn", + "os.run", + "shell.exec", + "shell.run", + "bash", + "bash.exec", + "process.spawn", + "process.exec", + "system.run", + "system.exec", + "exec", + "run" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "block-insecure-http", + "effect": "deny", + "principals": ["*"], + "actions": [ + "http.request", + "http.get", + "http.post", + "browser.navigate", + "browser.goto", + "fetch" + ], + "resources": ["http://*", "file://*", "data:*", "javascript:*", "ftp://*"], + "required_labels": [] + }, + { + "name": "block-sensitive-form-fields", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.type", "browser.fill", "browser.click"], + "resources": [ + "element:input[type=password*", + "element:input[name=password*", + "element:input[name=credit*", + "element:input[type=credit*", + "element:*[name=ssn*", + "element:*[name=social_security*", + "element:*[name=api_key*", + "element:*[name=secret*", + "element:*[name=token*" + ], + "required_labels": [] + }, + { + "name": "allow-navigation-trusted-domains", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.navigate", "browser.goto", "http.get", "fetch"], + "resources": [ + "https://www.google.com*", + "https://google.com*", + "https://www.github.com*", + "https://github.com*", + "https://api.github.com*", + "https://www.wikipedia.org*", + "https://en.wikipedia.org*", + "https://www.example.com*", + "https://example.com*" + ], + "required_labels": ["browser_initialized"] + }, + { + "name": "allow-browser-snapshot", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.snapshot", "browser.screenshot", "browser.get_state"], + "resources": ["*"], + "required_labels": ["browser_initialized"] + }, + { + "name": "allow-browser-click-safe-elements", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.click", "browser.element.click", "click"], + "resources": [ + "element:button[*", + "element:a[*", + "element:input[type=button*", + "element:input[type=submit*", + "element:input[type=checkbox*", + "element:input[type=radio*", + "element:role=button[*", + "element:role=link[*", + "element:role=tab[*", + "element:role=menuitem[*", + "element#*" + ], + "required_labels": ["element_visible", "snapshot_captured"] + }, + { + "name": "allow-browser-type-safe-inputs", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.type", "browser.fill", "browser.element.type"], + "resources": [ + "element:input[type=text*", + "element:input[type=search*", + "element:input[type=email*", + "element:input[type=url*", + "element:input[type=tel*", + "element:input[type=number*", + "element:textarea[*", + "element:role=textbox[*", + "element:role=searchbox[*", + "element:role=combobox[*" + ], + "required_labels": ["element_visible", "snapshot_captured"] + }, + { + "name": "allow-browser-read-content", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.read", "browser.extract", "browser.get_text", "browser.get_html"], + "resources": ["*"], + "required_labels": ["snapshot_captured"] + }, + { + "name": "allow-browser-scroll-wait", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.scroll", "browser.wait", "browser.wait_for", "browser.sleep"], + "resources": ["*"], + "required_labels": ["browser_initialized"] + }, + { + "name": "default-deny", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"], + "required_labels": [] + } + ] +} diff --git a/policies/strict.json b/policies/strict.json new file mode 100644 index 000000000000..7a308a36b6c7 --- /dev/null +++ b/policies/strict.json @@ -0,0 +1,300 @@ +{ + "version": "1.0", + "metadata": { + "name": "strict", + "description": "Balanced production policy with workspace isolation", + "environment": "production" + }, + "rules": [ + { + "name": "block-sensitive-files", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write", "fs.delete", "fs.*", "file.*", "read", "write", "edit"], + "resources": [ + "**/.env", + "**/.env.*", + "**/credentials*", + "**/secrets*", + "**/*.pem", + "**/*.key", + "**/*.p12", + "**/*.pfx", + "*/.ssh/*", + "/etc/ssh/*", + "*/.aws/*", + "*/.azure/*", + "*/.config/gcloud/*", + "*/.kube/config", + "*/.npmrc", + "*/.pypirc", + "*/.netrc", + "*/.docker/config.json", + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "/etc/sudoers.d/*" + ], + "required_labels": [] + }, + { + "name": "block-outside-workspace", + "effect": "deny", + "principals": ["*"], + "actions": [ + "fs.write", + "fs.delete", + "fs.create", + "fs.mkdir", + "fs.rmdir", + "fs.rename", + "file.write", + "file.delete", + "write", + "edit", + "delete" + ], + "resources": [ + "/etc/*", + "/usr/*", + "/bin/*", + "/sbin/*", + "/var/*", + "/tmp/*", + "/System/*", + "/Library/*", + "/Applications/*", + "C:\\Windows\\*", + "C:\\Program Files*" + ], + "required_labels": [] + }, + { + "name": "block-dangerous-commands", + "effect": "deny", + "principals": ["*"], + "actions": ["shell.exec", "bash", "exec", "run"], + "resources": [ + "rm -rf /*", + "rm -rf /", + "rm -rf ~/*", + ":(){ :|:& };:*", + "dd if=/dev/zero*", + "mkfs*", + "> /dev/sd*", + "sudo *", + "su *", + "doas *", + "pkexec *", + "runas *", + "chmod 777*", + "chmod -R 777*", + "chown root*", + "curl * | bash*", + "curl * | sh*", + "wget * | bash*", + "wget * | sh*", + "nc -l*", + "ncat -l*", + "*cat*id_rsa*", + "*cat*.pem*", + "echo $*_KEY*", + "echo $*_SECRET*", + "echo $*_TOKEN*", + "printenv*KEY*", + "printenv*SECRET*" + ], + "required_labels": [] + }, + { + "name": "block-insecure-protocols", + "effect": "deny", + "principals": ["*"], + "actions": ["http.request", "http.get", "http.post", "fetch", "browser.navigate"], + "resources": ["http://*", "ftp://*", "file://*", "data:text/html*", "javascript:*"], + "required_labels": [] + }, + { + "name": "allow-workspace-read", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "fs.read", + "fs.read_file", + "fs.list", + "fs.readdir", + "fs.stat", + "fs.exists", + "fs.glob", + "file.read", + "read", + "glob", + "grep" + ], + "resources": ["*"], + "required_labels": [] + }, + { + "name": "allow-workspace-write", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "fs.write", + "fs.write_file", + "fs.create", + "fs.mkdir", + "file.write", + "write", + "edit" + ], + "resources": [ + "*/workspace/*", + "*/projects/*", + "*/src/*", + "*/code/*", + "*/.secureclaw/*", + "*/Downloads/*", + "*/Documents/*", + "./*", + "src/*", + "lib/*", + "test/*", + "tests/*", + "docs/*" + ], + "required_labels": ["intent_verified"] + }, + { + "name": "allow-workspace-delete", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["fs.delete", "fs.remove", "fs.rmdir", "file.delete", "delete"], + "resources": ["*/workspace/*", "*/projects/*", "*/.secureclaw/*", "./*"], + "required_labels": ["intent_verified", "deletion_confirmed"] + }, + { + "name": "allow-safe-shell-commands", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["shell.exec", "bash", "exec", "run"], + "resources": [ + "cat *", + "head *", + "tail *", + "less *", + "bat *", + "ls*", + "tree *", + "pwd", + "cd *", + "find *", + "grep *", + "rg *", + "ag *", + "wc *", + "sort *", + "uniq *", + "cut *", + "awk *", + "sed *", + "jq *", + "whoami", + "hostname", + "uname *", + "date", + "env", + "which *", + "node *", + "npm run*", + "npm test*", + "npm install*", + "npx *", + "pnpm *", + "yarn *", + "bun *", + "python *", + "python3 *", + "pip install*", + "cargo *", + "go *", + "make *", + "git *", + "docker ps*", + "docker images*", + "docker logs*" + ], + "required_labels": [] + }, + { + "name": "allow-build-commands", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["shell.exec", "bash", "exec"], + "resources": [ + "npm run build*", + "npm run test*", + "pnpm build*", + "pnpm test*", + "yarn build*", + "yarn test*", + "make build*", + "make test*", + "cargo build*", + "cargo test*", + "go build*", + "go test*" + ], + "required_labels": ["intent_verified"] + }, + { + "name": "allow-https-requests", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "http.request", + "http.get", + "http.post", + "http.put", + "http.patch", + "http.delete", + "fetch" + ], + "resources": ["https://*"], + "required_labels": [] + }, + { + "name": "allow-browser-navigation", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["browser.navigate", "browser.goto"], + "resources": ["https://*"], + "required_labels": ["browser_initialized"] + }, + { + "name": "allow-browser-interactions", + "effect": "allow", + "principals": ["agent:*"], + "actions": [ + "browser.snapshot", + "browser.screenshot", + "browser.click", + "browser.type", + "browser.fill", + "browser.scroll", + "browser.wait", + "browser.read", + "browser.extract" + ], + "resources": ["*"], + "required_labels": ["browser_initialized", "snapshot_captured"] + }, + { + "name": "default-deny", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"], + "required_labels": [] + } + ] +} From cb4e26460c43926ba38b9ab3fbe92daf882ebb51 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 18:46:52 -0800 Subject: [PATCH 2/6] fix linting --- CONTRIBUTING.md | 6 ++--- policies/audit-only.json | 13 ++--------- policies/read-only-local.json | 44 +++++++++++------------------------ policies/strict-web-only.json | 23 +++++------------- policies/strict.json | 30 +++++++----------------- 5 files changed, 32 insertions(+), 84 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d640b8067dca..cf8a54a94ab9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,10 +57,10 @@ Welcome to the lobster tank! 🦞 - GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant) - **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT - - Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) - + - GitHub: [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) + - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - - Github [@jalehman](https://github.com/jalehman) · X: [@jlehman_](https://x.com/jlehman_) + - GitHub: [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) ## How to Contribute diff --git a/policies/audit-only.json b/policies/audit-only.json index 0b94e55ea426..6885d8725e57 100644 --- a/policies/audit-only.json +++ b/policies/audit-only.json @@ -1,11 +1,4 @@ { - "version": "1.0", - "metadata": { - "name": "audit-only", - "description": "Permissive policy for agent profiling - logs all actions", - "environment": "development", - "warning": "NOT FOR PRODUCTION USE" - }, "rules": [ { "name": "block-catastrophic-commands", @@ -23,8 +16,7 @@ "wget*|base64*", "cat*|nc*", "cat*|curl*" - ], - "required_labels": [] + ] }, { "name": "allow-all-filesystem-read-audit", @@ -146,8 +138,7 @@ "effect": "deny", "principals": ["*"], "actions": ["*"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] } ] } diff --git a/policies/read-only-local.json b/policies/read-only-local.json index 09a492ce5d5c..2a02414043ba 100644 --- a/policies/read-only-local.json +++ b/policies/read-only-local.json @@ -1,9 +1,4 @@ { - "version": "1.0", - "metadata": { - "name": "read-only-local", - "description": "Allows filesystem read for context gathering, blocks all writes" - }, "rules": [ { "name": "block-filesystem-write", @@ -18,8 +13,7 @@ "write", "edit" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-filesystem-delete", @@ -34,8 +28,7 @@ "file.delete", "delete" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-filesystem-modify", @@ -52,8 +45,7 @@ "file.rename", "file.move" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-sensitive-file-read", @@ -86,8 +78,7 @@ "**/*.key", "**/id_rsa*", "**/id_ed25519*" - ], - "required_labels": [] + ] }, { "name": "block-dangerous-shell-commands", @@ -136,24 +127,21 @@ "emacs *", "sed -i*", "awk -i*" - ], - "required_labels": [] + ] }, { "name": "block-http-mutations", "effect": "deny", "principals": ["*"], "actions": ["http.post", "http.put", "http.patch", "http.delete"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-browser-mutations", "effect": "deny", "principals": ["*"], "actions": ["browser.click", "browser.type", "browser.fill", "browser.submit"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "allow-filesystem-read", @@ -170,16 +158,14 @@ "file.read", "read" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "allow-filesystem-search", "effect": "allow", "principals": ["agent:*"], "actions": ["fs.glob", "fs.find", "fs.search", "glob", "grep"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "allow-readonly-shell-commands", @@ -242,16 +228,14 @@ "tokei *", "file *", "stat *" - ], - "required_labels": [] + ] }, { "name": "allow-https-get-requests", "effect": "allow", "principals": ["agent:*"], "actions": ["http.get", "http.request", "fetch", "browser.navigate", "browser.goto"], - "resources": ["https://*"], - "required_labels": [] + "resources": ["https://*"] }, { "name": "allow-browser-read-operations", @@ -268,16 +252,14 @@ "browser.scroll", "browser.wait" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "default-deny", "effect": "deny", "principals": ["*"], "actions": ["*"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] } ] } diff --git a/policies/strict-web-only.json b/policies/strict-web-only.json index 1345968cb14a..c475c58fd2a1 100644 --- a/policies/strict-web-only.json +++ b/policies/strict-web-only.json @@ -1,9 +1,4 @@ { - "version": "1.0", - "metadata": { - "name": "strict-web-only", - "description": "Browser automation only - blocks all filesystem and shell access" - }, "rules": [ { "name": "block-all-filesystem-read", @@ -18,8 +13,7 @@ "file.read", "read" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-all-filesystem-write", @@ -40,8 +34,7 @@ "write", "edit" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-all-system-execution", @@ -62,8 +55,7 @@ "exec", "run" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "block-insecure-http", @@ -77,8 +69,7 @@ "browser.goto", "fetch" ], - "resources": ["http://*", "file://*", "data:*", "javascript:*", "ftp://*"], - "required_labels": [] + "resources": ["http://*", "file://*", "data:*", "javascript:*", "ftp://*"] }, { "name": "block-sensitive-form-fields", @@ -95,8 +86,7 @@ "element:*[name=api_key*", "element:*[name=secret*", "element:*[name=token*" - ], - "required_labels": [] + ] }, { "name": "allow-navigation-trusted-domains", @@ -184,8 +174,7 @@ "effect": "deny", "principals": ["*"], "actions": ["*"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] } ] } diff --git a/policies/strict.json b/policies/strict.json index 7a308a36b6c7..3efc35d0d2b1 100644 --- a/policies/strict.json +++ b/policies/strict.json @@ -1,10 +1,4 @@ { - "version": "1.0", - "metadata": { - "name": "strict", - "description": "Balanced production policy with workspace isolation", - "environment": "production" - }, "rules": [ { "name": "block-sensitive-files", @@ -34,8 +28,7 @@ "/etc/shadow", "/etc/sudoers", "/etc/sudoers.d/*" - ], - "required_labels": [] + ] }, { "name": "block-outside-workspace", @@ -66,8 +59,7 @@ "/Applications/*", "C:\\Windows\\*", "C:\\Program Files*" - ], - "required_labels": [] + ] }, { "name": "block-dangerous-commands", @@ -103,16 +95,14 @@ "echo $*_TOKEN*", "printenv*KEY*", "printenv*SECRET*" - ], - "required_labels": [] + ] }, { "name": "block-insecure-protocols", "effect": "deny", "principals": ["*"], "actions": ["http.request", "http.get", "http.post", "fetch", "browser.navigate"], - "resources": ["http://*", "ftp://*", "file://*", "data:text/html*", "javascript:*"], - "required_labels": [] + "resources": ["http://*", "ftp://*", "file://*", "data:text/html*", "javascript:*"] }, { "name": "allow-workspace-read", @@ -131,8 +121,7 @@ "glob", "grep" ], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] }, { "name": "allow-workspace-write", @@ -222,8 +211,7 @@ "docker ps*", "docker images*", "docker logs*" - ], - "required_labels": [] + ] }, { "name": "allow-build-commands", @@ -259,8 +247,7 @@ "http.delete", "fetch" ], - "resources": ["https://*"], - "required_labels": [] + "resources": ["https://*"] }, { "name": "allow-browser-navigation", @@ -293,8 +280,7 @@ "effect": "deny", "principals": ["*"], "actions": ["*"], - "resources": ["*"], - "required_labels": [] + "resources": ["*"] } ] } From 48f80c663af63631790bfb32071d02fcf546409e Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 19:21:53 -0800 Subject: [PATCH 3/6] recover secureclaw files --- package.json | 1 + src/plugins/secureclaw/auto-register.ts | 39 ++ src/plugins/secureclaw/config.ts | 64 +++ src/plugins/secureclaw/env.ts | 101 +++++ src/plugins/secureclaw/index.ts | 27 ++ src/plugins/secureclaw/integration.test.ts | 335 ++++++++++++++++ src/plugins/secureclaw/plugin.test.ts | 377 ++++++++++++++++++ src/plugins/secureclaw/plugin.ts | 331 +++++++++++++++ src/plugins/secureclaw/predicate-claw.d.ts | 128 ++++++ .../secureclaw/resource-extractor.test.ts | 185 +++++++++ src/plugins/secureclaw/resource-extractor.ts | 202 ++++++++++ 11 files changed, 1790 insertions(+) create mode 100644 src/plugins/secureclaw/auto-register.ts create mode 100644 src/plugins/secureclaw/config.ts create mode 100644 src/plugins/secureclaw/env.ts create mode 100644 src/plugins/secureclaw/index.ts create mode 100644 src/plugins/secureclaw/integration.test.ts create mode 100644 src/plugins/secureclaw/plugin.test.ts create mode 100644 src/plugins/secureclaw/plugin.ts create mode 100644 src/plugins/secureclaw/predicate-claw.d.ts create mode 100644 src/plugins/secureclaw/resource-extractor.test.ts create mode 100644 src/plugins/secureclaw/resource-extractor.ts diff --git a/package.json b/package.json index 542166183853..c26e5e161553 100644 --- a/package.json +++ b/package.json @@ -205,6 +205,7 @@ "osc-progress": "^0.3.0", "pdfjs-dist": "^5.4.624", "playwright-core": "1.58.2", + "predicate-claw": "^0.1.0", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "sqlite-vec": "0.1.7-alpha.2", diff --git a/src/plugins/secureclaw/auto-register.ts b/src/plugins/secureclaw/auto-register.ts new file mode 100644 index 000000000000..814c7bfc690d --- /dev/null +++ b/src/plugins/secureclaw/auto-register.ts @@ -0,0 +1,39 @@ +/** + * SecureClaw Auto-Registration + * + * This module is imported early in the OpenClaw boot sequence to + * auto-register the SecureClaw security plugin. + */ + +import { isSecureClawEnabled } from "./env.js"; +import { createSecureClawPlugin } from "./plugin.js"; + +/** + * Auto-register SecureClaw with the plugin system. + * Returns the plugin definition for manual registration if needed. + */ +export function autoRegisterSecureClaw(): ReturnType | null { + if (!isSecureClawEnabled()) { + console.log("[SecureClaw] Disabled via SECURECLAW_DISABLED=true"); + return null; + } + + const plugin = createSecureClawPlugin(); + + console.log("[SecureClaw] Security middleware initialized"); + console.log("[SecureClaw] All tool calls will be authorized before execution"); + + return plugin; +} + +/** + * Get the SecureClaw plugin without auto-registering. + * Use this for manual plugin registration. + */ +export function getSecureClawPlugin(): ReturnType { + return createSecureClawPlugin(); +} + +// Export for direct import +export { createSecureClawPlugin } from "./plugin.js"; +export { isSecureClawEnabled } from "./env.js"; diff --git a/src/plugins/secureclaw/config.ts b/src/plugins/secureclaw/config.ts new file mode 100644 index 000000000000..ffe7ac2f6bb3 --- /dev/null +++ b/src/plugins/secureclaw/config.ts @@ -0,0 +1,64 @@ +/** + * SecureClaw Configuration + */ + +export interface SecureClawConfig { + /** Agent principal identifier for authorization requests */ + principal: string; + + /** Path to YAML policy file */ + policyFile: string; + + /** Predicate Authority sidecar URL */ + sidecarUrl: string; + + /** Fail closed when sidecar is unavailable (default: true) */ + failClosed: boolean; + + /** Enable post-execution verification via Snapshot Engine (default: true) */ + enablePostVerification: boolean; + + /** Enable verbose logging */ + verbose: boolean; + + /** Session ID for audit trail correlation */ + sessionId?: string; + + /** Tenant ID for multi-tenant deployments */ + tenantId?: string; + + /** User ID for audit attribution */ + userId?: string; +} + +export const defaultConfig: SecureClawConfig = { + principal: "agent:secureclaw", + policyFile: "./policies/default.json", + sidecarUrl: "http://127.0.0.1:8787", + failClosed: true, + enablePostVerification: true, + verbose: false, +}; + +export function loadConfigFromEnv(): Partial { + return { + principal: process.env.SECURECLAW_PRINCIPAL, + policyFile: process.env.SECURECLAW_POLICY, + sidecarUrl: process.env.PREDICATE_SIDECAR_URL, + failClosed: process.env.SECURECLAW_FAIL_OPEN !== "true", + enablePostVerification: process.env.SECURECLAW_VERIFY !== "false", + verbose: process.env.SECURECLAW_VERBOSE === "true", + tenantId: process.env.SECURECLAW_TENANT_ID, + userId: process.env.SECURECLAW_USER_ID, + }; +} + +export function mergeConfig( + base: SecureClawConfig, + overrides: Partial, +): SecureClawConfig { + return { + ...base, + ...Object.fromEntries(Object.entries(overrides).filter(([_, v]) => v !== undefined)), + } as SecureClawConfig; +} diff --git a/src/plugins/secureclaw/env.ts b/src/plugins/secureclaw/env.ts new file mode 100644 index 000000000000..97e8f69b9f76 --- /dev/null +++ b/src/plugins/secureclaw/env.ts @@ -0,0 +1,101 @@ +/** + * SecureClaw Environment Configuration + * + * All SecureClaw settings can be configured via environment variables. + * This file documents and validates all supported environment variables. + */ + +export interface SecureClawEnvConfig { + /** Agent principal identifier (default: "agent:secureclaw") */ + SECURECLAW_PRINCIPAL?: string; + + /** Path to YAML policy file (default: "./policies/default.yaml") */ + SECURECLAW_POLICY?: string; + + /** Predicate Authority sidecar URL (default: "http://127.0.0.1:9120") */ + PREDICATE_SIDECAR_URL?: string; + + /** Set to "true" to fail-open when sidecar is unavailable (default: false) */ + SECURECLAW_FAIL_OPEN?: string; + + /** Set to "false" to disable post-execution verification (default: true) */ + SECURECLAW_VERIFY?: string; + + /** Set to "true" for verbose logging (default: false) */ + SECURECLAW_VERBOSE?: string; + + /** Tenant ID for multi-tenant deployments */ + SECURECLAW_TENANT_ID?: string; + + /** User ID for audit attribution */ + SECURECLAW_USER_ID?: string; + + /** Set to "true" to completely disable SecureClaw */ + SECURECLAW_DISABLED?: string; +} + +/** + * Check if SecureClaw is enabled via environment. + */ +export function isSecureClawEnabled(): boolean { + return process.env.SECURECLAW_DISABLED !== "true"; +} + +/** + * Get all SecureClaw environment variables with their current values. + */ +export function getSecureClawEnv(): SecureClawEnvConfig { + return { + SECURECLAW_PRINCIPAL: process.env.SECURECLAW_PRINCIPAL, + SECURECLAW_POLICY: process.env.SECURECLAW_POLICY, + PREDICATE_SIDECAR_URL: process.env.PREDICATE_SIDECAR_URL, + SECURECLAW_FAIL_OPEN: process.env.SECURECLAW_FAIL_OPEN, + SECURECLAW_VERIFY: process.env.SECURECLAW_VERIFY, + SECURECLAW_VERBOSE: process.env.SECURECLAW_VERBOSE, + SECURECLAW_TENANT_ID: process.env.SECURECLAW_TENANT_ID, + SECURECLAW_USER_ID: process.env.SECURECLAW_USER_ID, + SECURECLAW_DISABLED: process.env.SECURECLAW_DISABLED, + }; +} + +/** + * Print SecureClaw configuration for debugging. + */ +export function printSecureClawConfig(): void { + const env = getSecureClawEnv(); + console.log("SecureClaw Configuration:"); + console.log(" SECURECLAW_PRINCIPAL:", env.SECURECLAW_PRINCIPAL ?? "(default: agent:secureclaw)"); + console.log( + " SECURECLAW_POLICY:", + env.SECURECLAW_POLICY ?? "(default: ./policies/default.yaml)", + ); + console.log( + " PREDICATE_SIDECAR_URL:", + env.PREDICATE_SIDECAR_URL ?? "(default: http://127.0.0.1:9120)", + ); + console.log(" SECURECLAW_FAIL_OPEN:", env.SECURECLAW_FAIL_OPEN ?? "(default: false)"); + console.log(" SECURECLAW_VERIFY:", env.SECURECLAW_VERIFY ?? "(default: true)"); + console.log(" SECURECLAW_VERBOSE:", env.SECURECLAW_VERBOSE ?? "(default: false)"); + console.log(" SECURECLAW_TENANT_ID:", env.SECURECLAW_TENANT_ID ?? "(not set)"); + console.log(" SECURECLAW_USER_ID:", env.SECURECLAW_USER_ID ?? "(not set)"); + console.log(" SECURECLAW_DISABLED:", env.SECURECLAW_DISABLED ?? "(default: false)"); +} + +/** + * Environment variable documentation for README. + */ +export const ENV_DOCS = ` +## SecureClaw Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| \`SECURECLAW_PRINCIPAL\` | \`agent:secureclaw\` | Agent identity for authorization requests | +| \`SECURECLAW_POLICY\` | \`./policies/default.yaml\` | Path to YAML policy file | +| \`PREDICATE_SIDECAR_URL\` | \`http://127.0.0.1:9120\` | Predicate Authority sidecar endpoint | +| \`SECURECLAW_FAIL_OPEN\` | \`false\` | Set to \`true\` to allow actions when sidecar is unavailable | +| \`SECURECLAW_VERIFY\` | \`true\` | Set to \`false\` to disable post-execution verification | +| \`SECURECLAW_VERBOSE\` | \`false\` | Set to \`true\` for detailed logging | +| \`SECURECLAW_TENANT_ID\` | *(none)* | Tenant ID for multi-tenant deployments | +| \`SECURECLAW_USER_ID\` | *(none)* | User ID for audit attribution | +| \`SECURECLAW_DISABLED\` | \`false\` | Set to \`true\` to completely disable SecureClaw | +`; diff --git a/src/plugins/secureclaw/index.ts b/src/plugins/secureclaw/index.ts new file mode 100644 index 000000000000..0025a6a8d7d2 --- /dev/null +++ b/src/plugins/secureclaw/index.ts @@ -0,0 +1,27 @@ +/** + * SecureClaw Plugin + * + * Zero-trust security middleware for OpenClaw. + * Intercepts all tool calls with pre-authorization and post-verification. + */ + +// Core plugin +export { createSecureClawPlugin, type SecureClawPluginOptions } from "./plugin.js"; + +// Resource extraction utilities +export { + extractResource, + extractAction, + redactResource, + isSensitiveResource, +} from "./resource-extractor.js"; + +// Configuration +export type { SecureClawConfig } from "./config.js"; +export { defaultConfig, loadConfigFromEnv, mergeConfig } from "./config.js"; + +// Environment variables +export { isSecureClawEnabled, getSecureClawEnv, printSecureClawConfig, ENV_DOCS } from "./env.js"; + +// Auto-registration +export { autoRegisterSecureClaw, getSecureClawPlugin } from "./auto-register.js"; diff --git a/src/plugins/secureclaw/integration.test.ts b/src/plugins/secureclaw/integration.test.ts new file mode 100644 index 000000000000..5a923c2ca8eb --- /dev/null +++ b/src/plugins/secureclaw/integration.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { createSecureClawPlugin } from "./plugin.js"; +import { extractAction, extractResource } from "./resource-extractor.js"; + +/** + * Integration tests for SecureClaw plugin. + * + * These tests verify the full authorization flow from tool call + * to predicate-claw SDK to decision enforcement. + * + * Note: These tests mock the predicate-claw SDK but test the full plugin integration. + * For live sidecar tests, see the e2e test suite. + */ + +// Use vi.hoisted to define mocks that will be available when vi.mock is hoisted +const { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError } = vi.hoisted(() => { + const mockGuardOrThrow = vi.fn(); + + class ActionDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionDeniedError"; + } + } + + class SidecarUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "SidecarUnavailableError"; + } + } + + return { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError }; +}); + +// Mock predicate-claw SDK +vi.mock("predicate-claw", () => { + class MockGuardedProvider { + guardOrThrow = mockGuardOrThrow; + } + + return { + GuardedProvider: MockGuardedProvider, + ActionDeniedError, + SidecarUnavailableError, + }; +}); + +describe("SecureClaw Integration", () => { + // Track all hook registrations + let mockLogger: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeAll(() => { + mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + describe("Full authorization flow", () => { + it("blocks sensitive file access with detailed reason", async () => { + const plugin = createSecureClawPlugin({ + principal: "agent:test", + sidecarUrl: "http://test-sidecar:8787", + failClosed: true, + verbose: true, + }); + + const hooks: Map = new Map(); + const mockApi = { + logger: mockLogger, + on: vi.fn((hookName: string, handler: Function) => { + hooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK to deny .ssh access + mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("sensitive_resource_blocked")); + + const beforeToolCall = hooks.get("before_tool_call")!; + + // Try to read SSH key + const result = await beforeToolCall( + { + toolName: "Read", + params: { file_path: "/home/user/.ssh/id_rsa" }, + }, + { toolName: "Read", agentId: "test-agent" }, + ); + + expect(result).toMatchObject({ + block: true, + blockReason: expect.stringContaining("sensitive_resource_blocked"), + }); + + // Verify SDK was called with correct action/resource + expect(mockGuardOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + action: "fs.read", + resource: "/home/user/.ssh/id_rsa", + }), + ); + }); + + it("allows safe operations and tracks metrics", async () => { + const plugin = createSecureClawPlugin({ + verbose: true, + }); + + const hooks: Map = new Map(); + const mockApi = { + logger: mockLogger, + on: vi.fn((hookName: string, handler: Function) => { + hooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Start session + const sessionStart = hooks.get("session_start")!; + sessionStart({ sessionId: "integration-test-123" }, { sessionId: "integration-test-123" }); + + // Mock SDK to allow with mandate ID + mockGuardOrThrow.mockResolvedValue("mandate-abc"); + + const beforeToolCall = hooks.get("before_tool_call")!; + + // Multiple tool calls + for (const file of ["index.ts", "utils.ts", "config.ts"]) { + const result = await beforeToolCall( + { + toolName: "Read", + params: { file_path: `/src/${file}` }, + }, + { toolName: "Read" }, + ); + expect(result).toBeUndefined(); // Allowed + } + + // End session - should log metrics + const sessionEnd = hooks.get("session_end")!; + sessionEnd( + { sessionId: "integration-test-123", messageCount: 5, durationMs: 1000 }, + { sessionId: "integration-test-123" }, + ); + + // Verify metrics logged + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Tool metrics")); + }); + + it("handles shell command authorization", async () => { + const plugin = createSecureClawPlugin({ verbose: false }); + + const hooks: Map = new Map(); + const mockApi = { + logger: mockLogger, + on: vi.fn((hookName: string, handler: Function) => { + hooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Test dangerous command - should be denied + mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("dangerous_shell_command")); + + const beforeToolCall = hooks.get("before_tool_call")!; + + const dangerousResult = await beforeToolCall( + { + toolName: "Bash", + params: { command: "rm -rf /" }, + }, + { toolName: "Bash" }, + ); + + expect(dangerousResult).toMatchObject({ + block: true, + }); + + // Test safe command - should be allowed + mockGuardOrThrow.mockResolvedValueOnce("safe-cmd"); + + const safeResult = await beforeToolCall( + { + toolName: "Bash", + params: { command: "npm test" }, + }, + { toolName: "Bash" }, + ); + + expect(safeResult).toBeUndefined(); + }); + }); + + describe("Action and resource extraction", () => { + it("correctly maps OpenClaw tools to Predicate actions", () => { + // File operations + expect(extractAction("Read")).toBe("fs.read"); + expect(extractAction("Write")).toBe("fs.write"); + expect(extractAction("Edit")).toBe("fs.write"); + expect(extractAction("Glob")).toBe("fs.list"); + + // Shell + expect(extractAction("Bash")).toBe("shell.exec"); + + // Network + expect(extractAction("WebFetch")).toBe("http.request"); + + // Browser + expect(extractAction("computer-use:navigate")).toBe("browser.navigate"); + expect(extractAction("computer-use:click")).toBe("browser.interact"); + + // Agent + expect(extractAction("Task")).toBe("agent.spawn"); + }); + + it("extracts resources from various param formats", () => { + // Standard file_path + expect(extractResource("Read", { file_path: "/app/src/main.ts" })).toBe("/app/src/main.ts"); + + // Alternative path key + expect(extractResource("Read", { path: "/app/config.json" })).toBe("/app/config.json"); + + // Bash command + expect(extractResource("Bash", { command: "npm install" })).toBe("npm install"); + + // URL + expect(extractResource("WebFetch", { url: "https://api.example.com" })).toBe( + "https://api.example.com", + ); + + // Browser navigation + expect(extractResource("computer-use:navigate", { url: "https://app.example.com" })).toBe( + "https://app.example.com", + ); + }); + }); + + describe("Error handling", () => { + it("handles sidecar timeout gracefully", async () => { + const plugin = createSecureClawPlugin({ + failClosed: true, + verbose: false, + }); + + const hooks: Map = new Map(); + const mockApi = { + logger: mockLogger, + on: vi.fn((hookName: string, handler: Function) => { + hooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock timeout via generic error (SDK converts to appropriate error) + mockGuardOrThrow.mockRejectedValueOnce(new Error("Timeout")); + + const beforeToolCall = hooks.get("before_tool_call")!; + + const result = await beforeToolCall( + { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + }, + { toolName: "Read" }, + ); + + // Should block in fail-closed mode + expect(result).toMatchObject({ + block: true, + blockReason: expect.stringContaining("unavailable"), + }); + }); + + it("handles SDK throwing ActionDeniedError correctly", async () => { + const plugin = createSecureClawPlugin({ + failClosed: true, + verbose: false, + }); + + const hooks: Map = new Map(); + const mockApi = { + logger: mockLogger, + on: vi.fn((hookName: string, handler: Function) => { + hooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK throwing ActionDeniedError (policy denied) + mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("no_matching_allow_rule")); + + const beforeToolCall = hooks.get("before_tool_call")!; + + const result = await beforeToolCall( + { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + }, + { toolName: "Read" }, + ); + + // Should block with policy reason + expect(result).toMatchObject({ + block: true, + blockReason: expect.stringContaining("no_matching_allow_rule"), + }); + }); + }); +}); diff --git a/src/plugins/secureclaw/plugin.test.ts b/src/plugins/secureclaw/plugin.test.ts new file mode 100644 index 000000000000..1de4abc272e5 --- /dev/null +++ b/src/plugins/secureclaw/plugin.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { + PluginHookBeforeToolCallEvent, + PluginHookAfterToolCallEvent, + PluginHookSessionStartEvent, + PluginHookSessionEndEvent, + PluginHookToolContext, + PluginHookSessionContext, +} from "../types.js"; +import { createSecureClawPlugin } from "./plugin.js"; + +// Use vi.hoisted to define mocks that will be available when vi.mock is hoisted +const { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError } = vi.hoisted(() => { + const mockGuardOrThrow = vi.fn(); + + class ActionDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionDeniedError"; + } + } + + class SidecarUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "SidecarUnavailableError"; + } + } + + return { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError }; +}); + +// Mock predicate-claw SDK +vi.mock("predicate-claw", () => { + class MockGuardedProvider { + guardOrThrow = mockGuardOrThrow; + } + + return { + GuardedProvider: MockGuardedProvider, + ActionDeniedError, + SidecarUnavailableError, + }; +}); + +describe("SecureClaw Plugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("createSecureClawPlugin", () => { + it("creates a plugin with correct metadata", () => { + const plugin = createSecureClawPlugin(); + + expect(plugin.id).toBe("secureclaw"); + expect(plugin.name).toBe("SecureClaw"); + expect(plugin.version).toBe("1.0.0"); + expect(plugin.description?.toLowerCase()).toContain("zero-trust"); + }); + + it("accepts custom options", () => { + const plugin = createSecureClawPlugin({ + principal: "agent:custom", + sidecarUrl: "http://localhost:9999", + failClosed: false, + verbose: true, + }); + + expect(plugin).toBeDefined(); + }); + }); + + describe("before_tool_call hook", () => { + it("blocks tool call when sidecar denies", async () => { + const plugin = createSecureClawPlugin({ verbose: false }); + + // Mock API to capture registered hooks + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + // Activate plugin + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK to throw ActionDeniedError + mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("policy_violation")); + + // Get the before_tool_call handler + const beforeToolCall = registeredHooks.get("before_tool_call"); + expect(beforeToolCall).toBeDefined(); + + // Call the handler + const event: PluginHookBeforeToolCallEvent = { + toolName: "Bash", + params: { command: "rm -rf /" }, + }; + const ctx: PluginHookToolContext = { + toolName: "Bash", + agentId: "test-agent", + sessionKey: "test-session", + }; + + const result = await beforeToolCall!(event, ctx); + + expect(result).toEqual({ + block: true, + blockReason: expect.stringContaining("policy_violation"), + }); + }); + + it("allows tool call when sidecar approves", async () => { + const plugin = createSecureClawPlugin({ verbose: false }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK to return mandate ID (allowed) + mockGuardOrThrow.mockResolvedValueOnce("mandate-123"); + + const beforeToolCall = registeredHooks.get("before_tool_call"); + const event: PluginHookBeforeToolCallEvent = { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + }; + const ctx: PluginHookToolContext = { + toolName: "Read", + }; + + const result = await beforeToolCall!(event, ctx); + + // Should return undefined (allow) + expect(result).toBeUndefined(); + }); + + it("blocks in fail-closed mode when sidecar unavailable", async () => { + const plugin = createSecureClawPlugin({ + failClosed: true, + verbose: false, + }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK to throw SidecarUnavailableError + mockGuardOrThrow.mockRejectedValueOnce(new SidecarUnavailableError("Connection refused")); + + const beforeToolCall = registeredHooks.get("before_tool_call"); + const event: PluginHookBeforeToolCallEvent = { + toolName: "Bash", + params: { command: "echo hello" }, + }; + const ctx: PluginHookToolContext = { + toolName: "Bash", + }; + + const result = await beforeToolCall!(event, ctx); + + expect(result).toEqual({ + block: true, + blockReason: expect.stringContaining("unavailable"), + }); + }); + + it("allows in fail-open mode when sidecar unavailable", async () => { + const plugin = createSecureClawPlugin({ + failClosed: false, + verbose: false, + }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Mock SDK to return null (fail-open behavior from guardOrThrow) + mockGuardOrThrow.mockResolvedValueOnce(null); + + const beforeToolCall = registeredHooks.get("before_tool_call"); + const event: PluginHookBeforeToolCallEvent = { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + }; + const ctx: PluginHookToolContext = { + toolName: "Read", + }; + + const result = await beforeToolCall!(event, ctx); + + // Should return undefined (allow in fail-open) + expect(result).toBeUndefined(); + }); + }); + + describe("session hooks", () => { + it("tracks session start and end", async () => { + const plugin = createSecureClawPlugin({ verbose: true }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + // Session start + const sessionStart = registeredHooks.get("session_start"); + expect(sessionStart).toBeDefined(); + + const startEvent: PluginHookSessionStartEvent = { + sessionId: "test-session-123", + }; + const startCtx: PluginHookSessionContext = { + sessionId: "test-session-123", + }; + + sessionStart!(startEvent, startCtx); + + expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Session started")); + + // Session end + const sessionEnd = registeredHooks.get("session_end"); + expect(sessionEnd).toBeDefined(); + + const endEvent: PluginHookSessionEndEvent = { + sessionId: "test-session-123", + messageCount: 10, + durationMs: 5000, + }; + + sessionEnd!(endEvent, startCtx); + + expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Session ended")); + }); + }); + + describe("after_tool_call hook", () => { + it("logs tool execution for verification", async () => { + const plugin = createSecureClawPlugin({ + enablePostVerification: true, + verbose: true, + }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + const afterToolCall = registeredHooks.get("after_tool_call"); + expect(afterToolCall).toBeDefined(); + + const event: PluginHookAfterToolCallEvent = { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + result: "file contents...", + durationMs: 50, + }; + const ctx: PluginHookToolContext = { + toolName: "Read", + }; + + await afterToolCall!(event, ctx); + + expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Post-verify")); + }); + + it("skips verification when disabled", async () => { + const plugin = createSecureClawPlugin({ + enablePostVerification: false, + verbose: true, + }); + + const registeredHooks: Map = new Map(); + const mockApi = { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + on: vi.fn((hookName: string, handler: Function) => { + registeredHooks.set(hookName, handler); + }), + }; + + await plugin.activate?.( + mockApi as unknown as Parameters>[0], + ); + + const afterToolCall = registeredHooks.get("after_tool_call"); + + const event: PluginHookAfterToolCallEvent = { + toolName: "Read", + params: { file_path: "/src/index.ts" }, + result: "file contents...", + durationMs: 50, + }; + const ctx: PluginHookToolContext = { + toolName: "Read", + }; + + await afterToolCall!(event, ctx); + + // Should not log post-verify when disabled + expect(mockApi.logger.info).not.toHaveBeenCalledWith(expect.stringContaining("Post-verify")); + }); + }); +}); diff --git a/src/plugins/secureclaw/plugin.ts b/src/plugins/secureclaw/plugin.ts new file mode 100644 index 000000000000..f509579ca30c --- /dev/null +++ b/src/plugins/secureclaw/plugin.ts @@ -0,0 +1,331 @@ +/** + * SecureClaw Plugin Implementation + * + * Integrates Predicate Authority for pre-execution authorization + * and post-execution verification into OpenClaw's hook system. + * + * Uses predicate-claw (openclaw-predicate-provider) for authorization + * via the GuardedProvider class, which communicates with the + * rust-predicate-authorityd sidecar. + */ + +import { + GuardedProvider, + ActionDeniedError, + SidecarUnavailableError, + type GuardRequest, + type GuardTelemetry, + type DecisionTelemetryEvent, + type DecisionAuditExporter, +} from "predicate-claw"; +import type { + OpenClawPluginDefinition, + OpenClawPluginApi, + PluginHookBeforeToolCallEvent, + PluginHookBeforeToolCallResult, + PluginHookAfterToolCallEvent, + PluginHookSessionStartEvent, + PluginHookSessionEndEvent, + PluginHookToolContext, + PluginHookSessionContext, +} from "../types.js"; +import { type SecureClawConfig, defaultConfig, loadConfigFromEnv, mergeConfig } from "./config.js"; +import { extractAction, extractResource, redactResource } from "./resource-extractor.js"; + +export interface SecureClawPluginOptions extends Partial {} + +/** + * Create the SecureClaw plugin instance. + */ +export function createSecureClawPlugin( + options: SecureClawPluginOptions = {}, +): OpenClawPluginDefinition { + // Merge config: defaults -> env -> explicit options + const envConfig = loadConfigFromEnv(); + const config = mergeConfig(mergeConfig(defaultConfig, envConfig), options); + + // Session tracking for audit trail + let currentSessionId: string | undefined; + let sessionStartTime: number | undefined; + const toolCallMetrics: Map = new Map(); + + return { + id: "secureclaw", + name: "SecureClaw", + description: "Zero-trust security middleware with pre-authorization and post-verification", + version: "1.0.0", + + async activate(api: OpenClawPluginApi) { + const log = api.logger; + + // Create telemetry handler for logging decisions + const telemetry: GuardTelemetry = { + onDecision(event: DecisionTelemetryEvent) { + if (config.verbose) { + const status = + event.outcome === "allow" + ? "ALLOWED" + : event.outcome === "deny" + ? "BLOCKED" + : "ERROR"; + log.info( + `[SecureClaw] ${status}: ${event.action} on ${event.resource} (${event.reason ?? "no reason"})`, + ); + } + }, + }; + + // Create audit exporter if needed + const auditExporter: DecisionAuditExporter = { + async exportDecision(_event: DecisionTelemetryEvent) { + // TODO: Send to centralized audit log (e.g., via OTLP) + // For now, this is a no-op placeholder + // In production: + // 1. Send to centralized audit log + // 2. Include correlation IDs for tracing + // 3. Ensure tamper-proof storage + }, + }; + + // Create GuardedProvider instance from predicate-claw SDK + const guardedProvider = new GuardedProvider({ + principal: config.principal, + config: { + baseUrl: config.sidecarUrl, + failClosed: config.failClosed, + timeoutMs: 5000, // 5 second timeout for tool calls + maxRetries: 0, + backoffInitialMs: 100, + }, + telemetry, + auditExporter, + }); + + if (config.verbose) { + log.info(`[SecureClaw] Activating with principal: ${config.principal}`); + log.info(`[SecureClaw] Sidecar URL: ${config.sidecarUrl}`); + log.info(`[SecureClaw] Fail closed: ${config.failClosed}`); + log.info(`[SecureClaw] Post-verification: ${config.enablePostVerification}`); + } + + // ======================================================================= + // Hook: session_start - Initialize audit trail + // ======================================================================= + api.on( + "session_start", + (event: PluginHookSessionStartEvent, _ctx: PluginHookSessionContext) => { + currentSessionId = event.sessionId; + sessionStartTime = Date.now(); + toolCallMetrics.clear(); + + if (config.verbose) { + log.info(`[SecureClaw] Session started: ${event.sessionId}`); + } + }, + { priority: 100 }, // High priority to run early + ); + + // ======================================================================= + // Hook: session_end - Finalize audit trail + // ======================================================================= + api.on( + "session_end", + (event: PluginHookSessionEndEvent, _ctx: PluginHookSessionContext) => { + const duration = sessionStartTime ? Date.now() - sessionStartTime : 0; + + if (config.verbose) { + log.info(`[SecureClaw] Session ended: ${event.sessionId}`); + log.info(`[SecureClaw] Duration: ${duration}ms`); + log.info(`[SecureClaw] Tool metrics:`); + for (const [tool, metrics] of toolCallMetrics) { + log.info(` ${tool}: ${metrics.count} calls, ${metrics.blocked} blocked`); + } + } + + // Reset state + currentSessionId = undefined; + sessionStartTime = undefined; + toolCallMetrics.clear(); + }, + { priority: 100 }, + ); + + // ======================================================================= + // Hook: before_tool_call - Pre-execution authorization gate + // ======================================================================= + api.on( + "before_tool_call", + async ( + event: PluginHookBeforeToolCallEvent, + ctx: PluginHookToolContext, + ): Promise => { + const { toolName, params } = event; + const action = extractAction(toolName); + const resource = extractResource(toolName, params); + + // Track metrics + const metrics = toolCallMetrics.get(toolName) ?? { count: 0, blocked: 0 }; + metrics.count++; + toolCallMetrics.set(toolName, metrics); + + if (config.verbose) { + log.info(`[SecureClaw] Pre-auth: ${action} on ${redactResource(resource)}`); + } + + try { + // Build guard request for predicate-claw SDK + const guardRequest: GuardRequest = { + action, + resource, + args: params, + context: { + session_id: currentSessionId ?? ctx.sessionKey, + tenant_id: config.tenantId, + user_id: config.userId, + agent_id: ctx.agentId, + source: "secureclaw", + }, + }; + + // Use guardOrThrow which handles fail-open/fail-closed internally + await guardedProvider.guardOrThrow(guardRequest); + + // If we get here, the action was allowed + return undefined; + } catch (error) { + // Handle ActionDeniedError - action was explicitly denied by policy + if (error instanceof ActionDeniedError) { + metrics.blocked++; + toolCallMetrics.set(toolName, metrics); + + const reason = error.message ?? "denied_by_policy"; + if (config.verbose) { + log.warn(`[SecureClaw] BLOCKED: ${action} - ${reason}`); + } + + return { + block: true, + blockReason: `[SecureClaw] Action blocked: ${reason}`, + }; + } + + // Handle SidecarUnavailableError - sidecar is down + if (error instanceof SidecarUnavailableError) { + // In fail-closed mode (handled by guardOrThrow), this error is thrown + // In fail-open mode, guardOrThrow returns null instead of throwing + metrics.blocked++; + toolCallMetrics.set(toolName, metrics); + + log.error(`[SecureClaw] Sidecar error (fail-closed): ${error.message}`); + return { + block: true, + blockReason: `[SecureClaw] Authorization service unavailable (fail-closed mode)`, + }; + } + + // Unknown error - treat as sidecar unavailable + const errorMessage = error instanceof Error ? error.message : String(error); + if (config.failClosed) { + metrics.blocked++; + toolCallMetrics.set(toolName, metrics); + + log.error(`[SecureClaw] Unknown error (fail-closed): ${errorMessage}`); + return { + block: true, + blockReason: `[SecureClaw] Authorization service unavailable (fail-closed mode)`, + }; + } + + log.warn(`[SecureClaw] Unknown error (fail-open): ${errorMessage}`); + return undefined; // Allow in fail-open mode + } + }, + { priority: 1000 }, // Very high priority - security checks first + ); + + // ======================================================================= + // Hook: after_tool_call - Post-execution verification + // ======================================================================= + api.on( + "after_tool_call", + async (event: PluginHookAfterToolCallEvent, _ctx: PluginHookToolContext): Promise => { + if (!config.enablePostVerification) { + return; + } + + const { toolName, params, result, error, durationMs } = event; + const action = extractAction(toolName); + const resource = extractResource(toolName, params); + + if (config.verbose) { + log.info( + `[SecureClaw] Post-verify: ${action} on ${redactResource(resource)} ` + + `(${durationMs ?? 0}ms, error: ${error ? "yes" : "no"})`, + ); + } + + // For browser operations, verify DOM state + if (action.startsWith("browser.")) { + await verifyBrowserState(toolName, params, result, log, config.verbose); + } + + // For file operations, verify write success + if (action === "fs.write" && !error) { + await verifyFileWrite(toolName, params, result, log, config.verbose); + } + }, + { priority: 100 }, + ); + + log.info("[SecureClaw] Plugin activated - all tool calls will be authorized"); + }, + }; +} + +/** + * Verify browser state after browser operations (placeholder for Snapshot Engine). + */ +async function verifyBrowserState( + toolName: string, + params: Record, + result: unknown, + log: { info: (msg: string) => void; warn: (msg: string) => void }, + verbose: boolean, +): Promise { + // TODO: Integrate with Snapshot Engine for DOM diffing + // This is a placeholder for post-execution verification + + if (verbose) { + log.info(`[SecureClaw] Browser verification: ${toolName} (placeholder)`); + } + + // In full implementation: + // 1. Capture DOM snapshot after operation + // 2. Compare against expected state from pre-operation snapshot + // 3. Verify only authorized changes occurred + // 4. Flag any unexpected DOM mutations +} + +/** + * Verify file write operations completed as expected. + */ +async function verifyFileWrite( + toolName: string, + params: Record, + result: unknown, + log: { info: (msg: string) => void; warn: (msg: string) => void }, + verbose: boolean, +): Promise { + // TODO: Implement file verification + // This is a placeholder for post-execution verification + + if (verbose) { + log.info(`[SecureClaw] File write verification: ${toolName} (placeholder)`); + } + + // In full implementation: + // 1. Read file after write + // 2. Compute hash of written content + // 3. Compare against intent_hash from authorization + // 4. Flag any discrepancies +} diff --git a/src/plugins/secureclaw/predicate-claw.d.ts b/src/plugins/secureclaw/predicate-claw.d.ts new file mode 100644 index 000000000000..2a3bbe2e6b4c --- /dev/null +++ b/src/plugins/secureclaw/predicate-claw.d.ts @@ -0,0 +1,128 @@ +/** + * Type declarations for predicate-claw SDK + * + * predicate-claw is the TypeScript SDK for integrating with + * Predicate Authority's rust-predicate-authorityd sidecar. + */ + +declare module "predicate-claw" { + /** + * Guard request sent to the sidecar for authorization. + */ + export interface GuardRequest { + /** The action being performed (e.g., "fs.read", "shell.exec") */ + action: string; + /** The resource being accessed (e.g., file path, command) */ + resource: string; + /** Optional arguments/parameters for the action */ + args?: Record; + /** Additional context for the authorization decision */ + context?: { + session_id?: string; + tenant_id?: string; + user_id?: string; + agent_id?: string; + source?: string; + [key: string]: unknown; + }; + } + + /** + * Telemetry event emitted after each authorization decision. + */ + export interface DecisionTelemetryEvent { + /** The action that was evaluated */ + action: string; + /** The resource that was accessed */ + resource: string; + /** The outcome of the authorization decision */ + outcome: "allow" | "deny" | "error"; + /** Human-readable reason for the decision */ + reason?: string; + /** Timestamp of the decision */ + timestamp?: number; + /** Duration of the decision in milliseconds */ + durationMs?: number; + /** Mandate ID if the action was allowed */ + mandateId?: string; + } + + /** + * Telemetry handler for logging/monitoring authorization decisions. + */ + export interface GuardTelemetry { + /** Called after each authorization decision */ + onDecision: (event: DecisionTelemetryEvent) => void; + } + + /** + * Audit exporter for persisting authorization decisions. + */ + export interface DecisionAuditExporter { + /** Export a decision for audit logging */ + exportDecision: (event: DecisionTelemetryEvent) => Promise; + } + + /** + * Configuration for the GuardedProvider. + */ + export interface GuardedProviderConfig { + /** Base URL of the predicate-authorityd sidecar */ + baseUrl: string; + /** Whether to fail closed (block) when sidecar is unavailable */ + failClosed: boolean; + /** Request timeout in milliseconds */ + timeoutMs?: number; + /** Maximum number of retries */ + maxRetries?: number; + /** Initial backoff delay in milliseconds */ + backoffInitialMs?: number; + } + + /** + * Options for creating a GuardedProvider. + */ + export interface GuardedProviderOptions { + /** The principal (agent identity) making requests */ + principal: string; + /** Configuration for the sidecar connection */ + config: GuardedProviderConfig; + /** Optional telemetry handler */ + telemetry?: GuardTelemetry; + /** Optional audit exporter */ + auditExporter?: DecisionAuditExporter; + } + + /** + * Error thrown when an action is denied by policy. + */ + export class ActionDeniedError extends Error { + constructor(message: string); + name: "ActionDeniedError"; + } + + /** + * Error thrown when the sidecar is unavailable. + */ + export class SidecarUnavailableError extends Error { + constructor(message: string); + name: "SidecarUnavailableError"; + } + + /** + * The main class for making guarded requests to the sidecar. + */ + export class GuardedProvider { + constructor(options: GuardedProviderOptions); + + /** + * Guard a request and throw if denied. + * + * @param request The guard request + * @returns The mandate ID if allowed, null if fail-open allowed + * @throws ActionDeniedError if the action is denied by policy + * @throws SidecarUnavailableError if the sidecar is unavailable (fail-closed mode) + */ + guardOrThrow(request: GuardRequest): Promise; + } +} diff --git a/src/plugins/secureclaw/resource-extractor.test.ts b/src/plugins/secureclaw/resource-extractor.test.ts new file mode 100644 index 000000000000..20f934a7beac --- /dev/null +++ b/src/plugins/secureclaw/resource-extractor.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import { + extractAction, + extractResource, + isSensitiveResource, + redactResource, +} from "./resource-extractor.js"; + +describe("extractAction", () => { + it("maps file read tools to fs.read", () => { + expect(extractAction("Read")).toBe("fs.read"); + }); + + it("maps file write tools to fs.write", () => { + expect(extractAction("Write")).toBe("fs.write"); + expect(extractAction("Edit")).toBe("fs.write"); + expect(extractAction("MultiEdit")).toBe("fs.write"); + }); + + it("maps Glob to fs.list", () => { + expect(extractAction("Glob")).toBe("fs.list"); + }); + + it("maps Bash to shell.exec", () => { + expect(extractAction("Bash")).toBe("shell.exec"); + }); + + it("maps Task to agent.spawn", () => { + expect(extractAction("Task")).toBe("agent.spawn"); + }); + + it("maps web tools to http.request", () => { + expect(extractAction("WebFetch")).toBe("http.request"); + expect(extractAction("WebSearch")).toBe("http.request"); + }); + + it("maps browser tools correctly", () => { + expect(extractAction("computer-use:screenshot")).toBe("browser.screenshot"); + expect(extractAction("computer-use:click")).toBe("browser.interact"); + expect(extractAction("computer-use:type")).toBe("browser.interact"); + expect(extractAction("computer-use:navigate")).toBe("browser.navigate"); + }); + + it("returns generic action for unknown tools", () => { + expect(extractAction("CustomTool")).toBe("tool.customtool"); + expect(extractAction("MyPlugin")).toBe("tool.myplugin"); + }); +}); + +describe("extractResource", () => { + describe("file operations", () => { + it("extracts file_path from Read params", () => { + expect(extractResource("Read", { file_path: "/src/index.ts" })).toBe("/src/index.ts"); + }); + + it("extracts file_path from Write params", () => { + expect(extractResource("Write", { file_path: "/src/new.ts", content: "..." })).toBe( + "/src/new.ts", + ); + }); + + it("extracts file_path from Edit params", () => { + expect( + extractResource("Edit", { file_path: "/src/edit.ts", old_string: "a", new_string: "b" }), + ).toBe("/src/edit.ts"); + }); + + it("handles missing file path", () => { + expect(extractResource("Read", {})).toBe("file:unknown"); + }); + }); + + describe("Glob operations", () => { + it("extracts pattern from Glob params", () => { + expect(extractResource("Glob", { pattern: "**/*.ts" })).toBe("**/*.ts"); + }); + + it("handles missing pattern", () => { + expect(extractResource("Glob", {})).toBe("*"); + }); + }); + + describe("Bash operations", () => { + it("extracts command from Bash params", () => { + expect(extractResource("Bash", { command: "npm test" })).toBe("npm test"); + }); + + it("truncates long commands", () => { + const longCommand = + "npm run build && npm test && npm run lint && npm run format && echo done"; + const result = extractResource("Bash", { command: longCommand }); + expect(result.length).toBeLessThanOrEqual(100); + expect(result).toContain("npm"); + }); + + it("handles missing command", () => { + expect(extractResource("Bash", {})).toBe("bash:unknown"); + }); + }); + + describe("network operations", () => { + it("extracts URL from WebFetch params", () => { + expect(extractResource("WebFetch", { url: "https://example.com/api" })).toBe( + "https://example.com/api", + ); + }); + + it("extracts query from WebSearch params", () => { + expect(extractResource("WebSearch", { query: "typescript tutorial" })).toBe( + "search:typescript tutorial", + ); + }); + }); + + describe("browser operations", () => { + it("extracts URL from navigate params", () => { + expect(extractResource("computer-use:navigate", { url: "https://example.com" })).toBe( + "https://example.com", + ); + }); + + it("returns browser:current for other browser operations", () => { + expect(extractResource("computer-use:screenshot", {})).toBe("browser:current"); + expect(extractResource("computer-use:click", { x: 100, y: 200 })).toBe("browser:current"); + }); + }); + + describe("Task operations", () => { + it("extracts prompt prefix from Task params", () => { + const result = extractResource("Task", { prompt: "Search for files containing the error" }); + expect(result).toContain("task:"); + expect(result.length).toBeLessThanOrEqual(60); + }); + }); +}); + +describe("isSensitiveResource", () => { + it("detects SSH paths", () => { + expect(isSensitiveResource("/home/user/.ssh/id_rsa")).toBe(true); + expect(isSensitiveResource("~/.ssh/config")).toBe(true); + }); + + it("detects AWS credentials", () => { + expect(isSensitiveResource("/home/user/.aws/credentials")).toBe(true); + expect(isSensitiveResource("aws_secret_key")).toBe(true); + }); + + it("detects environment files", () => { + expect(isSensitiveResource(".env")).toBe(true); + expect(isSensitiveResource(".env.local")).toBe(true); + expect(isSensitiveResource("/app/.env.production")).toBe(true); + }); + + it("detects key files", () => { + expect(isSensitiveResource("server.pem")).toBe(true); + expect(isSensitiveResource("private.key")).toBe(true); + expect(isSensitiveResource("id_ed25519")).toBe(true); + }); + + it("detects credential files", () => { + expect(isSensitiveResource("credentials.json")).toBe(true); + expect(isSensitiveResource("secrets.yaml")).toBe(true); + expect(isSensitiveResource("api_key.txt")).toBe(true); + }); + + it("allows safe paths", () => { + expect(isSensitiveResource("/src/index.ts")).toBe(false); + expect(isSensitiveResource("README.md")).toBe(false); + expect(isSensitiveResource("package.json")).toBe(false); + expect(isSensitiveResource("/app/dist/bundle.js")).toBe(false); + }); +}); + +describe("redactResource", () => { + it("redacts sensitive resources", () => { + expect(redactResource("/home/user/.ssh/id_rsa")).toBe("[REDACTED]"); + expect(redactResource(".env.local")).toBe("[REDACTED]"); + expect(redactResource("credentials.json")).toBe("[REDACTED]"); + }); + + it("passes through safe resources", () => { + expect(redactResource("/src/index.ts")).toBe("/src/index.ts"); + expect(redactResource("package.json")).toBe("package.json"); + }); +}); diff --git a/src/plugins/secureclaw/resource-extractor.ts b/src/plugins/secureclaw/resource-extractor.ts new file mode 100644 index 000000000000..e3dc8539dd14 --- /dev/null +++ b/src/plugins/secureclaw/resource-extractor.ts @@ -0,0 +1,202 @@ +/** + * Resource Extractor + * + * Maps OpenClaw tool calls to Predicate Authority action/resource pairs. + */ + +export type ActionResource = { + action: string; + resource: string; +}; + +/** + * Extract the action type from a tool name. + */ +export function extractAction(toolName: string): string { + // Map OpenClaw tool names to Predicate action categories + const actionMap: Record = { + // File system operations + Read: "fs.read", + Write: "fs.write", + Edit: "fs.write", + Glob: "fs.list", + MultiEdit: "fs.write", + + // Shell/process operations + Bash: "shell.exec", + Task: "agent.spawn", + + // Network operations + WebFetch: "http.request", + WebSearch: "http.request", + + // Browser automation + "computer-use:screenshot": "browser.screenshot", + "computer-use:click": "browser.interact", + "computer-use:type": "browser.interact", + "computer-use:scroll": "browser.interact", + "computer-use:navigate": "browser.navigate", + + // Notebook operations + NotebookRead: "notebook.read", + NotebookEdit: "notebook.write", + + // MCP tool calls + mcp_tool: "mcp.call", + }; + + return actionMap[toolName] ?? `tool.${toolName.toLowerCase()}`; +} + +/** + * Extract the resource identifier from tool parameters. + */ +export function extractResource(toolName: string, params: Record): string { + switch (toolName) { + // File operations - extract path + case "Read": + case "Write": + case "Edit": + case "MultiEdit": + return extractFilePath(params); + + // Glob - extract pattern as resource + case "Glob": + return typeof params.pattern === "string" ? params.pattern : "*"; + + // Bash - extract command (first 100 chars for safety) + case "Bash": + return extractBashCommand(params); + + // Network operations - extract URL + case "WebFetch": + case "WebSearch": + return typeof params.url === "string" + ? params.url + : typeof params.query === "string" + ? `search:${params.query}` + : "unknown"; + + // Browser operations - extract URL or target + case "computer-use:navigate": + return typeof params.url === "string" ? params.url : "browser:current"; + + case "computer-use:screenshot": + case "computer-use:click": + case "computer-use:type": + case "computer-use:scroll": + return "browser:current"; + + // Task/Agent spawning + case "Task": + return typeof params.prompt === "string" + ? `task:${params.prompt.slice(0, 50)}` + : "task:unknown"; + + // Notebook operations + case "NotebookRead": + case "NotebookEdit": + return typeof params.notebook_path === "string" ? params.notebook_path : "notebook:unknown"; + + // MCP tools - extract tool name and server + case "mcp_tool": + return extractMcpResource(params); + + default: + // For unknown tools, try common parameter names + return ( + extractFilePath(params) || + (typeof params.path === "string" ? params.path : null) || + (typeof params.target === "string" ? params.target : null) || + `${toolName}:params` + ); + } +} + +function extractFilePath(params: Record): string { + // Try common file path parameter names + const pathKeys = ["file_path", "filePath", "path", "file", "filename"]; + for (const key of pathKeys) { + if (typeof params[key] === "string") { + return params[key]; + } + } + return "file:unknown"; +} + +function extractBashCommand(params: Record): string { + const command = params.command; + if (typeof command !== "string") { + return "bash:unknown"; + } + + // Truncate long commands but preserve the essential part + const maxLen = 100; + if (command.length <= maxLen) { + return command; + } + + // Try to preserve the command name and first argument + const parts = command.split(/\s+/); + const cmdName = parts[0] ?? "cmd"; + const firstArg = parts[1] ?? ""; + + return `${cmdName} ${firstArg}...`.slice(0, maxLen); +} + +function extractMcpResource(params: Record): string { + const server = + typeof params.server === "string" + ? params.server + : typeof params.mcp_server === "string" + ? params.mcp_server + : "unknown"; + const tool = + typeof params.tool === "string" + ? params.tool + : typeof params.tool_name === "string" + ? params.tool_name + : "unknown"; + return `mcp:${server}/${tool}`; +} + +/** + * Check if a resource path should be considered sensitive. + * Used for redaction in audit logs. + */ +export function isSensitiveResource(resource: string): boolean { + const lowered = resource.toLowerCase(); + const sensitivePatterns = [ + "/.ssh/", + "/etc/passwd", + "/etc/shadow", + "id_rsa", + "id_ed25519", + "credentials", + ".env", + "secret", + "token", + "password", + "api_key", + "apikey", + "private_key", + "privatekey", + ".pem", + ".key", + "aws_", + "gcp_", + "azure_", + ]; + + return sensitivePatterns.some((pattern) => lowered.includes(pattern)); +} + +/** + * Redact sensitive resources for safe logging. + */ +export function redactResource(resource: string): string { + if (isSensitiveResource(resource)) { + return "[REDACTED]"; + } + return resource; +} From 4ad6a3078e6936fcc663b4a5c89d945ede4c5003 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 19:31:30 -0800 Subject: [PATCH 4/6] fix tests --- packages/clawdbot/package.json | 2 +- packages/moltbot/package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/clawdbot/package.json b/packages/clawdbot/package.json index f6332623f91a..2e028e5389c9 100644 --- a/packages/clawdbot/package.json +++ b/packages/clawdbot/package.json @@ -11,6 +11,6 @@ "./cli-entry": "./bin/clawdbot.js" }, "dependencies": { - "openclaw": "workspace:*" + "@predicatesystems/secureclaw": "workspace:*" } } diff --git a/packages/moltbot/package.json b/packages/moltbot/package.json index c9ada059dbda..ea3991ee6965 100644 --- a/packages/moltbot/package.json +++ b/packages/moltbot/package.json @@ -11,6 +11,6 @@ "./cli-entry": "./bin/moltbot.js" }, "dependencies": { - "openclaw": "workspace:*" + "@predicatesystems/secureclaw": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3e1adaa2492..512798296fde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: playwright-core: specifier: 1.58.2 version: 1.58.2 + predicate-claw: + specifier: ^0.1.0 + version: 0.1.0 qrcode-terminal: specifier: ^0.12.0 version: 0.12.0 @@ -465,13 +468,13 @@ importers: packages/clawdbot: dependencies: - openclaw: + '@predicatesystems/secureclaw': specifier: workspace:* version: link:../.. packages/moltbot: dependencies: - openclaw: + '@predicatesystems/secureclaw': specifier: workspace:* version: link:../.. @@ -2160,6 +2163,10 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@predicatesystems/authority@0.3.3': + resolution: {integrity: sha512-AGGfrzgnox7IG/9o3tAVLDd4eRkxvz+JTkNoQ+ypiQwxqRMchOX3gXyBP78pqg0TtkkBsCwtGMN8ml7XdE0otw==} + engines: {node: '>=20.0.0'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -4900,6 +4907,9 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} + predicate-claw@0.1.0: + resolution: {integrity: sha512-yV3cnnWJ9Ydjd6O6zJCF/z2ChO06kqMlMTBo7eJepw8Ya3zlZbum8fkH1iN8PNyI0KZjMZU6RDE9FFqDVBGM7Q==} + pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} @@ -7732,6 +7742,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@predicatesystems/authority@0.3.3': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -10836,6 +10848,10 @@ snapshots: postgres@3.4.8: {} + predicate-claw@0.1.0: + dependencies: + '@predicatesystems/authority': 0.3.3 + pretty-bytes@6.1.1: {} pretty-ms@8.0.0: From 893b4a15ec8631f5aaada3ef872115c72693e1e9 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 19:41:18 -0800 Subject: [PATCH 5/6] fix tests --- src/agents/pi-embedded-runner-extraparams.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 332cb430eec2..5207ee772de5 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -872,7 +872,7 @@ describe("applyExtraParamsToAgent", () => { id: "gpt-4o", baseUrl: "https://example.openai.azure.com/openai/v1", compat: { supportsStore: false }, - } as Model<"openai-responses">, + } as unknown as Model<"openai-responses">, }); expect(payload.store).toBe(false); }); From 264b229bc3315fc471080ae47b765eec7cd54c42 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Fri, 27 Feb 2026 19:57:38 -0800 Subject: [PATCH 6/6] restore ci --- .github/workflows/ci.yml | 1 + .github/workflows/secureclaw-ci.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7bef285a7ae..2d5c1fa7b355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/secureclaw-ci.yml b/.github/workflows/secureclaw-ci.yml index 5c915cbc0484..272e5df00bc1 100644 --- a/.github/workflows/secureclaw-ci.yml +++ b/.github/workflows/secureclaw-ci.yml @@ -8,9 +8,9 @@ name: SecureClaw CI on: push: - branches: [secureclaw, phase2] + branches: [secureclaw, phase2, rebrand] pull_request: - branches: [secureclaw, phase2] + branches: [secureclaw, phase2, rebrand] concurrency: group: secureclaw-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}