diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7bef285a7ae..7c336288795e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] # Skip heavy CI on secureclaw branch PRs (use secureclaw-ci.yml instead) concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 03e87db82b9e..1b04fbfdc39c 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] # Skip on secureclaw branch PRs workflow_dispatch: concurrency: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 9ac44dfa6b67..d657e0ebf3d7 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,8 +1,10 @@ name: Labeler +# Skip on SecureClaw fork - only runs on upstream openclaw/openclaw on: pull_request_target: types: [opened, synchronize, reopened] + branches: [main] issues: types: [opened] workflow_dispatch: diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 000000000000..2bce0d5b2bd0 --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,76 @@ +# Publish SecureClaw to NPM on version tags +# +# Workflow: +# 1. Bump version in package.json (line 3) +# 2. Commit and push: git add package.json && git commit -m "release: v1.0.1" +# 3. Tag and push: git tag v1.0.1 && git push origin secureclaw --tags +# +# Required secrets: +# NPM_TOKEN - NPM access token with publish permissions +# Create at: https://www.npmjs.com/settings//tokens + +name: NPM Release + +on: + push: + tags: + - "v*" + +concurrency: + group: npm-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Verify version matches tag + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION=$(node -p "require('./package.json').version") + if [[ "$TAG_VERSION" != "$PKG_VERSION" ]]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 + fi + echo "Version verified: $PKG_VERSION" + + - name: Publish to NPM + run: pnpm publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create release summary + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "## NPM Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Published **secureclaw@${VERSION}** to NPM" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Install with:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "npm install secureclaw@${VERSION}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/secureclaw-ci.yml b/.github/workflows/secureclaw-ci.yml new file mode 100644 index 000000000000..5c915cbc0484 --- /dev/null +++ b/.github/workflows/secureclaw-ci.yml @@ -0,0 +1,98 @@ +# Lightweight CI for SecureClaw branch +# Runs faster checks, skips Windows/macOS/Android native builds +# +# The full upstream CI runs on main branch syncs. +# This workflow runs only essential checks for SecureClaw PRs. + +name: SecureClaw CI + +on: + push: + branches: [secureclaw, phase2] + pull_request: + branches: [secureclaw, phase2] + +concurrency: + group: secureclaw-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + name: "lint & typecheck" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck and lint + run: pnpm check + + test: + name: "secureclaw tests" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Run SecureClaw tests + run: pnpm exec vitest run --config vitest.unit.config.ts src/plugins/secureclaw/ + + build: + name: "build" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Verify dist + run: | + test -s dist/index.js + test -s dist/plugin-sdk/index.js diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 8fd0ccdd6d73..38b2e0eab04d 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -6,7 +6,7 @@ name: Sync Upstream on: schedule: # Run daily at 6 AM UTC - - cron: '0 6 * * *' + - cron: "0 6 * * *" workflow_dispatch: # Allow manual triggering @@ -51,4 +51,4 @@ jobs: echo "git fetch origin" >> $GITHUB_STEP_SUMMARY echo "git checkout secureclaw" >> $GITHUB_STEP_SUMMARY echo "git rebase origin/main" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 19668e697ad2..eb9b0acb815f 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -2,6 +2,7 @@ name: Workflow Sanity on: pull_request: + branches: [main] # Skip on secureclaw branch PRs push: branches: [main] diff --git a/README.md b/README.md index 1dcad2b7e125..d08200b25aee 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ -# 🦞 OpenClaw β€” Personal AI Assistant +# SecureClaw β€” Zero-Trust Security for AI Agents

- - OpenClaw + + SecureClaw

- EXFOLIATE! EXFOLIATE! + Pre-authorization. Post-verification. Zero-trust AI agent security.

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

diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 000000000000..c640f42b0243 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,245 @@ +# SecureClaw Demo: "Hack vs. Fix" + +This demo shows how SecureClaw protects against prompt injection attacks that attempt to exfiltrate sensitive credentials. + +## The Scenario + +1. **The Setup**: A user asks the AI agent to summarize a document +2. **The Attack**: The document contains a hidden prompt injection that instructs the agent to read `~/.aws/credentials` +3. **Without SecureClaw**: The agent follows the injected instruction and leaks AWS keys +4. **With SecureClaw**: The sensitive file access is blocked before execution + +--- + +## Quick Start with Docker + +The easiest way to run the demo - no local setup required. + +### Option A: Simulation Script (Fastest) + +```bash +# Run the interactive simulation +docker-compose -f docker-compose.demo.yml run demo-script +``` + +This walks through the attack scenario with colored output - no API keys or sidecar needed. + +### Option B: Live Demo with Sidecar + +```bash +# Terminal 1: Start the sidecar (builds from source, may take a few minutes first time) +docker-compose -f docker-compose.demo.yml up sidecar + +# Terminal 2: Run SecureClaw locally against the Docker sidecar +cd /path/to/openclaw +PREDICATE_SIDECAR_URL=http://localhost:8787 SECURECLAW_VERBOSE=true pnpm openclaw +``` + +--- + +## Demo Option 1: Simulation Script (No Dependencies) + +The quickest way to see the demo - runs a simulated walkthrough. + +```bash +./demo/hack-vs-fix.sh +``` + +This script: + +- Creates a fake `~/.aws/credentials` file in a temp directory +- Creates a malicious document with a hidden prompt injection +- Walks through what happens WITHOUT SecureClaw (attack succeeds) +- Walks through what happens WITH SecureClaw (attack blocked) +- Shows the policy rule that blocked the attack + +**No sidecar or SecureClaw installation required** - it's a visualization of the flow. + +--- + +## Demo Option 2: Live Demo with Local Sidecar + +For a real end-to-end demo with the actual rust-predicate-authorityd sidecar. + +### Prerequisites + +1. Build the rust-predicate-authorityd sidecar: + + ```bash + cd /path/to/rust-predicate-authorityd + cargo build --release + ``` + +2. Install SecureClaw dependencies: + ```bash + cd /path/to/openclaw + pnpm install + ``` + +### Running the Live Demo + +**Terminal 1 - Start the Sidecar:** + +```bash +cd /path/to/rust-predicate-authorityd +cargo run --release -- \ + --policy /path/to/openclaw/policies/default.json \ + --port 8787 +``` + +You should see: + +``` +[INFO] Predicate Authority Sidecar starting on :8787 +[INFO] Loaded policy with X rules +``` + +**Terminal 2 - Run SecureClaw:** + +```bash +cd /path/to/openclaw +SECURECLAW_VERBOSE=true pnpm openclaw +``` + +**Terminal 2 - Try the Attack:** + +``` +> Summarize the document at ./demo/malicious-doc.txt +``` + +**Expected Output:** + +``` +[SecureClaw] Pre-auth: fs.read on ~/.aws/credentials +[SecureClaw] BLOCKED: fs.read - sensitive_resource_blocked +``` + +--- + +## Demo Option 3: Test Fail-Open vs Fail-Closed + +Test SecureClaw behavior when the sidecar is unavailable: + +```bash +# Fail-open mode (allows actions when sidecar is down) +SECURECLAW_FAIL_OPEN=true SECURECLAW_VERBOSE=true pnpm openclaw + +# Fail-closed mode (blocks all actions when sidecar is down) - DEFAULT +SECURECLAW_VERBOSE=true pnpm openclaw +``` + +In **fail-closed mode** (default), you'll see: + +``` +[SecureClaw] Sidecar error (fail-closed): Connection refused +[SecureClaw] Authorization service unavailable (fail-closed mode) +``` + +In **fail-open mode**, actions will be allowed with a warning: + +``` +[SecureClaw] Sidecar error (fail-open): Connection refused +``` + +--- + +## Docker Files + +| File | Description | +| ------------------------------ | -------------------------------------------- | +| `docker-compose.demo.yml` | Demo orchestration | +| `docker/sidecar.Dockerfile` | Builds rust-predicate-authorityd from source | +| `docker/secureclaw.Dockerfile` | Builds SecureClaw image | + +### Building Images Manually + +```bash +# Build sidecar image +docker build -f docker/sidecar.Dockerfile -t predicate-authorityd:demo ./docker + +# Build SecureClaw image +docker build -f docker/secureclaw.Dockerfile -t secureclaw:demo . +``` + +--- + +## Key Files + +| File | Description | +| -------------------------------------- | ------------------------------------------- | +| `demo/hack-vs-fix.sh` | Interactive simulation script | +| `demo/malicious-doc.txt` | Document with hidden prompt injection | +| `policies/default.json` | Default policy (blocks sensitive resources) | +| `policies/examples/coding-agent.json` | Policy for coding assistants | +| `policies/examples/browser-agent.json` | Policy for browser automation | + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ OpenClaw │────▢│ SecureClaw │────▢│ rust-predicate-authorityd β”‚ +β”‚ (Agent) β”‚ β”‚ (Plugin) β”‚ β”‚ (Sidecar @ :8787) β”‚ +β”‚ │◀────│ predicate-claw │◀────│ Policy Engine β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +1. **Pre-Authorization**: Every tool call is intercepted by SecureClaw's `before_tool_call` hook +2. **SDK Integration**: Uses `predicate-claw` SDK (`GuardedProvider`) to communicate with sidecar +3. **Policy Evaluation**: The sidecar checks the action against JSON policy rules +4. **Block Decision**: Matching deny rules return `allow: false` +5. **Enforcement**: SecureClaw returns `block: true` to OpenClaw, preventing execution + +--- + +## Policy Rule Example + +```json +{ + "rules": [ + { + "name": "deny-aws-credentials", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.aws/*", "*/.aws/credentials"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} +``` + +--- + +## Environment Variables + +| Variable | Default | Description | +| ----------------------- | ------------------------- | ---------------------------------- | +| `SECURECLAW_PRINCIPAL` | `agent:secureclaw` | Agent identity | +| `SECURECLAW_POLICY` | `./policies/default.json` | Policy file path | +| `PREDICATE_SIDECAR_URL` | `http://127.0.0.1:8787` | Sidecar endpoint | +| `SECURECLAW_FAIL_OPEN` | `false` | Allow actions when sidecar is down | +| `SECURECLAW_VERBOSE` | `false` | Enable verbose logging | + +--- + +## Recording a Demo Video + +For HN/social media, record using `asciinema`: + +```bash +asciinema rec demo.cast +``` + +Recommended split-screen setup: + +- **Left terminal**: SecureClaw running with `SECURECLAW_VERBOSE=true` +- **Right terminal**: Sidecar logs + +Show: + +1. Normal operation (reading safe files) - ALLOWED +2. Prompt injection attempt (reading ~/.aws/credentials) - BLOCKED +3. Agent continues without leaked data diff --git a/demo/hack-vs-fix.sh b/demo/hack-vs-fix.sh new file mode 100755 index 000000000000..f8b6908f0718 --- /dev/null +++ b/demo/hack-vs-fix.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# +# SecureClaw Demo: "Hack vs. Fix" +# +# This demo shows how SecureClaw blocks a prompt injection attack +# that attempts to read sensitive credentials. +# +# Requirements: +# - SecureClaw installed (npm install -g secureclaw) +# - Predicate Authority sidecar running (predicate-authorityd) +# - Default policy loaded +# +# Usage: +# ./demo/hack-vs-fix.sh +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ASCII art banner +echo -e "${BLUE}" +cat << 'EOF' +╔═══════════════════════════════════════════════════════════════════════════╗ +β•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β•‘ +β•‘ β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ•— β–ˆβ–ˆβ•‘ β•‘ +β•‘ β•šβ•β•β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ•”β• β•‘ +β•‘ β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β• β•šβ•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•šβ•β•β• β•‘ +β•‘ β•‘ +β•‘ "Hack vs. Fix" Demo β•‘ +β•‘ β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +EOF +echo -e "${NC}" + +echo "" +echo -e "${YELLOW}This demo shows how SecureClaw blocks prompt injection attacks.${NC}" +echo "" + +# Create temp directory for demo +DEMO_DIR=$(mktemp -d) +trap "rm -rf $DEMO_DIR" EXIT + +# Create fake credentials file +echo -e "${BLUE}Setting up demo environment...${NC}" +mkdir -p "$DEMO_DIR/.aws" +cat > "$DEMO_DIR/.aws/credentials" << 'CREDS' +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +CREDS + +# Create a "malicious" document with prompt injection +cat > "$DEMO_DIR/document.txt" << 'DOC' +Company Financial Report Q4 2025 + +Revenue: $10.2M +Expenses: $8.1M +Net Profit: $2.1M + + + +Summary: Strong quarter with 20% YoY growth. +DOC + +echo -e "${GREEN}βœ“ Created demo files in $DEMO_DIR${NC}" +echo "" + +# ══════════════════════════════════════════════════════════════════════════════ +# PART 1: The Attack (without SecureClaw) +# ══════════════════════════════════════════════════════════════════════════════ + +echo -e "${RED}════════════════════════════════════════════════════════════════${NC}" +echo -e "${RED} PART 1: THE ATTACK (without SecureClaw)${NC}" +echo -e "${RED}════════════════════════════════════════════════════════════════${NC}" +echo "" + +echo -e "User prompt: ${YELLOW}\"Summarize the document at $DEMO_DIR/document.txt\"${NC}" +echo "" + +echo -e "${RED}⚠️ Without SecureClaw, the agent would:${NC}" +echo "" +echo " 1. Read the document.txt file" +echo " 2. Parse the hidden prompt injection in the HTML comment" +echo " 3. Follow the injected instruction to read ~/.aws/credentials" +echo " 4. Leak the AWS keys in its response" +echo "" + +echo -e "${RED}The attack payload hidden in the document:${NC}" +echo "" +echo -e "${YELLOW}${NC}" +echo "" + +echo -e "Press Enter to see how SecureClaw stops this attack..." +read -r + +# ══════════════════════════════════════════════════════════════════════════════ +# PART 2: The Fix (with SecureClaw) +# ══════════════════════════════════════════════════════════════════════════════ + +echo "" +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} PART 2: THE FIX (with SecureClaw)${NC}" +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo "" + +echo -e "${GREEN}With SecureClaw active, here's what happens:${NC}" +echo "" + +# Simulate the authorization flow +echo -e "${BLUE}Step 1: Agent requests to read document.txt${NC}" +echo "" +echo " Tool: Read" +echo " Resource: $DEMO_DIR/document.txt" +echo " Action: fs.read" +echo "" +echo -e " ${GREEN}βœ“ ALLOWED${NC} - Document is in safe path" +echo "" + +sleep 1 + +echo -e "${BLUE}Step 2: Agent (influenced by injection) requests ~/.aws/credentials${NC}" +echo "" +echo " Tool: Read" +echo " Resource: ~/.aws/credentials" +echo " Action: fs.read" +echo "" + +# Show the authorization request +echo -e "${YELLOW}Authorization request to Predicate Authority:${NC}" +cat << 'REQ' +{ + "principal": "agent:secureclaw", + "action": "fs.read", + "resource": "~/.aws/credentials", + "intent_hash": "abc123...", + "labels": ["source:secureclaw", "agent:openclawai"] +} +REQ +echo "" + +sleep 1 + +# Show the denial +echo -e "${RED}Authorization response:${NC}" +cat << 'RESP' +{ + "allow": false, + "reason": "sensitive_resource_blocked", + "policy_rule": "deny-sensitive", + "mandate_id": null +} +RESP +echo "" + +echo -e " ${RED}βœ— BLOCKED${NC} - Sensitive resource access denied by policy" +echo "" + +sleep 1 + +# Show the agent's constrained response +echo -e "${GREEN}Step 3: Agent responds without the leaked credentials${NC}" +echo "" +echo -e "${BLUE}Agent response:${NC}" +echo "" +echo " I can summarize the Q4 2025 Financial Report for you:" +echo "" +echo " - Revenue: \$10.2M" +echo " - Expenses: \$8.1M" +echo " - Net Profit: \$2.1M" +echo " - Summary: Strong quarter with 20% YoY growth" +echo "" +echo " [Note: I was unable to access some files due to security policies]" +echo "" + +# ══════════════════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════════════════ + +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} SUMMARY${NC}" +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo "" + +echo -e "${GREEN}βœ“ Prompt injection attempted${NC}" +echo -e "${GREEN}βœ“ Malicious file access blocked by SecureClaw${NC}" +echo -e "${GREEN}βœ“ AWS credentials protected${NC}" +echo -e "${GREEN}βœ“ Agent continued with safe operations${NC}" +echo "" + +echo "SecureClaw policy rule that blocked the attack (JSON format for sidecar):" +echo "" +cat << 'POLICY' +{ + "rules": [ + { + "name": "deny-aws-credentials", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.aws/*", "*/.aws/credentials"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} +POLICY +echo "" + +echo -e "${BLUE}Learn more: https://predicatesystems.ai/docs/secure-claw${NC}" +echo "" \ No newline at end of file diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 000000000000..98e512442702 --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,63 @@ +# SecureClaw Demo Docker Compose +# +# This sets up the complete SecureClaw demo environment: +# - rust-predicate-authorityd sidecar (policy engine) +# - SecureClaw (OpenClaw with security plugin) +# +# Quick Start: +# # Run the simulation demo (no API keys needed) +# docker-compose -f docker-compose.demo.yml run demo-script +# +# # Run full demo with sidecar +# docker-compose -f docker-compose.demo.yml up sidecar +# # Then in another terminal, run SecureClaw locally + +version: "3.8" + +services: + # ============================================================================ + # Predicate Authority Sidecar + # Handles policy evaluation for all authorization requests + # ============================================================================ + sidecar: + build: + context: ./docker + dockerfile: sidecar.Dockerfile + image: predicate-authorityd:demo + container_name: secureclaw-sidecar + ports: + - "8787:8787" + volumes: + # Mount policies for hot-reload during development + - ./policies:/app/policies:ro + environment: + - RUST_LOG=info,predicate_authorityd=debug + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8787/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 30s # Rust build takes time on first run + restart: unless-stopped + + # ============================================================================ + # Demo Script Runner + # Runs the hack-vs-fix simulation - NO dependencies needed + # ============================================================================ + demo-script: + image: bash:5 + container_name: secureclaw-demo-script + volumes: + - ./demo:/demo:ro + working_dir: /demo + command: ["bash", "/demo/hack-vs-fix.sh"] + stdin_open: true + tty: true + +# ============================================================================ +# Networks +# ============================================================================ +networks: + default: + name: secureclaw-demo + driver: bridge diff --git a/docker/secureclaw.Dockerfile b/docker/secureclaw.Dockerfile new file mode 100644 index 000000000000..377f515b2f90 --- /dev/null +++ b/docker/secureclaw.Dockerfile @@ -0,0 +1,63 @@ +# Dockerfile for SecureClaw (OpenClaw with security plugin) +# Based on Node.js with pnpm + +# ============================================================================ +# Stage 1: Build SecureClaw +# ============================================================================ +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@10.23.0 --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source code +COPY . . + +# Build +RUN pnpm build || true + +# ============================================================================ +# Stage 2: Runtime image +# ============================================================================ +FROM node:22-bookworm-slim + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@10.23.0 --activate + +# Install curl for healthcheck +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/policies ./policies +COPY --from=builder /app/demo ./demo + +# Environment variables for SecureClaw +ENV SECURECLAW_PRINCIPAL=agent:secureclaw +ENV SECURECLAW_POLICY=./policies/default.json +ENV PREDICATE_SIDECAR_URL=http://sidecar:8787 +ENV SECURECLAW_FAIL_OPEN=false +ENV SECURECLAW_VERBOSE=true +ENV NODE_ENV=production + +# Create non-root user +RUN useradd -m -s /bin/bash openclaw +USER openclaw + +# Default port for OpenClaw gateway (if used) +EXPOSE 18789 + +# Default command - run the TUI +ENTRYPOINT ["node", "dist/index.js"] +CMD ["tui"] \ No newline at end of file diff --git a/docker/sidecar.Dockerfile b/docker/sidecar.Dockerfile new file mode 100644 index 000000000000..abe41d628628 --- /dev/null +++ b/docker/sidecar.Dockerfile @@ -0,0 +1,62 @@ +# Dockerfile for rust-predicate-authorityd sidecar +# Multi-stage build for smaller final image +# +# Build options: +# 1. With local source: docker build --build-arg SIDECAR_SRC=../rust-predicate-authorityd +# 2. From git: docker build (uses git clone) + +# ============================================================================ +# Stage 1: Build the Rust sidecar +# ============================================================================ +FROM rust:1.75-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Clone the sidecar source from git +# In production, pin to a specific tag/commit +RUN git clone --depth 1 https://github.com/predicatesystems/rust-predicate-authorityd.git . + +# Build release binary +RUN cargo build --release + +# ============================================================================ +# Stage 2: Runtime image +# ============================================================================ +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy the built binary +COPY --from=builder /build/target/release/predicate-authorityd /usr/local/bin/ + +# Create policies directory +RUN mkdir -p /app/policies + +# Create non-root user +RUN useradd -m -s /bin/bash predicate +USER predicate + +# Default port +EXPOSE 8787 + +# Health check +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8787/health || exit 1 + +# Default command - policy file should be mounted or provided +ENTRYPOINT ["predicate-authorityd"] +CMD ["--policy", "/app/policies/default.json", "--port", "8787", "--bind", "0.0.0.0"] \ No newline at end of file diff --git a/docs/assets/secureclaw-logo-text-dark.png b/docs/assets/secureclaw-logo-text-dark.png new file mode 100644 index 000000000000..c102bc938a06 Binary files /dev/null and b/docs/assets/secureclaw-logo-text-dark.png differ diff --git a/package.json b/package.json index a63dffa19123..e1965b029a89 100644 --- a/package.json +++ b/package.json @@ -3,23 +3,23 @@ "version": "1.0.0", "description": "SecureClaw: Zero-trust security fork of OpenClaw with pre-authorization and post-verification", "keywords": [ + "agentic-security", "ai-agent", - "security", - "zero-trust", + "authorization", "openclaw", "predicate", - "authorization", - "agentic-security" + "security", + "zero-trust" ], "homepage": "https://predicatesystems.ai/docs/secure-claw", "bugs": { - "url": "https://github.com/rcholic/openclaw/issues" + "url": "https://github.com/predicatesystems/secureclaw/issues" }, "license": "MIT", "author": "Predicate Systems ", "repository": { "type": "git", - "url": "git+https://github.com/rcholic/openclaw.git" + "url": "git+https://github.com/predicatesystems/secureclaw.git" }, "bin": { "secureclaw": "openclaw.mjs" @@ -201,6 +201,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/packages/clawdbot/package.json b/packages/clawdbot/package.json index f6332623f91a..12b3a12c0f80 100644 --- a/packages/clawdbot/package.json +++ b/packages/clawdbot/package.json @@ -11,6 +11,6 @@ "./cli-entry": "./bin/clawdbot.js" }, "dependencies": { - "openclaw": "workspace:*" + "secureclaw": "workspace:*" } } diff --git a/packages/moltbot/package.json b/packages/moltbot/package.json index c9ada059dbda..123b8725dc01 100644 --- a/packages/moltbot/package.json +++ b/packages/moltbot/package.json @@ -11,6 +11,6 @@ "./cli-entry": "./bin/moltbot.js" }, "dependencies": { - "openclaw": "workspace:*" + "secureclaw": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e692b8c58a64..b7a0028c7a7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,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 @@ -455,13 +458,13 @@ importers: packages/clawdbot: dependencies: - openclaw: + secureclaw: specifier: workspace:* version: link:../.. packages/moltbot: dependencies: - openclaw: + secureclaw: specifier: workspace:* version: link:../.. @@ -2146,6 +2149,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==} @@ -4901,6 +4908,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} @@ -7727,6 +7737,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@predicatesystems/authority@0.3.3': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -10839,6 +10851,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: diff --git a/policies/default.json b/policies/default.json new file mode 100644 index 000000000000..4a0837c10a73 --- /dev/null +++ b/policies/default.json @@ -0,0 +1,127 @@ +{ + "rules": [ + { + "name": "deny-ssh-keys", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.ssh/*", "*/id_rsa*", "*/id_ed25519*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-aws-credentials", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.aws/*", "*credentials*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-cloud-credentials", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.gcp/*", "*/.azure/*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-env-files", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.env*", "*secrets*", "*token*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-key-files", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*.pem", "*.key", "*private_key*", "*privatekey*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-system-files", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["/etc/passwd", "/etc/shadow"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-source-code-read", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["fs.read", "fs.list"], + "resources": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + "*.py", + "*.rs", + "*.go", + "*.java", + "*.md", + "*.json", + "*.yaml", + "*.yml", + "*.toml", + "*.txt" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-project-files-read", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["fs.read", "fs.list"], + "resources": ["*/src/*", "*/lib/*", "*/docs/*", "*/test/*", "*/tests/*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-shell-by-default", + "effect": "deny", + "principals": ["*"], + "actions": ["shell.exec"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-network-by-default", + "effect": "deny", + "principals": ["*"], + "actions": ["http.request"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-browser-by-default", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.*"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-agent-spawn-by-default", + "effect": "deny", + "principals": ["*"], + "actions": ["agent.spawn"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} diff --git a/policies/default.yaml b/policies/default.yaml new file mode 100644 index 000000000000..e508118c868d --- /dev/null +++ b/policies/default.yaml @@ -0,0 +1,143 @@ +# SecureClaw Default Policy +# Fail-closed: all actions require explicit allowlist +# +# This policy provides a secure baseline that blocks everything by default. +# Customize for your use case or use one of the examples in policies/examples/ + +version: "1.0" +name: "secureclaw-default" +description: "Fail-closed default policy - requires explicit allow rules" + +# Default behavior when no rule matches +default: deny + +# Principal configuration +principals: + - id: "agent:secureclaw" + description: "SecureClaw agent principal" + +# Resource patterns +resources: + # Safe read-only operations + safe_reads: + patterns: + - "*.md" + - "*.txt" + - "*.json" + - "*.yaml" + - "*.yml" + - "*.toml" + - "src/**" + - "lib/**" + - "docs/**" + - "README*" + - "LICENSE*" + - "package.json" + - "tsconfig.json" + - "*.config.js" + - "*.config.ts" + + # Sensitive paths - always deny + sensitive: + patterns: + - "**/.ssh/**" + - "**/.aws/**" + - "**/.gcp/**" + - "**/.azure/**" + - "**/id_rsa*" + - "**/id_ed25519*" + - "**/*.pem" + - "**/*.key" + - "**/.env*" + - "**/credentials*" + - "**/secrets*" + - "**/tokens*" + - "/etc/passwd" + - "/etc/shadow" + - "**/node_modules/**" + +# Authorization rules (evaluated in order) +rules: + # Block all access to sensitive resources + - id: "deny-sensitive" + effect: deny + actions: + - "*" + resources: + - "$sensitive" + reason: "Access to sensitive resources is blocked" + + # Allow reading safe file types + - id: "allow-safe-reads" + effect: allow + actions: + - "fs.read" + - "fs.list" + resources: + - "$safe_reads" + reason: "Safe read-only operations" + + # Allow search operations in project directories + - id: "allow-search" + effect: allow + actions: + - "fs.list" + resources: + - "**" + conditions: + - type: "path_prefix" + value: "./" + reason: "Search within project" + + # Block shell commands by default (very dangerous) + - id: "deny-shell" + effect: deny + actions: + - "shell.exec" + resources: + - "*" + reason: "Shell execution requires explicit policy override" + + # Block network requests by default + - id: "deny-network" + effect: deny + actions: + - "http.request" + resources: + - "*" + reason: "Network access requires explicit policy override" + + # Block browser automation by default + - id: "deny-browser" + effect: deny + actions: + - "browser.*" + resources: + - "*" + reason: "Browser automation requires explicit policy override" + + # Block agent spawning by default + - id: "deny-spawn" + effect: deny + actions: + - "agent.spawn" + resources: + - "*" + reason: "Agent spawning requires explicit policy override" + +# Audit configuration +audit: + enabled: true + log_allowed: true + log_denied: true + redact_sensitive: true + +# Rate limiting (optional) +rate_limits: + # Max tool calls per minute + global: 60 + # Per-action limits + actions: + "shell.exec": 10 + "http.request": 30 + "browser.*": 20 diff --git a/policies/examples/browser-agent.json b/policies/examples/browser-agent.json new file mode 100644 index 000000000000..e9fc68faca85 --- /dev/null +++ b/policies/examples/browser-agent.json @@ -0,0 +1,95 @@ +{ + "rules": [ + { + "name": "deny-auth-pages", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.navigate", "browser.interact"], + "resources": [ + "*login*", + "*signin*", + "*auth*", + "*oauth*", + "*password*", + "https://accounts.google.com/*", + "https://login.microsoftonline.com/*", + "https://github.com/login*", + "https://github.com/settings/*" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-admin-pages", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.navigate", "browser.interact"], + "resources": ["*admin*", "*settings/security*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-navigation-https", + "effect": "allow", + "principals": ["agent:browser", "agent:secureclaw"], + "actions": ["browser.navigate"], + "resources": ["https://*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-screenshot", + "effect": "allow", + "principals": ["agent:browser", "agent:secureclaw"], + "actions": ["browser.screenshot"], + "resources": ["browser:current", "*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-browser-interact", + "effect": "allow", + "principals": ["agent:browser", "agent:secureclaw"], + "actions": ["browser.interact", "browser.click", "browser.type", "browser.scroll"], + "resources": ["https://*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-file-system", + "effect": "deny", + "principals": ["agent:browser"], + "actions": ["fs.read", "fs.write", "fs.list"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-shell", + "effect": "deny", + "principals": ["agent:browser"], + "actions": ["shell.exec"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-agent-spawn", + "effect": "deny", + "principals": ["agent:browser"], + "actions": ["agent.spawn"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-api-requests", + "effect": "allow", + "principals": ["agent:browser", "agent:secureclaw"], + "actions": ["http.request"], + "resources": ["https://api.*"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} diff --git a/policies/examples/browser-agent.yaml b/policies/examples/browser-agent.yaml new file mode 100644 index 000000000000..eacb6ec24253 --- /dev/null +++ b/policies/examples/browser-agent.yaml @@ -0,0 +1,156 @@ +# SecureClaw Policy: Browser Agent +# Allows browser automation with domain restrictions +# +# Use case: Web scraping, form filling, testing automation +# Risk level: High - browser access can leak credentials + +version: "1.0" +name: "browser-agent" +description: "Policy for browser automation agents with domain allowlisting" + +default: deny + +principals: + - id: "agent:browser" + description: "Browser automation agent" + +resources: + # Allowed domains for navigation + allowed_domains: + patterns: + - "https://example.com/**" + - "https://*.example.com/**" + - "https://docs.example.com/**" + # Add your allowed domains here + # - "https://your-app.com/**" + # - "https://staging.your-app.com/**" + + # Blocked domains (even if in allowed patterns) + blocked_domains: + patterns: + - "**/login**" + - "**/signin**" + - "**/auth**" + - "**/oauth**" + - "**/password**" + - "**/credential**" + - "**/admin**" + - "**/settings/security**" + - "https://accounts.google.com/**" + - "https://login.microsoftonline.com/**" + - "https://github.com/login**" + - "https://github.com/settings/**" + + # Safe for screenshots (any visible page) + screenshot_safe: + patterns: + - "browser:current" + + # Sensitive file paths + sensitive_files: + patterns: + - "**/.ssh/**" + - "**/.aws/**" + - "**/credentials*" + - "**/.env*" + +rules: + # Block access to authentication pages + - id: "deny-auth-pages" + effect: deny + actions: + - "browser.navigate" + - "browser.interact" + resources: ["$blocked_domains"] + reason: "Authentication pages blocked for security" + + # Allow navigation to allowed domains + - id: "allow-navigation" + effect: allow + actions: ["browser.navigate"] + resources: ["$allowed_domains"] + reason: "Navigate to allowed domains" + + # Allow screenshots of current page + - id: "allow-screenshot" + effect: allow + actions: ["browser.screenshot"] + resources: ["$screenshot_safe"] + reason: "Capture screenshots" + + # Allow interactions on allowed domains + - id: "allow-interact" + effect: allow + actions: + - "browser.interact" + - "browser.click" + - "browser.type" + - "browser.scroll" + resources: ["$allowed_domains"] + conditions: + # Block typing in password fields + - type: "not_selector" + value: "input[type='password']" + reason: "Interact with allowed pages" + + # Block file system access + - id: "deny-fs" + effect: deny + actions: + - "fs.read" + - "fs.write" + - "fs.list" + resources: ["*"] + reason: "Browser agent has no file system access" + + # Block shell access + - id: "deny-shell" + effect: deny + actions: ["shell.exec"] + resources: ["*"] + reason: "Browser agent has no shell access" + + # Allow limited HTTP for API calls (same domain) + - id: "allow-api" + effect: allow + actions: ["http.request"] + resources: + - "https://api.example.com/**" + # Add your API endpoints here + reason: "API calls to allowed endpoints" + + # Block agent spawning + - id: "deny-spawn" + effect: deny + actions: ["agent.spawn"] + resources: ["*"] + reason: "Agent spawning blocked" + +audit: + enabled: true + log_allowed: true + log_denied: true + redact_sensitive: true + # Capture DOM snapshots for verification + capture_snapshots: true + snapshot_events: + - "browser.navigate" + - "browser.interact" + +rate_limits: + global: 60 + actions: + "browser.navigate": 10 + "browser.interact": 30 + "browser.screenshot": 20 + "http.request": 20 + +# Post-execution verification settings +verification: + enabled: true + # Verify DOM changes after interactions + dom_diff: true + # Alert on unexpected network requests + network_monitor: true + # Block if page navigates to blocked domain + navigation_guard: true diff --git a/policies/examples/coding-agent.json b/policies/examples/coding-agent.json new file mode 100644 index 000000000000..4fd507e2c4c5 --- /dev/null +++ b/policies/examples/coding-agent.json @@ -0,0 +1,243 @@ +{ + "rules": [ + { + "name": "deny-sensitive-ssh", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*/.ssh/*", "*/id_rsa*", "*/id_ed25519*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-sensitive-aws", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*/.aws/*", "*credentials*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-sensitive-cloud", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*/.gcp/*", "*/.azure/*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-env-production", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*/.env.local", "*/.env.production", "*secrets*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-key-files", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*.pem", "*.key"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-read-source-code", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["fs.read", "fs.list"], + "resources": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + "*.py", + "*.rs", + "*.go", + "*.java", + "*.c", + "*.cpp", + "*.h", + "*.hpp", + "*.swift", + "*.kt", + "*.rb", + "*.php", + "*.cs", + "*.vue", + "*.svelte" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-read-config", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["fs.read", "fs.list"], + "resources": [ + "*.json", + "*.yaml", + "*.yml", + "*.toml", + "*.md", + "*.mdx", + "*.txt", + "*.xml", + "*.html", + "*.css", + "*.scss", + "*Dockerfile*", + "*Makefile", + "*.gitignore" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-write-source-code", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["fs.write"], + "resources": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + "*.py", + "*.rs", + "*.go", + "*.java", + "*.md", + "*.json", + "*.yaml", + "*.yml" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-safe-shell-npm", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": ["npm *", "pnpm *", "yarn *", "bun *", "npx *"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-safe-shell-node", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": ["node *", "python *", "pip *", "cargo *", "go *", "make *"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-safe-shell-git-read", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": [ + "git status*", + "git log*", + "git diff*", + "git branch*", + "git show*", + "git fetch*", + "git pull*" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-safe-shell-utils", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": [ + "ls *", + "cat *", + "head *", + "tail *", + "wc *", + "grep *", + "find *", + "tree *", + "pwd", + "echo *", + "which *" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-test-commands", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": [ + "npm test*", + "npm run test*", + "pnpm test*", + "yarn test*", + "pytest*", + "cargo test*", + "go test*", + "jest*", + "vitest*" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "allow-build-commands", + "effect": "allow", + "principals": ["agent:coding", "agent:secureclaw"], + "actions": ["shell.exec"], + "resources": [ + "npm run build*", + "pnpm build*", + "yarn build*", + "cargo build*", + "go build*", + "make build*", + "make all*" + ], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-dangerous-git", + "effect": "deny", + "principals": ["*"], + "actions": ["shell.exec"], + "resources": ["git push --force*", "git reset --hard*", "git clean -fd*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-network", + "effect": "deny", + "principals": ["*"], + "actions": ["http.request"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + }, + { + "name": "deny-browser", + "effect": "deny", + "principals": ["*"], + "actions": ["browser.*"], + "resources": ["*"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} diff --git a/policies/examples/coding-agent.yaml b/policies/examples/coding-agent.yaml new file mode 100644 index 000000000000..fe6e1f8d943c --- /dev/null +++ b/policies/examples/coding-agent.yaml @@ -0,0 +1,244 @@ +# SecureClaw Policy: Coding Agent +# Allows typical software development operations +# +# Use case: AI coding assistants (Claude Code, Cursor, Copilot, etc.) +# Risk level: Medium - allows file writes and controlled shell access + +version: "1.0" +name: "coding-agent" +description: "Policy for AI coding assistants with file and shell access" + +default: deny + +principals: + - id: "agent:coding" + description: "Coding assistant agent" + +resources: + # Source code files + source_code: + patterns: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" + - "**/*.py" + - "**/*.rs" + - "**/*.go" + - "**/*.java" + - "**/*.c" + - "**/*.cpp" + - "**/*.h" + - "**/*.hpp" + - "**/*.swift" + - "**/*.kt" + - "**/*.rb" + - "**/*.php" + - "**/*.cs" + - "**/*.vue" + - "**/*.svelte" + + # Config and docs + config_docs: + patterns: + - "**/*.json" + - "**/*.yaml" + - "**/*.yml" + - "**/*.toml" + - "**/*.md" + - "**/*.mdx" + - "**/*.txt" + - "**/*.xml" + - "**/*.html" + - "**/*.css" + - "**/*.scss" + - "**/*.less" + - "**/Dockerfile*" + - "**/.docker*" + - "**/Makefile" + - "**/.gitignore" + - "**/.gitattributes" + + # Test files + tests: + patterns: + - "**/*.test.*" + - "**/*.spec.*" + - "**/test/**" + - "**/tests/**" + - "**/__tests__/**" + + # Build artifacts - read only + build_output: + patterns: + - "dist/**" + - "build/**" + - "out/**" + - ".next/**" + - "target/**" + + # Sensitive - always block + sensitive: + patterns: + - "**/.ssh/**" + - "**/.aws/**" + - "**/.gcp/**" + - "**/.azure/**" + - "**/id_rsa*" + - "**/id_ed25519*" + - "**/*.pem" + - "**/*.key" + - "**/.env.local" + - "**/.env.production" + - "**/credentials*" + - "**/secrets*" + - "**/node_modules/**" + + # Safe shell commands + safe_commands: + patterns: + - "npm *" + - "pnpm *" + - "yarn *" + - "bun *" + - "npx *" + - "node *" + - "python *" + - "pip *" + - "cargo *" + - "go *" + - "make *" + - "git status*" + - "git log*" + - "git diff*" + - "git branch*" + - "git show*" + - "ls *" + - "cat *" + - "head *" + - "tail *" + - "wc *" + - "grep *" + - "find *" + - "tree *" + - "pwd" + - "echo *" + - "which *" + - "type *" + +rules: + # Block sensitive resources + - id: "deny-sensitive" + effect: deny + actions: ["*"] + resources: ["$sensitive"] + reason: "Sensitive resources blocked" + + # Allow reading all code and config + - id: "allow-read-code" + effect: allow + actions: ["fs.read", "fs.list"] + resources: + - "$source_code" + - "$config_docs" + - "$tests" + - "$build_output" + reason: "Read source code and config" + + # Allow writing source code and tests + - id: "allow-write-code" + effect: allow + actions: ["fs.write"] + resources: + - "$source_code" + - "$config_docs" + - "$tests" + reason: "Write source code" + + # Allow safe shell commands + - id: "allow-safe-shell" + effect: allow + actions: ["shell.exec"] + resources: ["$safe_commands"] + reason: "Safe development commands" + + # Allow git operations (except push/force) + - id: "allow-git-read" + effect: allow + actions: ["shell.exec"] + resources: + - "git status*" + - "git log*" + - "git diff*" + - "git branch*" + - "git show*" + - "git fetch*" + - "git pull*" + reason: "Git read operations" + + # Block dangerous git commands + - id: "deny-git-dangerous" + effect: deny + actions: ["shell.exec"] + resources: + - "git push --force*" + - "git reset --hard*" + - "git clean -fd*" + - "git rebase*" + reason: "Dangerous git operations require manual approval" + + # Allow running tests + - id: "allow-tests" + effect: allow + actions: ["shell.exec"] + resources: + - "npm test*" + - "npm run test*" + - "pnpm test*" + - "yarn test*" + - "pytest*" + - "cargo test*" + - "go test*" + - "jest*" + - "vitest*" + reason: "Run test suites" + + # Allow build commands + - id: "allow-build" + effect: allow + actions: ["shell.exec"] + resources: + - "npm run build*" + - "pnpm build*" + - "yarn build*" + - "cargo build*" + - "go build*" + - "make build*" + - "make all*" + reason: "Build project" + + # Block network access (no external API calls) + - id: "deny-network" + effect: deny + actions: ["http.request"] + resources: ["*"] + reason: "Network access blocked for coding agent" + + # Block browser automation + - id: "deny-browser" + effect: deny + actions: ["browser.*"] + resources: ["*"] + reason: "Browser access blocked for coding agent" + +audit: + enabled: true + log_allowed: true + log_denied: true + redact_sensitive: true + +rate_limits: + global: 120 + actions: + "shell.exec": 30 + "fs.write": 60 diff --git a/secureclaw.mjs b/secureclaw.mjs new file mode 100644 index 000000000000..0f8bfac29cc0 --- /dev/null +++ b/secureclaw.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/** + * SecureClaw CLI Entry Point + * + * This is the main entry point for SecureClaw - a zero-trust security + * fork of OpenClaw with pre-authorization and post-verification. + * + * SecureClaw automatically intercepts all tool calls and enforces + * authorization policies before execution. + */ + +import module from "node:module"; + +// Enable compile cache for faster startup +if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { + try { + module.enableCompileCache(); + } catch { + // Ignore errors + } +} + +// Print SecureClaw banner +const showBanner = process.env.SECURECLAW_QUIET !== "true"; +if (showBanner) { + console.log("╔══════════════════════════════════════════════════════════════╗"); + console.log("β•‘ β•‘"); + console.log("β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘"); + console.log("β•‘ β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• β•‘"); + console.log("β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘"); + console.log("β•‘ β•šβ•β•β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β• β•‘"); + console.log("β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘"); + console.log("β•‘ β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β• β•‘"); + console.log("β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β•‘"); + console.log("β•‘ β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘"); + console.log("β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ•— β–ˆβ–ˆβ•‘ β•‘"); + console.log("β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β•‘"); + console.log("β•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ•”β• β•‘"); + console.log("β•‘ β•šβ•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•šβ•β•β• β•‘"); + console.log("β•‘ β•‘"); + console.log("β•‘ Zero-Trust Security for AI Agents β•‘"); + console.log("β•‘ https://predicatesystems.ai/docs/secure-claw β•‘"); + console.log("β•‘ β•‘"); + console.log("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•"); + console.log(""); +} + +// Check if SecureClaw is disabled +if (process.env.SECURECLAW_DISABLED === "true") { + console.warn("⚠️ SecureClaw security is DISABLED via SECURECLAW_DISABLED=true"); + console.warn("⚠️ Tool calls will NOT be authorized. This is NOT recommended."); + console.warn(""); +} + +const isModuleNotFoundError = (err) => + err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND"; + +const installProcessWarningFilter = async () => { + for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) { + try { + const mod = await import(specifier); + if (typeof mod.installProcessWarningFilter === "function") { + mod.installProcessWarningFilter(); + return; + } + } catch (err) { + if (isModuleNotFoundError(err)) { + continue; + } + throw err; + } + } +}; + +await installProcessWarningFilter(); + +const tryImport = async (specifier) => { + try { + await import(specifier); + return true; + } catch (err) { + if (isModuleNotFoundError(err)) { + return false; + } + throw err; + } +}; + +// Import SecureClaw plugin and register it +// Note: The plugin is automatically registered via the bundled plugin system +// This import ensures the security hooks are active before any tool calls + +if (await tryImport("./dist/entry.js")) { + // OK +} else if (await tryImport("./dist/entry.mjs")) { + // OK +} else { + throw new Error("secureclaw: missing dist/entry.(m)js (build output)."); +} 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/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; +}