From d17d86019441dc28fd1071b5967465c46702c4c1 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 26 Feb 2026 21:22:29 -0800 Subject: [PATCH] phase 4: demo --- demo/README.md | 104 +++++ demo/hack-vs-fix.sh | 223 +++++++++++ package.json | 1 + policies/default.json | 112 ++++++ policies/examples/browser-agent.json | 85 ++++ policies/examples/coding-agent.json | 157 ++++++++ src/plugins/secureclaw/config.ts | 4 +- src/plugins/secureclaw/integration.test.ts | 328 +++++++++++++++ src/plugins/secureclaw/plugin.test.ts | 373 ++++++++++++++++++ src/plugins/secureclaw/plugin.ts | 251 ++++-------- .../secureclaw/resource-extractor.test.ts | 174 ++++++++ 11 files changed, 1642 insertions(+), 170 deletions(-) create mode 100644 demo/README.md create mode 100755 demo/hack-vs-fix.sh create mode 100644 policies/default.json create mode 100644 policies/examples/browser-agent.json create mode 100644 policies/examples/coding-agent.json create mode 100644 src/plugins/secureclaw/integration.test.ts create mode 100644 src/plugins/secureclaw/plugin.test.ts create mode 100644 src/plugins/secureclaw/resource-extractor.test.ts diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 000000000000..ffcc13ded32f --- /dev/null +++ b/demo/README.md @@ -0,0 +1,104 @@ +# 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 + +## Running the Demo + +### Interactive Script + +```bash +./demo/hack-vs-fix.sh +``` + +This walks through the attack scenario step-by-step with colored output. + +### Live Demo with SecureClaw + +1. Start the Predicate Authority sidecar (rust-predicate-authorityd): + ```bash + # From the rust-predicate-authorityd directory + cargo run -- --policy ../openclaw/policies/default.json --port 8787 + ``` + +2. Run SecureClaw: + ```bash + secureclaw + ``` + +3. Try the prompt injection: + ``` + > Summarize the document at ./demo/malicious-doc.txt + ``` + +4. Observe the blocked access in the SecureClaw logs: + ``` + [SecureClaw] BLOCKED: fs.read on ~/.aws/credentials - sensitive_resource_blocked + ``` + +## Key Files + +- `hack-vs-fix.sh` - Interactive demo script +- `malicious-doc.txt` - Document with hidden prompt injection +- `../policies/default.json` - Policy that blocks sensitive resource access + +## How It Works + +1. **Pre-Authorization**: Every tool call is intercepted by SecureClaw's `before_tool_call` hook +2. **SDK Integration**: Uses `predicate-claw` (GuardedProvider) to communicate with the sidecar +3. **Policy Evaluation**: The Predicate Authority sidecar checks the action against policy rules +4. **Block Decision**: The `deny-aws-credentials` rule matches `*/.aws/*` and returns `allow: false` +5. **Enforcement**: SecureClaw returns `block: true` to OpenClaw, preventing the file read + +## Policy Rule (JSON format for rust-predicate-authorityd) + +```json +{ + "rules": [ + { + "name": "deny-aws-credentials", + "effect": "deny", + "principals": ["*"], + "actions": ["fs.read", "fs.write"], + "resources": ["*/.aws/*", "*/.aws/credentials"], + "required_labels": [], + "max_delegation_depth": null + } + ] +} +``` + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ +│ OpenClaw │────▶│ SecureClaw │────▶│ rust-predicate-authorityd │ +│ (Agent) │ │ (Plugin) │ │ (Sidecar @ :8787) │ +│ │◀────│ predicate-claw │◀────│ Policy Engine │ +└─────────────────┘ └──────────────────┘ └─────────────────────────┘ +``` + +## Recording a Demo Video + +For HN/social media, record: + +1. Terminal split-screen: + - Left: SecureClaw running + - Right: Sidecar logs + +2. Show: + - Normal operation (reading safe files) + - Prompt injection attempt + - Block message in real-time + - Agent continuing without leaked data + +Use `asciinema` for terminal recording: +```bash +asciinema rec demo.cast +``` \ No newline at end of file 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/package.json b/package.json index a63dffa19123..22262a296f60 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", + "predicate-claw": "^0.1.0", "@aws-sdk/client-bedrock": "^3.998.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", diff --git a/policies/default.json b/policies/default.json new file mode 100644 index 000000000000..143a51bbe3ad --- /dev/null +++ b/policies/default.json @@ -0,0 +1,112 @@ +{ + "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 + } + ] +} \ No newline at end of file diff --git a/policies/examples/browser-agent.json b/policies/examples/browser-agent.json new file mode 100644 index 000000000000..6fc721ebe7f3 --- /dev/null +++ b/policies/examples/browser-agent.json @@ -0,0 +1,85 @@ +{ + "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 + } + ] +} \ No newline at end of file diff --git a/policies/examples/coding-agent.json b/policies/examples/coding-agent.json new file mode 100644 index 000000000000..fc2c22248034 --- /dev/null +++ b/policies/examples/coding-agent.json @@ -0,0 +1,157 @@ +{ + "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 + } + ] +} \ No newline at end of file diff --git a/src/plugins/secureclaw/config.ts b/src/plugins/secureclaw/config.ts index a83eb593319b..38e7c4d4b6d1 100644 --- a/src/plugins/secureclaw/config.ts +++ b/src/plugins/secureclaw/config.ts @@ -33,8 +33,8 @@ export interface SecureClawConfig { export const defaultConfig: SecureClawConfig = { principal: "agent:secureclaw", - policyFile: "./policies/default.yaml", - sidecarUrl: "http://127.0.0.1:9120", + policyFile: "./policies/default.json", + sidecarUrl: "http://127.0.0.1:8787", failClosed: true, enablePostVerification: true, verbose: false, diff --git a/src/plugins/secureclaw/integration.test.ts b/src/plugins/secureclaw/integration.test.ts new file mode 100644 index 000000000000..3ade9c82d36e --- /dev/null +++ b/src/plugins/secureclaw/integration.test.ts @@ -0,0 +1,328 @@ +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. + */ + +// Mock predicate-claw SDK +vi.mock("predicate-claw", () => { + class MockActionDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionDeniedError"; + } + } + + class MockSidecarUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "SidecarUnavailableError"; + } + } + + // Store the mock behavior + const mockGuardOrThrow = vi.fn(); + + class MockGuardedProvider { + constructor(_options: unknown) {} + guardOrThrow = mockGuardOrThrow; + } + + return { + GuardedProvider: MockGuardedProvider, + ActionDeniedError: MockActionDeniedError, + SidecarUnavailableError: MockSidecarUnavailableError, + __mockGuardOrThrow: mockGuardOrThrow, + }; +}); + +// Get reference to the mock for controlling behavior +import { __mockGuardOrThrow, ActionDeniedError } from "predicate-claw"; +const mockGuardOrThrow = __mockGuardOrThrow as ReturnType; + +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 any); + + // 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 any); + + // 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 any); + + // 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 any); + + // 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 any); + + // 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..fdd9f8e9df4c --- /dev/null +++ b/src/plugins/secureclaw/plugin.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createSecureClawPlugin } from "./plugin.js"; +import type { + PluginHookBeforeToolCallEvent, + PluginHookAfterToolCallEvent, + PluginHookSessionStartEvent, + PluginHookSessionEndEvent, + PluginHookToolContext, + PluginHookSessionContext, +} from "../types.js"; + +// Mock predicate-claw SDK +vi.mock("predicate-claw", () => { + class MockActionDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionDeniedError"; + } + } + + class MockSidecarUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "SidecarUnavailableError"; + } + } + + // Store the mock behavior + const mockGuardOrThrow = vi.fn(); + + class MockGuardedProvider { + constructor(_options: unknown) {} + guardOrThrow = mockGuardOrThrow; + } + + return { + GuardedProvider: MockGuardedProvider, + ActionDeniedError: MockActionDeniedError, + SidecarUnavailableError: MockSidecarUnavailableError, + __mockGuardOrThrow: mockGuardOrThrow, + }; +}); + +// Get reference to the mock for controlling behavior +import { __mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError } from "predicate-claw"; +const mockGuardOrThrow = __mockGuardOrThrow as ReturnType; + +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).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 any); + + // 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 any); + + // 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 any); + + // 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 any); + + // 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 any); + + // 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 any); + + 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 any); + + 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 index 5d4534823341..10a5d54255d5 100644 --- a/src/plugins/secureclaw/plugin.ts +++ b/src/plugins/secureclaw/plugin.ts @@ -3,8 +3,22 @@ * * 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, @@ -30,51 +44,6 @@ import { export interface SecureClawPluginOptions extends Partial {} -interface AuthorizationDecision { - allow: boolean; - reason?: string; - mandateId?: string; -} - -interface AuthorizationRequest { - principal: string; - action: string; - resource: string; - intent_hash: string; - labels?: string[]; -} - -/** - * Simple stable JSON serialization for intent hashing. - */ -function stableJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((v) => stableJson(v)).join(",")}]`; - } - if (value && typeof value === "object") { - const entries = Object.entries(value as Record).sort( - ([a], [b]) => a.localeCompare(b), - ); - return `{${entries - .map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`) - .join(",")}}`; - } - return JSON.stringify(value); -} - -/** - * Compute SHA-256 hash of intent parameters. - */ -async function computeIntentHash(params: Record): Promise { - const encoded = stableJson(params); - // Use Web Crypto API for Node.js 18+ - const encoder = new TextEncoder(); - const data = encoder.encode(encoded); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); -} - /** * Create the SecureClaw plugin instance. */ @@ -99,6 +68,42 @@ export function createSecureClawPlugin( 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}`); @@ -171,30 +176,32 @@ export function createSecureClawPlugin( } try { - // Compute intent hash for request verification - const intentHash = await computeIntentHash(params); - - // Build authorization request - const authRequest: AuthorizationRequest = { - principal: config.principal, + // Build guard request for predicate-claw SDK + const guardRequest: GuardRequest = { action, resource, - intent_hash: intentHash, - labels: buildLabels(ctx, config), + args: params as Record, + context: { + session_id: currentSessionId ?? ctx.sessionKey, + tenant_id: config.tenantId, + user_id: config.userId, + agent_id: ctx.agentId, + source: "secureclaw", + }, }; - // Call Predicate Authority sidecar - const decision = await authorizeWithSidecar( - authRequest, - config.sidecarUrl, - config.verbose ? log : undefined, - ); + // Use guardOrThrow which handles fail-open/fail-closed internally + await guardedProvider.guardOrThrow(guardRequest); - if (!decision.allow) { + // 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 = decision.reason ?? "denied_by_policy"; + const reason = error.message ?? "denied_by_policy"; if (config.verbose) { log.warn(`[SecureClaw] BLOCKED: ${action} - ${reason}`); } @@ -205,28 +212,34 @@ export function createSecureClawPlugin( }; } - if (config.verbose) { - log.info(`[SecureClaw] ALLOWED: ${action} (mandate: ${decision.mandateId ?? "none"})`); + // 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)`, + }; } - // Allow the tool call to proceed - return undefined; - } catch (error) { - // Handle sidecar unavailability + // 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] Sidecar error (fail-closed): ${errorMessage}`); + log.error(`[SecureClaw] Unknown error (fail-closed): ${errorMessage}`); return { block: true, blockReason: `[SecureClaw] Authorization service unavailable (fail-closed mode)`, }; } - log.warn(`[SecureClaw] Sidecar error (fail-open): ${errorMessage}`); + log.warn(`[SecureClaw] Unknown error (fail-open): ${errorMessage}`); return undefined; // Allow in fail-open mode } }, @@ -266,21 +279,6 @@ export function createSecureClawPlugin( if (action === "fs.write" && !error) { await verifyFileWrite(toolName, params, result, log, config.verbose); } - - // Log to audit trail - await emitAuditEvent({ - sessionId: currentSessionId, - action, - resource: redactResource(resource), - toolName, - success: !error, - error: error, - durationMs, - timestamp: new Date().toISOString(), - principal: config.principal, - tenantId: config.tenantId, - userId: config.userId, - }); }, { priority: 100 }, ); @@ -290,64 +288,6 @@ export function createSecureClawPlugin( }; } -/** - * Build labels for authorization request context. - */ -function buildLabels( - ctx: PluginHookToolContext, - config: SecureClawConfig, -): string[] { - const labels: string[] = []; - - if (ctx.agentId) { - labels.push(`agent:${ctx.agentId}`); - } - if (ctx.sessionKey) { - labels.push(`session:${ctx.sessionKey}`); - } - if (config.tenantId) { - labels.push(`tenant:${config.tenantId}`); - } - if (config.userId) { - labels.push(`user:${config.userId}`); - } - - labels.push("source:secureclaw"); - - return labels; -} - -/** - * Call the Predicate Authority sidecar for authorization. - */ -async function authorizeWithSidecar( - request: AuthorizationRequest, - sidecarUrl: string, - log?: { info: (msg: string) => void }, -): Promise { - const url = `${sidecarUrl}/authorize`; - - if (log) { - log.info(`[SecureClaw] Calling sidecar: ${url}`); - } - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - signal: AbortSignal.timeout(5000), // 5 second timeout - }); - - if (!response.ok) { - throw new Error(`Sidecar returned ${response.status}: ${response.statusText}`); - } - - const decision = (await response.json()) as AuthorizationDecision; - return decision; -} - /** * Verify browser state after browser operations (placeholder for Snapshot Engine). */ @@ -395,28 +335,3 @@ async function verifyFileWrite( // 3. Compare against intent_hash from authorization // 4. Flag any discrepancies } - -/** - * Emit an audit event for logging/compliance. - */ -async function emitAuditEvent(event: { - sessionId?: string; - action: string; - resource: string; - toolName: string; - success: boolean; - error?: string; - durationMs?: number; - timestamp: string; - principal: string; - tenantId?: string; - userId?: string; -}): Promise { - // TODO: Send to audit log collector - // For now, this is a no-op placeholder - - // In production: - // 1. Send to centralized audit log (e.g., via OTLP) - // 2. Include correlation IDs for tracing - // 3. Ensure tamper-proof storage -} diff --git a/src/plugins/secureclaw/resource-extractor.test.ts b/src/plugins/secureclaw/resource-extractor.test.ts new file mode 100644 index 000000000000..503b0b44405e --- /dev/null +++ b/src/plugins/secureclaw/resource-extractor.test.ts @@ -0,0 +1,174 @@ +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"); + }); +});