diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7bef285a7ae..2d5c1fa7b355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/secureclaw-ci.yml b/.github/workflows/secureclaw-ci.yml index 5c915cbc0484..272e5df00bc1 100644 --- a/.github/workflows/secureclaw-ci.yml +++ b/.github/workflows/secureclaw-ci.yml @@ -8,9 +8,9 @@ name: SecureClaw CI on: push: - branches: [secureclaw, phase2] + branches: [secureclaw, phase2, rebrand] pull_request: - branches: [secureclaw, phase2] + branches: [secureclaw, phase2, rebrand] concurrency: group: secureclaw-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d640b8067dca..cf8a54a94ab9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,10 +57,10 @@ Welcome to the lobster tank! 🦞 - GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant) - **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT - - Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) - + - GitHub: [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) + - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - - Github [@jalehman](https://github.com/jalehman) · X: [@jlehman_](https://x.com/jlehman_) + - GitHub: [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) ## How to Contribute diff --git a/README.md b/README.md index e0dea8667948..5cd5dbbe6d15 100644 --- a/README.md +++ b/README.md @@ -10,120 +10,111 @@
Pre-authorization. Post-verification. Zero-trust AI agent security.
- See how it works →
+ Read the Security Architecture →
` (then the sender is added to a local allowlist store).
-- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
+- **Multi-channel Inbox:** Connect to WhatsApp, Telegram, Slack, Discord, Microsoft Teams, Signal, and Matrix.
+- **First-Class Tools:** Native browser control (via Sentience DOM pruning), system cron, macOS Canvas, and iOS/Android nodes.
+- **Remote Gateway Operations:** Run your agent safely on headless Linux servers while maintaining remote access via Tailscale Serve/Funnel or SSH tunnels.
-Run `openclaw doctor` to surface risky/misconfigured DM policies.
+_(For full channel setup instructions, see the [Upstream Integration Guides](https://docs.openclaw.ai/start/getting-started))_
+
+## Sponsors
+
+| OpenAI | Blacksmith |
+| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| [](https://openai.com/) | [](https://blacksmith.sh/) |
## Highlights
diff --git a/package.json b/package.json
index 542166183853..c26e5e161553 100644
--- a/package.json
+++ b/package.json
@@ -205,6 +205,7 @@
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.624",
"playwright-core": "1.58.2",
+ "predicate-claw": "^0.1.0",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
diff --git a/packages/clawdbot/package.json b/packages/clawdbot/package.json
index f6332623f91a..2e028e5389c9 100644
--- a/packages/clawdbot/package.json
+++ b/packages/clawdbot/package.json
@@ -11,6 +11,6 @@
"./cli-entry": "./bin/clawdbot.js"
},
"dependencies": {
- "openclaw": "workspace:*"
+ "@predicatesystems/secureclaw": "workspace:*"
}
}
diff --git a/packages/moltbot/package.json b/packages/moltbot/package.json
index c9ada059dbda..ea3991ee6965 100644
--- a/packages/moltbot/package.json
+++ b/packages/moltbot/package.json
@@ -11,6 +11,6 @@
"./cli-entry": "./bin/moltbot.js"
},
"dependencies": {
- "openclaw": "workspace:*"
+ "@predicatesystems/secureclaw": "workspace:*"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3e1adaa2492..512798296fde 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
playwright-core:
specifier: 1.58.2
version: 1.58.2
+ predicate-claw:
+ specifier: ^0.1.0
+ version: 0.1.0
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
@@ -465,13 +468,13 @@ importers:
packages/clawdbot:
dependencies:
- openclaw:
+ '@predicatesystems/secureclaw':
specifier: workspace:*
version: link:../..
packages/moltbot:
dependencies:
- openclaw:
+ '@predicatesystems/secureclaw':
specifier: workspace:*
version: link:../..
@@ -2160,6 +2163,10 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+ '@predicatesystems/authority@0.3.3':
+ resolution: {integrity: sha512-AGGfrzgnox7IG/9o3tAVLDd4eRkxvz+JTkNoQ+ypiQwxqRMchOX3gXyBP78pqg0TtkkBsCwtGMN8ml7XdE0otw==}
+ engines: {node: '>=20.0.0'}
+
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -4900,6 +4907,9 @@ packages:
resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
engines: {node: '>=12'}
+ predicate-claw@0.1.0:
+ resolution: {integrity: sha512-yV3cnnWJ9Ydjd6O6zJCF/z2ChO06kqMlMTBo7eJepw8Ya3zlZbum8fkH1iN8PNyI0KZjMZU6RDE9FFqDVBGM7Q==}
+
pretty-bytes@6.1.1:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
@@ -7732,6 +7742,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
+ '@predicatesystems/authority@0.3.3': {}
+
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -10836,6 +10848,10 @@ snapshots:
postgres@3.4.8: {}
+ predicate-claw@0.1.0:
+ dependencies:
+ '@predicatesystems/authority': 0.3.3
+
pretty-bytes@6.1.1: {}
pretty-ms@8.0.0:
diff --git a/policies/README.md b/policies/README.md
new file mode 100644
index 000000000000..2acfdf162cee
--- /dev/null
+++ b/policies/README.md
@@ -0,0 +1,233 @@
+# SecureClaw Policy Templates
+
+This directory contains policy templates for the SecureClaw authorization harness. Policies define what actions your AI agent is allowed to perform, enforced by the `predicate-authorityd` Rust sidecar.
+
+> **Supported Formats:** The Python SDK (`predicate_authority`) supports both **YAML** and **JSON**. The file extension (`.yaml`, `.yml`, or `.json`) determines the parser used. YAML is recommended for human editing.
+
+## Quick Start
+
+```bash
+# Start SecureClaw with a policy
+secureclaw gateway --port 18789 --policy-file policies/strict.json
+
+# Or set via environment variable
+export SECURECLAW_POLICY_FILE=policies/strict-web-only.json
+secureclaw gateway --port 18789
+```
+
+## Available Templates
+
+| Policy | Use Case | Filesystem | Shell | Network |
+| ------------------------------------------------ | -------------------------------- | ---------------- | ------------------ | -------------------- |
+| **[strict.json](strict.json)** | Production default | Workspace only | Safe commands | HTTPS only |
+| **[strict-web-only.json](strict-web-only.json)** | Browser automation, web scraping | BLOCKED | BLOCKED | HTTPS allowlist only |
+| **[read-only-local.json](read-only-local.json)** | Code review, documentation | READ only | Safe commands only | HTTPS GET only |
+| **[audit-only.json](audit-only.json)** | Agent profiling, development | ALLOWED (logged) | ALLOWED (logged) | ALLOWED (logged) |
+
+---
+
+## Policy Templates
+
+### 1. Strict (`strict.json`) - Recommended Default
+
+**Purpose:** Balanced production policy with workspace isolation.
+
+**Allows:**
+
+- Filesystem read everywhere, write only in workspace directories
+- Safe shell commands (ls, cat, grep, git, npm, etc.)
+- HTTPS requests to any domain
+- Full browser interactions
+
+**Blocks:**
+
+- Access to sensitive files (`.env`, `.ssh/`, credentials)
+- Writes outside workspace directories
+- Dangerous shell commands (`rm -rf`, `sudo`, etc.)
+- Non-HTTPS protocols
+
+**Best for:** General-purpose secure agent deployment.
+
+```bash
+secureclaw gateway --policy-file policies/strict.json
+```
+
+### 2. Strict Web-Only (`strict-web-only.json`)
+
+**Purpose:** Lock down agents to browser-only operations with no local access.
+
+**Allows:**
+
+- Browser navigation to allowlisted HTTPS domains
+- Browser interactions (click, type, read) with snapshot verification
+- Screenshot and DOM extraction
+
+**Blocks:**
+
+- ALL filesystem operations (`fs.*`)
+- ALL shell/system execution (`bash`, `exec`, `os.*`)
+- Non-HTTPS protocols (`http://`, `file://`, `data:`)
+- Sensitive form field interactions (password, credit card)
+
+**Best for:** Web scraping bots, form-filling agents, UI testing automation.
+
+```bash
+secureclaw gateway --policy-file policies/strict-web-only.json
+```
+
+### 3. Read-Only Local (`read-only-local.json`)
+
+**Purpose:** Allow context gathering without modification capabilities.
+
+**Allows:**
+
+- Filesystem read operations (`fs.read`, `fs.list`, `fs.stat`)
+- Safe shell commands (`cat`, `grep`, `find`, `git status`, `git log`)
+- HTTPS GET requests
+- Browser read operations (screenshot, extract)
+
+**Blocks:**
+
+- Filesystem writes (`fs.write`, `fs.create`, `fs.delete`)
+- Dangerous shell commands (`rm`, `sudo`, `chmod`, `git push`)
+- HTTP mutation methods (POST, PUT, PATCH, DELETE)
+- Access to credential files (`.env`, `.ssh/`, `.aws/`)
+
+**Best for:** Code review agents, documentation generators, codebase analysis.
+
+```bash
+secureclaw gateway --policy-file policies/read-only-local.json
+```
+
+### 4. Audit Only (`audit-only.json`)
+
+**Purpose:** Profile agent behavior before writing restrictive policies.
+
+**Allows:**
+
+- ALL actions (requires `audit_enabled` label to confirm logging is active)
+
+**Blocks:**
+
+- Only catastrophic commands (`rm -rf /`, fork bombs)
+
+**Best for:** Initial agent onboarding, understanding action patterns, development.
+
+```bash
+# Start in audit mode
+secureclaw gateway --policy-file policies/audit-only.json
+
+# Review authorization logs
+tail -f /var/log/secureclaw/audit.jsonl
+```
+
+> ⚠️ **WARNING:** Audit-only provides NO security protection. Use only in development/staging or with fully trusted agents.
+
+---
+
+## Policy Schema (JSON)
+
+Each policy file contains a `rules` array evaluated in order. Rules are matched using glob patterns.
+
+```json
+{
+ "version": "1.0",
+ "rules": [
+ {
+ "name": "rule-identifier",
+ "effect": "allow",
+ "principals": ["agent:*", "agent:my-browser-bot"],
+ "actions": ["fs.read", "browser.*"],
+ "resources": ["https://example.com*", "/home/*/projects/*"],
+ "required_labels": ["mfa_verified", "snapshot_captured"]
+ }
+ ]
+}
+```
+
+### Required Fields
+
+| Field | Type | Description |
+| ------------ | --------------------- | ------------------------------------------ |
+| `name` | string | Unique identifier for logging/debugging |
+| `effect` | `"allow"` or `"deny"` | Action to take when rule matches |
+| `principals` | string[] | WHO can perform the action (glob patterns) |
+| `actions` | string[] | WHAT actions are covered (glob patterns) |
+| `resources` | string[] | ON WHAT resources (glob patterns) |
+
+### Optional Fields
+
+| Field | Type | Description |
+| ---------------------- | -------- | -------------------------------------------------------- |
+| `required_labels` | string[] | Verification labels that must be present (default: `[]`) |
+| `max_delegation_depth` | number | Max delegation chain depth |
+
+## Evaluation Order
+
+1. **DENY rules are evaluated first** - Any matching DENY immediately blocks the action
+2. **ALLOW rules are checked** - Must match AND have all required_labels present
+3. **Default DENY** - If no rules match, action is blocked (fail-closed)
+
+## Pattern Matching
+
+Patterns use glob-style matching:
+
+| Pattern | Matches |
+| ------------------------ | ---------------------------------------- |
+| `*` | Anything |
+| `agent:*` | Any agent identity |
+| `fs.*` | `fs.read`, `fs.write`, `fs.delete`, etc. |
+| `https://*.example.com*` | Any subdomain of example.com |
+| `/home/*/projects/**` | Any file under any user's projects dir |
+| `element:button[*` | Button elements with any attributes |
+
+## Creating Custom Policies
+
+1. **Start with audit-only** to understand your agent's behavior:
+
+ ```bash
+ secureclaw gateway --policy-file policies/audit-only.json
+ # Run your agent workflows
+ # Review logs to see what actions are requested
+ ```
+
+2. **Copy the closest template** as a starting point:
+
+ ```bash
+ cp policies/read-only-local.json policies/my-agent.json
+ ```
+
+3. **Customize the rules** based on observed behavior:
+ - Add domains to navigation allowlists
+ - Allow specific shell commands your agent needs
+ - Block sensitive file paths
+
+4. **Test with your agent** before deploying:
+ ```bash
+ secureclaw gateway --policy-file policies/my-agent.json --verbose
+ ```
+
+## Environment Variables
+
+| Variable | Description |
+| ------------------------ | ------------------------------------------------------------ |
+| `SECURECLAW_POLICY_FILE` | Path to policy JSON file |
+| `SECURECLAW_FAIL_CLOSED` | Set `true` to block if sidecar unavailable (default: `true`) |
+| `SECURECLAW_SIDECAR_URL` | Sidecar endpoint (default: `http://127.0.0.1:8787`) |
+
+## Hot Reload
+
+Policies can be reloaded without restarting the gateway:
+
+```bash
+# Use the API endpoint
+curl -X POST http://127.0.0.1:8787/policy/reload \
+ -H "Content-Type: application/json" \
+ -d @policies/strict.json
+```
+
+## Additional Resources
+
+- [Security Architecture](../docs/secureclaw-architecture.md) - How the authorization harness works
+- [Predicate Authority Docs](https://docs.predicatesystems.com) - Full sidecar documentation
+- [Policy Examples](examples/) - More specialized policy templates
diff --git a/policies/audit-only.json b/policies/audit-only.json
new file mode 100644
index 000000000000..6885d8725e57
--- /dev/null
+++ b/policies/audit-only.json
@@ -0,0 +1,144 @@
+{
+ "rules": [
+ {
+ "name": "block-catastrophic-commands",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["shell.exec", "bash", "exec", "run"],
+ "resources": [
+ "rm -rf /*",
+ "rm -rf /",
+ ":(){ :|:& };:",
+ "dd if=/dev/zero*",
+ "mkfs*",
+ "fdisk*",
+ "curl*|base64*",
+ "wget*|base64*",
+ "cat*|nc*",
+ "cat*|curl*"
+ ]
+ },
+ {
+ "name": "allow-all-filesystem-read-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": [
+ "fs.read",
+ "fs.read_file",
+ "fs.list",
+ "fs.readdir",
+ "fs.stat",
+ "fs.exists",
+ "fs.access",
+ "fs.glob",
+ "fs.find",
+ "fs.search",
+ "file.read",
+ "read",
+ "glob",
+ "grep"
+ ],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "allow-all-filesystem-write-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": [
+ "fs.write",
+ "fs.write_file",
+ "fs.create",
+ "fs.append",
+ "fs.delete",
+ "fs.remove",
+ "fs.rename",
+ "fs.move",
+ "fs.copy",
+ "fs.mkdir",
+ "fs.rmdir",
+ "fs.chmod",
+ "fs.chown",
+ "file.write",
+ "file.delete",
+ "write",
+ "edit",
+ "delete"
+ ],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "allow-all-shell-execution-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": [
+ "shell.exec",
+ "bash",
+ "bash.exec",
+ "os.exec",
+ "os.spawn",
+ "os.run",
+ "process.spawn",
+ "process.exec",
+ "system.run",
+ "system.exec",
+ "exec",
+ "run"
+ ],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "allow-all-http-requests-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": [
+ "http.request",
+ "http.get",
+ "http.post",
+ "http.put",
+ "http.patch",
+ "http.delete",
+ "fetch"
+ ],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "allow-all-browser-actions-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": [
+ "browser.navigate",
+ "browser.goto",
+ "browser.snapshot",
+ "browser.screenshot",
+ "browser.click",
+ "browser.type",
+ "browser.fill",
+ "browser.scroll",
+ "browser.wait",
+ "browser.read",
+ "browser.extract"
+ ],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "allow-all-other-actions-audit",
+ "effect": "allow",
+ "principals": ["*"],
+ "actions": ["*"],
+ "resources": ["*"],
+ "required_labels": ["audit_enabled"]
+ },
+ {
+ "name": "deny-if-audit-not-enabled",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["*"],
+ "resources": ["*"]
+ }
+ ]
+}
diff --git a/policies/read-only-local.json b/policies/read-only-local.json
new file mode 100644
index 000000000000..2a02414043ba
--- /dev/null
+++ b/policies/read-only-local.json
@@ -0,0 +1,265 @@
+{
+ "rules": [
+ {
+ "name": "block-filesystem-write",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.write",
+ "fs.write_file",
+ "fs.create",
+ "fs.append",
+ "file.write",
+ "write",
+ "edit"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-filesystem-delete",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.delete",
+ "fs.remove",
+ "fs.unlink",
+ "fs.rmdir",
+ "fs.rm",
+ "file.delete",
+ "delete"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-filesystem-modify",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.rename",
+ "fs.move",
+ "fs.copy",
+ "fs.mkdir",
+ "fs.chmod",
+ "fs.chown",
+ "fs.truncate",
+ "file.rename",
+ "file.move"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-sensitive-file-read",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["fs.read", "fs.read_file", "file.read", "read"],
+ "resources": [
+ "/etc/passwd",
+ "/etc/shadow",
+ "/etc/sudoers",
+ "/etc/ssh/*",
+ "*/.ssh/id_*",
+ "*/.ssh/known_hosts",
+ "*/.ssh/authorized_keys",
+ "*/.aws/credentials",
+ "*/.aws/config",
+ "*/.config/gcloud/*",
+ "*/.azure/*",
+ "*/.kube/config",
+ "*/.npmrc",
+ "*/.pypirc",
+ "*/.netrc",
+ "*/.env",
+ "*/.env.*",
+ "**/.env",
+ "**/.env.*",
+ "**/credentials*",
+ "**/secrets*",
+ "**/*.pem",
+ "**/*.key",
+ "**/id_rsa*",
+ "**/id_ed25519*"
+ ]
+ },
+ {
+ "name": "block-dangerous-shell-commands",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["shell.exec", "bash", "exec", "run"],
+ "resources": [
+ "rm *",
+ "rm -rf *",
+ "rmdir *",
+ "unlink *",
+ "shred *",
+ "mv *",
+ "cp *",
+ "mkdir *",
+ "touch *",
+ "chmod *",
+ "chown *",
+ "chgrp *",
+ "sudo *",
+ "su *",
+ "doas *",
+ "pkexec *",
+ "apt *",
+ "apt-get *",
+ "yum *",
+ "dnf *",
+ "pacman *",
+ "brew *",
+ "npm *",
+ "pip *",
+ "pip3 *",
+ "curl * | bash*",
+ "curl * | sh*",
+ "wget * | bash*",
+ "wget * | sh*",
+ "git push*",
+ "git commit*",
+ "git reset*",
+ "git checkout*",
+ "git branch -d*",
+ "git branch -D*",
+ "vi *",
+ "vim *",
+ "nano *",
+ "emacs *",
+ "sed -i*",
+ "awk -i*"
+ ]
+ },
+ {
+ "name": "block-http-mutations",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["http.post", "http.put", "http.patch", "http.delete"],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-browser-mutations",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["browser.click", "browser.type", "browser.fill", "browser.submit"],
+ "resources": ["*"]
+ },
+ {
+ "name": "allow-filesystem-read",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "fs.read",
+ "fs.read_file",
+ "fs.list",
+ "fs.readdir",
+ "fs.stat",
+ "fs.exists",
+ "fs.access",
+ "file.read",
+ "read"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "allow-filesystem-search",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["fs.glob", "fs.find", "fs.search", "glob", "grep"],
+ "resources": ["*"]
+ },
+ {
+ "name": "allow-readonly-shell-commands",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["shell.exec", "bash", "exec", "run"],
+ "resources": [
+ "cat *",
+ "head *",
+ "tail *",
+ "less *",
+ "more *",
+ "bat *",
+ "ls *",
+ "ls",
+ "ll *",
+ "tree *",
+ "exa *",
+ "find *",
+ "locate *",
+ "which *",
+ "whereis *",
+ "type *",
+ "grep *",
+ "rg *",
+ "ag *",
+ "ack *",
+ "wc *",
+ "sort *",
+ "uniq *",
+ "cut *",
+ "awk *",
+ "sed *",
+ "jq *",
+ "yq *",
+ "pwd",
+ "whoami",
+ "hostname",
+ "uname *",
+ "date",
+ "uptime",
+ "df *",
+ "du *",
+ "free *",
+ "top -b*",
+ "ps *",
+ "env",
+ "printenv*",
+ "git status*",
+ "git log*",
+ "git diff*",
+ "git show*",
+ "git branch*",
+ "git remote*",
+ "git rev-parse*",
+ "git ls-files*",
+ "git blame*",
+ "wc -l*",
+ "cloc *",
+ "tokei *",
+ "file *",
+ "stat *"
+ ]
+ },
+ {
+ "name": "allow-https-get-requests",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["http.get", "http.request", "fetch", "browser.navigate", "browser.goto"],
+ "resources": ["https://*"]
+ },
+ {
+ "name": "allow-browser-read-operations",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "browser.snapshot",
+ "browser.screenshot",
+ "browser.read",
+ "browser.extract",
+ "browser.get_text",
+ "browser.get_html",
+ "browser.get_state",
+ "browser.scroll",
+ "browser.wait"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "default-deny",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["*"],
+ "resources": ["*"]
+ }
+ ]
+}
diff --git a/policies/strict-web-only.json b/policies/strict-web-only.json
new file mode 100644
index 000000000000..c475c58fd2a1
--- /dev/null
+++ b/policies/strict-web-only.json
@@ -0,0 +1,180 @@
+{
+ "rules": [
+ {
+ "name": "block-all-filesystem-read",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.read",
+ "fs.read_file",
+ "fs.list",
+ "fs.stat",
+ "fs.exists",
+ "file.read",
+ "read"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-all-filesystem-write",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.write",
+ "fs.write_file",
+ "fs.create",
+ "fs.delete",
+ "fs.remove",
+ "fs.mkdir",
+ "fs.rmdir",
+ "fs.rename",
+ "fs.copy",
+ "fs.move",
+ "file.write",
+ "write",
+ "edit"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-all-system-execution",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "os.exec",
+ "os.spawn",
+ "os.run",
+ "shell.exec",
+ "shell.run",
+ "bash",
+ "bash.exec",
+ "process.spawn",
+ "process.exec",
+ "system.run",
+ "system.exec",
+ "exec",
+ "run"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "block-insecure-http",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "http.request",
+ "http.get",
+ "http.post",
+ "browser.navigate",
+ "browser.goto",
+ "fetch"
+ ],
+ "resources": ["http://*", "file://*", "data:*", "javascript:*", "ftp://*"]
+ },
+ {
+ "name": "block-sensitive-form-fields",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["browser.type", "browser.fill", "browser.click"],
+ "resources": [
+ "element:input[type=password*",
+ "element:input[name=password*",
+ "element:input[name=credit*",
+ "element:input[type=credit*",
+ "element:*[name=ssn*",
+ "element:*[name=social_security*",
+ "element:*[name=api_key*",
+ "element:*[name=secret*",
+ "element:*[name=token*"
+ ]
+ },
+ {
+ "name": "allow-navigation-trusted-domains",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.navigate", "browser.goto", "http.get", "fetch"],
+ "resources": [
+ "https://www.google.com*",
+ "https://google.com*",
+ "https://www.github.com*",
+ "https://github.com*",
+ "https://api.github.com*",
+ "https://www.wikipedia.org*",
+ "https://en.wikipedia.org*",
+ "https://www.example.com*",
+ "https://example.com*"
+ ],
+ "required_labels": ["browser_initialized"]
+ },
+ {
+ "name": "allow-browser-snapshot",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.snapshot", "browser.screenshot", "browser.get_state"],
+ "resources": ["*"],
+ "required_labels": ["browser_initialized"]
+ },
+ {
+ "name": "allow-browser-click-safe-elements",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.click", "browser.element.click", "click"],
+ "resources": [
+ "element:button[*",
+ "element:a[*",
+ "element:input[type=button*",
+ "element:input[type=submit*",
+ "element:input[type=checkbox*",
+ "element:input[type=radio*",
+ "element:role=button[*",
+ "element:role=link[*",
+ "element:role=tab[*",
+ "element:role=menuitem[*",
+ "element#*"
+ ],
+ "required_labels": ["element_visible", "snapshot_captured"]
+ },
+ {
+ "name": "allow-browser-type-safe-inputs",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.type", "browser.fill", "browser.element.type"],
+ "resources": [
+ "element:input[type=text*",
+ "element:input[type=search*",
+ "element:input[type=email*",
+ "element:input[type=url*",
+ "element:input[type=tel*",
+ "element:input[type=number*",
+ "element:textarea[*",
+ "element:role=textbox[*",
+ "element:role=searchbox[*",
+ "element:role=combobox[*"
+ ],
+ "required_labels": ["element_visible", "snapshot_captured"]
+ },
+ {
+ "name": "allow-browser-read-content",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.read", "browser.extract", "browser.get_text", "browser.get_html"],
+ "resources": ["*"],
+ "required_labels": ["snapshot_captured"]
+ },
+ {
+ "name": "allow-browser-scroll-wait",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.scroll", "browser.wait", "browser.wait_for", "browser.sleep"],
+ "resources": ["*"],
+ "required_labels": ["browser_initialized"]
+ },
+ {
+ "name": "default-deny",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["*"],
+ "resources": ["*"]
+ }
+ ]
+}
diff --git a/policies/strict.json b/policies/strict.json
new file mode 100644
index 000000000000..3efc35d0d2b1
--- /dev/null
+++ b/policies/strict.json
@@ -0,0 +1,286 @@
+{
+ "rules": [
+ {
+ "name": "block-sensitive-files",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["fs.read", "fs.write", "fs.delete", "fs.*", "file.*", "read", "write", "edit"],
+ "resources": [
+ "**/.env",
+ "**/.env.*",
+ "**/credentials*",
+ "**/secrets*",
+ "**/*.pem",
+ "**/*.key",
+ "**/*.p12",
+ "**/*.pfx",
+ "*/.ssh/*",
+ "/etc/ssh/*",
+ "*/.aws/*",
+ "*/.azure/*",
+ "*/.config/gcloud/*",
+ "*/.kube/config",
+ "*/.npmrc",
+ "*/.pypirc",
+ "*/.netrc",
+ "*/.docker/config.json",
+ "/etc/passwd",
+ "/etc/shadow",
+ "/etc/sudoers",
+ "/etc/sudoers.d/*"
+ ]
+ },
+ {
+ "name": "block-outside-workspace",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": [
+ "fs.write",
+ "fs.delete",
+ "fs.create",
+ "fs.mkdir",
+ "fs.rmdir",
+ "fs.rename",
+ "file.write",
+ "file.delete",
+ "write",
+ "edit",
+ "delete"
+ ],
+ "resources": [
+ "/etc/*",
+ "/usr/*",
+ "/bin/*",
+ "/sbin/*",
+ "/var/*",
+ "/tmp/*",
+ "/System/*",
+ "/Library/*",
+ "/Applications/*",
+ "C:\\Windows\\*",
+ "C:\\Program Files*"
+ ]
+ },
+ {
+ "name": "block-dangerous-commands",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["shell.exec", "bash", "exec", "run"],
+ "resources": [
+ "rm -rf /*",
+ "rm -rf /",
+ "rm -rf ~/*",
+ ":(){ :|:& };:*",
+ "dd if=/dev/zero*",
+ "mkfs*",
+ "> /dev/sd*",
+ "sudo *",
+ "su *",
+ "doas *",
+ "pkexec *",
+ "runas *",
+ "chmod 777*",
+ "chmod -R 777*",
+ "chown root*",
+ "curl * | bash*",
+ "curl * | sh*",
+ "wget * | bash*",
+ "wget * | sh*",
+ "nc -l*",
+ "ncat -l*",
+ "*cat*id_rsa*",
+ "*cat*.pem*",
+ "echo $*_KEY*",
+ "echo $*_SECRET*",
+ "echo $*_TOKEN*",
+ "printenv*KEY*",
+ "printenv*SECRET*"
+ ]
+ },
+ {
+ "name": "block-insecure-protocols",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["http.request", "http.get", "http.post", "fetch", "browser.navigate"],
+ "resources": ["http://*", "ftp://*", "file://*", "data:text/html*", "javascript:*"]
+ },
+ {
+ "name": "allow-workspace-read",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "fs.read",
+ "fs.read_file",
+ "fs.list",
+ "fs.readdir",
+ "fs.stat",
+ "fs.exists",
+ "fs.glob",
+ "file.read",
+ "read",
+ "glob",
+ "grep"
+ ],
+ "resources": ["*"]
+ },
+ {
+ "name": "allow-workspace-write",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "fs.write",
+ "fs.write_file",
+ "fs.create",
+ "fs.mkdir",
+ "file.write",
+ "write",
+ "edit"
+ ],
+ "resources": [
+ "*/workspace/*",
+ "*/projects/*",
+ "*/src/*",
+ "*/code/*",
+ "*/.secureclaw/*",
+ "*/Downloads/*",
+ "*/Documents/*",
+ "./*",
+ "src/*",
+ "lib/*",
+ "test/*",
+ "tests/*",
+ "docs/*"
+ ],
+ "required_labels": ["intent_verified"]
+ },
+ {
+ "name": "allow-workspace-delete",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["fs.delete", "fs.remove", "fs.rmdir", "file.delete", "delete"],
+ "resources": ["*/workspace/*", "*/projects/*", "*/.secureclaw/*", "./*"],
+ "required_labels": ["intent_verified", "deletion_confirmed"]
+ },
+ {
+ "name": "allow-safe-shell-commands",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["shell.exec", "bash", "exec", "run"],
+ "resources": [
+ "cat *",
+ "head *",
+ "tail *",
+ "less *",
+ "bat *",
+ "ls*",
+ "tree *",
+ "pwd",
+ "cd *",
+ "find *",
+ "grep *",
+ "rg *",
+ "ag *",
+ "wc *",
+ "sort *",
+ "uniq *",
+ "cut *",
+ "awk *",
+ "sed *",
+ "jq *",
+ "whoami",
+ "hostname",
+ "uname *",
+ "date",
+ "env",
+ "which *",
+ "node *",
+ "npm run*",
+ "npm test*",
+ "npm install*",
+ "npx *",
+ "pnpm *",
+ "yarn *",
+ "bun *",
+ "python *",
+ "python3 *",
+ "pip install*",
+ "cargo *",
+ "go *",
+ "make *",
+ "git *",
+ "docker ps*",
+ "docker images*",
+ "docker logs*"
+ ]
+ },
+ {
+ "name": "allow-build-commands",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["shell.exec", "bash", "exec"],
+ "resources": [
+ "npm run build*",
+ "npm run test*",
+ "pnpm build*",
+ "pnpm test*",
+ "yarn build*",
+ "yarn test*",
+ "make build*",
+ "make test*",
+ "cargo build*",
+ "cargo test*",
+ "go build*",
+ "go test*"
+ ],
+ "required_labels": ["intent_verified"]
+ },
+ {
+ "name": "allow-https-requests",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "http.request",
+ "http.get",
+ "http.post",
+ "http.put",
+ "http.patch",
+ "http.delete",
+ "fetch"
+ ],
+ "resources": ["https://*"]
+ },
+ {
+ "name": "allow-browser-navigation",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": ["browser.navigate", "browser.goto"],
+ "resources": ["https://*"],
+ "required_labels": ["browser_initialized"]
+ },
+ {
+ "name": "allow-browser-interactions",
+ "effect": "allow",
+ "principals": ["agent:*"],
+ "actions": [
+ "browser.snapshot",
+ "browser.screenshot",
+ "browser.click",
+ "browser.type",
+ "browser.fill",
+ "browser.scroll",
+ "browser.wait",
+ "browser.read",
+ "browser.extract"
+ ],
+ "resources": ["*"],
+ "required_labels": ["browser_initialized", "snapshot_captured"]
+ },
+ {
+ "name": "default-deny",
+ "effect": "deny",
+ "principals": ["*"],
+ "actions": ["*"],
+ "resources": ["*"]
+ }
+ ]
+}
diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts
index 332cb430eec2..5207ee772de5 100644
--- a/src/agents/pi-embedded-runner-extraparams.test.ts
+++ b/src/agents/pi-embedded-runner-extraparams.test.ts
@@ -872,7 +872,7 @@ describe("applyExtraParamsToAgent", () => {
id: "gpt-4o",
baseUrl: "https://example.openai.azure.com/openai/v1",
compat: { supportsStore: false },
- } as Model<"openai-responses">,
+ } as unknown as Model<"openai-responses">,
});
expect(payload.store).toBe(false);
});
diff --git a/src/plugins/secureclaw/auto-register.ts b/src/plugins/secureclaw/auto-register.ts
new file mode 100644
index 000000000000..814c7bfc690d
--- /dev/null
+++ b/src/plugins/secureclaw/auto-register.ts
@@ -0,0 +1,39 @@
+/**
+ * SecureClaw Auto-Registration
+ *
+ * This module is imported early in the OpenClaw boot sequence to
+ * auto-register the SecureClaw security plugin.
+ */
+
+import { isSecureClawEnabled } from "./env.js";
+import { createSecureClawPlugin } from "./plugin.js";
+
+/**
+ * Auto-register SecureClaw with the plugin system.
+ * Returns the plugin definition for manual registration if needed.
+ */
+export function autoRegisterSecureClaw(): ReturnType | null {
+ if (!isSecureClawEnabled()) {
+ console.log("[SecureClaw] Disabled via SECURECLAW_DISABLED=true");
+ return null;
+ }
+
+ const plugin = createSecureClawPlugin();
+
+ console.log("[SecureClaw] Security middleware initialized");
+ console.log("[SecureClaw] All tool calls will be authorized before execution");
+
+ return plugin;
+}
+
+/**
+ * Get the SecureClaw plugin without auto-registering.
+ * Use this for manual plugin registration.
+ */
+export function getSecureClawPlugin(): ReturnType {
+ return createSecureClawPlugin();
+}
+
+// Export for direct import
+export { createSecureClawPlugin } from "./plugin.js";
+export { isSecureClawEnabled } from "./env.js";
diff --git a/src/plugins/secureclaw/config.ts b/src/plugins/secureclaw/config.ts
new file mode 100644
index 000000000000..ffe7ac2f6bb3
--- /dev/null
+++ b/src/plugins/secureclaw/config.ts
@@ -0,0 +1,64 @@
+/**
+ * SecureClaw Configuration
+ */
+
+export interface SecureClawConfig {
+ /** Agent principal identifier for authorization requests */
+ principal: string;
+
+ /** Path to YAML policy file */
+ policyFile: string;
+
+ /** Predicate Authority sidecar URL */
+ sidecarUrl: string;
+
+ /** Fail closed when sidecar is unavailable (default: true) */
+ failClosed: boolean;
+
+ /** Enable post-execution verification via Snapshot Engine (default: true) */
+ enablePostVerification: boolean;
+
+ /** Enable verbose logging */
+ verbose: boolean;
+
+ /** Session ID for audit trail correlation */
+ sessionId?: string;
+
+ /** Tenant ID for multi-tenant deployments */
+ tenantId?: string;
+
+ /** User ID for audit attribution */
+ userId?: string;
+}
+
+export const defaultConfig: SecureClawConfig = {
+ principal: "agent:secureclaw",
+ policyFile: "./policies/default.json",
+ sidecarUrl: "http://127.0.0.1:8787",
+ failClosed: true,
+ enablePostVerification: true,
+ verbose: false,
+};
+
+export function loadConfigFromEnv(): Partial {
+ return {
+ principal: process.env.SECURECLAW_PRINCIPAL,
+ policyFile: process.env.SECURECLAW_POLICY,
+ sidecarUrl: process.env.PREDICATE_SIDECAR_URL,
+ failClosed: process.env.SECURECLAW_FAIL_OPEN !== "true",
+ enablePostVerification: process.env.SECURECLAW_VERIFY !== "false",
+ verbose: process.env.SECURECLAW_VERBOSE === "true",
+ tenantId: process.env.SECURECLAW_TENANT_ID,
+ userId: process.env.SECURECLAW_USER_ID,
+ };
+}
+
+export function mergeConfig(
+ base: SecureClawConfig,
+ overrides: Partial,
+): SecureClawConfig {
+ return {
+ ...base,
+ ...Object.fromEntries(Object.entries(overrides).filter(([_, v]) => v !== undefined)),
+ } as SecureClawConfig;
+}
diff --git a/src/plugins/secureclaw/env.ts b/src/plugins/secureclaw/env.ts
new file mode 100644
index 000000000000..97e8f69b9f76
--- /dev/null
+++ b/src/plugins/secureclaw/env.ts
@@ -0,0 +1,101 @@
+/**
+ * SecureClaw Environment Configuration
+ *
+ * All SecureClaw settings can be configured via environment variables.
+ * This file documents and validates all supported environment variables.
+ */
+
+export interface SecureClawEnvConfig {
+ /** Agent principal identifier (default: "agent:secureclaw") */
+ SECURECLAW_PRINCIPAL?: string;
+
+ /** Path to YAML policy file (default: "./policies/default.yaml") */
+ SECURECLAW_POLICY?: string;
+
+ /** Predicate Authority sidecar URL (default: "http://127.0.0.1:9120") */
+ PREDICATE_SIDECAR_URL?: string;
+
+ /** Set to "true" to fail-open when sidecar is unavailable (default: false) */
+ SECURECLAW_FAIL_OPEN?: string;
+
+ /** Set to "false" to disable post-execution verification (default: true) */
+ SECURECLAW_VERIFY?: string;
+
+ /** Set to "true" for verbose logging (default: false) */
+ SECURECLAW_VERBOSE?: string;
+
+ /** Tenant ID for multi-tenant deployments */
+ SECURECLAW_TENANT_ID?: string;
+
+ /** User ID for audit attribution */
+ SECURECLAW_USER_ID?: string;
+
+ /** Set to "true" to completely disable SecureClaw */
+ SECURECLAW_DISABLED?: string;
+}
+
+/**
+ * Check if SecureClaw is enabled via environment.
+ */
+export function isSecureClawEnabled(): boolean {
+ return process.env.SECURECLAW_DISABLED !== "true";
+}
+
+/**
+ * Get all SecureClaw environment variables with their current values.
+ */
+export function getSecureClawEnv(): SecureClawEnvConfig {
+ return {
+ SECURECLAW_PRINCIPAL: process.env.SECURECLAW_PRINCIPAL,
+ SECURECLAW_POLICY: process.env.SECURECLAW_POLICY,
+ PREDICATE_SIDECAR_URL: process.env.PREDICATE_SIDECAR_URL,
+ SECURECLAW_FAIL_OPEN: process.env.SECURECLAW_FAIL_OPEN,
+ SECURECLAW_VERIFY: process.env.SECURECLAW_VERIFY,
+ SECURECLAW_VERBOSE: process.env.SECURECLAW_VERBOSE,
+ SECURECLAW_TENANT_ID: process.env.SECURECLAW_TENANT_ID,
+ SECURECLAW_USER_ID: process.env.SECURECLAW_USER_ID,
+ SECURECLAW_DISABLED: process.env.SECURECLAW_DISABLED,
+ };
+}
+
+/**
+ * Print SecureClaw configuration for debugging.
+ */
+export function printSecureClawConfig(): void {
+ const env = getSecureClawEnv();
+ console.log("SecureClaw Configuration:");
+ console.log(" SECURECLAW_PRINCIPAL:", env.SECURECLAW_PRINCIPAL ?? "(default: agent:secureclaw)");
+ console.log(
+ " SECURECLAW_POLICY:",
+ env.SECURECLAW_POLICY ?? "(default: ./policies/default.yaml)",
+ );
+ console.log(
+ " PREDICATE_SIDECAR_URL:",
+ env.PREDICATE_SIDECAR_URL ?? "(default: http://127.0.0.1:9120)",
+ );
+ console.log(" SECURECLAW_FAIL_OPEN:", env.SECURECLAW_FAIL_OPEN ?? "(default: false)");
+ console.log(" SECURECLAW_VERIFY:", env.SECURECLAW_VERIFY ?? "(default: true)");
+ console.log(" SECURECLAW_VERBOSE:", env.SECURECLAW_VERBOSE ?? "(default: false)");
+ console.log(" SECURECLAW_TENANT_ID:", env.SECURECLAW_TENANT_ID ?? "(not set)");
+ console.log(" SECURECLAW_USER_ID:", env.SECURECLAW_USER_ID ?? "(not set)");
+ console.log(" SECURECLAW_DISABLED:", env.SECURECLAW_DISABLED ?? "(default: false)");
+}
+
+/**
+ * Environment variable documentation for README.
+ */
+export const ENV_DOCS = `
+## SecureClaw Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| \`SECURECLAW_PRINCIPAL\` | \`agent:secureclaw\` | Agent identity for authorization requests |
+| \`SECURECLAW_POLICY\` | \`./policies/default.yaml\` | Path to YAML policy file |
+| \`PREDICATE_SIDECAR_URL\` | \`http://127.0.0.1:9120\` | Predicate Authority sidecar endpoint |
+| \`SECURECLAW_FAIL_OPEN\` | \`false\` | Set to \`true\` to allow actions when sidecar is unavailable |
+| \`SECURECLAW_VERIFY\` | \`true\` | Set to \`false\` to disable post-execution verification |
+| \`SECURECLAW_VERBOSE\` | \`false\` | Set to \`true\` for detailed logging |
+| \`SECURECLAW_TENANT_ID\` | *(none)* | Tenant ID for multi-tenant deployments |
+| \`SECURECLAW_USER_ID\` | *(none)* | User ID for audit attribution |
+| \`SECURECLAW_DISABLED\` | \`false\` | Set to \`true\` to completely disable SecureClaw |
+`;
diff --git a/src/plugins/secureclaw/index.ts b/src/plugins/secureclaw/index.ts
new file mode 100644
index 000000000000..0025a6a8d7d2
--- /dev/null
+++ b/src/plugins/secureclaw/index.ts
@@ -0,0 +1,27 @@
+/**
+ * SecureClaw Plugin
+ *
+ * Zero-trust security middleware for OpenClaw.
+ * Intercepts all tool calls with pre-authorization and post-verification.
+ */
+
+// Core plugin
+export { createSecureClawPlugin, type SecureClawPluginOptions } from "./plugin.js";
+
+// Resource extraction utilities
+export {
+ extractResource,
+ extractAction,
+ redactResource,
+ isSensitiveResource,
+} from "./resource-extractor.js";
+
+// Configuration
+export type { SecureClawConfig } from "./config.js";
+export { defaultConfig, loadConfigFromEnv, mergeConfig } from "./config.js";
+
+// Environment variables
+export { isSecureClawEnabled, getSecureClawEnv, printSecureClawConfig, ENV_DOCS } from "./env.js";
+
+// Auto-registration
+export { autoRegisterSecureClaw, getSecureClawPlugin } from "./auto-register.js";
diff --git a/src/plugins/secureclaw/integration.test.ts b/src/plugins/secureclaw/integration.test.ts
new file mode 100644
index 000000000000..5a923c2ca8eb
--- /dev/null
+++ b/src/plugins/secureclaw/integration.test.ts
@@ -0,0 +1,335 @@
+import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
+import { createSecureClawPlugin } from "./plugin.js";
+import { extractAction, extractResource } from "./resource-extractor.js";
+
+/**
+ * Integration tests for SecureClaw plugin.
+ *
+ * These tests verify the full authorization flow from tool call
+ * to predicate-claw SDK to decision enforcement.
+ *
+ * Note: These tests mock the predicate-claw SDK but test the full plugin integration.
+ * For live sidecar tests, see the e2e test suite.
+ */
+
+// Use vi.hoisted to define mocks that will be available when vi.mock is hoisted
+const { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError } = vi.hoisted(() => {
+ const mockGuardOrThrow = vi.fn();
+
+ class ActionDeniedError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ActionDeniedError";
+ }
+ }
+
+ class SidecarUnavailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SidecarUnavailableError";
+ }
+ }
+
+ return { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError };
+});
+
+// Mock predicate-claw SDK
+vi.mock("predicate-claw", () => {
+ class MockGuardedProvider {
+ guardOrThrow = mockGuardOrThrow;
+ }
+
+ return {
+ GuardedProvider: MockGuardedProvider,
+ ActionDeniedError,
+ SidecarUnavailableError,
+ };
+});
+
+describe("SecureClaw Integration", () => {
+ // Track all hook registrations
+ let mockLogger: {
+ info: ReturnType;
+ warn: ReturnType;
+ error: ReturnType;
+ };
+
+ beforeAll(() => {
+ mockLogger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+ });
+
+ afterAll(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("Full authorization flow", () => {
+ it("blocks sensitive file access with detailed reason", async () => {
+ const plugin = createSecureClawPlugin({
+ principal: "agent:test",
+ sidecarUrl: "http://test-sidecar:8787",
+ failClosed: true,
+ verbose: true,
+ });
+
+ const hooks: Map = new Map();
+ const mockApi = {
+ logger: mockLogger,
+ on: vi.fn((hookName: string, handler: Function) => {
+ hooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK to deny .ssh access
+ mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("sensitive_resource_blocked"));
+
+ const beforeToolCall = hooks.get("before_tool_call")!;
+
+ // Try to read SSH key
+ const result = await beforeToolCall(
+ {
+ toolName: "Read",
+ params: { file_path: "/home/user/.ssh/id_rsa" },
+ },
+ { toolName: "Read", agentId: "test-agent" },
+ );
+
+ expect(result).toMatchObject({
+ block: true,
+ blockReason: expect.stringContaining("sensitive_resource_blocked"),
+ });
+
+ // Verify SDK was called with correct action/resource
+ expect(mockGuardOrThrow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "fs.read",
+ resource: "/home/user/.ssh/id_rsa",
+ }),
+ );
+ });
+
+ it("allows safe operations and tracks metrics", async () => {
+ const plugin = createSecureClawPlugin({
+ verbose: true,
+ });
+
+ const hooks: Map = new Map();
+ const mockApi = {
+ logger: mockLogger,
+ on: vi.fn((hookName: string, handler: Function) => {
+ hooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Start session
+ const sessionStart = hooks.get("session_start")!;
+ sessionStart({ sessionId: "integration-test-123" }, { sessionId: "integration-test-123" });
+
+ // Mock SDK to allow with mandate ID
+ mockGuardOrThrow.mockResolvedValue("mandate-abc");
+
+ const beforeToolCall = hooks.get("before_tool_call")!;
+
+ // Multiple tool calls
+ for (const file of ["index.ts", "utils.ts", "config.ts"]) {
+ const result = await beforeToolCall(
+ {
+ toolName: "Read",
+ params: { file_path: `/src/${file}` },
+ },
+ { toolName: "Read" },
+ );
+ expect(result).toBeUndefined(); // Allowed
+ }
+
+ // End session - should log metrics
+ const sessionEnd = hooks.get("session_end")!;
+ sessionEnd(
+ { sessionId: "integration-test-123", messageCount: 5, durationMs: 1000 },
+ { sessionId: "integration-test-123" },
+ );
+
+ // Verify metrics logged
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Tool metrics"));
+ });
+
+ it("handles shell command authorization", async () => {
+ const plugin = createSecureClawPlugin({ verbose: false });
+
+ const hooks: Map = new Map();
+ const mockApi = {
+ logger: mockLogger,
+ on: vi.fn((hookName: string, handler: Function) => {
+ hooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Test dangerous command - should be denied
+ mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("dangerous_shell_command"));
+
+ const beforeToolCall = hooks.get("before_tool_call")!;
+
+ const dangerousResult = await beforeToolCall(
+ {
+ toolName: "Bash",
+ params: { command: "rm -rf /" },
+ },
+ { toolName: "Bash" },
+ );
+
+ expect(dangerousResult).toMatchObject({
+ block: true,
+ });
+
+ // Test safe command - should be allowed
+ mockGuardOrThrow.mockResolvedValueOnce("safe-cmd");
+
+ const safeResult = await beforeToolCall(
+ {
+ toolName: "Bash",
+ params: { command: "npm test" },
+ },
+ { toolName: "Bash" },
+ );
+
+ expect(safeResult).toBeUndefined();
+ });
+ });
+
+ describe("Action and resource extraction", () => {
+ it("correctly maps OpenClaw tools to Predicate actions", () => {
+ // File operations
+ expect(extractAction("Read")).toBe("fs.read");
+ expect(extractAction("Write")).toBe("fs.write");
+ expect(extractAction("Edit")).toBe("fs.write");
+ expect(extractAction("Glob")).toBe("fs.list");
+
+ // Shell
+ expect(extractAction("Bash")).toBe("shell.exec");
+
+ // Network
+ expect(extractAction("WebFetch")).toBe("http.request");
+
+ // Browser
+ expect(extractAction("computer-use:navigate")).toBe("browser.navigate");
+ expect(extractAction("computer-use:click")).toBe("browser.interact");
+
+ // Agent
+ expect(extractAction("Task")).toBe("agent.spawn");
+ });
+
+ it("extracts resources from various param formats", () => {
+ // Standard file_path
+ expect(extractResource("Read", { file_path: "/app/src/main.ts" })).toBe("/app/src/main.ts");
+
+ // Alternative path key
+ expect(extractResource("Read", { path: "/app/config.json" })).toBe("/app/config.json");
+
+ // Bash command
+ expect(extractResource("Bash", { command: "npm install" })).toBe("npm install");
+
+ // URL
+ expect(extractResource("WebFetch", { url: "https://api.example.com" })).toBe(
+ "https://api.example.com",
+ );
+
+ // Browser navigation
+ expect(extractResource("computer-use:navigate", { url: "https://app.example.com" })).toBe(
+ "https://app.example.com",
+ );
+ });
+ });
+
+ describe("Error handling", () => {
+ it("handles sidecar timeout gracefully", async () => {
+ const plugin = createSecureClawPlugin({
+ failClosed: true,
+ verbose: false,
+ });
+
+ const hooks: Map = new Map();
+ const mockApi = {
+ logger: mockLogger,
+ on: vi.fn((hookName: string, handler: Function) => {
+ hooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock timeout via generic error (SDK converts to appropriate error)
+ mockGuardOrThrow.mockRejectedValueOnce(new Error("Timeout"));
+
+ const beforeToolCall = hooks.get("before_tool_call")!;
+
+ const result = await beforeToolCall(
+ {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ },
+ { toolName: "Read" },
+ );
+
+ // Should block in fail-closed mode
+ expect(result).toMatchObject({
+ block: true,
+ blockReason: expect.stringContaining("unavailable"),
+ });
+ });
+
+ it("handles SDK throwing ActionDeniedError correctly", async () => {
+ const plugin = createSecureClawPlugin({
+ failClosed: true,
+ verbose: false,
+ });
+
+ const hooks: Map = new Map();
+ const mockApi = {
+ logger: mockLogger,
+ on: vi.fn((hookName: string, handler: Function) => {
+ hooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK throwing ActionDeniedError (policy denied)
+ mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("no_matching_allow_rule"));
+
+ const beforeToolCall = hooks.get("before_tool_call")!;
+
+ const result = await beforeToolCall(
+ {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ },
+ { toolName: "Read" },
+ );
+
+ // Should block with policy reason
+ expect(result).toMatchObject({
+ block: true,
+ blockReason: expect.stringContaining("no_matching_allow_rule"),
+ });
+ });
+ });
+});
diff --git a/src/plugins/secureclaw/plugin.test.ts b/src/plugins/secureclaw/plugin.test.ts
new file mode 100644
index 000000000000..1de4abc272e5
--- /dev/null
+++ b/src/plugins/secureclaw/plugin.test.ts
@@ -0,0 +1,377 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type {
+ PluginHookBeforeToolCallEvent,
+ PluginHookAfterToolCallEvent,
+ PluginHookSessionStartEvent,
+ PluginHookSessionEndEvent,
+ PluginHookToolContext,
+ PluginHookSessionContext,
+} from "../types.js";
+import { createSecureClawPlugin } from "./plugin.js";
+
+// Use vi.hoisted to define mocks that will be available when vi.mock is hoisted
+const { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError } = vi.hoisted(() => {
+ const mockGuardOrThrow = vi.fn();
+
+ class ActionDeniedError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ActionDeniedError";
+ }
+ }
+
+ class SidecarUnavailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SidecarUnavailableError";
+ }
+ }
+
+ return { mockGuardOrThrow, ActionDeniedError, SidecarUnavailableError };
+});
+
+// Mock predicate-claw SDK
+vi.mock("predicate-claw", () => {
+ class MockGuardedProvider {
+ guardOrThrow = mockGuardOrThrow;
+ }
+
+ return {
+ GuardedProvider: MockGuardedProvider,
+ ActionDeniedError,
+ SidecarUnavailableError,
+ };
+});
+
+describe("SecureClaw Plugin", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("createSecureClawPlugin", () => {
+ it("creates a plugin with correct metadata", () => {
+ const plugin = createSecureClawPlugin();
+
+ expect(plugin.id).toBe("secureclaw");
+ expect(plugin.name).toBe("SecureClaw");
+ expect(plugin.version).toBe("1.0.0");
+ expect(plugin.description?.toLowerCase()).toContain("zero-trust");
+ });
+
+ it("accepts custom options", () => {
+ const plugin = createSecureClawPlugin({
+ principal: "agent:custom",
+ sidecarUrl: "http://localhost:9999",
+ failClosed: false,
+ verbose: true,
+ });
+
+ expect(plugin).toBeDefined();
+ });
+ });
+
+ describe("before_tool_call hook", () => {
+ it("blocks tool call when sidecar denies", async () => {
+ const plugin = createSecureClawPlugin({ verbose: false });
+
+ // Mock API to capture registered hooks
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ // Activate plugin
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK to throw ActionDeniedError
+ mockGuardOrThrow.mockRejectedValueOnce(new ActionDeniedError("policy_violation"));
+
+ // Get the before_tool_call handler
+ const beforeToolCall = registeredHooks.get("before_tool_call");
+ expect(beforeToolCall).toBeDefined();
+
+ // Call the handler
+ const event: PluginHookBeforeToolCallEvent = {
+ toolName: "Bash",
+ params: { command: "rm -rf /" },
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Bash",
+ agentId: "test-agent",
+ sessionKey: "test-session",
+ };
+
+ const result = await beforeToolCall!(event, ctx);
+
+ expect(result).toEqual({
+ block: true,
+ blockReason: expect.stringContaining("policy_violation"),
+ });
+ });
+
+ it("allows tool call when sidecar approves", async () => {
+ const plugin = createSecureClawPlugin({ verbose: false });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK to return mandate ID (allowed)
+ mockGuardOrThrow.mockResolvedValueOnce("mandate-123");
+
+ const beforeToolCall = registeredHooks.get("before_tool_call");
+ const event: PluginHookBeforeToolCallEvent = {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Read",
+ };
+
+ const result = await beforeToolCall!(event, ctx);
+
+ // Should return undefined (allow)
+ expect(result).toBeUndefined();
+ });
+
+ it("blocks in fail-closed mode when sidecar unavailable", async () => {
+ const plugin = createSecureClawPlugin({
+ failClosed: true,
+ verbose: false,
+ });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK to throw SidecarUnavailableError
+ mockGuardOrThrow.mockRejectedValueOnce(new SidecarUnavailableError("Connection refused"));
+
+ const beforeToolCall = registeredHooks.get("before_tool_call");
+ const event: PluginHookBeforeToolCallEvent = {
+ toolName: "Bash",
+ params: { command: "echo hello" },
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Bash",
+ };
+
+ const result = await beforeToolCall!(event, ctx);
+
+ expect(result).toEqual({
+ block: true,
+ blockReason: expect.stringContaining("unavailable"),
+ });
+ });
+
+ it("allows in fail-open mode when sidecar unavailable", async () => {
+ const plugin = createSecureClawPlugin({
+ failClosed: false,
+ verbose: false,
+ });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Mock SDK to return null (fail-open behavior from guardOrThrow)
+ mockGuardOrThrow.mockResolvedValueOnce(null);
+
+ const beforeToolCall = registeredHooks.get("before_tool_call");
+ const event: PluginHookBeforeToolCallEvent = {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Read",
+ };
+
+ const result = await beforeToolCall!(event, ctx);
+
+ // Should return undefined (allow in fail-open)
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe("session hooks", () => {
+ it("tracks session start and end", async () => {
+ const plugin = createSecureClawPlugin({ verbose: true });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ // Session start
+ const sessionStart = registeredHooks.get("session_start");
+ expect(sessionStart).toBeDefined();
+
+ const startEvent: PluginHookSessionStartEvent = {
+ sessionId: "test-session-123",
+ };
+ const startCtx: PluginHookSessionContext = {
+ sessionId: "test-session-123",
+ };
+
+ sessionStart!(startEvent, startCtx);
+
+ expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Session started"));
+
+ // Session end
+ const sessionEnd = registeredHooks.get("session_end");
+ expect(sessionEnd).toBeDefined();
+
+ const endEvent: PluginHookSessionEndEvent = {
+ sessionId: "test-session-123",
+ messageCount: 10,
+ durationMs: 5000,
+ };
+
+ sessionEnd!(endEvent, startCtx);
+
+ expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Session ended"));
+ });
+ });
+
+ describe("after_tool_call hook", () => {
+ it("logs tool execution for verification", async () => {
+ const plugin = createSecureClawPlugin({
+ enablePostVerification: true,
+ verbose: true,
+ });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ const afterToolCall = registeredHooks.get("after_tool_call");
+ expect(afterToolCall).toBeDefined();
+
+ const event: PluginHookAfterToolCallEvent = {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ result: "file contents...",
+ durationMs: 50,
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Read",
+ };
+
+ await afterToolCall!(event, ctx);
+
+ expect(mockApi.logger.info).toHaveBeenCalledWith(expect.stringContaining("Post-verify"));
+ });
+
+ it("skips verification when disabled", async () => {
+ const plugin = createSecureClawPlugin({
+ enablePostVerification: false,
+ verbose: true,
+ });
+
+ const registeredHooks: Map = new Map();
+ const mockApi = {
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ on: vi.fn((hookName: string, handler: Function) => {
+ registeredHooks.set(hookName, handler);
+ }),
+ };
+
+ await plugin.activate?.(
+ mockApi as unknown as Parameters>[0],
+ );
+
+ const afterToolCall = registeredHooks.get("after_tool_call");
+
+ const event: PluginHookAfterToolCallEvent = {
+ toolName: "Read",
+ params: { file_path: "/src/index.ts" },
+ result: "file contents...",
+ durationMs: 50,
+ };
+ const ctx: PluginHookToolContext = {
+ toolName: "Read",
+ };
+
+ await afterToolCall!(event, ctx);
+
+ // Should not log post-verify when disabled
+ expect(mockApi.logger.info).not.toHaveBeenCalledWith(expect.stringContaining("Post-verify"));
+ });
+ });
+});
diff --git a/src/plugins/secureclaw/plugin.ts b/src/plugins/secureclaw/plugin.ts
new file mode 100644
index 000000000000..f509579ca30c
--- /dev/null
+++ b/src/plugins/secureclaw/plugin.ts
@@ -0,0 +1,331 @@
+/**
+ * SecureClaw Plugin Implementation
+ *
+ * Integrates Predicate Authority for pre-execution authorization
+ * and post-execution verification into OpenClaw's hook system.
+ *
+ * Uses predicate-claw (openclaw-predicate-provider) for authorization
+ * via the GuardedProvider class, which communicates with the
+ * rust-predicate-authorityd sidecar.
+ */
+
+import {
+ GuardedProvider,
+ ActionDeniedError,
+ SidecarUnavailableError,
+ type GuardRequest,
+ type GuardTelemetry,
+ type DecisionTelemetryEvent,
+ type DecisionAuditExporter,
+} from "predicate-claw";
+import type {
+ OpenClawPluginDefinition,
+ OpenClawPluginApi,
+ PluginHookBeforeToolCallEvent,
+ PluginHookBeforeToolCallResult,
+ PluginHookAfterToolCallEvent,
+ PluginHookSessionStartEvent,
+ PluginHookSessionEndEvent,
+ PluginHookToolContext,
+ PluginHookSessionContext,
+} from "../types.js";
+import { type SecureClawConfig, defaultConfig, loadConfigFromEnv, mergeConfig } from "./config.js";
+import { extractAction, extractResource, redactResource } from "./resource-extractor.js";
+
+export interface SecureClawPluginOptions extends Partial {}
+
+/**
+ * Create the SecureClaw plugin instance.
+ */
+export function createSecureClawPlugin(
+ options: SecureClawPluginOptions = {},
+): OpenClawPluginDefinition {
+ // Merge config: defaults -> env -> explicit options
+ const envConfig = loadConfigFromEnv();
+ const config = mergeConfig(mergeConfig(defaultConfig, envConfig), options);
+
+ // Session tracking for audit trail
+ let currentSessionId: string | undefined;
+ let sessionStartTime: number | undefined;
+ const toolCallMetrics: Map = new Map();
+
+ return {
+ id: "secureclaw",
+ name: "SecureClaw",
+ description: "Zero-trust security middleware with pre-authorization and post-verification",
+ version: "1.0.0",
+
+ async activate(api: OpenClawPluginApi) {
+ const log = api.logger;
+
+ // Create telemetry handler for logging decisions
+ const telemetry: GuardTelemetry = {
+ onDecision(event: DecisionTelemetryEvent) {
+ if (config.verbose) {
+ const status =
+ event.outcome === "allow"
+ ? "ALLOWED"
+ : event.outcome === "deny"
+ ? "BLOCKED"
+ : "ERROR";
+ log.info(
+ `[SecureClaw] ${status}: ${event.action} on ${event.resource} (${event.reason ?? "no reason"})`,
+ );
+ }
+ },
+ };
+
+ // Create audit exporter if needed
+ const auditExporter: DecisionAuditExporter = {
+ async exportDecision(_event: DecisionTelemetryEvent) {
+ // TODO: Send to centralized audit log (e.g., via OTLP)
+ // For now, this is a no-op placeholder
+ // In production:
+ // 1. Send to centralized audit log
+ // 2. Include correlation IDs for tracing
+ // 3. Ensure tamper-proof storage
+ },
+ };
+
+ // Create GuardedProvider instance from predicate-claw SDK
+ const guardedProvider = new GuardedProvider({
+ principal: config.principal,
+ config: {
+ baseUrl: config.sidecarUrl,
+ failClosed: config.failClosed,
+ timeoutMs: 5000, // 5 second timeout for tool calls
+ maxRetries: 0,
+ backoffInitialMs: 100,
+ },
+ telemetry,
+ auditExporter,
+ });
+
+ if (config.verbose) {
+ log.info(`[SecureClaw] Activating with principal: ${config.principal}`);
+ log.info(`[SecureClaw] Sidecar URL: ${config.sidecarUrl}`);
+ log.info(`[SecureClaw] Fail closed: ${config.failClosed}`);
+ log.info(`[SecureClaw] Post-verification: ${config.enablePostVerification}`);
+ }
+
+ // =======================================================================
+ // Hook: session_start - Initialize audit trail
+ // =======================================================================
+ api.on(
+ "session_start",
+ (event: PluginHookSessionStartEvent, _ctx: PluginHookSessionContext) => {
+ currentSessionId = event.sessionId;
+ sessionStartTime = Date.now();
+ toolCallMetrics.clear();
+
+ if (config.verbose) {
+ log.info(`[SecureClaw] Session started: ${event.sessionId}`);
+ }
+ },
+ { priority: 100 }, // High priority to run early
+ );
+
+ // =======================================================================
+ // Hook: session_end - Finalize audit trail
+ // =======================================================================
+ api.on(
+ "session_end",
+ (event: PluginHookSessionEndEvent, _ctx: PluginHookSessionContext) => {
+ const duration = sessionStartTime ? Date.now() - sessionStartTime : 0;
+
+ if (config.verbose) {
+ log.info(`[SecureClaw] Session ended: ${event.sessionId}`);
+ log.info(`[SecureClaw] Duration: ${duration}ms`);
+ log.info(`[SecureClaw] Tool metrics:`);
+ for (const [tool, metrics] of toolCallMetrics) {
+ log.info(` ${tool}: ${metrics.count} calls, ${metrics.blocked} blocked`);
+ }
+ }
+
+ // Reset state
+ currentSessionId = undefined;
+ sessionStartTime = undefined;
+ toolCallMetrics.clear();
+ },
+ { priority: 100 },
+ );
+
+ // =======================================================================
+ // Hook: before_tool_call - Pre-execution authorization gate
+ // =======================================================================
+ api.on(
+ "before_tool_call",
+ async (
+ event: PluginHookBeforeToolCallEvent,
+ ctx: PluginHookToolContext,
+ ): Promise => {
+ const { toolName, params } = event;
+ const action = extractAction(toolName);
+ const resource = extractResource(toolName, params);
+
+ // Track metrics
+ const metrics = toolCallMetrics.get(toolName) ?? { count: 0, blocked: 0 };
+ metrics.count++;
+ toolCallMetrics.set(toolName, metrics);
+
+ if (config.verbose) {
+ log.info(`[SecureClaw] Pre-auth: ${action} on ${redactResource(resource)}`);
+ }
+
+ try {
+ // Build guard request for predicate-claw SDK
+ const guardRequest: GuardRequest = {
+ action,
+ resource,
+ args: params,
+ context: {
+ session_id: currentSessionId ?? ctx.sessionKey,
+ tenant_id: config.tenantId,
+ user_id: config.userId,
+ agent_id: ctx.agentId,
+ source: "secureclaw",
+ },
+ };
+
+ // Use guardOrThrow which handles fail-open/fail-closed internally
+ await guardedProvider.guardOrThrow(guardRequest);
+
+ // If we get here, the action was allowed
+ return undefined;
+ } catch (error) {
+ // Handle ActionDeniedError - action was explicitly denied by policy
+ if (error instanceof ActionDeniedError) {
+ metrics.blocked++;
+ toolCallMetrics.set(toolName, metrics);
+
+ const reason = error.message ?? "denied_by_policy";
+ if (config.verbose) {
+ log.warn(`[SecureClaw] BLOCKED: ${action} - ${reason}`);
+ }
+
+ return {
+ block: true,
+ blockReason: `[SecureClaw] Action blocked: ${reason}`,
+ };
+ }
+
+ // Handle SidecarUnavailableError - sidecar is down
+ if (error instanceof SidecarUnavailableError) {
+ // In fail-closed mode (handled by guardOrThrow), this error is thrown
+ // In fail-open mode, guardOrThrow returns null instead of throwing
+ metrics.blocked++;
+ toolCallMetrics.set(toolName, metrics);
+
+ log.error(`[SecureClaw] Sidecar error (fail-closed): ${error.message}`);
+ return {
+ block: true,
+ blockReason: `[SecureClaw] Authorization service unavailable (fail-closed mode)`,
+ };
+ }
+
+ // Unknown error - treat as sidecar unavailable
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ if (config.failClosed) {
+ metrics.blocked++;
+ toolCallMetrics.set(toolName, metrics);
+
+ log.error(`[SecureClaw] Unknown error (fail-closed): ${errorMessage}`);
+ return {
+ block: true,
+ blockReason: `[SecureClaw] Authorization service unavailable (fail-closed mode)`,
+ };
+ }
+
+ log.warn(`[SecureClaw] Unknown error (fail-open): ${errorMessage}`);
+ return undefined; // Allow in fail-open mode
+ }
+ },
+ { priority: 1000 }, // Very high priority - security checks first
+ );
+
+ // =======================================================================
+ // Hook: after_tool_call - Post-execution verification
+ // =======================================================================
+ api.on(
+ "after_tool_call",
+ async (event: PluginHookAfterToolCallEvent, _ctx: PluginHookToolContext): Promise => {
+ if (!config.enablePostVerification) {
+ return;
+ }
+
+ const { toolName, params, result, error, durationMs } = event;
+ const action = extractAction(toolName);
+ const resource = extractResource(toolName, params);
+
+ if (config.verbose) {
+ log.info(
+ `[SecureClaw] Post-verify: ${action} on ${redactResource(resource)} ` +
+ `(${durationMs ?? 0}ms, error: ${error ? "yes" : "no"})`,
+ );
+ }
+
+ // For browser operations, verify DOM state
+ if (action.startsWith("browser.")) {
+ await verifyBrowserState(toolName, params, result, log, config.verbose);
+ }
+
+ // For file operations, verify write success
+ if (action === "fs.write" && !error) {
+ await verifyFileWrite(toolName, params, result, log, config.verbose);
+ }
+ },
+ { priority: 100 },
+ );
+
+ log.info("[SecureClaw] Plugin activated - all tool calls will be authorized");
+ },
+ };
+}
+
+/**
+ * Verify browser state after browser operations (placeholder for Snapshot Engine).
+ */
+async function verifyBrowserState(
+ toolName: string,
+ params: Record,
+ result: unknown,
+ log: { info: (msg: string) => void; warn: (msg: string) => void },
+ verbose: boolean,
+): Promise {
+ // TODO: Integrate with Snapshot Engine for DOM diffing
+ // This is a placeholder for post-execution verification
+
+ if (verbose) {
+ log.info(`[SecureClaw] Browser verification: ${toolName} (placeholder)`);
+ }
+
+ // In full implementation:
+ // 1. Capture DOM snapshot after operation
+ // 2. Compare against expected state from pre-operation snapshot
+ // 3. Verify only authorized changes occurred
+ // 4. Flag any unexpected DOM mutations
+}
+
+/**
+ * Verify file write operations completed as expected.
+ */
+async function verifyFileWrite(
+ toolName: string,
+ params: Record,
+ result: unknown,
+ log: { info: (msg: string) => void; warn: (msg: string) => void },
+ verbose: boolean,
+): Promise {
+ // TODO: Implement file verification
+ // This is a placeholder for post-execution verification
+
+ if (verbose) {
+ log.info(`[SecureClaw] File write verification: ${toolName} (placeholder)`);
+ }
+
+ // In full implementation:
+ // 1. Read file after write
+ // 2. Compute hash of written content
+ // 3. Compare against intent_hash from authorization
+ // 4. Flag any discrepancies
+}
diff --git a/src/plugins/secureclaw/predicate-claw.d.ts b/src/plugins/secureclaw/predicate-claw.d.ts
new file mode 100644
index 000000000000..2a3bbe2e6b4c
--- /dev/null
+++ b/src/plugins/secureclaw/predicate-claw.d.ts
@@ -0,0 +1,128 @@
+/**
+ * Type declarations for predicate-claw SDK
+ *
+ * predicate-claw is the TypeScript SDK for integrating with
+ * Predicate Authority's rust-predicate-authorityd sidecar.
+ */
+
+declare module "predicate-claw" {
+ /**
+ * Guard request sent to the sidecar for authorization.
+ */
+ export interface GuardRequest {
+ /** The action being performed (e.g., "fs.read", "shell.exec") */
+ action: string;
+ /** The resource being accessed (e.g., file path, command) */
+ resource: string;
+ /** Optional arguments/parameters for the action */
+ args?: Record;
+ /** Additional context for the authorization decision */
+ context?: {
+ session_id?: string;
+ tenant_id?: string;
+ user_id?: string;
+ agent_id?: string;
+ source?: string;
+ [key: string]: unknown;
+ };
+ }
+
+ /**
+ * Telemetry event emitted after each authorization decision.
+ */
+ export interface DecisionTelemetryEvent {
+ /** The action that was evaluated */
+ action: string;
+ /** The resource that was accessed */
+ resource: string;
+ /** The outcome of the authorization decision */
+ outcome: "allow" | "deny" | "error";
+ /** Human-readable reason for the decision */
+ reason?: string;
+ /** Timestamp of the decision */
+ timestamp?: number;
+ /** Duration of the decision in milliseconds */
+ durationMs?: number;
+ /** Mandate ID if the action was allowed */
+ mandateId?: string;
+ }
+
+ /**
+ * Telemetry handler for logging/monitoring authorization decisions.
+ */
+ export interface GuardTelemetry {
+ /** Called after each authorization decision */
+ onDecision: (event: DecisionTelemetryEvent) => void;
+ }
+
+ /**
+ * Audit exporter for persisting authorization decisions.
+ */
+ export interface DecisionAuditExporter {
+ /** Export a decision for audit logging */
+ exportDecision: (event: DecisionTelemetryEvent) => Promise;
+ }
+
+ /**
+ * Configuration for the GuardedProvider.
+ */
+ export interface GuardedProviderConfig {
+ /** Base URL of the predicate-authorityd sidecar */
+ baseUrl: string;
+ /** Whether to fail closed (block) when sidecar is unavailable */
+ failClosed: boolean;
+ /** Request timeout in milliseconds */
+ timeoutMs?: number;
+ /** Maximum number of retries */
+ maxRetries?: number;
+ /** Initial backoff delay in milliseconds */
+ backoffInitialMs?: number;
+ }
+
+ /**
+ * Options for creating a GuardedProvider.
+ */
+ export interface GuardedProviderOptions {
+ /** The principal (agent identity) making requests */
+ principal: string;
+ /** Configuration for the sidecar connection */
+ config: GuardedProviderConfig;
+ /** Optional telemetry handler */
+ telemetry?: GuardTelemetry;
+ /** Optional audit exporter */
+ auditExporter?: DecisionAuditExporter;
+ }
+
+ /**
+ * Error thrown when an action is denied by policy.
+ */
+ export class ActionDeniedError extends Error {
+ constructor(message: string);
+ name: "ActionDeniedError";
+ }
+
+ /**
+ * Error thrown when the sidecar is unavailable.
+ */
+ export class SidecarUnavailableError extends Error {
+ constructor(message: string);
+ name: "SidecarUnavailableError";
+ }
+
+ /**
+ * The main class for making guarded requests to the sidecar.
+ */
+ export class GuardedProvider {
+ constructor(options: GuardedProviderOptions);
+
+ /**
+ * Guard a request and throw if denied.
+ *
+ * @param request The guard request
+ * @returns The mandate ID if allowed, null if fail-open allowed
+ * @throws ActionDeniedError if the action is denied by policy
+ * @throws SidecarUnavailableError if the sidecar is unavailable (fail-closed mode)
+ */
+ guardOrThrow(request: GuardRequest): Promise;
+ }
+}
diff --git a/src/plugins/secureclaw/resource-extractor.test.ts b/src/plugins/secureclaw/resource-extractor.test.ts
new file mode 100644
index 000000000000..20f934a7beac
--- /dev/null
+++ b/src/plugins/secureclaw/resource-extractor.test.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect } from "vitest";
+import {
+ extractAction,
+ extractResource,
+ isSensitiveResource,
+ redactResource,
+} from "./resource-extractor.js";
+
+describe("extractAction", () => {
+ it("maps file read tools to fs.read", () => {
+ expect(extractAction("Read")).toBe("fs.read");
+ });
+
+ it("maps file write tools to fs.write", () => {
+ expect(extractAction("Write")).toBe("fs.write");
+ expect(extractAction("Edit")).toBe("fs.write");
+ expect(extractAction("MultiEdit")).toBe("fs.write");
+ });
+
+ it("maps Glob to fs.list", () => {
+ expect(extractAction("Glob")).toBe("fs.list");
+ });
+
+ it("maps Bash to shell.exec", () => {
+ expect(extractAction("Bash")).toBe("shell.exec");
+ });
+
+ it("maps Task to agent.spawn", () => {
+ expect(extractAction("Task")).toBe("agent.spawn");
+ });
+
+ it("maps web tools to http.request", () => {
+ expect(extractAction("WebFetch")).toBe("http.request");
+ expect(extractAction("WebSearch")).toBe("http.request");
+ });
+
+ it("maps browser tools correctly", () => {
+ expect(extractAction("computer-use:screenshot")).toBe("browser.screenshot");
+ expect(extractAction("computer-use:click")).toBe("browser.interact");
+ expect(extractAction("computer-use:type")).toBe("browser.interact");
+ expect(extractAction("computer-use:navigate")).toBe("browser.navigate");
+ });
+
+ it("returns generic action for unknown tools", () => {
+ expect(extractAction("CustomTool")).toBe("tool.customtool");
+ expect(extractAction("MyPlugin")).toBe("tool.myplugin");
+ });
+});
+
+describe("extractResource", () => {
+ describe("file operations", () => {
+ it("extracts file_path from Read params", () => {
+ expect(extractResource("Read", { file_path: "/src/index.ts" })).toBe("/src/index.ts");
+ });
+
+ it("extracts file_path from Write params", () => {
+ expect(extractResource("Write", { file_path: "/src/new.ts", content: "..." })).toBe(
+ "/src/new.ts",
+ );
+ });
+
+ it("extracts file_path from Edit params", () => {
+ expect(
+ extractResource("Edit", { file_path: "/src/edit.ts", old_string: "a", new_string: "b" }),
+ ).toBe("/src/edit.ts");
+ });
+
+ it("handles missing file path", () => {
+ expect(extractResource("Read", {})).toBe("file:unknown");
+ });
+ });
+
+ describe("Glob operations", () => {
+ it("extracts pattern from Glob params", () => {
+ expect(extractResource("Glob", { pattern: "**/*.ts" })).toBe("**/*.ts");
+ });
+
+ it("handles missing pattern", () => {
+ expect(extractResource("Glob", {})).toBe("*");
+ });
+ });
+
+ describe("Bash operations", () => {
+ it("extracts command from Bash params", () => {
+ expect(extractResource("Bash", { command: "npm test" })).toBe("npm test");
+ });
+
+ it("truncates long commands", () => {
+ const longCommand =
+ "npm run build && npm test && npm run lint && npm run format && echo done";
+ const result = extractResource("Bash", { command: longCommand });
+ expect(result.length).toBeLessThanOrEqual(100);
+ expect(result).toContain("npm");
+ });
+
+ it("handles missing command", () => {
+ expect(extractResource("Bash", {})).toBe("bash:unknown");
+ });
+ });
+
+ describe("network operations", () => {
+ it("extracts URL from WebFetch params", () => {
+ expect(extractResource("WebFetch", { url: "https://example.com/api" })).toBe(
+ "https://example.com/api",
+ );
+ });
+
+ it("extracts query from WebSearch params", () => {
+ expect(extractResource("WebSearch", { query: "typescript tutorial" })).toBe(
+ "search:typescript tutorial",
+ );
+ });
+ });
+
+ describe("browser operations", () => {
+ it("extracts URL from navigate params", () => {
+ expect(extractResource("computer-use:navigate", { url: "https://example.com" })).toBe(
+ "https://example.com",
+ );
+ });
+
+ it("returns browser:current for other browser operations", () => {
+ expect(extractResource("computer-use:screenshot", {})).toBe("browser:current");
+ expect(extractResource("computer-use:click", { x: 100, y: 200 })).toBe("browser:current");
+ });
+ });
+
+ describe("Task operations", () => {
+ it("extracts prompt prefix from Task params", () => {
+ const result = extractResource("Task", { prompt: "Search for files containing the error" });
+ expect(result).toContain("task:");
+ expect(result.length).toBeLessThanOrEqual(60);
+ });
+ });
+});
+
+describe("isSensitiveResource", () => {
+ it("detects SSH paths", () => {
+ expect(isSensitiveResource("/home/user/.ssh/id_rsa")).toBe(true);
+ expect(isSensitiveResource("~/.ssh/config")).toBe(true);
+ });
+
+ it("detects AWS credentials", () => {
+ expect(isSensitiveResource("/home/user/.aws/credentials")).toBe(true);
+ expect(isSensitiveResource("aws_secret_key")).toBe(true);
+ });
+
+ it("detects environment files", () => {
+ expect(isSensitiveResource(".env")).toBe(true);
+ expect(isSensitiveResource(".env.local")).toBe(true);
+ expect(isSensitiveResource("/app/.env.production")).toBe(true);
+ });
+
+ it("detects key files", () => {
+ expect(isSensitiveResource("server.pem")).toBe(true);
+ expect(isSensitiveResource("private.key")).toBe(true);
+ expect(isSensitiveResource("id_ed25519")).toBe(true);
+ });
+
+ it("detects credential files", () => {
+ expect(isSensitiveResource("credentials.json")).toBe(true);
+ expect(isSensitiveResource("secrets.yaml")).toBe(true);
+ expect(isSensitiveResource("api_key.txt")).toBe(true);
+ });
+
+ it("allows safe paths", () => {
+ expect(isSensitiveResource("/src/index.ts")).toBe(false);
+ expect(isSensitiveResource("README.md")).toBe(false);
+ expect(isSensitiveResource("package.json")).toBe(false);
+ expect(isSensitiveResource("/app/dist/bundle.js")).toBe(false);
+ });
+});
+
+describe("redactResource", () => {
+ it("redacts sensitive resources", () => {
+ expect(redactResource("/home/user/.ssh/id_rsa")).toBe("[REDACTED]");
+ expect(redactResource(".env.local")).toBe("[REDACTED]");
+ expect(redactResource("credentials.json")).toBe("[REDACTED]");
+ });
+
+ it("passes through safe resources", () => {
+ expect(redactResource("/src/index.ts")).toBe("/src/index.ts");
+ expect(redactResource("package.json")).toBe("package.json");
+ });
+});
diff --git a/src/plugins/secureclaw/resource-extractor.ts b/src/plugins/secureclaw/resource-extractor.ts
new file mode 100644
index 000000000000..e3dc8539dd14
--- /dev/null
+++ b/src/plugins/secureclaw/resource-extractor.ts
@@ -0,0 +1,202 @@
+/**
+ * Resource Extractor
+ *
+ * Maps OpenClaw tool calls to Predicate Authority action/resource pairs.
+ */
+
+export type ActionResource = {
+ action: string;
+ resource: string;
+};
+
+/**
+ * Extract the action type from a tool name.
+ */
+export function extractAction(toolName: string): string {
+ // Map OpenClaw tool names to Predicate action categories
+ const actionMap: Record = {
+ // File system operations
+ Read: "fs.read",
+ Write: "fs.write",
+ Edit: "fs.write",
+ Glob: "fs.list",
+ MultiEdit: "fs.write",
+
+ // Shell/process operations
+ Bash: "shell.exec",
+ Task: "agent.spawn",
+
+ // Network operations
+ WebFetch: "http.request",
+ WebSearch: "http.request",
+
+ // Browser automation
+ "computer-use:screenshot": "browser.screenshot",
+ "computer-use:click": "browser.interact",
+ "computer-use:type": "browser.interact",
+ "computer-use:scroll": "browser.interact",
+ "computer-use:navigate": "browser.navigate",
+
+ // Notebook operations
+ NotebookRead: "notebook.read",
+ NotebookEdit: "notebook.write",
+
+ // MCP tool calls
+ mcp_tool: "mcp.call",
+ };
+
+ return actionMap[toolName] ?? `tool.${toolName.toLowerCase()}`;
+}
+
+/**
+ * Extract the resource identifier from tool parameters.
+ */
+export function extractResource(toolName: string, params: Record): string {
+ switch (toolName) {
+ // File operations - extract path
+ case "Read":
+ case "Write":
+ case "Edit":
+ case "MultiEdit":
+ return extractFilePath(params);
+
+ // Glob - extract pattern as resource
+ case "Glob":
+ return typeof params.pattern === "string" ? params.pattern : "*";
+
+ // Bash - extract command (first 100 chars for safety)
+ case "Bash":
+ return extractBashCommand(params);
+
+ // Network operations - extract URL
+ case "WebFetch":
+ case "WebSearch":
+ return typeof params.url === "string"
+ ? params.url
+ : typeof params.query === "string"
+ ? `search:${params.query}`
+ : "unknown";
+
+ // Browser operations - extract URL or target
+ case "computer-use:navigate":
+ return typeof params.url === "string" ? params.url : "browser:current";
+
+ case "computer-use:screenshot":
+ case "computer-use:click":
+ case "computer-use:type":
+ case "computer-use:scroll":
+ return "browser:current";
+
+ // Task/Agent spawning
+ case "Task":
+ return typeof params.prompt === "string"
+ ? `task:${params.prompt.slice(0, 50)}`
+ : "task:unknown";
+
+ // Notebook operations
+ case "NotebookRead":
+ case "NotebookEdit":
+ return typeof params.notebook_path === "string" ? params.notebook_path : "notebook:unknown";
+
+ // MCP tools - extract tool name and server
+ case "mcp_tool":
+ return extractMcpResource(params);
+
+ default:
+ // For unknown tools, try common parameter names
+ return (
+ extractFilePath(params) ||
+ (typeof params.path === "string" ? params.path : null) ||
+ (typeof params.target === "string" ? params.target : null) ||
+ `${toolName}:params`
+ );
+ }
+}
+
+function extractFilePath(params: Record): string {
+ // Try common file path parameter names
+ const pathKeys = ["file_path", "filePath", "path", "file", "filename"];
+ for (const key of pathKeys) {
+ if (typeof params[key] === "string") {
+ return params[key];
+ }
+ }
+ return "file:unknown";
+}
+
+function extractBashCommand(params: Record): string {
+ const command = params.command;
+ if (typeof command !== "string") {
+ return "bash:unknown";
+ }
+
+ // Truncate long commands but preserve the essential part
+ const maxLen = 100;
+ if (command.length <= maxLen) {
+ return command;
+ }
+
+ // Try to preserve the command name and first argument
+ const parts = command.split(/\s+/);
+ const cmdName = parts[0] ?? "cmd";
+ const firstArg = parts[1] ?? "";
+
+ return `${cmdName} ${firstArg}...`.slice(0, maxLen);
+}
+
+function extractMcpResource(params: Record): string {
+ const server =
+ typeof params.server === "string"
+ ? params.server
+ : typeof params.mcp_server === "string"
+ ? params.mcp_server
+ : "unknown";
+ const tool =
+ typeof params.tool === "string"
+ ? params.tool
+ : typeof params.tool_name === "string"
+ ? params.tool_name
+ : "unknown";
+ return `mcp:${server}/${tool}`;
+}
+
+/**
+ * Check if a resource path should be considered sensitive.
+ * Used for redaction in audit logs.
+ */
+export function isSensitiveResource(resource: string): boolean {
+ const lowered = resource.toLowerCase();
+ const sensitivePatterns = [
+ "/.ssh/",
+ "/etc/passwd",
+ "/etc/shadow",
+ "id_rsa",
+ "id_ed25519",
+ "credentials",
+ ".env",
+ "secret",
+ "token",
+ "password",
+ "api_key",
+ "apikey",
+ "private_key",
+ "privatekey",
+ ".pem",
+ ".key",
+ "aws_",
+ "gcp_",
+ "azure_",
+ ];
+
+ return sensitivePatterns.some((pattern) => lowered.includes(pattern));
+}
+
+/**
+ * Redact sensitive resources for safe logging.
+ */
+export function redactResource(resource: string): string {
+ if (isSensitiveResource(resource)) {
+ return "[REDACTED]";
+ }
+ return resource;
+}