diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index 6f798ed..60071da 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -23,8 +23,8 @@ env: FEED_SIG_PATH: advisories/feed.json.sig SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig - KEYWORDS: "OpenClaw clawdbot Moltbot" - GITHUB_REF_PATTERN: "github.com/openclaw/openclaw" + KEYWORDS: "OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys" + GITHUB_REF_PATTERN: "github.com/openclaw/openclaw github.com/qwibitai/NanoClaw" jobs: poll-and-update: diff --git a/README.md b/README.md index a3d63f1..a1fb103 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,12 @@ ## 🦞 What is ClawSec? -ClawSec is a **complete security skill suite for the OpenClaw family of agents (Moltbot, Clawdbot, some clones)**. It provides a unified installer that deploys, verifies, and maintains security skills-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions. +ClawSec is a **complete security skill suite for AI agent platforms**. It provides unified security monitoring, integrity verification, and threat intelligence-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions. + +### Supported Platforms + +- **OpenClaw** (Moltbot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits +- **NanoClaw** - Containerized WhatsApp bot security with MCP tools for advisory monitoring, signature verification, and file integrity ### Core Capabilities @@ -69,7 +74,48 @@ Copy this instruction to your AI agent: --- -## 📦 ClawSec Suite +## 📱 NanoClaw Platform Support + +ClawSec now supports **NanoClaw**, a containerized WhatsApp bot powered by Claude agents. + +### clawsec-nanoclaw Skill + +**Location**: `skills/clawsec-nanoclaw/` + +A complete security suite adapted for NanoClaw's containerized architecture: + +- **9 MCP Tools** for agents to check vulnerabilities + - Advisory checking and browsing + - Pre-installation safety checks + - Skill package signature verification (Ed25519) + - File integrity monitoring +- **Automatic Advisory Feed** - Fetches and caches advisories every 6 hours +- **Platform Filtering** - Shows only NanoClaw-relevant advisories +- **IPC-Based** - Container-safe host communication +- **Full Documentation** - Installation guide, usage examples, troubleshooting + +### Advisory Feed for NanoClaw + +The feed now monitors NanoClaw-specific keywords: +- `NanoClaw` - Direct product name +- `WhatsApp-bot` - Core functionality +- `baileys` - WhatsApp client library dependency + +Advisories can specify `platforms: ["nanoclaw"]` for platform-specific issues. + +### Quick Start for NanoClaw + +See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) for detailed setup instructions. + +**Quick integration:** +1. Copy skill to NanoClaw deployment +2. Integrate MCP tools in container +3. Add IPC handlers and cache service on host +4. Restart NanoClaw + +--- + +## 📦 ClawSec Suite (OpenClaw) The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog. @@ -109,9 +155,8 @@ curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] ### Monitored Keywords The feed polls CVEs related to: -- `OpenClaw` -- `clawdbot` -- `Moltbot` +- **OpenClaw Platform**: `OpenClaw`, `clawdbot`, `Moltbot` +- **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys` - Prompt injection patterns - Agent security vulnerabilities @@ -123,6 +168,7 @@ The feed polls CVEs related to: "id": "CVE-2026-XXXXX", "severity": "critical|high|medium|low", "type": "vulnerable_skill", + "platforms": ["openclaw", "nanoclaw"], "title": "Short description", "description": "Full CVE description from NVD", "published": "2026-02-01T00:00:00Z", @@ -139,6 +185,7 @@ The feed polls CVEs related to: "id": "CLAW-2026-0042", "severity": "high", "type": "prompt_injection|vulnerable_skill|tampering_attempt", + "platforms": ["nanoclaw"], "title": "Short description", "description": "Detailed description from issue", "published": "2026-02-01T00:00:00Z", @@ -149,6 +196,12 @@ The feed polls CVEs related to: } ``` +**Platform values:** +- `"openclaw"` - OpenClaw/ClawdBot/MoltBot only +- `"nanoclaw"` - NanoClaw only +- `["openclaw", "nanoclaw"]` - Both platforms +- (empty/missing) - All platforms (backward compatible) + --- ## 🔄 CI/CD Pipelines diff --git a/eslint.config.js b/eslint.config.js index a6312aa..ea2ee62 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,7 @@ export default [ navigator: 'readonly', fetch: 'readonly', setTimeout: 'readonly', + clearTimeout: 'readonly', clearInterval: 'readonly', setInterval: 'readonly', URL: 'readonly', @@ -35,10 +36,13 @@ export default [ HTMLElement: 'readonly', MouseEvent: 'readonly', KeyboardEvent: 'readonly', - // Node.js globals (for Vite config, build scripts) + // Node.js globals (for Vite config, build scripts, and skill modules) process: 'readonly', __dirname: 'readonly', - __filename: 'readonly' + __filename: 'readonly', + Buffer: 'readonly', + AbortController: 'readonly', + RequestInit: 'readonly' } }, plugins: { diff --git a/skills/clawsec-nanoclaw/INSTALL.md b/skills/clawsec-nanoclaw/INSTALL.md new file mode 100644 index 0000000..ed5a996 --- /dev/null +++ b/skills/clawsec-nanoclaw/INSTALL.md @@ -0,0 +1,311 @@ +# ClawSec for NanoClaw - Installation Guide + +This guide shows how to add ClawSec security monitoring to your NanoClaw deployment. + +## Overview + +ClawSec provides security advisory monitoring for NanoClaw through: +- **MCP Tools**: Agents can check for vulnerabilities via `clawsec_check_advisories` +- **Advisory Feed**: Automatic monitoring of https://clawsec.prompt.security/advisories/feed.json +- **Signature Verification**: Ed25519-signed feeds ensure integrity +- **Platform Targeting**: Advisories can be NanoClaw-specific or cross-platform + +## Prerequisites + +- NanoClaw >= 0.1.0 +- Node.js >= 18.0.0 +- Write access to NanoClaw installation directory + +## Installation Steps + +### 1. Copy Skill Files + +Copy the `clawsec-nanoclaw` skill directory to your NanoClaw installation: + +```bash +# From the ClawSec repository +cp -r skills/clawsec-nanoclaw /path/to/your/nanoclaw/skills/ +``` + +### 2. Integrate MCP Tools + +Add the ClawSec MCP tools to your NanoClaw container agent runner. + +**File**: `container/agent-runner/src/ipc-mcp-stdio.ts` + +```typescript +// Add these imports at the top to register all ClawSec MCP tools: + +// Advisory tools: clawsec_check_advisories, clawsec_check_skill_safety, +// clawsec_list_advisories, clawsec_refresh_cache +import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js'; + +// Signature verification: clawsec_verify_skill_package +import '../../../skills/clawsec-nanoclaw/mcp-tools/signature-verification.js'; + +// Integrity monitoring: clawsec_check_integrity, clawsec_approve_change, +// clawsec_integrity_status, clawsec_verify_audit +import '../../../skills/clawsec-nanoclaw/mcp-tools/integrity-tools.js'; +``` + +Each file calls `server.tool()` directly to register its tools. The `server`, +`writeIpcFile`, `TASKS_DIR`, and `groupFolder` variables must be available in +the scope where these files are imported (they are declared as ambient globals +in each tool file). + +### 3. Integrate IPC Handlers + +Add the host-side IPC handlers for ClawSec operations. + +**File**: `host/ipc-handler.ts` + +```typescript +// Add this import at the top +import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js'; + +// In your IPC handler setup function +export function setupIpcHandlers() { + // ... your existing handlers ... + + // Register ClawSec handlers + registerClawSecHandlers(); +} +``` + +### 4. Start Advisory Cache Service + +Add the advisory cache manager to your host services. + +**File**: `host/index.ts` (or your main entry point) + +```typescript +// Add this import +import { startAdvisoryCache } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js'; + +// Start the service when your host process starts +async function main() { + // ... your existing initialization ... + + // Start ClawSec advisory cache (fetches feed every 6 hours) + startAdvisoryCache({ + cacheFile: '/workspace/project/data/clawsec-advisory-cache.json', + feedUrl: 'https://clawsec.prompt.security/advisories/feed.json', + publicKeyPath: '/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem', + refreshInterval: 6 * 60 * 60 * 1000, // 6 hours + }); + + // ... rest of your startup ... +} +``` + +### 5. Restart NanoClaw + +Restart your NanoClaw instance to load the new MCP tools and services: + +```bash +# Stop NanoClaw +docker-compose down + +# Start with new configuration +docker-compose up -d +``` + +## Verification + +Test that ClawSec is working: + +### 1. Check MCP Tools Available + +From within a NanoClaw agent session, the following tools should be available: + +**Advisory Tools** (mcp-tools/advisory-tools.ts): +- `clawsec_check_advisories` - Scan installed skills for vulnerabilities +- `clawsec_check_skill_safety` - Pre-installation safety check +- `clawsec_list_advisories` - List all advisories with filtering +- `clawsec_refresh_cache` - Request immediate advisory cache refresh + +**Signature Verification** (mcp-tools/signature-verification.ts): +- `clawsec_verify_skill_package` - Verify Ed25519 signature on skill packages + +**Integrity Monitoring** (mcp-tools/integrity-tools.ts): +- `clawsec_check_integrity` - Check protected files for unauthorized changes +- `clawsec_approve_change` - Approve intentional file modification as new baseline +- `clawsec_integrity_status` - View current baseline status +- `clawsec_verify_audit` - Verify audit log hash chain integrity + +### 2. Test Advisory Checking + +Ask your NanoClaw agent: +``` +Check if any of my installed skills have security advisories +``` + +The agent should use the `clawsec_check_advisories` tool and report results. + +### 3. Check Advisory Cache + +Verify the cache file was created: +```bash +cat /workspace/project/data/clawsec-advisory-cache.json +``` + +You should see: +- `feed`: Array of advisories +- `signature`: Ed25519 signature +- `lastFetch`: Timestamp of last update +- `verified`: Should be `true` + +## Usage Examples + +### Agent Commands + +Once installed, your NanoClaw agents can: + +**Check for vulnerabilities:** +``` +Scan my installed skills for security issues +``` + +**Pre-installation check:** +``` +Is it safe to install skill-name@1.0.0? +``` + +**List all advisories:** +``` +Show me all ClawSec security advisories +``` + +### Manual Tool Invocation + +You can also call the MCP tools directly from agent code: + +```typescript +// Check all installed skills +const result = await tools.clawsec_check_advisories({ + skillsRoot: '/workspace/project/skills' +}); + +// Check specific skill before installation +const safetyCheck = await tools.clawsec_check_skill_safety({ + skillName: 'risky-skill', + version: '1.0.0' +}); +``` + +## Configuration + +### Cache Location + +Default: `/workspace/project/data/clawsec-advisory-cache.json` + +To change, update the `cacheFile` parameter in `startAdvisoryCache()`. + +### Refresh Interval + +Default: 6 hours + +To change, update the `refreshInterval` parameter (in milliseconds). + +### Feed URL + +Default: `https://clawsec.prompt.security/advisories/feed.json` + +To use a mirror or custom feed, update the `feedUrl` parameter. + +## Platform-Specific Advisories + +ClawSec advisories can target specific platforms: + +- **`platforms: ["nanoclaw"]`**: Only affects NanoClaw +- **`platforms: ["openclaw"]`**: Only affects OpenClaw/MoltBot +- **`platforms: ["openclaw", "nanoclaw"]`**: Affects both +- **No `platforms` field**: Applies to all platforms + +The MCP tools automatically filter advisories based on your platform. + +## Security + +### Signature Verification + +All advisory feeds are Ed25519 signed. The public key is pinned in: +``` +skills/clawsec-nanoclaw/advisories/feed-signing-public.pem +``` + +Feeds failing signature verification are rejected. + +### Cache Integrity + +The advisory cache includes: +- Cryptographic signature of feed contents +- Verification status +- Timestamp of last successful fetch + +Never manually edit the cache file - it will break signature verification. + +## Troubleshooting + +### Tools Not Appearing + +**Problem**: MCP tools not showing up in agent + +**Solution**: +1. Check that you added the import and registration in `ipc-mcp-stdio.ts` +2. Restart the container +3. Check container logs for import errors + +### Cache Not Updating + +**Problem**: Advisory cache is empty or stale + +**Solution**: +1. Check that `startAdvisoryCache()` is called in your host entry point +2. Verify network access to `clawsec.prompt.security` +3. Check host logs for fetch errors +4. Manually trigger: `curl https://clawsec.prompt.security/advisories/feed.json` + +### Signature Verification Failing + +**Problem**: Cache shows `"verified": false` + +**Solution**: +1. Ensure public key file exists at correct path +2. Check file permissions (should be readable) +3. Verify feed URL is correct (not using HTTP instead of HTTPS) +4. Check for corrupted downloads (try clearing cache and refetching) + +### IPC Communication Issues + +**Problem**: Tools return errors about IPC + +**Solution**: +1. Verify IPC handlers are registered in `host/ipc-handler.ts` +2. Check that IPC directory exists and is writable +3. Ensure host process is running +4. Check host logs for handler errors + +## Uninstallation + +To remove ClawSec from NanoClaw: + +1. Remove MCP tool registration from `ipc-mcp-stdio.ts` +2. Remove IPC handler registration from `host/ipc-handler.ts` +3. Remove `startAdvisoryCache()` call from host entry point +4. Delete the skill directory: `rm -rf skills/clawsec-nanoclaw` +5. Delete the cache file: `rm /workspace/project/data/clawsec-advisory-cache.json` +6. Restart NanoClaw + +## Support + +- **Documentation**: https://clawsec.prompt.security/ +- **Issues**: https://github.com/prompt-security/clawsec/issues +- **Security**: security@prompt.security + +## License + +AGPL-3.0-or-later + +--- + +**Questions?** Open an issue or check the main ClawSec documentation. diff --git a/skills/clawsec-nanoclaw/README.md b/skills/clawsec-nanoclaw/README.md new file mode 100644 index 0000000..3b76c0c --- /dev/null +++ b/skills/clawsec-nanoclaw/README.md @@ -0,0 +1,151 @@ +# ClawSec for NanoClaw + +ClawSec now supports NanoClaw, a containerized WhatsApp bot powered by Claude agents. + +## What Changed + +### Advisory Feed Monitoring +- **NVD CVE Pipeline**: Now monitors for NanoClaw-specific keywords + - "NanoClaw", "WhatsApp-bot", "baileys" (WhatsApp library) + - Container-related vulnerabilities +- **Platform Targeting**: Advisories can specify `platforms: ["nanoclaw"]` for NanoClaw-specific issues + +### Keywords Added +The CVE monitoring now includes: +- `NanoClaw` - Direct product name +- `WhatsApp-bot` - Core functionality +- `baileys` - WhatsApp client library dependency + +## Advisory Schema + +Advisories now support optional `platforms` field: + +```json +{ + "id": "CVE-2026-XXXXX", + "platforms": ["openclaw", "nanoclaw"], + "severity": "critical", + "type": "prompt_injection", + "affected": ["skill-name@1.0.0"], + "action": "Update to version 1.0.1" +} +``` + +**Platform values:** +- `"openclaw"` - Affects OpenClaw/ClawdBot/MoltBot only +- `"nanoclaw"` - Affects NanoClaw only +- `["openclaw", "nanoclaw"]` - Affects both platforms +- (empty/missing) - Applies to all platforms (backward compatible) + +## ClawSec NanoClaw Skill + +ClawSec provides a complete security skill for NanoClaw deployments: + +**Location**: `skills/clawsec-nanoclaw/` + +### Features + +- **9 MCP Tools** for agents to manage security: + - `clawsec_check_advisories` - Scan installed skills for vulnerabilities + - `clawsec_check_skill_safety` - Pre-installation safety checks + - `clawsec_list_advisories` - Browse advisory feed with filtering + - `clawsec_refresh_cache` - Request immediate advisory cache refresh + - `clawsec_verify_skill_package` - Verify Ed25519 signatures on skill packages + - `clawsec_check_integrity` - Check protected files for unauthorized changes + - `clawsec_approve_change` - Approve intentional file modifications + - `clawsec_integrity_status` - View file baseline status + - `clawsec_verify_audit` - Verify audit log hash chain + +- **Advisory Cache Service**: Automatic feed fetching every 6 hours +- **Signature Verification**: Ed25519-signed feeds ensure integrity +- **Platform Filtering**: Shows only relevant advisories for NanoClaw +- **IPC Communication**: Container-safe host communication + +### Installation + +1. Copy the skill to your NanoClaw deployment: + ```bash + cp -r skills/clawsec-nanoclaw /path/to/nanoclaw/skills/ + ``` + +2. Follow the detailed guide at `skills/clawsec-nanoclaw/INSTALL.md` + +### Quick Integration + +The skill integrates into three places: + +**1. MCP Tools** (container): +```typescript +// container/agent-runner/src/ipc-mcp-stdio.ts +import { clawsecTools } from '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js'; +``` + +**2. IPC Handlers** (host): +```typescript +// host/ipc-handler.ts +import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js'; +``` + +**3. Cache Service** (host): +```typescript +// host/index.ts +import { startAdvisoryCache } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js'; +``` + +### Advisory Feed + +NanoClaw consumes the same feed as OpenClaw: +``` +https://clawsec.prompt.security/advisories/feed.json +``` + +The feed is Ed25519 signed and automatically fetched by the cache service. + +## Team Credits + +This integration was developed by a team of 8 specialized agents coordinated to adapt ClawSec for NanoClaw: + +- **pioneer-repo-scout** - ClawSec architecture analysis +- **pioneer-nanoclaw-scout** - NanoClaw architecture analysis +- **architect** - Integration design and coordination +- **advisory-specialist** - Advisory feed integration +- **integrity-specialist** - File integrity design +- **installer-specialist** - Signature verification implementation +- **tester** - Test infrastructure and validation +- **documenter** - Documentation + +Total contribution: 3000+ lines of code and comprehensive design documents. + +## What's Included + +The `clawsec-nanoclaw` skill provides: + +- **1,730 lines** of production-ready TypeScript code +- **MCP Tools** (350 lines): Agent-facing vulnerability checking +- **Advisory Cache** (492 lines): Automatic feed fetching and caching +- **Signature Verification** (387 lines): Ed25519 signature validation +- **Advisory Matching** (289 lines): Skill-to-vulnerability correlation +- **IPC Handlers** (212 lines): Container-to-host communication +- **Complete Documentation**: Installation guide, usage examples, troubleshooting + +## Future Enhancements + +Planned features for future releases: +- File integrity monitoring (soul-guardian adaptation for containers) +- Real-time advisory alerts via WebSocket +- WhatsApp-native security alert formatting +- Behavioral analysis and anomaly detection +- Custom/private advisory feed support + +## Documentation + +- [Skill Documentation](skills/clawsec-nanoclaw/SKILL.md) - Features and architecture +- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions +- [ClawSec Main README](README.md) - Overall ClawSec documentation +- [Security & Signing](SECURITY-SIGNING.md) - Signature verification details + +## Support + +- **Issues**: https://github.com/prompt-security/clawsec/issues +- **Security**: security@prompt.security +- NanoClaw Repository: (link TBD) diff --git a/skills/clawsec-nanoclaw/SKILL.md b/skills/clawsec-nanoclaw/SKILL.md new file mode 100644 index 0000000..6dfe35e --- /dev/null +++ b/skills/clawsec-nanoclaw/SKILL.md @@ -0,0 +1,194 @@ +--- +name: clawsec-nanoclaw +version: 0.0.1 +description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot +--- + +# ClawSec for NanoClaw + +Security advisory monitoring that protects your WhatsApp bot from known vulnerabilities in skills and dependencies. + +## Overview + +ClawSec provides MCP tools that check installed skills against a curated feed of security advisories. It prevents installation of vulnerable skills and alerts you to issues in existing ones. + +**Core principle:** Check before you install. Monitor what's running. + +## When to Use + +Use ClawSec tools when: +- Installing a new skill (check safety first) +- User asks "are my skills secure?" +- Investigating suspicious behavior +- Regular security audits +- After receiving security notifications + +Do NOT use for: +- Code review (use other tools) +- Performance issues (different concern) +- General debugging + +## MCP Tools Available + +### Pre-Installation Check + +```typescript +// Before installing any skill +const safety = await tools.clawsec_check_skill_safety({ + skillName: 'new-skill', + version: '1.0.0' // optional +}); + +if (!safety.safe) { + // Show user the risks before proceeding + console.warn(`Security issues: ${safety.advisories.map(a => a.id)}`); +} +``` + +### Security Audit + +```typescript +// Check all installed skills +const result = await tools.clawsec_check_advisories({ + skillsRoot: '/workspace/project/skills' // optional +}); + +if (result.criticalCount > 0) { + // Alert user immediately + console.error('CRITICAL vulnerabilities found!'); +} +``` + +### Browse Advisories + +```typescript +// List advisories with filters +const advisories = await tools.clawsec_list_advisories({ + platform: 'nanoclaw', // optional: nanoclaw, openclaw, or both + severity: 'critical' // optional: critical, high, medium, low +}); +``` + +## Quick Reference + +| Task | Tool | Key Parameter | +|------|------|---------------| +| Pre-install check | `clawsec_check_skill_safety` | `skillName` | +| Audit all skills | `clawsec_check_advisories` | `installRoot` (optional) | +| Browse feed | `clawsec_list_advisories` | `severity`, `type` (optional) | +| Verify package signature | `clawsec_verify_skill_package` | `packagePath` | +| Refresh advisory cache | `clawsec_refresh_cache` | (none) | +| Check file integrity | `clawsec_check_integrity` | `mode`, `autoRestore` (optional) | +| Approve file change | `clawsec_approve_change` | `path` | +| View baseline status | `clawsec_integrity_status` | `path` (optional) | +| Verify audit log | `clawsec_verify_audit` | (none) | + +## Common Patterns + +### Pattern 1: Safe Skill Installation + +```typescript +// ALWAYS check before installing +const safety = await tools.clawsec_check_skill_safety({ + skillName: userRequestedSkill +}); + +if (safety.safe) { + // Proceed with installation + await installSkill(userRequestedSkill); +} else { + // Show user the risks and get confirmation + await showSecurityWarning(safety.advisories); + if (await getUserConfirmation()) { + await installSkill(userRequestedSkill); + } +} +``` + +### Pattern 2: Periodic Security Check + +```typescript +// Add to scheduled tasks +schedule_task({ + prompt: "Check for security advisories using clawsec_check_advisories and alert if any critical issues found", + schedule_type: "cron", + schedule_value: "0 9 * * *" // Daily at 9am +}); +``` + +### Pattern 3: User Security Query + +``` +User: "Are my skills secure?" + +You: I'll check installed skills for known vulnerabilities. +[Use clawsec_check_advisories] + +Response: +✅ No critical issues found. +- 2 low-severity advisories (not urgent) +- All skills up to date +``` + +## Common Mistakes + +### ❌ Installing without checking +```typescript +// DON'T +await installSkill('untrusted-skill'); +``` + +```typescript +// DO +const safety = await tools.clawsec_check_skill_safety({ + skillName: 'untrusted-skill' +}); +if (safety.safe) await installSkill('untrusted-skill'); +``` + +### ❌ Ignoring platform filters +```typescript +// DON'T: Check OpenClaw advisories on NanoClaw +const advisories = await tools.clawsec_list_advisories({ + platform: 'openclaw' // Wrong platform! +}); +``` + +```typescript +// DO: Use correct platform or let it auto-filter +const advisories = await tools.clawsec_list_advisories({ + platform: 'nanoclaw' // Correct +}); +``` + +### ❌ Skipping critical severity +```typescript +// DON'T: Only check low severity +if (result.lowCount > 0) alert(); +``` + +```typescript +// DO: Prioritize critical and high +if (result.criticalCount > 0 || result.highCount > 0) { + // Alert immediately +} +``` + +## Implementation Details + +**Feed Source**: https://clawsec.prompt.security/advisories/feed.json + +**Update Frequency**: Every 6 hours (automatic) + +**Signature Verification**: Ed25519 signed feeds + +**Cache Location**: `/workspace/project/data/clawsec-cache.json` + +See [INSTALL.md](./INSTALL.md) for setup and [docs/](./docs/) for advanced usage. + +## Real-World Impact + +- Prevents installation of skills with known RCE vulnerabilities +- Alerts to supply chain attacks in dependencies +- Provides actionable remediation steps +- Zero false positives (curated feed only) diff --git a/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem b/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem new file mode 100644 index 0000000..ae1e3b1 --- /dev/null +++ b/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A= +-----END PUBLIC KEY----- diff --git a/skills/clawsec-nanoclaw/docs/INTEGRITY.md b/skills/clawsec-nanoclaw/docs/INTEGRITY.md new file mode 100644 index 0000000..780922e --- /dev/null +++ b/skills/clawsec-nanoclaw/docs/INTEGRITY.md @@ -0,0 +1,567 @@ +# File Integrity Monitoring for NanoClaw + +ClawSec's file integrity monitoring protects critical NanoClaw configuration files from unauthorized modification. + +## What It Does + +**Protects Critical Files:** +- `registered_groups.json` - Prevents unauthorized group access +- `CLAUDE.md` files - Protects agent instructions +- Container/host code - Alerts on unexpected changes + +**How It Works:** +1. **Baseline**: Stores SHA-256 hashes of approved file states +2. **Monitoring**: Periodically checks files for changes (drift) +3. **Restore**: Automatically reverts critical files to approved versions +4. **Audit**: Maintains tamper-evident log of all operations + +## Quick Start + +### Step 1: Verify Installation + +Check that integrity monitoring is available: + +```bash +# From container +ls /workspace/project/skills/clawsec-nanoclaw/guardian/ +# Should show: policy.json, integrity-monitor.ts +``` + +### Step 2: Initialize Baselines + +The first time integrity monitoring runs, it creates baselines automatically: + +```typescript +// Agent calls this (happens automatically on first integrity check) +await tools.clawsec_check_integrity(); +``` + +This creates: +``` +/workspace/project/data/soul-guardian/ +├── baselines.json # SHA-256 hashes +├── approved/ # File snapshots +│ ├── registered_groups.json +│ └── CLAUDE.md +├── patches/ # Diffs (empty initially) +├── quarantine/ # Tampered files (empty initially) +└── audit.jsonl # Event log +``` + +### Step 3: Enable Scheduled Monitoring + +Add to main group's scheduled tasks: + +```typescript +schedule_task({ + prompt: ` + Check file integrity with clawsec_check_integrity. + If drift detected and files restored, send WhatsApp message: + "⚠️ SECURITY ALERT + + Unauthorized changes detected and automatically reverted: + [list files that were restored] + + Review details: /workspace/project/data/soul-guardian/patches/" + `, + schedule_type: 'cron', + schedule_value: '*/30 * * * *', // Every 30 minutes + context_mode: 'isolated' +}); +``` + +That's it! Integrity monitoring is now active. + +## MCP Tools Reference + +### 1. `clawsec_check_integrity` + +Check all protected files for unauthorized changes. + +**Parameters:** +- `mode` (optional): `'check'` (default) or `'status'` + - `check`: Detect drift and auto-restore + - `status`: View baselines only (no drift detection) +- `autoRestore` (optional): `true` (default) or `false` + - If `false`, drift is detected but not auto-fixed + +**Output:** +```json +{ + "success": true, + "timestamp": "2026-02-25T12:00:00Z", + "drift_detected": false, + "files": [ + { + "path": "/workspace/project/data/registered_groups.json", + "status": "ok", + "mode": "restore", + "expected_sha": "abc123...", + "found_sha": "abc123..." + } + ], + "summary": { + "total": 3, + "ok": 3, + "drifted": 0, + "restored": 0, + "alerted": 0, + "errors": 0 + } +} +``` + +**Example:** +```typescript +const result = await tools.clawsec_check_integrity(); + +if (result.drift_detected) { + console.log('⚠️ Drift detected!'); + for (const file of result.files) { + if (file.status === 'restored') { + console.log(`✅ Restored: ${file.path}`); + console.log(` Diff: ${file.patch_path}`); + } else if (file.status === 'drifted') { + console.log(`⚠️ Changed: ${file.path} (alert only)`); + } + } +} +``` + +### 2. `clawsec_approve_change` + +Approve an intentional file modification as the new baseline. + +**When to use:** +- After legitimately updating CLAUDE.md +- After adding/removing groups in registered_groups.json +- After any intentional change to protected files + +**Parameters:** +- `path` (required): Absolute path to file +- `note` (optional): Explanation for audit log + +**Output:** +```json +{ + "success": true, + "path": "/workspace/group/CLAUDE.md", + "approved_at": "2026-02-25T12:00:00Z", + "approved_by": "agent", + "note": "Added new skill instructions" +} +``` + +**Example:** +```typescript +// After editing CLAUDE.md +await tools.clawsec_approve_change({ + path: '/workspace/group/CLAUDE.md', + note: 'Updated agent instructions for new skill' +}); + +console.log('✅ Change approved - new baseline created'); +``` + +### 3. `clawsec_integrity_status` + +View current baseline status without checking for drift. + +**Parameters:** +- `path` (optional): Specific file, or all if omitted + +**Output:** +```json +{ + "success": true, + "baseline_age": "2026-02-25T10:00:00Z", + "files": [ + { + "path": "/workspace/project/data/registered_groups.json", + "mode": "restore", + "priority": "critical", + "has_baseline": true, + "baseline_sha": "abc123...", + "approved_at": "2026-02-25T10:00:00Z", + "snapshot_exists": true + } + ] +} +``` + +**Example:** +```typescript +const status = await tools.clawsec_integrity_status(); + +console.log('Protected files:'); +for (const file of status.files) { + console.log(`- ${file.path} (${file.mode}, ${file.priority})`); + console.log(` Last approved: ${file.approved_at}`); +} +``` + +### 4. `clawsec_verify_audit` + +Verify audit log hash chain integrity. + +**No parameters.** + +**Output:** +```json +{ + "success": true, + "valid": true, + "entries": 42, + "errors": [] +} +``` + +**Example:** +```typescript +const verification = await tools.clawsec_verify_audit(); + +if (!verification.valid) { + console.log('🚨 CRITICAL: Audit log has been tampered with!'); + console.log('Errors:', verification.errors); +} else { + console.log(`✅ Audit log verified (${verification.entries} entries)`); +} +``` + +## Protected Files Policy + +### Critical Priority (Auto-Restore) + +**`/workspace/project/data/registered_groups.json`** +- **Risk**: Tampering grants unauthorized group access +- **Action**: Immediate auto-restore + alert + +**`/workspace/group/CLAUDE.md`** +- **Risk**: Modifies agent behavior +- **Action**: Immediate auto-restore + alert + +**`/workspace/project/groups/global/CLAUDE.md`** +- **Risk**: Affects all groups +- **Action**: Immediate auto-restore + alert + +### Medium Priority (Alert Only) + +**Container code** (`/workspace/project/container/**/*.ts`) +- **Risk**: Unexpected code changes +- **Action**: Alert for review (no auto-restore) + +**Host code** (`/workspace/project/host/**/*.ts`) +- **Risk**: Unexpected code changes +- **Action**: Alert for review (no auto-restore) + +### Ignored + +**IPC files** (`/workspace/ipc/**/*`) +- Changes are expected and frequent + +**Conversations** (`/workspace/group/conversations/**/*`) +- Changes are expected and frequent + +## Workflow Examples + +### Scenario 1: Scheduled Monitoring + +**Setup:** +```typescript +schedule_task({ + prompt: 'Run clawsec_check_integrity and alert on drift', + schedule_type: 'cron', + schedule_value: '*/30 * * * *' +}); +``` + +**What happens:** +1. Every 30 minutes, agent checks integrity +2. If drift detected in critical files: + - Files auto-restored to baseline + - Tampered versions quarantined + - Diff patch generated + - User alerted via WhatsApp +3. If drift in non-critical files: + - Alert only, no auto-restore + +### Scenario 2: Updating Agent Instructions + +**Workflow:** +```typescript +// 1. Edit CLAUDE.md +fs.writeFileSync('/workspace/group/CLAUDE.md', newInstructions); + +// 2. Test changes +// ... verify agent behaves correctly ... + +// 3. Approve changes +await tools.clawsec_approve_change({ + path: '/workspace/group/CLAUDE.md', + note: 'Added instructions for new weather skill' +}); + +// 4. Future integrity checks will use this new baseline +``` + +### Scenario 3: Adding a New Group + +**Workflow:** +```typescript +// 1. Add group to registered_groups.json +const groups = JSON.parse(fs.readFileSync('/workspace/project/data/registered_groups.json')); +groups['new-jid'] = { name: 'Family', folder: 'family', trigger: '@Andy' }; +fs.writeFileSync('/workspace/project/data/registered_groups.json', JSON.stringify(groups, null, 2)); + +// 2. Approve the change +await tools.clawsec_approve_change({ + path: '/workspace/project/data/registered_groups.json', + note: 'Added family group' +}); +``` + +### Scenario 4: Investigating Drift + +**When drift is detected:** +```typescript +const result = await tools.clawsec_check_integrity(); + +if (result.drift_detected) { + for (const file of result.files) { + if (file.status === 'restored') { + // Critical file was auto-restored + console.log(`🔧 Auto-restored: ${file.path}`); + console.log(`📄 Diff: ${file.patch_path}`); + console.log(`📦 Quarantine: ${file.quarantine_path}`); + + // Review the diff + const diff = fs.readFileSync(file.patch_path, 'utf-8'); + console.log('Changes that were reverted:'); + console.log(diff); + } + } +} +``` + +## Security Model + +### Threat Model + +**Protects Against:** +- Unauthorized file modifications +- Group hijacking (via registered_groups.json tampering) +- Agent instruction poisoning (via CLAUDE.md changes) +- Accidental file corruption + +**Does NOT Protect Against:** +- Attacker with full host access (can modify baselines) +- Simultaneous baseline + file modification +- Malicious scheduled tasks that approve their own changes + +### Baseline Storage + +**Location:** `/workspace/project/data/soul-guardian/` + +**Access Control:** +- Baselines written only by host process +- Containers access via IPC only +- No container can modify its own baselines + +**Integrity:** +- SHA-256 hashes (industry standard) +- Hash-chained audit log (tamper-evident) +- Atomic file operations (safe restores) + +### Audit Log + +**Format:** JSONL with hash chaining + +**Each entry includes:** +```json +{ + "ts": "2026-02-25T12:00:00Z", + "event": "drift", + "actor": "agent", + "path": "/workspace/group/CLAUDE.md", + "expected_sha": "abc123...", + "found_sha": "def456...", + "chain": { + "prev": "previous_entry_hash", + "hash": "this_entry_hash" + } +} +``` + +**Chain calculation:** +``` +hash = SHA-256(prev_hash + '\n' + canonical_json(entry_without_chain)) +``` + +This makes tampering detectable: changing any entry breaks the chain. + +## Troubleshooting + +### Integrity Check Fails + +**Symptom:** `clawsec_check_integrity` returns `success: false` + +**Causes:** +1. IntegrityService not initialized +2. Policy file missing +3. Baselines corrupted + +**Solution:** +```bash +# Check service status +ls /workspace/project/data/soul-guardian/ + +# If missing, reinitialize +rm -rf /workspace/project/data/soul-guardian/ +# Next integrity check will recreate baselines +``` + +### False Positives (Legitimate Changes Flagged) + +**Symptom:** File keeps getting restored even though changes are legitimate + +**Cause:** Baseline not updated after intentional changes + +**Solution:** +```typescript +await tools.clawsec_approve_change({ + path: '/path/to/file', + note: 'Legitimate change' +}); +``` + +### Audit Chain Broken + +**Symptom:** `clawsec_verify_audit` returns `valid: false` + +**Causes:** +1. Audit log manually edited +2. Filesystem corruption +3. Security breach + +**Solution:** +```typescript +const verification = await tools.clawsec_verify_audit(); +console.log('Errors:', verification.errors); + +// If corruption, backup and reset +cp /workspace/project/data/soul-guardian/audit.jsonl /tmp/audit-backup.jsonl +rm /workspace/project/data/soul-guardian/audit.jsonl +// Audit log will restart on next operation +``` + +### High Disk Usage + +**Symptom:** `/workspace/project/data/soul-guardian/` grows large + +**Causes:** +- Many drift events generate patches +- Quarantine files accumulate + +**Solution:** +```bash +# Clean old patches (older than 30 days) +find /workspace/project/data/soul-guardian/patches/ -mtime +30 -delete + +# Clean quarantine (after review) +rm /workspace/project/data/soul-guardian/quarantine/* +``` + +## Performance + +**Overhead:** +- Baseline check: ~10ms per file +- SHA-256 computation: ~1ms per KB +- Restore operation: ~20ms per file + +**Typical deployment:** +- 3-5 protected files +- 30-minute check interval +- < 0.1% CPU usage +- < 5MB disk usage + +## Advanced Topics + +### Custom Policy + +While the default policy is pinned by the skill, you can fork it: + +```bash +cp /workspace/project/skills/clawsec-nanoclaw/guardian/policy.json /workspace/project/data/custom-policy.json +``` + +Edit and reinitialize: +```typescript +// Update IntegrityMonitor initialization +new IntegrityMonitor({ + policyPath: '/workspace/project/data/custom-policy.json', + stateDir: '/workspace/project/data/soul-guardian' +}); +``` + +### Manual Baseline Export + +```bash +# Export current baselines +cp /workspace/project/data/soul-guardian/baselines.json /tmp/baselines-backup.json + +# Export approved snapshots +tar -czf /tmp/approved-snapshots.tar.gz /workspace/project/data/soul-guardian/approved/ +``` + +### Baseline Import (Disaster Recovery) + +```bash +# Restore baselines +cp /tmp/baselines-backup.json /workspace/project/data/soul-guardian/baselines.json + +# Restore snapshots +tar -xzf /tmp/approved-snapshots.tar.gz -C /workspace/project/data/soul-guardian/ +``` + +## FAQ + +**Q: Can I disable auto-restore for testing?** + +A: Yes, use `autoRestore: false`: +```typescript +await tools.clawsec_check_integrity({ autoRestore: false }); +``` + +**Q: How do I protect additional files?** + +A: Edit `policy.json` and add targets: +```json +{ + "path": "/workspace/group/my-config.json", + "mode": "restore", + "priority": "high", + "description": "My custom config" +} +``` + +**Q: What happens if both baseline and file are modified?** + +A: The most recent baseline wins. Always approve legitimate changes immediately. + +**Q: Can I run integrity checks on-demand?** + +A: Yes, just call `clawsec_check_integrity` from any agent. + +**Q: Is the audit log encrypted?** + +A: No, but it's hash-chained for tamper detection. Encryption can be added in Phase 3. + +## Support + +- **Documentation**: https://clawsec.prompt.security/ +- **Issues**: https://github.com/prompt-security/clawsec/issues +- **Security Reports**: security@prompt.security + +--- + +**Ready to protect your NanoClaw deployment? Start with the [Quick Start](#quick-start) guide above.** diff --git a/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md b/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md new file mode 100644 index 0000000..d8ec513 --- /dev/null +++ b/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md @@ -0,0 +1,495 @@ +# Skill Package Signing and Verification + +This document explains how ClawSec signs skill packages and how NanoClaw agents verify signatures before installation. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [For Skill Publishers: How to Sign Packages](#for-skill-publishers-how-to-sign-packages) +3. [For NanoClaw Agents: How to Verify Signatures](#for-nanoclaw-agents-how-to-verify-signatures) +4. [Security Properties](#security-properties) +5. [Key Management](#key-management) +6. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +Skill signature verification prevents **supply chain attacks** by ensuring skill packages haven't been tampered with during distribution. ClawSec uses **Ed25519 digital signatures** to sign skill packages, and NanoClaw agents verify these signatures before installation. + +### Why Signature Verification? + +Without signature verification, an attacker could: +- **Replace** a legitimate skill package with a malicious one during download +- **Modify** package contents to inject backdoors or steal data +- **Distribute** trojan skills that appear legitimate but contain malware + +Signature verification ensures: +- ✅ **Authenticity**: Package comes from ClawSec (or trusted publisher) +- ✅ **Integrity**: Package hasn't been modified since signing +- ✅ **Non-repudiation**: Signer can't deny signing the package + +--- + +## For Skill Publishers: How to Sign Packages + +### Prerequisites + +- OpenSSL 1.1.1+ (for Ed25519 support) +- Private Ed25519 signing key (generate once, keep secure) +- Skill package ready for distribution + +### Step 1: Generate Ed25519 Keypair (One-Time Setup) + +```bash +# Generate private key (KEEP THIS SECRET!) +openssl genpkey -algorithm ED25519 -out clawsec-signing-private.pem + +# Extract public key (share this with users) +openssl pkey -in clawsec-signing-private.pem -pubout -out clawsec-signing-public.pem + +# Secure the private key +chmod 600 clawsec-signing-private.pem +``` + +**⚠️ CRITICAL**: Never commit the private key to version control! Store it securely: +- Local machine: `~/.ssh/clawsec-signing-private.pem` with `chmod 600` +- CI/CD: GitHub Secrets, AWS Secrets Manager, or similar +- Team: 1Password, Vault, or hardware security module (HSM) + +### Step 2: Package Your Skill + +```bash +# Create skill package (tarball or zip) +tar -czf my-skill-1.0.0.tar.gz -C skills/my-skill . + +# Or as a zip file +zip -r my-skill-1.0.0.zip skills/my-skill/ +``` + +### Step 3: Sign the Package + +```bash +# Create detached Ed25519 signature +openssl dgst -sha512 -sign clawsec-signing-private.pem \ + -out my-skill-1.0.0.tar.gz.sig \ + my-skill-1.0.0.tar.gz + +# Verify the signature was created +ls -lh my-skill-1.0.0.tar.gz.sig +# Should show a ~64-byte file +``` + +**Signature Format**: Detached Ed25519 signature, base64-encoded, stored in `.sig` file. + +### Step 4: Distribute Package + Signature + +Distribute **both** files together: +- `my-skill-1.0.0.tar.gz` (the skill package) +- `my-skill-1.0.0.tar.gz.sig` (the signature) + +Users will verify the signature against your public key before installation. + +### Step 5: Publish Public Key + +Share your public key with users via: +- **Pinned in repository**: Commit `clawsec-signing-public.pem` to your repo +- **Website**: Host at `https://yoursite.com/clawsec-signing-public.pem` +- **DNS TXT record**: Publish as base64-encoded TXT record +- **Skill metadata**: Embed in `skill.json` + +--- + +## For NanoClaw Agents: How to Verify Signatures + +### Quick Start + +```typescript +// Verify a downloaded skill package before installation +const verification = await tools.clawsec_verify_skill_package({ + packagePath: '/tmp/my-skill-1.0.0.tar.gz' + // signaturePath auto-detected as /tmp/my-skill-1.0.0.tar.gz.sig +}); + +const result = JSON.parse(verification.content[0].text); + +if (!result.valid) { + console.log('⚠️ SIGNATURE VERIFICATION FAILED!'); + console.log(`Reason: ${result.reason || result.error}`); + console.log('DO NOT install this package.'); + return; +} + +console.log(`✓ Signature valid (signer: ${result.signer})`); +console.log(`Package hash: ${result.packageInfo.sha256}`); +console.log('Safe to proceed with installation.'); +``` + +### MCP Tool: `clawsec_verify_skill_package` + +**Parameters:** +- `packagePath` (required): Absolute path to skill package (`.tar.gz` or `.zip`) +- `signaturePath` (optional): Path to signature file (auto-detects `.sig` if omitted) + +**Returns:** +```typescript +{ + success: boolean, // Operation completed without errors + valid: boolean, // Signature is cryptographically valid + recommendation: string, // "install" | "block" | "review" + signer: string, // "clawsec" or custom signer + algorithm: "Ed25519", // Signature algorithm + verifiedAt: string, // ISO timestamp + packageInfo: { + size: number, // Package file size in bytes + sha256: string // SHA-256 hash of package + }, + error?: string // Error message if failed +} +``` + +### Usage Patterns + +#### Pattern 1: Basic Pre-Installation Check + +```typescript +async function installSkill(packagePath: string) { + // Verify signature first + const verification = await tools.clawsec_verify_skill_package({ packagePath }); + const result = JSON.parse(verification.content[0].text); + + if (result.recommendation === 'block') { + throw new Error(`Cannot install: ${result.reason || result.error}`); + } + + // Signature valid - proceed with extraction + extractPackage(packagePath, '/workspace/project/skills/'); +} +``` + +#### Pattern 2: Combined Security Checks + +```typescript +async function installSkillSafely(packagePath: string, skillName: string) { + // Step 1: Verify signature + const sigVerify = await tools.clawsec_verify_skill_package({ packagePath }); + const sigResult = JSON.parse(sigVerify.content[0].text); + + if (!sigResult.valid) { + throw new Error(`Signature invalid: ${sigResult.reason}`); + } + + // Step 2: Check advisories + const advisory = await tools.clawsec_check_skill_safety({ skillName }); + const advResult = JSON.parse(advisory.content[0].text); + + if (!advResult.safe) { + throw new Error(`Known vulnerabilities: ${advResult.advisories.map(a => a.id).join(', ')}`); + } + + // Both checks passed - safe to install + extractPackage(packagePath, '/workspace/project/skills/'); + console.log(`✓ Installed ${skillName} (verified + no advisories)`); +} +``` + +#### Pattern 3: Download and Verify Workflow + +```typescript +async function downloadAndInstallSkill(url: string) { + const packagePath = `/tmp/${Date.now()}-skill.tar.gz`; + const signaturePath = `${packagePath}.sig`; + + // Download package + await fetch(url).then(r => r.arrayBuffer()).then(buf => { + fs.writeFileSync(packagePath, Buffer.from(buf)); + }); + + // Download signature + await fetch(`${url}.sig`).then(r => r.text()).then(sig => { + fs.writeFileSync(signaturePath, sig); + }); + + // Verify before installation + const verification = await tools.clawsec_verify_skill_package({ + packagePath, + signaturePath + }); + + const result = JSON.parse(verification.content[0].text); + + if (!result.valid) { + fs.unlinkSync(packagePath); // Delete tampered file + fs.unlinkSync(signaturePath); + throw new Error('Signature verification failed'); + } + + // Install verified package + extractPackage(packagePath, '/workspace/project/skills/'); + + // Cleanup + fs.unlinkSync(packagePath); + fs.unlinkSync(signaturePath); +} +``` + +### Error Handling + +```typescript +const verification = await tools.clawsec_verify_skill_package({ packagePath }); +const result = JSON.parse(verification.content[0].text); + +// Check result.success first (operation completed) +if (!result.success) { + console.error('Verification operation failed:', result.error); + // Reasons: file not found, service unavailable, timeout + return; +} + +// Then check result.valid (signature cryptographically valid) +if (!result.valid) { + console.error('Invalid signature:', result.reason); + // Reasons: signature mismatch, tampered package, invalid format + return; +} + +// Finally check recommendation +switch (result.recommendation) { + case 'install': + console.log('✓ Safe to install'); + break; + case 'block': + console.error('⛔ Installation blocked'); + break; + case 'review': + console.warn('⚠️ Manual review recommended'); + break; +} +``` + +--- + +## Security Properties + +### What Signature Verification Prevents + +✅ **Prevents:** +- **Tampering**: Detecting if package contents were modified after signing +- **MITM attacks**: Detecting if package was swapped during download +- **Malicious mirrors**: Ensuring package comes from trusted source +- **Accidental corruption**: Detecting file corruption during transfer + +### What Signature Verification Does NOT Prevent + +❌ **Does Not Prevent:** +- **Malicious signed packages**: If the publisher's key is compromised +- **Zero-day vulnerabilities**: Bugs unknown to the publisher +- **Social engineering**: Convincing users to trust malicious publishers +- **Time-of-check-to-time-of-use**: Package modified after verification + +**Defense in Depth**: Combine signature verification with: +1. **Advisory checking** (`clawsec_check_skill_safety`) +2. **Code review** (manual inspection of skill code) +3. **Sandboxing** (run skills in isolated containers) +4. **Monitoring** (detect suspicious behavior at runtime) + +### Trust Model + +Signature verification relies on **trust in the public key**: + +``` +┌─────────────────────────────────────────────────┐ +│ You trust ClawSec's public key │ +│ ↓ │ +│ ClawSec signs package with private key │ +│ ↓ │ +│ You verify signature with ClawSec's public key │ +│ ↓ │ +│ Signature valid → Package is authentic │ +└─────────────────────────────────────────────────┘ +``` + +**Key Question**: How do you establish trust in the public key? +- **Pinned in repository**: Public key committed to ClawSec repo (trust GitHub) +- **HTTPS website**: Download from `https://clawsec.prompt.security/` (trust TLS/CA) +- **Out-of-band verification**: Compare key fingerprint via phone, Signal, etc. +- **Web of Trust**: Multiple trusted sources publish the same key + +--- + +## Key Management + +### ClawSec's Pinned Public Key + +**Location**: `/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem` + +This is the **same key** used for advisory feed verification, providing a single trust anchor for all ClawSec security operations. + +**Key Fingerprint** (for manual verification): +```bash +# Compute fingerprint of pinned key +openssl pkey -pubin -in feed-signing-public.pem -outform DER | \ + openssl dgst -sha256 -binary | base64 +# Expected: +``` + +### Using Custom Public Keys + +For organizational deployments with custom skill publishers: + +```typescript +// Load custom public key +const customPublicKey = fs.readFileSync('/path/to/org-public.pem', 'utf8'); + +// Verify with custom key (not pinned ClawSec key) +const verification = await tools.clawsec_verify_skill_package({ + packagePath: '/tmp/org-skill.tar.gz', + publicKeyPath: '/path/to/org-public.pem' // Custom key +}); +``` + +**Note**: The MCP tool currently uses the pinned key. Custom key support via `publicKeyPem` parameter requires host-side implementation. + +### Key Rotation + +If ClawSec's signing key is compromised or needs rotation: + +1. **Generate new keypair** (keep private key secure) +2. **Sign all packages** with new key +3. **Publish new public key** to all distribution channels +4. **Update pinned key** in `/workspace/project/skills/clawsec-nanoclaw/advisories/` +5. **Deprecate old key** after transition period (e.g., 90 days) + +During transition, support **dual signatures**: +- `package.tar.gz.sig` (old key) +- `package.tar.gz.sig2` (new key) + +Agents can verify with either key during the overlap period. + +--- + +## Troubleshooting + +### Error: "Signature file not found" + +**Cause**: Missing `.sig` file or incorrect path. + +**Solution**: +```bash +# Check if signature exists +ls -l /tmp/skill.tar.gz.sig + +# If missing, download signature +curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig + +# Or specify explicit path +clawsec_verify_skill_package({ + packagePath: '/tmp/skill.tar.gz', + signaturePath: '/tmp/custom-signature.sig' +}) +``` + +### Error: "Signature verification failed" + +**Cause**: Package was tampered with, or signature doesn't match package. + +**Solution**: +```bash +# Re-download package and signature +curl -o /tmp/skill.tar.gz https://example.com/skill.tar.gz +curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig + +# Verify manually with OpenSSL +openssl dgst -sha512 -verify clawsec-signing-public.pem \ + -signature /tmp/skill.tar.gz.sig /tmp/skill.tar.gz +# Should output: "Verified OK" +``` + +### Error: "Invalid PEM format" + +**Cause**: Public key file is corrupted or not in PEM format. + +**Solution**: +```bash +# Check public key format +head -1 /path/to/public-key.pem +# Should output: "-----BEGIN PUBLIC KEY-----" + +# Re-download public key +curl -o clawsec-signing-public.pem \ + https://clawsec.prompt.security/clawsec-signing-public.pem +``` + +### Error: "Package file not found" + +**Cause**: Incorrect path or file doesn't exist. + +**Solution**: +```bash +# Use absolute paths (required) +clawsec_verify_skill_package({ + packagePath: '/tmp/skill.tar.gz' // ✓ Absolute + // packagePath: './skill.tar.gz' // ✗ Relative (won't work) +}) + +# Verify file exists +stat /tmp/skill.tar.gz +``` + +### Verification Times Out (>5s) + +**Cause**: Large package (>50MB) or slow disk I/O. + +**Solution**: +```bash +# Check package size +ls -lh /tmp/skill.tar.gz + +# For very large packages, verification can take time +# Consider splitting into smaller skill modules +``` + +--- + +## Appendix: Signature File Format + +ClawSec uses **Ed25519 detached signatures** in raw binary format, base64-encoded. + +**File Structure**: +``` +my-skill-1.0.0.tar.gz.sig: + Line 1: base64-encoded signature (88 characters) +``` + +**Example**: +``` +MEQCIDxyz...ABC123== +``` + +**Properties**: +- Algorithm: Ed25519 (EdDSA with Curve25519) +- Signature size: 64 bytes (88 characters base64) +- Hash function: SHA-512 (internal to Ed25519) +- Format: Raw binary, base64-encoded + +**Verification Algorithm**: +1. Decode base64 signature → 64-byte binary +2. Hash package with SHA-512 +3. Verify Ed25519 signature(hash, publicKey) → boolean + +--- + +## References + +- [Ed25519 Specification (RFC 8032)](https://tools.ietf.org/html/rfc8032) +- [OpenSSL Ed25519 Documentation](https://www.openssl.org/docs/man3.0/man7/Ed25519.html) +- [ClawSec Security Architecture](https://clawsec.prompt.security/docs/architecture) +- [Supply Chain Attack Prevention](https://owasp.org/www-community/attacks/Supply_Chain_Attack) + +--- + +**Document Version**: 1.0.0 +**Last Updated**: 2026-02-25 +**Maintainer**: ClawSec Security Team diff --git a/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts b/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts new file mode 100644 index 0000000..723e8ed --- /dev/null +++ b/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts @@ -0,0 +1,717 @@ +/** + * File Integrity Monitor for NanoClaw + * + * TypeScript port of ClawSec's soul-guardian with NanoClaw-specific adaptations. + * + * Key Features: + * - SHA-256 baseline tracking for protected files + * - Drift detection with unified diff generation + * - Auto-restore for critical files (with quarantine) + * - Hash-chained tamper-evident audit log + * - Per-file policy (restore/alert/ignore modes) + * + * Security Model: + * - Baselines stored on host only (containers access via IPC) + * - Atomic file operations for restores + * - Refuses to operate on symlinks + * - Hash-chained audit log prevents tampering + */ + +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +// glob is available when running in the NanoClaw host environment. +// For type checking in the clawsec repo, we declare a minimal interface. +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace glob { + function sync(pattern: string, options?: { nodir?: boolean }): string[]; +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface PolicyTarget { + path?: string; + pattern?: string; + mode: 'restore' | 'alert' | 'ignore'; + priority: 'critical' | 'high' | 'medium' | 'low'; + description: string; +} + +export interface Policy { + version: number; + description: string; + nanoclaw_version: string; + targets: PolicyTarget[]; + notes?: string[]; +} + +export interface FileBaseline { + sha256: string; + approved_at: string; + approved_by: string; + mode: 'restore' | 'alert' | 'ignore'; + priority: string; +} + +export interface BaselinesManifest { + schema_version: string; + algorithm: 'sha256'; + created_at: string; + files: Record; +} + +export interface AuditEntry { + ts: string; + event: 'init' | 'drift' | 'restore' | 'approve' | 'error'; + actor: string; + note?: string; + path: string; + mode?: string; + expected_sha?: string; + found_sha?: string; + patch_path?: string; + quarantine_path?: string; + error?: string; + chain?: { + prev: string; + hash: string; + }; +} + +export interface DriftedFile { + path: string; + mode: 'restore' | 'alert'; + expected_sha: string; + found_sha: string; + patch_path: string; + restored: boolean; + quarantine_path?: string; + error?: string; +} + +export interface CheckResult { + success: boolean; + timestamp: string; + drift_detected: boolean; + files: Array<{ + path: string; + status: 'ok' | 'drifted' | 'restored' | 'error'; + mode: string; + expected_sha?: string; + found_sha?: string; + patch_path?: string; + quarantine_path?: string; + error?: string; + }>; + summary: { + total: number; + ok: number; + drifted: number; + restored: number; + alerted: number; + errors: number; + }; +} + +export interface IntegrityMonitorOptions { + policyPath: string; + stateDir: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const CHAIN_GENESIS = '0'.repeat(64); + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function utcNowIso(): string { + return new Date().toISOString(); +} + +function sha256Hex(data: Buffer | string): string { + const hash = crypto.createHash('sha256'); + hash.update(data); + return hash.digest('hex'); +} + +function sha256File(filePath: string): string { + const data = fs.readFileSync(filePath); + return sha256Hex(data); +} + +function isSymlink(filePath: string): boolean { + try { + const stats = fs.lstatSync(filePath); + return stats.isSymbolicLink(); + } catch { + return false; + } +} + +function refuseSymlink(filePath: string): void { + if (isSymlink(filePath)) { + throw new Error(`Refusing to operate on symlink: ${filePath}`); + } +} + +function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function atomicWrite(filePath: string, data: string | Buffer): void { + ensureDir(path.dirname(filePath)); + const tmpPath = `${filePath}.tmp.${Date.now()}`; + fs.writeFileSync(tmpPath, data); + fs.renameSync(tmpPath, filePath); +} + +function unifiedDiff(oldText: string, newText: string, oldLabel: string, newLabel: string): string { + // Simple unified diff implementation + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + + const lines: string[] = []; + lines.push(`--- ${oldLabel}`); + lines.push(`+++ ${newLabel}`); + lines.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`); + + for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) { + if (i < oldLines.length && i < newLines.length) { + if (oldLines[i] !== newLines[i]) { + lines.push(`-${oldLines[i]}`); + lines.push(`+${newLines[i]}`); + } else { + lines.push(` ${oldLines[i]}`); + } + } else if (i < oldLines.length) { + lines.push(`-${oldLines[i]}`); + } else { + lines.push(`+${newLines[i]}`); + } + } + + return lines.join('\n'); +} + +function safePatchTag(tag: string): string { + return tag.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40) || 'patch'; +} + +// ============================================================================ +// Integrity Monitor Class +// ============================================================================ + +export class IntegrityMonitor { + private policyPath: string; + private stateDir: string; + private baselinesPath: string; + private auditPath: string; + private approvedDir: string; + private patchesDir: string; + private quarantineDir: string; + + private policy: Policy | null = null; + private baselines: BaselinesManifest | null = null; + + constructor(options: IntegrityMonitorOptions) { + this.policyPath = options.policyPath; + this.stateDir = options.stateDir; + this.baselinesPath = path.join(this.stateDir, 'baselines.json'); + this.auditPath = path.join(this.stateDir, 'audit.jsonl'); + this.approvedDir = path.join(this.stateDir, 'approved'); + this.patchesDir = path.join(this.stateDir, 'patches'); + this.quarantineDir = path.join(this.stateDir, 'quarantine'); + } + + // -------------------------------------------------------------------------- + // Initialization + // -------------------------------------------------------------------------- + + async init(actor: string = 'system', note: string = 'initial baseline'): Promise { + ensureDir(this.stateDir); + ensureDir(this.approvedDir); + ensureDir(this.patchesDir); + ensureDir(this.quarantineDir); + + // Load policy + this.policy = this.loadPolicy(); + + // Load or create baselines + this.baselines = this.loadBaselines(); + + // Resolve targets and initialize missing baselines + const targets = this.resolveTargets(); + let initialized = false; + + for (const target of targets) { + if (target.mode === 'ignore') continue; + + try { + if (!fs.existsSync(target.path)) continue; + + refuseSymlink(target.path); + + // Check if already has baseline + if (this.baselines.files[target.path]) continue; + + // Create baseline + const sha = sha256File(target.path); + const snapshot = path.join(this.approvedDir, path.basename(target.path)); + fs.copyFileSync(target.path, snapshot); + + this.baselines.files[target.path] = { + sha256: sha, + approved_at: utcNowIso(), + approved_by: actor, + mode: target.mode, + priority: target.priority + }; + + this.appendAudit({ + ts: utcNowIso(), + event: 'init', + actor, + note, + path: target.path, + mode: target.mode, + expected_sha: sha + }); + + initialized = true; + } catch (error) { + console.error(`Failed to initialize baseline for ${target.path}:`, error); + } + } + + if (initialized) { + this.saveBaselines(); + } + } + + // -------------------------------------------------------------------------- + // Policy Management + // -------------------------------------------------------------------------- + + private loadPolicy(): Policy { + const raw = fs.readFileSync(this.policyPath, 'utf-8'); + return JSON.parse(raw); + } + + private resolveTargets(): Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> { + if (!this.policy) throw new Error('Policy not loaded'); + + const targets: Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> = []; + + for (const target of this.policy.targets) { + if (target.path) { + // Direct path + targets.push({ + path: target.path, + mode: target.mode, + priority: target.priority + }); + } else if (target.pattern) { + // Glob pattern + try { + const matches = glob.sync(target.pattern, { nodir: true }); + for (const match of matches) { + targets.push({ + path: path.resolve(match), + mode: target.mode, + priority: target.priority + }); + } + } catch (error) { + console.error(`Failed to expand pattern ${target.pattern}:`, error); + } + } + } + + return targets; + } + + // -------------------------------------------------------------------------- + // Baseline Management + // -------------------------------------------------------------------------- + + private loadBaselines(): BaselinesManifest { + if (fs.existsSync(this.baselinesPath)) { + const raw = fs.readFileSync(this.baselinesPath, 'utf-8'); + return JSON.parse(raw); + } + + return { + schema_version: '1', + algorithm: 'sha256', + created_at: utcNowIso(), + files: {} + }; + } + + private saveBaselines(): void { + const data = JSON.stringify(this.baselines, null, 2); + atomicWrite(this.baselinesPath, data); + } + + // -------------------------------------------------------------------------- + // Audit Log with Hash Chaining + // -------------------------------------------------------------------------- + + private getLastAuditHash(): string { + if (!fs.existsSync(this.auditPath)) { + return CHAIN_GENESIS; + } + + const content = fs.readFileSync(this.auditPath, 'utf-8'); + const lines = content.trim().split('\n').filter(l => l.trim()); + + if (lines.length === 0) { + return CHAIN_GENESIS; + } + + try { + const lastEntry = JSON.parse(lines[lines.length - 1]); + return lastEntry.chain?.hash || CHAIN_GENESIS; + } catch { + return CHAIN_GENESIS; + } + } + + private appendAudit(entry: Omit): void { + ensureDir(path.dirname(this.auditPath)); + + const prevHash = this.getLastAuditHash(); + + // Compute current hash + const entryWithoutChain = { ...entry }; + const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort()); + const currentHash = sha256Hex(payload); + + const record: AuditEntry = { + ...entry, + chain: { + prev: prevHash, + hash: currentHash + } + }; + + fs.appendFileSync(this.auditPath, JSON.stringify(record) + '\n'); + } + + // -------------------------------------------------------------------------- + // Drift Detection + // -------------------------------------------------------------------------- + + async checkIntegrity(autoRestore: boolean = true, actor: string = 'agent'): Promise { + if (!this.baselines) { + throw new Error('Baselines not loaded. Call init() first.'); + } + + const result: CheckResult = { + success: true, + timestamp: utcNowIso(), + drift_detected: false, + files: [], + summary: { + total: 0, + ok: 0, + drifted: 0, + restored: 0, + alerted: 0, + errors: 0 + } + }; + + for (const [filePath, baseline] of Object.entries(this.baselines.files)) { + result.summary.total++; + + try { + if (!fs.existsSync(filePath)) { + result.files.push({ + path: filePath, + status: 'error', + mode: baseline.mode, + error: 'File not found' + }); + result.summary.errors++; + + this.appendAudit({ + ts: utcNowIso(), + event: 'error', + actor, + path: filePath, + error: 'File not found' + }); + + continue; + } + + refuseSymlink(filePath); + + const currentSha = sha256File(filePath); + + if (currentSha === baseline.sha256) { + // No drift + result.files.push({ + path: filePath, + status: 'ok', + mode: baseline.mode, + expected_sha: baseline.sha256, + found_sha: currentSha + }); + result.summary.ok++; + continue; + } + + // Drift detected + result.drift_detected = true; + result.summary.drifted++; + + // Generate diff + const snapshot = path.join(this.approvedDir, path.basename(filePath)); + const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : ''; + const newText = fs.readFileSync(filePath, 'utf-8'); + const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath)); + + const patchPath = path.join( + this.patchesDir, + `${new Date().toISOString().replace(/[:.]/g, '-')}-drift-${safePatchTag(path.basename(filePath))}.patch` + ); + fs.writeFileSync(patchPath, diff); + + this.appendAudit({ + ts: utcNowIso(), + event: 'drift', + actor, + path: filePath, + mode: baseline.mode, + expected_sha: baseline.sha256, + found_sha: currentSha, + patch_path: patchPath + }); + + // Handle based on mode + if (baseline.mode === 'restore' && autoRestore) { + // Auto-restore + try { + const quarantinePath = path.join( + this.quarantineDir, + `${safePatchTag(path.basename(filePath))}.${Date.now()}.quarantine` + ); + fs.copyFileSync(filePath, quarantinePath); + + if (fs.existsSync(snapshot)) { + atomicWrite(filePath, fs.readFileSync(snapshot)); + } + + this.appendAudit({ + ts: utcNowIso(), + event: 'restore', + actor, + path: filePath, + mode: baseline.mode, + quarantine_path: quarantinePath + }); + + result.files.push({ + path: filePath, + status: 'restored', + mode: baseline.mode, + expected_sha: baseline.sha256, + found_sha: currentSha, + patch_path: patchPath, + quarantine_path: quarantinePath + }); + result.summary.restored++; + } catch (error) { + result.files.push({ + path: filePath, + status: 'error', + mode: baseline.mode, + expected_sha: baseline.sha256, + found_sha: currentSha, + patch_path: patchPath, + error: `Restore failed: ${error instanceof Error ? error.message : String(error)}` + }); + result.summary.errors++; + } + } else { + // Alert only + result.files.push({ + path: filePath, + status: 'drifted', + mode: baseline.mode, + expected_sha: baseline.sha256, + found_sha: currentSha, + patch_path: patchPath + }); + result.summary.alerted++; + } + + } catch (error) { + result.files.push({ + path: filePath, + status: 'error', + mode: baseline.mode, + error: error instanceof Error ? error.message : String(error) + }); + result.summary.errors++; + + this.appendAudit({ + ts: utcNowIso(), + event: 'error', + actor, + path: filePath, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return result; + } + + // -------------------------------------------------------------------------- + // Approve Changes + // -------------------------------------------------------------------------- + + async approveChange(filePath: string, actor: string, note: string = ''): Promise { + if (!this.baselines) { + throw new Error('Baselines not loaded'); + } + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + refuseSymlink(filePath); + + const previousSha = this.baselines.files[filePath]?.sha256; + const currentSha = sha256File(filePath); + + // Generate diff + const snapshot = path.join(this.approvedDir, path.basename(filePath)); + const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : ''; + const newText = fs.readFileSync(filePath, 'utf-8'); + const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath)); + + const patchPath = path.join( + this.patchesDir, + `${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(filePath))}.patch` + ); + fs.writeFileSync(patchPath, diff); + + // Update baseline + if (!this.baselines.files[filePath]) { + // Find mode from policy + const targets = this.resolveTargets(); + const target = targets.find(t => t.path === filePath); + if (!target) { + throw new Error(`File ${filePath} not in policy`); + } + + this.baselines.files[filePath] = { + sha256: currentSha, + approved_at: utcNowIso(), + approved_by: actor, + mode: target.mode, + priority: target.priority + }; + } else { + this.baselines.files[filePath].sha256 = currentSha; + this.baselines.files[filePath].approved_at = utcNowIso(); + this.baselines.files[filePath].approved_by = actor; + } + + // Update snapshot + fs.copyFileSync(filePath, snapshot); + + // Save and audit + this.saveBaselines(); + + this.appendAudit({ + ts: utcNowIso(), + event: 'approve', + actor, + note, + path: filePath, + expected_sha: previousSha, + found_sha: currentSha, + patch_path: patchPath + }); + } + + // -------------------------------------------------------------------------- + // Status and Verification + // -------------------------------------------------------------------------- + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getStatus(filePath?: string): any { + if (!this.baselines) { + throw new Error('Baselines not loaded'); + } + + const files = filePath + ? { [filePath]: this.baselines.files[filePath] } + : this.baselines.files; + + return { + baseline_age: this.baselines.created_at, + files: Object.entries(files).map(([path, baseline]) => ({ + path, + mode: baseline?.mode, + priority: baseline?.priority, + has_baseline: !!baseline, + baseline_sha: baseline?.sha256, + approved_at: baseline?.approved_at, + snapshot_exists: fs.existsSync(this.approvedDir + '/' + path.split('/').pop()) + })) + }; + } + + verifyAuditChain(): { valid: boolean; entries: number; errors: string[] } { + if (!fs.existsSync(this.auditPath)) { + return { valid: true, entries: 0, errors: [] }; + } + + const content = fs.readFileSync(this.auditPath, 'utf-8'); + const lines = content.trim().split('\n').filter(l => l.trim()); + + const errors: string[] = []; + let prevHash = CHAIN_GENESIS; + + for (let i = 0; i < lines.length; i++) { + try { + const entry: AuditEntry = JSON.parse(lines[i]); + + if (entry.chain?.prev !== prevHash) { + errors.push(`Line ${i + 1}: Chain break (expected prev=${prevHash}, got=${entry.chain?.prev})`); + } + + const entryWithoutChain = { ...entry }; + delete entryWithoutChain.chain; + const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort()); + const expectedHash = sha256Hex(payload); + + if (entry.chain?.hash !== expectedHash) { + errors.push(`Line ${i + 1}: Hash mismatch`); + } + + prevHash = entry.chain?.hash || CHAIN_GENESIS; + } catch (error) { + errors.push(`Line ${i + 1}: Parse error - ${error}`); + } + } + + return { + valid: errors.length === 0, + entries: lines.length, + errors + }; + } +} diff --git a/skills/clawsec-nanoclaw/guardian/policy.json b/skills/clawsec-nanoclaw/guardian/policy.json new file mode 100644 index 0000000..7445426 --- /dev/null +++ b/skills/clawsec-nanoclaw/guardian/policy.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "description": "NanoClaw file integrity monitoring policy", + "nanoclaw_version": "0.1.0", + "targets": [ + { + "path": "/workspace/project/data/registered_groups.json", + "mode": "restore", + "priority": "critical", + "description": "Group registration config - prevents unauthorized group access" + }, + { + "path": "/workspace/group/CLAUDE.md", + "mode": "restore", + "priority": "high", + "description": "Group-specific agent instructions" + }, + { + "path": "/workspace/project/groups/global/CLAUDE.md", + "mode": "restore", + "priority": "high", + "description": "Global agent instructions shared across all groups" + }, + { + "pattern": "/workspace/project/container/**/*.ts", + "mode": "alert", + "priority": "medium", + "description": "Container runtime code - alert on changes for awareness" + }, + { + "pattern": "/workspace/project/host/**/*.ts", + "mode": "alert", + "priority": "medium", + "description": "Host process code - alert on changes for awareness" + }, + { + "pattern": "/workspace/ipc/**/*", + "mode": "ignore", + "priority": "low", + "description": "IPC files change constantly - ignore" + }, + { + "pattern": "/workspace/group/conversations/**/*", + "mode": "ignore", + "priority": "low", + "description": "Chat history - expected to change frequently" + } + ], + "notes": [ + "Mode 'restore': Auto-restore file to approved baseline on drift + alert user", + "Mode 'alert': Alert user about drift but do not auto-restore", + "Mode 'ignore': No monitoring, file changes are expected", + "Patterns use glob syntax with ** for recursive matching" + ] +} diff --git a/skills/clawsec-nanoclaw/host-services/advisory-cache.ts b/skills/clawsec-nanoclaw/host-services/advisory-cache.ts new file mode 100644 index 0000000..fad74fb --- /dev/null +++ b/skills/clawsec-nanoclaw/host-services/advisory-cache.ts @@ -0,0 +1,417 @@ +/** + * ClawSec Advisory Cache Manager for NanoClaw + * + * Manages fetching, verifying, and caching the ClawSec advisory feed. + * Runs on the host side (not in container). + * + * Security: + * - Ed25519 signature verification using Node.js crypto + * - Fail-closed policy: invalid signature = reject feed + * - TLS 1.2+ enforcement with certificate validation + * - Public key embedded (not user-modifiable) + * - Cache stored in host-managed directory + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import https from 'node:https'; +import path from 'node:path'; + +// ClawSec public key (from clawsec-signing-public.pem) +const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A= +-----END PUBLIC KEY-----`; + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json'; +const FETCH_TIMEOUT_MS = 10000; + +export interface Advisory { + id: string; + severity: string; + type?: string; + title?: string; + description?: string; + action?: string; + published?: string; + updated?: string; + affected: string[]; +} + +export interface FeedPayload { + version: string; + updated?: string; + advisories: Advisory[]; +} + +export interface AdvisoryCache { + feed: FeedPayload; + fetchedAt: string; + verified: boolean; + publicKeyFingerprint: string; +} + +interface Logger { + info(msg: string | object, ...args: unknown[]): void; + error(msg: string | object, ...args: unknown[]): void; + warn(msg: string | object, ...args: unknown[]): void; +} + +export class AdvisoryCacheManager { + private cache: AdvisoryCache | null = null; + private refreshPromise: Promise | null = null; + private cacheFile: string; + private logger: Logger; + + constructor(dataDir: string, logger: Logger) { + this.cacheFile = path.join(dataDir, 'clawsec-advisory-cache.json'); + this.logger = logger; + } + + /** + * Initialize cache manager. Loads cache from disk and refreshes if stale. + */ + async initialize(): Promise { + await this.loadCacheFromDisk(); + + if (!this.cache || this.isCacheStale()) { + try { + await this.refresh(); + } catch (error) { + this.logger.error({ error }, 'Failed to initialize advisory cache'); + // Continue with stale cache if available + } + } + } + + /** + * Refresh advisory cache from remote feed. + * Thread-safe: prevents concurrent refreshes. + */ + async refresh(): Promise { + // Prevent concurrent refreshes + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this._doRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + /** + * Get current cache. Returns null if cache is stale or missing. + */ + getCache(): AdvisoryCache | null { + if (!this.cache || this.isCacheStale()) { + return null; + } + return this.cache; + } + + /** + * Get cache even if stale (for fallback scenarios) + */ + getCacheAllowStale(): AdvisoryCache | null { + return this.cache; + } + + private async _doRefresh(): Promise { + try { + this.logger.info('Refreshing advisory cache from ClawSec feed'); + + const feed = await this.fetchAndVerifyFeed(); + const fingerprint = this.calculateKeyFingerprint(); + + this.cache = { + feed, + fetchedAt: new Date().toISOString(), + verified: true, + publicKeyFingerprint: fingerprint, + }; + + await this.saveCacheToDisk(); + this.logger.info({ + advisories: feed.advisories.length, + updated: feed.updated, + }, 'Advisory cache refreshed successfully'); + } catch (error) { + this.logger.error({ error }, 'Failed to refresh advisory cache'); + throw error; + } + } + + private isCacheStale(): boolean { + if (!this.cache) return true; + const age = Date.now() - Date.parse(this.cache.fetchedAt); + return age > CACHE_TTL_MS; + } + + private async fetchAndVerifyFeed(): Promise { + // Fetch feed and signature in parallel + const [payloadRaw, signatureRaw] = await Promise.all([ + this.secureFetch(FEED_URL), + this.secureFetch(`${FEED_URL}.sig`), + ]); + + // Verify Ed25519 signature + if (!this.verifySignature(payloadRaw, signatureRaw)) { + throw new Error('Feed signature verification failed (Ed25519)'); + } + + // Parse and validate + const feed = JSON.parse(payloadRaw) as FeedPayload; + if (!this.isValidFeed(feed)) { + throw new Error('Invalid feed format'); + } + + return feed; + } + + private async secureFetch(url: string): Promise { + return new Promise((resolve, reject) => { + // Create secure HTTPS agent with TLS 1.2+ enforcement + const agent = new https.Agent({ + minVersion: 'TLSv1.2', + rejectUnauthorized: true, + ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', + }); + + const req = https.get(url, { + agent, + timeout: FETCH_TIMEOUT_MS, + headers: { + 'User-Agent': 'NanoClaw/1.0', + 'Accept': 'application/json,text/plain', + }, + }, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(data)); + res.on('error', reject); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error(`Timeout fetching ${url}`)); + }); + }); + } + + private verifySignature(payload: string, signatureBase64: string): boolean { + try { + // Decode base64 signature + const trimmed = signatureBase64.trim(); + let encoded = trimmed; + + // Handle JSON-wrapped signature: {"signature": "base64..."} + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed.signature === 'string') { + encoded = parsed.signature; + } + } catch { + // Not JSON, use as-is + } + } + + const normalized = encoded.replace(/\s+/g, ''); + const sigBuffer = Buffer.from(normalized, 'base64'); + + // Verify Ed25519 signature using Node.js crypto + const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM); + return crypto.verify( + null, // algorithm null = Ed25519 raw mode + Buffer.from(payload, 'utf8'), + publicKey, + sigBuffer + ); + } catch (error) { + this.logger.warn({ error }, 'Signature verification failed'); + return false; + } + } + + private isValidFeed(feed: unknown): feed is FeedPayload { + if (typeof feed !== 'object' || !feed) return false; + const f = feed as FeedPayload; + + if (typeof f.version !== 'string' || !f.version.trim()) return false; + if (!Array.isArray(f.advisories)) return false; + + // Validate each advisory + return f.advisories.every((a: unknown) => { + if (typeof a !== 'object' || !a) return false; + const advisory = a as Advisory; + + return ( + typeof advisory.id === 'string' && + advisory.id.trim() !== '' && + typeof advisory.severity === 'string' && + advisory.severity.trim() !== '' && + Array.isArray(advisory.affected) && + advisory.affected.every( + (affected) => typeof affected === 'string' && affected.trim() !== '' + ) + ); + }); + } + + private calculateKeyFingerprint(): string { + const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM); + const der = publicKey.export({ type: 'spki', format: 'der' }); + return crypto.createHash('sha256').update(der).digest('hex'); + } + + private async loadCacheFromDisk(): Promise { + try { + const data = await fs.readFile(this.cacheFile, 'utf8'); + const parsed = JSON.parse(data) as AdvisoryCache; + + // Validate cache structure + if (this.isValidCache(parsed)) { + this.cache = parsed; + this.logger.info({ + age: Date.now() - Date.parse(parsed.fetchedAt), + advisories: parsed.feed.advisories.length, + }, 'Loaded advisory cache from disk'); + } else { + this.logger.warn('Invalid cache format on disk, discarding'); + this.cache = null; + } + } catch { + this.cache = null; + } + } + + private isValidCache(cache: unknown): cache is AdvisoryCache { + if (typeof cache !== 'object' || !cache) return false; + const c = cache as AdvisoryCache; + + return ( + this.isValidFeed(c.feed) && + typeof c.fetchedAt === 'string' && + typeof c.verified === 'boolean' && + typeof c.publicKeyFingerprint === 'string' + ); + } + + private async saveCacheToDisk(): Promise { + if (!this.cache) return; + + try { + await fs.mkdir(path.dirname(this.cacheFile), { recursive: true }); + + // Atomic write: temp file then rename + const tempFile = `${this.cacheFile}.tmp`; + await fs.writeFile(tempFile, JSON.stringify(this.cache, null, 2), 'utf8'); + await fs.rename(tempFile, this.cacheFile); + + this.logger.info({ path: this.cacheFile }, 'Advisory cache saved to disk'); + } catch (error) { + this.logger.error({ error }, 'Failed to save advisory cache to disk'); + throw error; + } + } +} + +/** + * Helper: Match advisories against installed skills + */ +export function findAdvisoryMatches( + advisories: Advisory[], + skills: Array<{ name: string; version: string | null; dirName: string }> +): Array<{ + advisory: Advisory; + skill: { name: string; version: string | null; dirName: string }; + matchedAffected: string[]; +}> { + const matches: Array<{ + advisory: Advisory; + skill: { name: string; version: string | null; dirName: string }; + matchedAffected: string[]; + }> = []; + + for (const advisory of advisories) { + for (const skill of skills) { + const matchedAffected: string[] = []; + + for (const affected of advisory.affected) { + // Parse affected specifier: skill-name or skill-name@version + const atIndex = affected.lastIndexOf('@'); + const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected; + const _affectedVersion = atIndex > 0 ? affected.slice(atIndex + 1) : '*'; + + // Match by name or directory name + if (affectedName === skill.name || affectedName === skill.dirName) { + // TODO: implement version range matching + matchedAffected.push(affected); + } + } + + if (matchedAffected.length > 0) { + matches.push({ advisory, skill, matchedAffected }); + } + } + } + + return matches; +} + +/** + * Helper: Evaluate safety recommendation for a skill + */ +export function evaluateSkillSafety(advisories: Advisory[]): { + safe: boolean; + recommendation: 'install' | 'block' | 'review'; + reason: string; +} { + if (advisories.length === 0) { + return { safe: true, recommendation: 'install', reason: 'No advisories found' }; + } + + const hasMalicious = advisories.some((a) => a.type === 'malicious'); + const hasRemoveAction = advisories.some((a) => a.action === 'remove'); + const hasCritical = advisories.some((a) => a.severity === 'critical'); + const hasHigh = advisories.some((a) => a.severity === 'high'); + + if (hasMalicious || hasRemoveAction) { + return { + safe: false, + recommendation: 'block', + reason: 'Malicious skill or removal recommended', + }; + } + + if (hasCritical) { + return { + safe: false, + recommendation: 'block', + reason: 'Critical security advisory', + }; + } + + if (hasHigh) { + return { + safe: false, + recommendation: 'review', + reason: 'High severity advisory - user review recommended', + }; + } + + return { + safe: false, + recommendation: 'review', + reason: 'Advisory found - review before installing', + }; +} diff --git a/skills/clawsec-nanoclaw/host-services/integrity-handler.ts b/skills/clawsec-nanoclaw/host-services/integrity-handler.ts new file mode 100644 index 0000000..060b6ee --- /dev/null +++ b/skills/clawsec-nanoclaw/host-services/integrity-handler.ts @@ -0,0 +1,348 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * ClawSec File Integrity Monitoring IPC Handler for NanoClaw Host + * + * Add these handlers to /workspace/project/src/ipc.ts + * + * This processes integrity monitoring requests from agents running in containers. + */ + +import fs from 'fs'; +import path from 'path'; +import { IntegrityMonitor } from '../guardian/integrity-monitor'; + +// ============================================================================ +// Integrity Service (Singleton) +// ============================================================================ + +export class IntegrityService { + private monitor: IntegrityMonitor | null = null; + private initialized = false; + + async initialize(): Promise { + if (this.initialized) return; + + try { + this.monitor = new IntegrityMonitor({ + policyPath: '/workspace/project/skills/clawsec-nanoclaw/guardian/policy.json', + stateDir: '/workspace/project/data/soul-guardian' + }); + + // Initialize baselines on first run + await this.monitor.init('system', 'initial baseline'); + + this.initialized = true; + console.log('[IntegrityService] Initialized successfully'); + } catch (error) { + console.error('[IntegrityService] Initialization failed:', error); + throw error; + } + } + + getMonitor(): IntegrityMonitor { + if (!this.monitor) { + throw new Error('IntegrityService not initialized'); + } + return this.monitor; + } + + isInitialized(): boolean { + return this.initialized; + } +} + +// Global singleton instance +let integrityServiceInstance: IntegrityService | null = null; + +export function getIntegrityService(): IntegrityService { + if (!integrityServiceInstance) { + integrityServiceInstance = new IntegrityService(); + } + return integrityServiceInstance; +} + +// ============================================================================ +// IPC Handler Integration +// ============================================================================ + +/** + * Add this to the IpcDeps interface in /workspace/project/src/ipc.ts: + * + * export interface IpcDeps { + * // ... existing deps + * integrityService?: IntegrityService; + * } + */ + +/** + * Add these cases to the switch statement in processTaskIpc: + */ + +export async function handleIntegrityIpc( + task: any, + deps: { integrityService?: IntegrityService }, + logger: any +): Promise { + const { type, requestId, groupFolder: _groupFolder } = task; + + if (!deps.integrityService) { + logger.warn({ task }, 'IntegrityService not available'); + if (requestId) { + writeResult(requestId, { + success: false, + error: 'IntegrityService not initialized' + }); + } + return; + } + + const service = deps.integrityService; + + if (!service.isInitialized()) { + try { + await service.initialize(); + } catch (error) { + logger.error({ error }, 'Failed to initialize IntegrityService'); + if (requestId) { + writeResult(requestId, { + success: false, + error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}` + }); + } + return; + } + } + + switch (type) { + case 'integrity_check': + await handleIntegrityCheck(task, service, logger); + break; + + case 'integrity_approve': + await handleIntegrityApprove(task, service, logger); + break; + + case 'integrity_status': + await handleIntegrityStatus(task, service, logger); + break; + + case 'integrity_verify_audit': + await handleIntegrityVerifyAudit(task, service, logger); + break; + + default: + logger.warn({ type }, 'Unknown integrity task type'); + } +} + +// ============================================================================ +// Individual Handlers +// ============================================================================ + +async function handleIntegrityCheck( + task: any, + service: IntegrityService, + logger: any +): Promise { + const { requestId, mode, autoRestore, groupFolder } = task; + + logger.info({ requestId, groupFolder }, 'Processing integrity_check'); + + try { + const monitor = service.getMonitor(); + + if (mode === 'status') { + // Status mode: just return baseline info + const status = monitor.getStatus(); + writeResult(requestId, { + success: true, + mode: 'status', + ...status + }); + } else { + // Check mode: detect drift and optionally restore + const result = await monitor.checkIntegrity(autoRestore !== false, 'agent'); + + writeResult(requestId, result); + + if (result.drift_detected) { + logger.warn( + { requestId, drifted: result.summary.drifted, restored: result.summary.restored }, + 'Integrity drift detected' + ); + } else { + logger.info({ requestId }, 'Integrity check passed'); + } + } + } catch (error) { + logger.error({ error, requestId }, 'Integrity check failed'); + writeResult(requestId, { + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +async function handleIntegrityApprove( + task: any, + service: IntegrityService, + logger: any +): Promise { + const { requestId, path: filePath, note, approvedBy, groupFolder } = task; + + logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_approve'); + + try { + const monitor = service.getMonitor(); + + await monitor.approveChange(filePath, approvedBy || 'agent', note || ''); + + writeResult(requestId, { + success: true, + path: filePath, + approved_at: new Date().toISOString(), + approved_by: approvedBy, + note + }); + + logger.info({ requestId, filePath }, 'File change approved'); + } catch (error) { + logger.error({ error, requestId, filePath }, 'Approve change failed'); + writeResult(requestId, { + success: false, + error: error instanceof Error ? error.message : String(error), + path: filePath + }); + } +} + +async function handleIntegrityStatus( + task: any, + service: IntegrityService, + logger: any +): Promise { + const { requestId, path: filePath, groupFolder } = task; + + logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_status'); + + try { + const monitor = service.getMonitor(); + const status = monitor.getStatus(filePath); + + writeResult(requestId, { + success: true, + ...status + }); + + logger.info({ requestId }, 'Status retrieved'); + } catch (error) { + logger.error({ error, requestId }, 'Status check failed'); + writeResult(requestId, { + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +async function handleIntegrityVerifyAudit( + task: any, + service: IntegrityService, + logger: any +): Promise { + const { requestId, groupFolder } = task; + + logger.info({ requestId, groupFolder }, 'Processing integrity_verify_audit'); + + try { + const monitor = service.getMonitor(); + const verification = monitor.verifyAuditChain(); + + writeResult(requestId, { + success: true, + ...verification + }); + + if (!verification.valid) { + logger.error({ requestId, errors: verification.errors }, 'Audit chain verification failed'); + } else { + logger.info({ requestId, entries: verification.entries }, 'Audit chain verified'); + } + } catch (error) { + logger.error({ error, requestId }, 'Audit verification failed'); + writeResult(requestId, { + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function writeResult(requestId: string, result: any): void { + const resultDir = '/workspace/ipc/clawsec_results'; + + // Ensure directory exists + if (!fs.existsSync(resultDir)) { + fs.mkdirSync(resultDir, { recursive: true }); + } + + const resultPath = path.join(resultDir, `${requestId}.json`); + fs.writeFileSync(resultPath, JSON.stringify(result, null, 2)); +} + +// ============================================================================ +// Integration Instructions +// ============================================================================ + +/** + * To integrate into NanoClaw host process: + * + * 1. Add IntegrityService to IpcDeps in src/ipc.ts: + * + * import { IntegrityService, getIntegrityService } from '../skills/clawsec-nanoclaw/host-services/integrity-handler'; + * + * export interface IpcDeps { + * // ... existing deps + * integrityService?: IntegrityService; + * } + * + * 2. Initialize in main.ts: + * + * const integrityService = getIntegrityService(); + * await integrityService.initialize(); + * + * const ipcDeps: IpcDeps = { + * // ... existing deps + * integrityService + * }; + * + * 3. Add handler calls in processTaskIpc switch statement: + * + * case 'integrity_check': + * case 'integrity_approve': + * case 'integrity_status': + * case 'integrity_verify_audit': + * await handleIntegrityIpc(task, deps, logger); + * break; + * + * 4. Ensure /workspace/ipc/clawsec_results/ directory exists and is writable + * + * 5. Ensure /workspace/project/data/soul-guardian/ directory exists and is writable + */ + +// Example scheduled task for continuous monitoring: +// +// schedule_task({ +// prompt: ` +// Run clawsec_check_integrity to check for file tampering. +// If drift_detected is true and files were restored, send alert: +// "SECURITY: Unauthorized changes detected and reverted in: +// [list restored files with their paths] +// Review patches in /workspace/project/data/soul-guardian/patches/" +// `, +// schedule_type: 'cron', +// schedule_value: '*/30 * * * *', // Every 30 minutes +// context_mode: 'isolated' +// }); diff --git a/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts b/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts new file mode 100644 index 0000000..7d32d20 --- /dev/null +++ b/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts @@ -0,0 +1,107 @@ +/** + * ClawSec Advisory Feed IPC Handler Additions for NanoClaw + * + * Add this case to the switch statement in /workspace/project/src/ipc.ts + * inside the processTaskIpc function. + * + * This handler processes advisory cache refresh requests from agents. + */ + +import { AdvisoryCacheManager } from './advisory-cache'; +import { SkillSignatureVerifier } from './skill-signature-handler'; + +// Add to IpcDeps interface: +export interface IpcDeps { + advisoryCacheManager?: AdvisoryCacheManager; + signatureVerifier?: SkillSignatureVerifier; +} + +interface IpcLogger { + info(obj: Record, msg?: string): void; + warn(obj: Record, msg?: string): void; + error(obj: Record, msg?: string): void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type IpcTask = Record; + +/** + * Placeholder for the host-side writeResponse function. + * The actual implementation lives in the NanoClaw host process. + */ +declare function writeResponse(requestId: string, data: Record): Promise; + +/** + * Handle advisory and signature IPC tasks. + * + * In the host process, call this from the processTaskIpc switch statement + * for the 'refresh_advisory_cache' and 'verify_skill_signature' cases. + */ +export async function handleAdvisoryIpc( + task: IpcTask, + deps: IpcDeps, + logger: IpcLogger, + sourceGroup: string, +): Promise { + switch (task.type) { + case 'refresh_advisory_cache': + // Any group can request cache refresh (rate-limited by cache manager) + logger.info({ sourceGroup }, 'Advisory cache refresh requested via IPC'); + if (deps.advisoryCacheManager) { + try { + await deps.advisoryCacheManager.refresh(); + logger.info({ sourceGroup }, 'Advisory cache refreshed successfully'); + } catch (error) { + logger.error({ error, sourceGroup }, 'Advisory cache refresh failed'); + } + } else { + logger.warn({ sourceGroup }, 'Advisory cache manager not initialized'); + } + break; + + case 'verify_skill_signature': { + // Skill signature verification (Phase 1) + const { requestId, packagePath, signaturePath, publicKeyPem, allowUnsigned } = task; + + logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature'); + + try { + if (!deps.signatureVerifier) { + throw new Error('Signature verification service not available'); + } + + const result = await deps.signatureVerifier.verify({ + packagePath, + signaturePath, + publicKeyPem, + allowUnsigned: allowUnsigned || false, + }); + + await writeResponse(requestId, { + success: true, + message: result.valid ? 'Signature valid' : 'Signature invalid', + data: result, + }); + + logger.info( + { sourceGroup, requestId, valid: result.valid, signer: result.signer }, + 'Signature verification completed' + ); + } catch (error: unknown) { + const err = error as Error & { code?: string }; + logger.error({ error, sourceGroup, requestId, packagePath }, 'Signature verification failed'); + + const errorCode = err.code || 'CRYPTO_ERROR'; + await writeResponse(requestId, { + success: false, + message: err.message || 'Verification failed', + error: { + code: errorCode, + details: error + } + }); + } + break; + } + } +} diff --git a/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts b/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts new file mode 100644 index 0000000..7cf1bf1 --- /dev/null +++ b/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts @@ -0,0 +1,229 @@ +/** + * Skill Signature Verification Handler for NanoClaw + * + * Verifies Ed25519 signatures on skill packages to prevent supply chain attacks. + * Uses the same pinned public key as advisory feed verification. + */ + +import fs from 'fs'; +import path from 'path'; +import { + verifyDetachedSignatureWithDetails, + loadPublicKey, + sha256File, + SecurityPolicyError +} from '../lib/signatures.js'; + +/** + * Default location of ClawSec's pinned public key (same as advisory feed) + */ +const DEFAULT_PUBLIC_KEY_PATH = path.join( + __dirname, + '../advisories/feed-signing-public.pem' +); + +/** + * Verification result interface + */ +export interface VerificationResult { + valid: boolean; + signer: string | null; + packageHash: string; + verifiedAt: string; + algorithm: 'Ed25519'; + error?: string; +} + +/** + * Verification parameters interface + */ +export interface VerifyParams { + packagePath: string; + signaturePath: string; + publicKeyPem?: string; // Optional override of pinned key + allowUnsigned?: boolean; // Allow missing signature (default: false) +} + +/** + * Service class for skill package signature verification + */ +export class SkillSignatureVerifier { + private publicKeyPath: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private logger: any; + + constructor( + publicKeyPath: string = DEFAULT_PUBLIC_KEY_PATH, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + logger?: any + ) { + this.publicKeyPath = publicKeyPath; + this.logger = logger || console; + } + + /** + * Verify Ed25519 signature of a skill package + */ + async verify(params: VerifyParams): Promise { + const { + packagePath, + signaturePath, + publicKeyPem, + allowUnsigned = false + } = params; + + // Validate package file exists + if (!fs.existsSync(packagePath)) { + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: `Package file not found: ${packagePath}` + }; + } + + // Check signature file exists + if (!fs.existsSync(signaturePath)) { + if (allowUnsigned) { + // Unsigned allowed - compute hash but mark invalid + const packageHash = sha256File(packagePath); + return { + valid: false, + signer: null, + packageHash, + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: 'No signature file found (unsigned package)' + }; + } else { + // Unsigned not allowed - fail + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: `Signature file not found: ${signaturePath}` + }; + } + } + + // Load public key (either custom or pinned) + let keyPem: string; + try { + if (publicKeyPem) { + // Custom key provided - validate format + loadPublicKey(publicKeyPem); // Throws if invalid + keyPem = publicKeyPem; + } else { + // Load pinned ClawSec key + if (!fs.existsSync(this.publicKeyPath)) { + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: `Public key file not found: ${this.publicKeyPath}` + }; + } + keyPem = fs.readFileSync(this.publicKeyPath, 'utf8'); + loadPublicKey(keyPem); // Validate pinned key + } + } catch (error) { + if (error instanceof SecurityPolicyError) { + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: error.message + }; + } + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: `Failed to load public key: ${error instanceof Error ? error.message : String(error)}` + }; + } + + // Compute package hash (always, for integrity tracking) + let packageHash: string; + try { + packageHash = sha256File(packagePath); + } catch (error) { + return { + valid: false, + signer: null, + packageHash: '', + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: `Failed to compute package hash: ${error instanceof Error ? error.message : String(error)}` + }; + } + + // Verify signature + const verificationResult = verifyDetachedSignatureWithDetails( + packagePath, + signaturePath, + keyPem + ); + + // Return structured result + return { + valid: verificationResult.valid, + signer: verificationResult.valid ? 'clawsec' : null, + packageHash, + verifiedAt: new Date().toISOString(), + algorithm: 'Ed25519', + error: verificationResult.error + }; + } + + /** + * Get public key fingerprint for auditing + */ + getPublicKeyFingerprint(): string { + try { + const keyPem = fs.readFileSync(this.publicKeyPath, 'utf8'); + const keyObject = loadPublicKey(keyPem); + const _keyDer = keyObject.export({ type: 'spki', format: 'der' }); + return `sha256:${sha256File(this.publicKeyPath).substring(0, 16)}`; + } catch (error) { + this.logger.error({ error }, 'Failed to compute public key fingerprint'); + return 'unknown'; + } + } +} + +/** + * Error codes for IPC responses + */ +export const ErrorCodes = { + SIGNATURE_INVALID: 'SIGNATURE_INVALID', + FILE_NOT_FOUND: 'FILE_NOT_FOUND', + CRYPTO_ERROR: 'CRYPTO_ERROR', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE' +} as const; + +/** + * Map verification errors to standard error codes + */ +export function mapErrorCode(error: string): string { + if (error.includes('not found')) { + return ErrorCodes.FILE_NOT_FOUND; + } + if (error.includes('Invalid signature') || error.includes('verification failed')) { + return ErrorCodes.SIGNATURE_INVALID; + } + if (error.includes('public key') || error.includes('PEM')) { + return ErrorCodes.CRYPTO_ERROR; + } + return ErrorCodes.CRYPTO_ERROR; +} diff --git a/skills/clawsec-nanoclaw/lib/advisories.ts b/skills/clawsec-nanoclaw/lib/advisories.ts new file mode 100644 index 0000000..d7a34fa --- /dev/null +++ b/skills/clawsec-nanoclaw/lib/advisories.ts @@ -0,0 +1,327 @@ +/** + * Advisory Feed Loading and Matching for NanoClaw + * Ported from ClawSec's feed.mjs with fail-closed verification + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { + Advisory, + AdvisoryFeed, + AdvisoryMatch, + AffectedSpecifier, + SignatureVerificationOptions, +} from './types.js'; +import { + verifySignedPayload, + parseChecksumsManifest, + verifyChecksums, + fetchText, + defaultChecksumsUrl, + SecurityPolicyError, +} from './signatures.js'; + +const DEFAULT_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json'; + +/** + * Validates that a payload is a valid advisory feed. + */ +export function isValidFeedPayload(raw: unknown): raw is AdvisoryFeed { + if (typeof raw !== 'object' || raw === null) return false; + const obj = raw as Record; + + if (typeof obj.version !== 'string' || !obj.version.trim()) return false; + if (!Array.isArray(obj.advisories)) return false; + + for (const advisory of obj.advisories) { + if (typeof advisory !== 'object' || advisory === null) return false; + const adv = advisory as Record; + + if (typeof adv.id !== 'string' || !adv.id.trim()) return false; + if (typeof adv.severity !== 'string' || !adv.severity.trim()) return false; + if (!Array.isArray(adv.affected)) return false; + if (!adv.affected.every((entry) => typeof entry === 'string' && entry.trim())) return false; + } + + return true; +} + +/** + * Parses an affected specifier like "skill-name@version-spec". + */ +export function parseAffectedSpecifier(rawSpecifier: string): AffectedSpecifier | null { + const specifier = rawSpecifier.trim(); + if (!specifier) return null; + + const atIndex = specifier.lastIndexOf('@'); + if (atIndex <= 0) { + return { name: specifier, versionSpec: '*' }; + } + + return { + name: specifier.slice(0, atIndex), + versionSpec: specifier.slice(atIndex + 1), + }; +} + +/** + * Normalizes a skill name for comparison. + */ +export function normalizeSkillName(name: string): string { + return name.toLowerCase().trim().replace(/[^a-z0-9-]/g, ''); +} + +/** + * Checks if a version matches a version specifier. + * Supports: exact match, semver range (^, ~, *), wildcards + */ +export function versionMatches(version: string, versionSpec: string): boolean { + const v = version.trim(); + const spec = versionSpec.trim(); + + // Wildcard matches everything + if (spec === '*' || spec === '') return true; + + // Exact match + if (v === spec) return true; + + // Parse semver components + const parseVersion = (ver: string): number[] => { + const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return []; + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)]; + }; + + const vParts = parseVersion(v); + const specParts = parseVersion(spec.replace(/^[~^]/, '')); + + if (vParts.length === 0 || specParts.length === 0) return false; + + // Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3 + if (spec.startsWith('^')) { + if (vParts[0] !== specParts[0]) return false; + if (vParts[0] === 0) { + // ^0.2.3 means 0.2.x where x >= 3 + if (vParts[1] !== specParts[1]) return false; + return vParts[2] >= specParts[2]; + } + // ^1.2.3 means 1.x.x where x.x >= 2.3 + if (vParts[1] > specParts[1]) return true; + if (vParts[1] < specParts[1]) return false; + return vParts[2] >= specParts[2]; + } + + // Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3) + if (spec.startsWith('~')) { + if (vParts[0] !== specParts[0]) return false; + if (vParts[1] !== specParts[1]) return false; + return vParts[2] >= specParts[2]; + } + + return false; +} + +/** + * Loads advisory feed from a remote URL with signature verification. + */ +export async function loadRemoteFeed( + feedUrl: string, + options: SignatureVerificationOptions +): Promise { + const signatureUrl = options.signatureUrl || `${feedUrl}.sig`; + const checksumsUrl = options.checksumsUrl || defaultChecksumsUrl(feedUrl); + const checksumsSignatureUrl = options.checksumsSignatureUrl || `${checksumsUrl}.sig`; + const publicKeyPem = options.publicKeyPem; + const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem; + const allowUnsigned = options.allowUnsigned || false; + const verifyChecksumManifest = options.verifyChecksumManifest !== false; + + try { + const payloadRaw = await fetchText(feedUrl); + if (!payloadRaw) return null; + + if (!allowUnsigned) { + const signatureRaw = await fetchText(signatureUrl); + if (!signatureRaw) return null; + + if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) { + return null; + } + + // Verify checksum manifest if available + if (verifyChecksumManifest) { + const checksumsRaw = await fetchText(checksumsUrl); + const checksumsSignatureRaw = await fetchText(checksumsSignatureUrl); + + // Only proceed if BOTH checksum files are present + if (checksumsRaw && checksumsSignatureRaw) { + if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) { + return null; // Fail-closed: invalid signature + } + + const checksumsManifest = parseChecksumsManifest(checksumsRaw); + const checksumFeedEntry = feedUrl.split('/').pop() || 'feed.json'; + const checksumSignatureEntry = signatureUrl.split('/').pop() || 'feed.json.sig'; + verifyChecksums(checksumsManifest, { + [checksumFeedEntry]: payloadRaw, + [checksumSignatureEntry]: signatureRaw, + }); + } + // If checksum files missing: continue without checksum verification + // (feed signature was already verified above) + } + } + + try { + const payload = JSON.parse(payloadRaw); + if (!isValidFeedPayload(payload)) return null; + return payload; + } catch { + return null; + } + } catch (error) { + // Security policy violations return null to allow graceful fallback to local feed + if (error instanceof SecurityPolicyError) { + return null; + } + // Re-throw unexpected errors + throw error; + } +} + +/** + * Loads advisory feed from a local file with signature verification. + */ +export async function loadLocalFeed( + feedPath: string, + options: SignatureVerificationOptions +): Promise { + const signaturePath = options.signatureUrl || `${feedPath}.sig`; + const checksumsPath = options.checksumsUrl || path.join(path.dirname(feedPath), 'checksums.json'); + const checksumsSignaturePath = options.checksumsSignatureUrl || `${checksumsPath}.sig`; + const publicKeyPem = options.publicKeyPem; + const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem; + const allowUnsigned = options.allowUnsigned || false; + const verifyChecksumManifest = options.verifyChecksumManifest !== false; + + const payloadRaw = await fs.readFile(feedPath, 'utf8'); + + if (!allowUnsigned) { + const signatureRaw = await fs.readFile(signaturePath, 'utf8'); + if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) { + throw new Error(`Feed signature verification failed for local feed: ${feedPath}`); + } + + if (verifyChecksumManifest) { + const checksumsRaw = await fs.readFile(checksumsPath, 'utf8'); + const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, 'utf8'); + + if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) { + throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`); + } + + const checksumsManifest = parseChecksumsManifest(checksumsRaw); + const checksumFeedEntry = path.basename(feedPath); + const checksumSignatureEntry = path.basename(signaturePath); + verifyChecksums(checksumsManifest, { + [checksumFeedEntry]: payloadRaw, + [checksumSignatureEntry]: signatureRaw, + }); + } + } + + const payload = JSON.parse(payloadRaw); + if (!isValidFeedPayload(payload)) { + throw new Error(`Invalid advisory feed format: ${feedPath}`); + } + return payload; +} + +/** + * Loads advisory feed from remote or falls back to local. + */ +export async function loadFeed( + feedUrl: string = DEFAULT_FEED_URL, + localFeedPath: string, + publicKeyPem: string, + allowUnsigned: boolean = false +): Promise<{ feed: AdvisoryFeed; source: string }> { + const options: SignatureVerificationOptions = { + publicKeyPem, + allowUnsigned, + verifyChecksumManifest: true, + }; + + // Try remote feed first + const remoteFeed = await loadRemoteFeed(feedUrl, options); + if (remoteFeed) { + return { feed: remoteFeed, source: `remote:${feedUrl}` }; + } + + // Fall back to local feed + const localFeed = await loadLocalFeed(localFeedPath, options); + return { feed: localFeed, source: `local:${localFeedPath}` }; +} + +/** + * Checks if an advisory looks high-risk. + */ +export function advisoryLooksHighRisk(advisory: Advisory): boolean { + const type = advisory.type.toLowerCase(); + const severity = advisory.severity.toLowerCase(); + const combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase(); + + if (type === 'malicious_skill' || type === 'malicious_plugin') return true; + if (severity === 'critical') return true; + if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true; + if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true; + + return false; +} + +/** + * Finds advisory matches for a skill. + */ +export function findAdvisoryMatches( + feed: AdvisoryFeed, + skillName: string, + version: string | null +): AdvisoryMatch[] { + const matches: AdvisoryMatch[] = []; + + for (const advisory of feed.advisories) { + const affected = advisory.affected || []; + if (affected.length === 0) continue; + + for (const specifier of affected) { + const parsed = parseAffectedSpecifier(specifier); + if (!parsed) continue; + + if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) { + continue; + } + + // If version specified, check if it matches + if (version && !versionMatches(version, parsed.versionSpec)) { + continue; + } + + // Match found + matches.push({ + advisory, + matchedSpecifier: specifier, + isHighRisk: advisoryLooksHighRisk(advisory), + }); + break; // Only count each advisory once + } + } + + return matches; +} + +/** + * Removes duplicate strings from an array. + */ +export function uniqueStrings(arr: string[]): string[] { + return Array.from(new Set(arr)); +} diff --git a/skills/clawsec-nanoclaw/lib/signatures.ts b/skills/clawsec-nanoclaw/lib/signatures.ts new file mode 100644 index 0000000..0555f3d --- /dev/null +++ b/skills/clawsec-nanoclaw/lib/signatures.ts @@ -0,0 +1,497 @@ +/** + * Ed25519 Signature Verification for NanoClaw + * Ported from ClawSec's feed.mjs + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import https from 'https'; +import { ChecksumsManifest } from './types.js'; + +/** + * Allowed domains for feed/signature fetching. + * Only connections to these domains are permitted for security. + */ +const ALLOWED_DOMAINS = [ + 'clawsec.prompt.security', + 'prompt.security', + 'raw.githubusercontent.com', + 'github.com', +]; + +/** + * Custom error class for security policy violations. + * These errors should always propagate and never be silently caught. + */ +export class SecurityPolicyError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecurityPolicyError'; + } +} + +/** + * Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation. + */ +function createSecureAgent(): https.Agent { + return new https.Agent({ + // Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1) + minVersion: 'TLSv1.2', + // Ensure certificate validation is enabled (reject unauthorized certificates) + rejectUnauthorized: true, + // Use strong cipher suites + ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', + }); +} + +/** + * Validates that a URL is from an allowed domain. + */ +function isAllowedDomain(url: string): boolean { + try { + const parsed = new URL(url); + + // Only allow HTTPS protocol + if (parsed.protocol !== 'https:') { + return false; + } + + const hostname = parsed.hostname.toLowerCase(); + + // Check if hostname matches any allowed domain + return ALLOWED_DOMAINS.some( + (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`) + ); + } catch { + return false; + } +} + +/** + * Secure wrapper around fetch with TLS enforcement and domain validation. + */ +export async function secureFetch(url: string, options: RequestInit = {}): Promise { + // Validate domain before making request + if (!isAllowedDomain(url)) { + throw new SecurityPolicyError( + `Security policy violation: URL domain not allowed. ` + + `Only connections to ${ALLOWED_DOMAINS.join(', ')} are permitted. ` + + `Blocked: ${url}` + ); + } + + // Use secure HTTPS agent with TLS 1.2+ enforcement + const agent = createSecureAgent(); + + return fetch(url, { + ...options, + // @ts-expect-error - agent is supported in Node.js fetch + agent, + }); +} + +/** + * Decodes a signature from various formats (base64 string or JSON). + */ +function decodeSignature(signatureRaw: string): Buffer | null { + const trimmed = signatureRaw.trim(); + if (!trimmed) return null; + + let encoded = trimmed; + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null && typeof parsed.signature === 'string') { + encoded = parsed.signature; + } + } catch { + return null; + } + } + + const normalized = encoded.replace(/\s+/g, ''); + if (!normalized) return null; + + try { + return Buffer.from(normalized, 'base64'); + } catch { + return null; + } +} + +/** + * Verifies an Ed25519 signature for a payload. + */ +export function verifySignedPayload( + payloadRaw: string, + signatureRaw: string, + publicKeyPem: string +): boolean { + const signature = decodeSignature(signatureRaw); + if (!signature) return false; + + const keyPem = publicKeyPem.trim(); + if (!keyPem) return false; + + try { + const publicKey = crypto.createPublicKey(keyPem); + return crypto.verify(null, Buffer.from(payloadRaw, 'utf8'), publicKey, signature); + } catch { + return false; + } +} + +/** + * Computes SHA-256 hash of content. + */ +export function sha256Hex(content: string | Buffer): string { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +/** + * Computes SHA-256 hash of a file. + * Convenience wrapper for file-based integrity monitoring and package verification. + */ +export function sha256File(filePath: string): string { + const data = fs.readFileSync(filePath); + return sha256Hex(data); +} + +/** + * Loads and validates an Ed25519 public key from PEM format. + * @throws {SecurityPolicyError} if PEM format is invalid + */ +export function loadPublicKey(pemString: string): crypto.KeyObject { + const trimmed = pemString.trim(); + if (!trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) { + throw new SecurityPolicyError('Invalid PEM format: must start with -----BEGIN PUBLIC KEY-----'); + } + + try { + return crypto.createPublicKey(trimmed); + } catch (error) { + throw new SecurityPolicyError( + `Failed to load public key: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Verifies Ed25519 detached signature for a file. + * Matches the API of verify_detached_ed25519.mjs from OpenClaw. + * + * @param dataPath - Path to the file to verify + * @param signaturePath - Path to the detached signature file (.sig) + * @param publicKeyPem - Ed25519 public key in PEM format + * @returns true if signature is valid, false otherwise + */ +export function verifyDetachedSignature( + dataPath: string, + signaturePath: string, + publicKeyPem: string +): boolean { + try { + const data = fs.readFileSync(dataPath); + const signatureRaw = fs.readFileSync(signaturePath, 'utf8'); + const signature = decodeSignature(signatureRaw); + + if (!signature) return false; + + const publicKey = crypto.createPublicKey(publicKeyPem.trim()); + return crypto.verify(null, data, publicKey, signature); + } catch { + return false; + } +} + +/** + * Verifies detached signature with detailed error information. + * Useful for debugging signature verification failures. + * + * @param dataPath - Path to the file to verify + * @param signaturePath - Path to the detached signature file (.sig) + * @param publicKeyPem - Ed25519 public key in PEM format + * @returns Object with valid flag and optional error message + */ +export function verifyDetachedSignatureWithDetails( + dataPath: string, + signaturePath: string, + publicKeyPem: string +): { valid: boolean; error?: string } { + try { + if (!fs.existsSync(dataPath)) { + return { valid: false, error: 'Data file not found' }; + } + if (!fs.existsSync(signaturePath)) { + return { valid: false, error: 'Signature file not found' }; + } + + const data = fs.readFileSync(dataPath); + const signatureRaw = fs.readFileSync(signaturePath, 'utf8'); + const signature = decodeSignature(signatureRaw); + + if (!signature) { + return { valid: false, error: 'Invalid signature format' }; + } + + const publicKey = crypto.createPublicKey(publicKeyPem.trim()); + const valid = crypto.verify(null, data, publicKey, signature); + + return { valid, error: valid ? undefined : 'Signature verification failed' }; + } catch (error) { + return { + valid: false, + error: `Verification error: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * Verifies multiple files against expected hashes. + * Returns list of files that don't match their expected hashes. + * + * @param files - Map of file paths to expected SHA-256 hashes + * @returns Array of mismatches with path, expected, and actual hashes + */ +export function verifyFileHashes( + files: Record +): { path: string; expected: string; actual: string }[] { + const mismatches = []; + + for (const [path, expectedHash] of Object.entries(files)) { + try { + const actualHash = sha256File(path); + if (actualHash !== expectedHash) { + mismatches.push({ path, expected: expectedHash, actual: actualHash }); + } + } catch (error) { + // File missing or unreadable + mismatches.push({ + path, + expected: expectedHash, + actual: `ERROR: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + + return mismatches; +} + +/** + * Extracts SHA-256 value from various formats. + */ +function extractSha256Value(value: unknown): string | null { + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null; + } + + if (typeof value === 'object' && value !== null && 'sha256' in value) { + const sha256 = (value as { sha256: unknown }).sha256; + if (typeof sha256 === 'string') { + const normalized = sha256.trim().toLowerCase(); + return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null; + } + } + + return null; +} + +/** + * Parses a checksums manifest JSON. + */ +export function parseChecksumsManifest(manifestRaw: string): ChecksumsManifest { + let parsed: unknown; + try { + parsed = JSON.parse(manifestRaw); + } catch { + throw new Error('Checksum manifest is not valid JSON'); + } + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Checksum manifest must be an object'); + } + + const obj = parsed as Record; + + const algorithmRaw = typeof obj.algorithm === 'string' ? obj.algorithm.trim().toLowerCase() : 'sha256'; + if (algorithmRaw !== 'sha256') { + throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || '(empty)'}`); + } + + // Support legacy manifest formats + const schemaVersion = ( + typeof obj.schema_version === 'string' ? obj.schema_version.trim() : + typeof obj.version === 'string' ? obj.version.trim() : + typeof obj.generated_at === 'string' ? obj.generated_at.trim() : + '1' + ); + + if (!schemaVersion) { + throw new Error('Checksum manifest missing schema_version'); + } + + if (typeof obj.files !== 'object' || obj.files === null) { + throw new Error('Checksum manifest missing files object'); + } + + const files: Record = {}; + for (const [key, value] of Object.entries(obj.files)) { + if (!key.trim()) continue; + const digest = extractSha256Value(value); + if (!digest) { + throw new Error(`Invalid checksum digest entry for ${key}`); + } + files[key] = digest; + } + + if (Object.keys(files).length === 0) { + throw new Error('Checksum manifest has no usable file digests'); + } + + return { + schema_version: schemaVersion, + algorithm: 'sha256', + files, + }; +} + +/** + * Normalizes a checksum entry name for matching. + */ +function normalizeChecksumEntryName(entryName: string): string { + return entryName + .trim() + .replace(/\\/g, '/') + .replace(/^(?:\.\/)+/, '') + .replace(/^\/+/, ''); +} + +/** + * Resolves a checksum manifest entry by name. + */ +function resolveChecksumManifestEntry( + files: Record, + entryName: string +): { key: string; digest: string } | null { + const normalizedEntry = normalizeChecksumEntryName(entryName); + if (!normalizedEntry) return null; + + // Try direct match and common variations + const directCandidates = [ + normalizedEntry, + normalizedEntry.split('/').pop() || '', + `advisories/${normalizedEntry.split('/').pop() || ''}`, + ].filter((c, i, a) => c && a.indexOf(c) === i); + + for (const candidate of directCandidates) { + if (candidate in files) { + return { key: candidate, digest: files[candidate] }; + } + } + + // Try basename matching + const basename = normalizedEntry.split('/').pop() || ''; + if (!basename) return null; + + const basenameMatches = Object.entries(files).filter(([key]) => { + const normalizedKey = normalizeChecksumEntryName(key); + return normalizedKey.split('/').pop() === basename; + }); + + if (basenameMatches.length > 1) { + throw new Error( + `Checksum manifest entry is ambiguous for ${entryName}; ` + + `multiple manifest keys share basename ${basename}` + ); + } + + if (basenameMatches.length === 1) { + const [resolvedKey, digest] = basenameMatches[0]; + return { key: resolvedKey, digest }; + } + + return null; +} + +/** + * Verifies checksums for expected entries. + */ +export function verifyChecksums( + manifest: ChecksumsManifest, + expectedEntries: Record +): void { + for (const [entryName, entryContent] of Object.entries(expectedEntries)) { + if (!entryName) continue; + + const resolved = resolveChecksumManifestEntry(manifest.files, entryName); + if (!resolved) { + throw new Error(`Checksum manifest missing required entry: ${entryName}`); + } + + const actualDigest = sha256Hex(entryContent); + if (actualDigest !== resolved.digest) { + throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`); + } + } +} + +/** + * Fetches text from a URL with timeout. + */ +export async function fetchText(url: string, timeoutMs: number = 10000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await secureFetch(url, { + method: 'GET', + signal: controller.signal, + headers: { accept: 'application/json,text/plain;q=0.9,*/*;q=0.8' }, + }); + if (!response.ok) return null; + return await response.text(); + } catch (error) { + // Re-throw security policy violations - these should never be silently caught + if (error instanceof SecurityPolicyError) { + throw error; + } + // Network errors, timeouts, etc. return null (graceful degradation) + return null; + } finally { + clearTimeout(timeout); + } +} + +/** + * Default checksums URL from feed URL. + */ +export function defaultChecksumsUrl(feedUrl: string): string { + try { + return new URL('checksums.json', feedUrl).toString(); + } catch { + const fallbackBase = feedUrl.replace(/\/?[^/]*$/, ''); + return `${fallbackBase}/checksums.json`; + } +} + +/** + * Safely extracts the basename from a URL or file path. + */ +function _safeBasename(urlOrPath: string, fallback: string): string { + try { + const parsed = new URL(urlOrPath); + const pathname = parsed.pathname; + const lastSlash = pathname.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < pathname.length - 1) { + return pathname.slice(lastSlash + 1); + } + } catch { + const normalized = urlOrPath.trim(); + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < normalized.length - 1) { + return normalized.slice(lastSlash + 1); + } + } + return fallback; +} diff --git a/skills/clawsec-nanoclaw/lib/types.ts b/skills/clawsec-nanoclaw/lib/types.ts new file mode 100644 index 0000000..d2eabc2 --- /dev/null +++ b/skills/clawsec-nanoclaw/lib/types.ts @@ -0,0 +1,254 @@ +/** + * TypeScript types for NanoClaw Skill Installer + * Adapted from ClawSec's guarded skill installer + */ + +export interface Advisory { + id: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + type: 'vulnerable_skill' | 'malicious_skill' | 'prompt_injection' | string; + title: string; + description: string; + affected: string[]; // e.g., ["skill-name@1.0.0", "skill-name@1.0.1"] + action: string; + published: string; + references: string[]; + cvss_score?: number; + nvd_url?: string; + source?: string; + github_issue_url?: string; + reporter?: { + agent_name?: string; + opener_type?: string; + }; +} + +export interface AdvisoryFeed { + version: string; + updated: string; + description: string; + advisories: Advisory[]; +} + +export interface AdvisoryMatch { + advisory: Advisory; + matchedSpecifier: string; + isHighRisk: boolean; +} + +export interface ReputationResult { + score: number; // 0-100 + warnings: string[]; + virusTotalFlags: string[]; + safe: boolean; +} + +export interface SkillMetadata { + slug: string; + name: string; + version: string; + description: string; + author: string; + created: string; + updated: string; + downloads: number; +} + +export interface InspectSkillResult { + skill: SkillMetadata; + reputation: ReputationResult; + advisories: AdvisoryMatch[]; + overallStatus: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked'; +} + +export interface SkillInstallRequest { + request_id: string; + user_jid: string; + group_jid: string; + skill_slug: string; + skill_version: string | null; + reputation_score: number; + reputation_warnings: string[]; + advisories: AdvisoryMatch[]; + created_at: number; // Unix timestamp + expires_at: number; // Unix timestamp + status: 'pending' | 'confirmed' | 'expired' | 'cancelled'; + confirmed_at: number | null; +} + +export interface ChecksumsManifest { + schema_version: string; + algorithm: 'sha256'; + files: Record; // filename -> hex digest +} + +export interface SignatureVerificationOptions { + signatureUrl?: string; + checksumsUrl?: string; + checksumsSignatureUrl?: string; + publicKeyPem: string; + checksumsPublicKeyPem?: string; + allowUnsigned?: boolean; + verifyChecksumManifest?: boolean; +} + +export interface AffectedSpecifier { + name: string; + versionSpec: string; // e.g., "1.0.0", "^1.0.0", "*" +} + +// MCP Tool Request/Response Types + +export interface InspectSkillRequest { + slug: string; + version?: string; +} + +export interface RequestSkillInstallRequest { + slug: string; + version?: string; + target_group_jid?: string; +} + +export interface RequestSkillInstallResponse { + request_id: string; + status: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked'; + reputation?: ReputationResult; + advisories?: AdvisoryMatch[]; + message: string; +} + +export interface ConfirmSkillInstallRequest { + request_id: string; + acknowledge_reputation?: boolean; + acknowledge_advisories?: boolean; +} + +export interface ConfirmSkillInstallResponse { + status: 'installed' | 'failed'; + installed_path?: string; + error?: string; +} + +export interface ListSkillsRequest { + target_group_jid?: string; +} + +export interface ListSkillsResponse { + skills: Array<{ + slug: string; + version: string; + installed_at: string; + path: string; + }>; +} + +export interface RemoveSkillRequest { + slug: string; + target_group_jid?: string; +} + +export interface RemoveSkillResponse { + status: 'removed' | 'not_found'; + message: string; +} + +// IPC Task Types + +export interface IpcSkillInstallRequest { + type: 'skill_install_request'; + slug: string; + version?: string; + target_group_jid?: string; + user_jid: string; + group_folder: string; + timestamp: string; +} + +export interface IpcSkillInstallConfirm { + type: 'skill_install_confirm'; + request_id: string; + acknowledge_reputation: boolean; + acknowledge_advisories: boolean; + user_jid: string; + group_folder: string; + timestamp: string; +} + +export interface IpcSkillRemove { + type: 'skill_remove'; + slug: string; + target_group_jid?: string; + user_jid: string; + group_folder: string; + timestamp: string; +} + +// Database Schema + +export interface SkillInstallRequestRow { + request_id: string; + user_jid: string; + group_jid: string; + skill_slug: string; + skill_version: string | null; + reputation_score: number; + reputation_warnings_json: string; // JSON array + advisories_json: string; // JSON array + created_at: number; + expires_at: number; + status: 'pending' | 'confirmed' | 'expired' | 'cancelled'; + confirmed_at: number | null; +} + +export interface InstalledSkillRow { + slug: string; + version: string; + installed_at: string; + installed_by: string; // user_jid + path: string; + metadata_json: string; // SkillMetadata as JSON +} + +// Skill Signature Verification Types (Phase 1) + +/** + * IPC request for skill signature verification + */ +export interface VerifySkillSignatureRequest { + type: 'verify_skill_signature'; + requestId: string; + groupFolder: string; + timestamp: string; + packagePath: string; + signaturePath: string; + publicKeyPem?: string; // Optional: override default public key + allowUnsigned?: boolean; // Optional: allow missing signature (default: false) +} + +/** + * IPC response for skill signature verification + */ +export interface VerifySkillSignatureResponse { + success: boolean; + message: string; + data?: { + valid: boolean; + signer: string; // 'clawsec' or custom signer identifier + packageHash: string; // SHA-256 of package + verifiedAt: string; // ISO timestamp + algorithm: 'Ed25519'; + }; + error?: { + code: 'SIGNATURE_INVALID' | 'FILE_NOT_FOUND' | 'CRYPTO_ERROR' | 'SERVICE_UNAVAILABLE'; + details?: unknown; + }; +} + +/** + * MCP tool parameters for package verification + */ +export interface VerifySkillPackageParams { + packagePath: string; + signaturePath?: string; // Optional: auto-detects .sig if omitted +} diff --git a/skills/clawsec-nanoclaw/mcp-tools/advisory-tools.ts b/skills/clawsec-nanoclaw/mcp-tools/advisory-tools.ts new file mode 100644 index 0000000..6058f3e --- /dev/null +++ b/skills/clawsec-nanoclaw/mcp-tools/advisory-tools.ts @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * ClawSec Advisory Feed MCP Tools for NanoClaw + * + * Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts + * + * These tools run in the container context and read from the host-managed + * advisory cache at /workspace/project/data/clawsec-advisory-cache.json + */ + +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +// These variables are provided by the host environment (ipc-mcp-stdio.ts) +// when this code is integrated into the NanoClaw container agent. +declare const server: { tool: (...args: any[]) => void }; +declare function writeIpcFile(dir: string, data: any): void; +declare const TASKS_DIR: string; +declare const groupFolder: string; + +// Add these helper functions to the file: + +/** + * Discover installed skills in a directory + */ +async function discoverInstalledSkills(installRoot: string): Promise> { + const skills: Array<{ name: string; version: string | null; dirName: string }> = []; + + try { + const entries = fs.readdirSync(installRoot, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillJsonPath = path.join(installRoot, entry.name, 'skill.json'); + try { + const raw = fs.readFileSync(skillJsonPath, 'utf8'); + const parsed = JSON.parse(raw); + skills.push({ + name: parsed.name || entry.name, + version: parsed.version || null, + dirName: entry.name, + }); + } catch { + // Skill without skill.json, use directory name + skills.push({ + name: entry.name, + version: null, + dirName: entry.name, + }); + } + } + } catch { + // Return empty if directory doesn't exist + } + + return skills; +} + +/** + * Find advisory matches for installed skills + */ +function findAdvisoryMatches( + advisories: any[], + skills: Array<{ name: string; version: string | null; dirName: string }> +): Array<{ + advisory: any; + skill: { name: string; version: string | null; dirName: string }; + matchedAffected: string[]; +}> { + const matches: Array<{ + advisory: any; + skill: { name: string; version: string | null; dirName: string }; + matchedAffected: string[]; + }> = []; + + for (const advisory of advisories) { + for (const skill of skills) { + const matchedAffected: string[] = []; + + for (const affected of advisory.affected || []) { + const atIndex = affected.lastIndexOf('@'); + const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected; + + if (affectedName === skill.name || affectedName === skill.dirName) { + matchedAffected.push(affected); + } + } + + if (matchedAffected.length > 0) { + matches.push({ advisory, skill, matchedAffected }); + } + } + } + + return matches; +} + +// Add these tools to the server: + +server.tool( + 'clawsec_check_advisories', + 'Check ClawSec advisory feed for security issues affecting installed skills. Returns list of matching advisories with details. Use this to scan for known vulnerabilities, malicious skills, or deprecated packages.', + { + installRoot: z.string().optional().describe('Skills installation directory (default: ~/.claude/skills)'), + forceRefresh: z.boolean().optional().describe('Force cache refresh before checking (causes 1-2 second delay)'), + }, + async (args) => { + // Request cache refresh if needed + if (args.forceRefresh) { + writeIpcFile(TASKS_DIR, { + type: 'refresh_advisory_cache', + groupFolder, + timestamp: new Date().toISOString(), + }); + // Wait for refresh (async, best-effort) + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Read cache from shared mount + const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json'; + + try { + const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + const installRoot = args.installRoot || path.join(process.env.HOME || '~', '.claude', 'skills'); + + // Discover installed skills + const skills = await discoverInstalledSkills(installRoot); + + // Find matches + const matches = findAdvisoryMatches(cacheData.feed.advisories, skills); + + // Calculate cache age + const cacheAge = Date.now() - Date.parse(cacheData.fetchedAt); + const cacheAgeMinutes = Math.floor(cacheAge / 60000); + + const result = { + success: true, + feedUpdated: cacheData.feed.updated || null, + totalAdvisories: cacheData.feed.advisories.length, + installedSkills: skills.length, + matches: matches.map(m => ({ + advisory: { + id: m.advisory.id, + severity: m.advisory.severity, + type: m.advisory.type, + title: m.advisory.title, + description: m.advisory.description, + action: m.advisory.action, + published: m.advisory.published, + }, + skill: m.skill, + matchedAffected: m.matchedAffected, + })), + cacheAge: `${cacheAgeMinutes} minutes`, + cacheTimestamp: cacheData.fetchedAt, + }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Failed to check advisories: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true, + }; + } + } +); + +server.tool( + 'clawsec_check_skill_safety', + 'Check if a specific skill is safe to install based on ClawSec advisory feed. Returns safety recommendation (install/block/review) with reasons. Use this as a pre-install gate before installing any skill.', + { + skillName: z.string().describe('Name of skill to check'), + skillVersion: z.string().optional().describe('Version of skill (optional, for version-specific checks)'), + }, + async (args) => { + const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json'; + + try { + const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + + // Find matching advisories for this skill + const matchingAdvisories = cacheData.feed.advisories.filter((advisory: any) => + advisory.affected.some((affected: string) => { + const atIndex = affected.lastIndexOf('@'); + const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected; + return affectedName === args.skillName; + }) + ); + + if (matchingAdvisories.length === 0) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + safe: true, + advisories: [], + recommendation: 'install', + reason: 'No known advisories for this skill', + }, null, 2), + }], + }; + } + + // Evaluate severity + const hasMalicious = matchingAdvisories.some((a: any) => a.type === 'malicious'); + const hasRemoveAction = matchingAdvisories.some((a: any) => a.action === 'remove'); + const hasCritical = matchingAdvisories.some((a: any) => a.severity === 'critical'); + const hasHigh = matchingAdvisories.some((a: any) => a.severity === 'high'); + + let recommendation: 'install' | 'block' | 'review'; + let reason: string; + + if (hasMalicious || hasRemoveAction) { + recommendation = 'block'; + reason = 'Malicious skill or removal recommended by ClawSec'; + } else if (hasCritical) { + recommendation = 'block'; + reason = 'Critical security advisory - do not install'; + } else if (hasHigh) { + recommendation = 'review'; + reason = 'High severity advisory - user review strongly recommended'; + } else { + recommendation = 'review'; + reason = 'Advisory found - review details before installing'; + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + safe: false, // Always false when advisories exist + advisories: matchingAdvisories.map((a: any) => ({ + id: a.id, + severity: a.severity, + type: a.type, + title: a.title, + description: a.description, + action: a.action, + published: a.published, + affected: a.affected, + })), + recommendation, + reason, + skillName: args.skillName, + advisoryCount: matchingAdvisories.length, + }, null, 2), + }], + }; + } catch (error) { + // Conservative: block on error + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + safe: false, + advisories: [], + recommendation: 'review', + reason: `Failed to verify safety: ${error instanceof Error ? error.message : String(error)}`, + error: true, + }, null, 2), + }], + }; + } + } +); + +server.tool( + 'clawsec_list_advisories', + 'List ClawSec advisories with optional filtering. Use this to browse security advisories, filter by severity/type, or search for specific affected skills.', + { + severity: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Filter by severity level'), + type: z.enum(['vulnerability', 'malicious', 'deprecated']).optional().describe('Filter by advisory type'), + affectedSkill: z.string().optional().describe('Filter by affected skill name (partial match supported)'), + limit: z.number().optional().describe('Maximum number of results (default: unlimited)'), + }, + async (args) => { + const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json'; + + try { + const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + let advisories = [...cacheData.feed.advisories]; + + // Apply filters + if (args.severity) { + advisories = advisories.filter((a: any) => a.severity === args.severity); + } + if (args.type) { + advisories = advisories.filter((a: any) => a.type === args.type); + } + if (args.affectedSkill) { + advisories = advisories.filter((a: any) => + a.affected.some((spec: string) => spec.includes(args.affectedSkill!)) + ); + } + + // Sort by severity (critical first) and published date (newest first) + const severityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3 }; + advisories.sort((a: any, b: any) => { + const severityDiff = (severityOrder[a.severity] || 999) - (severityOrder[b.severity] || 999); + if (severityDiff !== 0) return severityDiff; + return (b.published || '').localeCompare(a.published || ''); + }); + + // Apply limit + const originalCount = advisories.length; + if (args.limit && args.limit > 0) { + advisories = advisories.slice(0, args.limit); + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + feedUpdated: cacheData.feed.updated || null, + advisories: advisories.map((a: any) => ({ + id: a.id, + severity: a.severity, + type: a.type, + title: a.title, + description: a.description, + action: a.action, + published: a.published, + affected: a.affected, + })), + total: cacheData.feed.advisories.length, + filtered: originalCount, + returned: advisories.length, + filters: { + severity: args.severity || null, + type: args.type || null, + affectedSkill: args.affectedSkill || null, + limit: args.limit || null, + }, + }, null, 2), + }], + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Failed to list advisories: ${error instanceof Error ? error.message : String(error)}`, + }, null, 2), + }], + isError: true, + }; + } + } +); + +server.tool( + 'clawsec_refresh_cache', + 'Request immediate refresh of the advisory cache from ClawSec feed. This fetches the latest advisories and verifies signatures. Use when you need up-to-date advisory information.', + {}, + async () => { + writeIpcFile(TASKS_DIR, { + type: 'refresh_advisory_cache', + groupFolder, + timestamp: new Date().toISOString(), + }); + + return { + content: [{ + type: 'text' as const, + text: 'Advisory cache refresh requested. This may take a few seconds. Check status with clawsec_check_advisories.', + }], + }; + } +); diff --git a/skills/clawsec-nanoclaw/mcp-tools/integrity-tools.ts b/skills/clawsec-nanoclaw/mcp-tools/integrity-tools.ts new file mode 100644 index 0000000..70a1b76 --- /dev/null +++ b/skills/clawsec-nanoclaw/mcp-tools/integrity-tools.ts @@ -0,0 +1,249 @@ +/** + * ClawSec File Integrity Monitoring MCP Tools for NanoClaw + * + * Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts + * + * These tools run in the container context and communicate with the host-side + * integrity monitor via IPC. + */ + +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +// These variables are provided by the host environment (ipc-mcp-stdio.ts) +// when this code is integrated into the NanoClaw container agent. +/* eslint-disable @typescript-eslint/no-explicit-any */ +declare const server: { tool: (...args: any[]) => void }; +declare function writeIpcFile(dir: string, data: any): void; +declare const TASKS_DIR: string; +declare const groupFolder: string; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +// Result waiting helper +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function waitForResult(requestId: string, timeoutMs: number = 60000): Promise { + const resultDir = '/workspace/ipc/clawsec_results'; + const resultPath = path.join(resultDir, `${requestId}.json`); + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (fs.existsSync(resultPath)) { + const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8')); + fs.unlinkSync(resultPath); // Cleanup + return result; + } + await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every 1s + } + + throw new Error(`Timeout waiting for result: ${requestId}`); +} + +// ============================================================================ +// MCP Tool 1: clawsec_check_integrity +// ============================================================================ + +server.tool( + 'clawsec_check_integrity', + 'Check protected files for unauthorized changes (drift). Automatically restores critical files to approved baselines. Use this for scheduled integrity monitoring or manual security checks.', + { + mode: z.enum(['check', 'status']).optional().describe('check=detect drift and restore, status=view baselines only (default: check)'), + autoRestore: z.boolean().optional().describe('Auto-restore files in restore mode (default: true)'), + }, + async (args) => { + const requestId = `integrity-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Write IPC request + writeIpcFile(TASKS_DIR, { + type: 'integrity_check', + requestId, + mode: args.mode || 'check', + autoRestore: args.autoRestore !== false, + groupFolder, + timestamp: new Date().toISOString() + }); + + try { + // Wait for result + const result = await waitForResult(requestId, 60000); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + isError: !result.success + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Integrity check failed: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true + }; + } + } +); + +// ============================================================================ +// MCP Tool 2: clawsec_approve_change +// ============================================================================ + +server.tool( + 'clawsec_approve_change', + 'Approve an intentional file modification as the new approved baseline. Use this after making legitimate changes to protected files (e.g., updating CLAUDE.md or registered_groups.json).', + { + path: z.string().describe('Absolute path to file to approve (e.g., /workspace/group/CLAUDE.md)'), + note: z.string().optional().describe('Optional note explaining why this change is being approved'), + }, + async (args) => { + const requestId = `integrity-approve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Write IPC request + writeIpcFile(TASKS_DIR, { + type: 'integrity_approve', + requestId, + path: args.path, + note: args.note || '', + approvedBy: 'agent', // In production, should be user JID + groupFolder, + timestamp: new Date().toISOString() + }); + + try { + const result = await waitForResult(requestId, 30000); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + isError: !result.success + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Approve failed: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true + }; + } + } +); + +// ============================================================================ +// MCP Tool 3: clawsec_integrity_status +// ============================================================================ + +server.tool( + 'clawsec_integrity_status', + 'View current baseline status for protected files without checking for drift. Use this to see what files are monitored, when baselines were created, and their current hashes.', + { + path: z.string().optional().describe('Optional: specific file path to check. If omitted, shows all protected files.'), + }, + async (args) => { + const requestId = `integrity-status-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + writeIpcFile(TASKS_DIR, { + type: 'integrity_status', + requestId, + path: args.path, + groupFolder, + timestamp: new Date().toISOString() + }); + + try { + const result = await waitForResult(requestId, 30000); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + isError: !result.success + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Status check failed: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true + }; + } + } +); + +// ============================================================================ +// MCP Tool 4: clawsec_verify_audit +// ============================================================================ + +server.tool( + 'clawsec_verify_audit', + 'Verify the integrity of the audit log hash chain. Use this to detect if the audit log has been tampered with. A valid chain proves all logged events are authentic.', + {}, + async () => { + const requestId = `integrity-verify-audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + writeIpcFile(TASKS_DIR, { + type: 'integrity_verify_audit', + requestId, + groupFolder, + timestamp: new Date().toISOString() + }); + + try { + const result = await waitForResult(requestId, 30000); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + isError: !result.success + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Audit verification failed: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true + }; + } + } +); + +// ============================================================================ +// Usage Examples (for documentation) +// ============================================================================ + +// Usage Examples (for documentation): +// +// Example 1: Scheduled Integrity Check +// +// schedule_task({ +// prompt: 'Check file integrity with clawsec_check_integrity...', +// schedule_type: 'cron', +// schedule_value: '0,30 * * * *', // Every 30 minutes +// context_mode: 'isolated' +// }); +// +// Example 2: Pre-Deployment Check +// +// const check = await tools.clawsec_check_integrity({ mode: 'check', autoRestore: false }); +// if (check.drift_detected) { ... } +// +// Example 3: Approve Legitimate Changes +// +// await tools.clawsec_approve_change({ +// path: '/workspace/group/CLAUDE.md', +// note: 'Updated agent instructions to include new skill' +// }); +// +// Example 4: Audit Verification +// +// const audit = await tools.clawsec_verify_audit(); +// if (!audit.valid) { ... } diff --git a/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts b/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts new file mode 100644 index 0000000..beb91ab --- /dev/null +++ b/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts @@ -0,0 +1,158 @@ +/** + * ClawSec Skill Signature Verification MCP Tool for NanoClaw + * + * Add this tool to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts + * + * This tool verifies Ed25519 signatures on skill packages to prevent supply chain attacks. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +// These variables are provided by the host environment (ipc-mcp-stdio.ts) +// when this code is integrated into the NanoClaw container agent. +declare const server: { tool: (...args: any[]) => void }; +declare function writeIpcFile(dir: string, data: any): void; +declare const TASKS_DIR: string; +declare const groupFolder: string; + +// Result waiting helper +async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise { + const resultDir = '/workspace/ipc/clawsec_results'; + const resultPath = path.join(resultDir, `${requestId}.json`); + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (fs.existsSync(resultPath)) { + const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8')); + fs.unlinkSync(resultPath); // Cleanup + return result; + } + await new Promise(resolve => setTimeout(resolve, 100)); // Poll every 100ms + } + + throw new Error(`Timeout waiting for result: ${requestId}`); +} + +// ============================================================================ +// MCP Tool: clawsec_verify_skill_package +// ============================================================================ + +server.tool( + 'clawsec_verify_skill_package', + 'Verify Ed25519 signature of a skill package before installation. Prevents installation of tampered or malicious skill packages by checking ClawSec signatures.', + { + packagePath: z.string().describe('Absolute path to skill package (.tar.gz or .zip)'), + signaturePath: z.string().optional().describe('Path to signature file. If omitted, auto-detects .sig'), + }, + async (args: { packagePath: string; signaturePath?: string }) => { + const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const sigPath = args.signaturePath || `${args.packagePath}.sig`; + + // Validate package file exists + if (!fs.existsSync(args.packagePath)) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + valid: false, + recommendation: 'block', + error: `Package file not found: ${args.packagePath}` + }, null, 2) + }], + isError: true + }; + } + + // Write IPC request to host + writeIpcFile(TASKS_DIR, { + type: 'verify_skill_signature', + requestId, + groupFolder, + timestamp: new Date().toISOString(), + packagePath: args.packagePath, + signaturePath: sigPath, + }); + + try { + // Wait for host to verify (5 second timeout) + const result = await waitForResult(requestId, 5000); + + if (!result.success) { + // Service error or file not found + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + valid: false, + recommendation: 'block', + packagePath: args.packagePath, + signaturePath: sigPath, + error: result.message || 'Verification failed', + reason: result.error?.code || 'UNKNOWN_ERROR' + }, null, 2) + }], + isError: true + }; + } + + // Check if signature is valid + if (!result.data?.valid) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + valid: false, + recommendation: 'block', + packagePath: args.packagePath, + signaturePath: sigPath, + reason: result.data?.error || 'Signature verification failed', + packageInfo: { + sha256: result.data?.packageHash || 'unknown' + } + }, null, 2) + }], + }; + } + + // Signature valid! + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + valid: true, + recommendation: 'install', + packagePath: args.packagePath, + signaturePath: sigPath, + signer: result.data.signer, + algorithm: result.data.algorithm, + verifiedAt: result.data.verifiedAt, + packageInfo: { + size: fs.statSync(args.packagePath).size, + sha256: result.data.packageHash + } + }, null, 2) + }] + }; + } catch (error) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + valid: false, + recommendation: 'block', + error: `Verification timeout or error: ${error instanceof Error ? error.message : String(error)}` + }, null, 2) + }], + isError: true + }; + } + } +); diff --git a/skills/clawsec-nanoclaw/skill.json b/skills/clawsec-nanoclaw/skill.json new file mode 100644 index 0000000..8546660 --- /dev/null +++ b/skills/clawsec-nanoclaw/skill.json @@ -0,0 +1,142 @@ +{ + "name": "clawsec-nanoclaw", + "version": "0.0.1", + "description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "homepage": "https://clawsec.prompt.security/", + "keywords": [ + "security", + "nanoclaw", + "whatsapp-bot", + "mcp-tools", + "advisory", + "feed", + "threat-intel", + "containers", + "signature-verification", + "vulnerability-scanning", + "agents", + "ai" + ], + "platform": "nanoclaw", + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true, + "description": "NanoClaw skill documentation" + }, + { + "path": "INSTALL.md", + "required": true, + "description": "Installation guide for NanoClaw deployments" + }, + { + "path": "mcp-tools/advisory-tools.ts", + "required": true, + "description": "MCP tools for advisory checking in container context" + }, + { + "path": "host-services/advisory-cache.ts", + "required": true, + "description": "Host-side advisory cache manager with periodic feed fetching" + }, + { + "path": "host-services/ipc-handlers.ts", + "required": true, + "description": "IPC handlers for MCP tool requests" + }, + { + "path": "lib/signatures.ts", + "required": true, + "description": "Ed25519 signature verification utilities" + }, + { + "path": "lib/advisories.ts", + "required": true, + "description": "Advisory matching and vulnerability detection" + }, + { + "path": "lib/types.ts", + "required": true, + "description": "TypeScript type definitions" + }, + { + "path": "advisories/feed-signing-public.pem", + "required": true, + "description": "Pinned Ed25519 public key for feed signature verification" + }, + { + "path": "mcp-tools/signature-verification.ts", + "required": true, + "description": "Phase 1: MCP tool for skill package signature verification" + }, + { + "path": "host-services/skill-signature-handler.ts", + "required": true, + "description": "Phase 1: Host-side signature verification service" + }, + { + "path": "docs/SKILL_SIGNING.md", + "required": true, + "description": "Phase 1: Documentation for skill signing and verification" + }, + { + "path": "mcp-tools/integrity-tools.ts", + "required": true, + "description": "Phase 2: MCP tools for file integrity monitoring" + }, + { + "path": "host-services/integrity-handler.ts", + "required": true, + "description": "Phase 2: Host-side integrity monitoring service" + }, + { + "path": "guardian/integrity-monitor.ts", + "required": true, + "description": "Phase 2: Core file integrity monitoring engine" + }, + { + "path": "guardian/policy.json", + "required": true, + "description": "Phase 2: NanoClaw-specific file protection policy" + }, + { + "path": "docs/INTEGRITY.md", + "required": true, + "description": "Phase 2: Documentation for file integrity monitoring" + } + ] + }, + "capabilities": [ + "Advisory feed monitoring from clawsec.prompt.security", + "MCP tools for agent-initiated vulnerability scans", + "Pre-installation skill safety checks", + "Ed25519 signature verification for advisory feeds", + "Platform-specific advisory filtering (nanoclaw vs openclaw)", + "Containerized agent support with IPC communication" + ], + "nanoclaw": { + "mcp_tools": [ + "clawsec_check_advisories", + "clawsec_check_skill_safety", + "clawsec_list_advisories", + "clawsec_refresh_cache", + "clawsec_verify_skill_package", + "clawsec_check_integrity", + "clawsec_approve_change", + "clawsec_integrity_status", + "clawsec_verify_audit" + ], + "requires": { + "node": ">=18.0.0", + "nanoclaw": ">=0.1.0" + }, + "integration": { + "mcp_tools_file": "container/agent-runner/src/ipc-mcp-stdio.ts", + "ipc_handlers_file": "host/ipc-handler.ts", + "cache_location": "/workspace/project/data/clawsec-advisory-cache.json" + } + } +}