
Clawsec Nanoclaw
- 177 installs
- 1.1k repo stars
- Updated August 4, 2026
- prompt-security/clawsec
Embed lightweight prompt-injection and tool-abuse guardrails when wiring Claude Code skills, MCP tools, or small agent runtimes in prompt-security/clawsec.
About
clawsec-nanoclaw delivers a compact security layer from prompt-security/clawsec for agent and skill integrations, enforcing prompt-injection and unsafe-tool-use guardrails with low overhead during build.
- Minimal clawsec footprint for agent runtimes
- Guards prompts and tool calls at wiring time
- Pairs with clawsec-scanner for layered checks
- Targets prompt-security/clawsec agent workflows
- Reduces injection risk before ship
Clawsec Nanoclaw by the numbers
- 177 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #824 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/prompt-security/clawsec --skill clawsec-nanoclawAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 177 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 4, 2026 |
| Repository | prompt-security/clawsec ↗ |
What it does
Embed lightweight prompt-injection and tool-abuse guardrails when wiring Claude Code skills, MCP tools, or small agent runtimes in prompt-security/clawsec.
Files
ClawSec for NanoClaw
Security advisory monitoring that protects your WhatsApp bot from known vulnerabilities in skills and dependencies.
Vercel Skills Installation
Install with the Vercel Skills CLI for this harness:
npx skills add prompt-security/clawsec --skill clawsec-nanoclaw -a openclaw -yOverview
ClawSec provides MCP tools that check installed skills against a curated feed of security advisories. It prevents installation of vulnerable skills, includes exploitability context for triage, 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
// Before installing any skill
const safety = await tools.clawsec_check_skill_safety({
skillName: 'new-skill',
skillVersion: '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
// Check all installed skills (defaults to ~/.claude/skills in the container)
const result = await tools.clawsec_check_advisories({
installRoot: '/home/node/.claude/skills' // optional
});
if (result.matches.some((m) =>
m.advisory.severity === 'critical' || m.advisory.exploitability_score === 'high'
)) {
// Alert user immediately
console.error('Urgent advisories found!');
}Browse Advisories
// List advisories with filters
const advisories = await tools.clawsec_list_advisories({
severity: 'high', // optional
exploitabilityScore: 'high' // optional
});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, exploitabilityScore (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
// 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
// Add to scheduled tasks
schedule_task({
prompt: "Check advisories using clawsec_check_advisories and alert when critical or high-exploitability matches appear",
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 urgent issues found.
- 2 low-severity/low-exploitability advisories
- All skills up to dateCommon Mistakes
❌ Installing without checking
// DON'T
await installSkill('untrusted-skill');// DO
const safety = await tools.clawsec_check_skill_safety({
skillName: 'untrusted-skill'
});
if (safety.safe) await installSkill('untrusted-skill');❌ Ignoring exploitability context
// DON'T: Use severity only
if (advisory.severity === 'high') {
notifyNow(advisory);
}// DO: Use exploitability + severity
if (
advisory.exploitability_score === 'high' ||
advisory.severity === 'critical'
) {
notifyNow(advisory);
}❌ Skipping critical severity
// DON'T: Ignore high exploitability in medium severity advisories
if (advisory.severity === 'critical') alert();// DO: Prioritize exploitability and severity together
if (advisory.exploitability_score === 'high' || advisory.severity === 'critical') {
// Alert immediately
}Implementation Details
Feed Source: https://clawsec.prompt.security/advisories/feed.json
This signed feed is consolidated. NanoClaw receives NVD CVEs, approved community advisories, and provisional GHSA-without-CVE advisories through the same default URL.
Update Frequency: Every 6 hours (automatic)
Signature Verification: Ed25519 signed feeds Package Verification Policy: pinned key only, bounded package/signature paths
Cache Location: /workspace/project/data/clawsec-advisory-cache.json
See INSTALL.md for setup and 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)
Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting SKILL.md, skill.json, or the archive. The skill.json file is the package metadata/SBOM source, and the release pipeline signs checksums.json with the ClawSec release key.
set -euo pipefail
SKILL_NAME="clawsec-nanoclaw"
VERSION="0.0.6"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."Only install or extract the archive after this verification succeeds.
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
Changelog
[0.0.8] - 2026-06-10
Changed
- Re-released skill package with updated marketplace grouping and signed release trust artifacts for Vercel-compatible skill installation.
[0.0.7] - 2026-06-07
Security
- Added comparator range support for NanoClaw advisory matching and fail-closed handling for malformed affected specifiers.
- Added strict integrity IPC request ID validation and result path containment before host-side result writes.
[0.0.6] - 2026-05-24
Changed
- Documented that NanoClaw consumes the consolidated signed advisory feed containing NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
- Added advisory metadata typing for GHSA lifecycle fields used by the consolidated feed.
[0.0.5] - 2026-05-14
Security
- Added explicit signed release artifact verification instructions for standalone installs, including
checksums.json,checksums.sig,signing-public.pem, archive hash verification, andSKILL.md/skill.jsonchecksum checks.
All notable changes to the ClawSec NanoClaw compatibility skill will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.0.4] - 2026-04-16
Changed
- Moved signature-related local file reads into
lib/local_file_io.tsand kept network fetch logic isolated inlib/signatures.ts.
Security
- Reduced static false-positive exfiltration signals by separating local file I/O and remote fetch code paths.
[0.0.3] - 2026-03-09
Security
- Removed runtime public-key override from host-side package signature verification; verification now always uses the pinned ClawSec key.
- Removed unsigned-package override path in host-side verification flow.
- Added strict package/signature path policy for signature verification (
/tmp,/var/tmp,/workspace/ipc,/workspace/project/data,/workspace/project/tmp,/workspace/project/downloads) with absolute-path, extension, symlink, and realpath boundary checks. - Added policy-bound path enforcement for integrity approvals: approvals now require normalized paths that are explicitly present in non-ignored integrity policy targets.
Changed
- Updated MCP signature verification tool docs and behavior to align with bounded path policy and pinned-key-only verification.
- Added regression tests for signature-verification and integrity-approval hardening invariants.
[0.0.2] - 2026-02-28
Added
- Exploitability-aware advisory output in NanoClaw MCP tools (
exploitability_score,exploitability_rationale). - Exploitability filtering (
exploitabilityScore) forclawsec_list_advisories.
Changed
- Updated NanoClaw advisory sorting and pre-install safety recommendation logic to prioritize exploitability context.
- Updated NanoClaw integration docs to match current host/container integration points (
src/ipc.ts,src/index.ts) and current cache schema. - Removed duplicate exploitability normalization logic from MCP advisory tools and now reuse
normalizeExploitabilityScorefromlib/risk.ts. - Reused
matchesAffectedSpecifierfromlib/advisories.tsin MCP advisory tools to keep skill/version matching logic centralized and consistent.
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 accessCLAUDE.mdfiles - 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:
# From container
ls /workspace/project/skills/clawsec-nanoclaw/guardian/
# Should show: policy.json, integrity-monitor.tsStep 2: Initialize Baselines
The first time integrity monitoring runs, it creates baselines automatically:
// 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 logStep 3: Enable Scheduled Monitoring
Add to main group's scheduled tasks:
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-restorestatus: View baselines only (no drift detection)autoRestore(optional):true(default) orfalse- If
false, drift is detected but not auto-fixed
Output:
{
"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:
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 filenote(optional): Explanation for audit log
Output:
{
"success": true,
"path": "/workspace/group/CLAUDE.md",
"approved_at": "2026-02-25T12:00:00Z",
"approved_by": "agent",
"note": "Added new skill instructions"
}Example:
// 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:
{
"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:
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:
{
"success": true,
"valid": true,
"entries": 42,
"errors": []
}Example:
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:
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:
// 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 baselineScenario 3: Adding a New Group
Workflow:
// 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:
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:
{
"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:
# Check service status
ls /workspace/project/data/soul-guardian/
# If missing, reinitialize
rm -rf /workspace/project/data/soul-guardian/
# Next integrity check will recreate baselinesFalse Positives (Legitimate Changes Flagged)
Symptom: File keeps getting restored even though changes are legitimate
Cause: Baseline not updated after intentional changes
Solution:
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:
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 operationHigh Disk Usage
Symptom: /workspace/project/data/soul-guardian/ grows large
Causes:
- Many drift events generate patches
- Quarantine files accumulate
Solution:
# 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:
cp /workspace/project/skills/clawsec-nanoclaw/guardian/policy.json /workspace/project/data/custom-policy.jsonEdit and reinitialize:
// Update IntegrityMonitor initialization
new IntegrityMonitor({
policyPath: '/workspace/project/data/custom-policy.json',
stateDir: '/workspace/project/data/soul-guardian'
});Manual Baseline Export
# 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)
# 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:
await tools.clawsec_check_integrity({ autoRestore: false });Q: How do I protect additional files?
A: Edit policy.json and add targets:
{
"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.
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 2. For Skill Publishers: How to Sign Packages 3. For NanoClaw Agents: How to Verify Signatures 4. Security Properties 5. Key Management 6. 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)
# 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.pemwithchmod 600 - CI/CD: GitHub Secrets, AWS Secrets Manager, or similar
- Team: 1Password, Vault, or hardware security module (HSM)
Step 2: Package Your Skill
# 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
# 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 fileSignature 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.pemto 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
// 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,.tar,.tgz, or.zip)signaturePath(optional): Path to signature file (auto-detects.sigif omitted)
Path policy:
- Files must be under one of:
/tmp,/var/tmp,/workspace/ipc,/workspace/project/data,/workspace/project/tmp,/workspace/project/downloads - Symlinks are rejected
- Signatures must use
.sig
Returns:
{
success: boolean, // Operation completed without errors
valid: boolean, // Signature is cryptographically valid
recommendation: string, // "install" | "block" | "review"
signer: string, // "clawsec"
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
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
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
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
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):
# Compute fingerprint of pinned key
openssl pkey -pubin -in feed-signing-public.pem -outform DER | \
openssl dgst -sha256 -binary | base64
# Expected: <will be filled in after key generation>Public Key Policy
The verifier always uses the pinned ClawSec public key from this skill package. Runtime public-key overrides are intentionally not supported.
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:
# 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:
# 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:
# 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.pemError: "Package file not found"
Cause: Incorrect path or file doesn't exist.
Solution:
# 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.gzVerification Times Out (>5s)
Cause: Large package (>50MB) or slow disk I/O.
Solution:
# 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)
- OpenSSL Ed25519 Documentation
- ClawSec Security Architecture
- Supply Chain Attack Prevention
---
Document Version: 1.0.0 Last Updated: 2026-02-25 Maintainer: ClawSec Security Team
/**
* 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<string, FileBaseline>;
}
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<void> {
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: path.resolve(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;
}
private normalizeBaselines(manifest: BaselinesManifest): BaselinesManifest {
const normalizedFiles: Record<string, FileBaseline> = {};
for (const [filePath, baseline] of Object.entries(manifest.files || {})) {
normalizedFiles[path.resolve(filePath)] = baseline;
}
return {
...manifest,
files: normalizedFiles,
};
}
// --------------------------------------------------------------------------
// Baseline Management
// --------------------------------------------------------------------------
private loadBaselines(): BaselinesManifest {
if (fs.existsSync(this.baselinesPath)) {
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
return this.normalizeBaselines(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<AuditEntry, 'chain'>): 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<CheckResult> {
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<void> {
if (!this.baselines) {
throw new Error('Baselines not loaded');
}
const normalizedFilePath = path.resolve(filePath);
if (!fs.existsSync(normalizedFilePath)) {
throw new Error(`File not found: ${normalizedFilePath}`);
}
refuseSymlink(normalizedFilePath);
const targets = this.resolveTargets();
const target = targets.find(t => t.path === normalizedFilePath);
if (!target || target.mode === 'ignore') {
throw new Error(`File ${normalizedFilePath} not in policy`);
}
const previousSha = this.baselines.files[normalizedFilePath]?.sha256;
const currentSha = sha256File(normalizedFilePath);
// Generate diff
const snapshot = path.join(this.approvedDir, path.basename(normalizedFilePath));
const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
const newText = fs.readFileSync(normalizedFilePath, 'utf-8');
const diff = unifiedDiff(
oldText,
newText,
`approved/${path.basename(normalizedFilePath)}`,
path.basename(normalizedFilePath)
);
const patchPath = path.join(
this.patchesDir,
`${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(normalizedFilePath))}.patch`
);
fs.writeFileSync(patchPath, diff);
// Update baseline
if (!this.baselines.files[normalizedFilePath]) {
this.baselines.files[normalizedFilePath] = {
sha256: currentSha,
approved_at: utcNowIso(),
approved_by: actor,
mode: target.mode,
priority: target.priority
};
} else {
this.baselines.files[normalizedFilePath].sha256 = currentSha;
this.baselines.files[normalizedFilePath].approved_at = utcNowIso();
this.baselines.files[normalizedFilePath].approved_by = actor;
}
// Update snapshot
fs.copyFileSync(normalizedFilePath, snapshot);
// Save and audit
this.saveBaselines();
this.appendAudit({
ts: utcNowIso(),
event: 'approve',
actor,
note,
path: normalizedFilePath,
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 normalizedFilePath = filePath ? path.resolve(filePath) : null;
const files = normalizedFilePath
? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] }
: 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
};
}
}
{
"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"
]
}
/**
* 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';
import { evaluateAdvisoryRisk } from '../lib/risk.js';
// 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;
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown' | string;
exploitability_rationale?: 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<void> | 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<void> {
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<void> {
// 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<void> {
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<FeedPayload> {
// 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<string> {
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<void> {
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<void> {
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;
} {
return evaluateAdvisoryRisk(advisories);
}
/* 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';
const RESULT_DIR = '/workspace/ipc/clawsec_results';
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
// ============================================================================
// Integrity Service (Singleton)
// ============================================================================
export class IntegrityService {
private monitor: IntegrityMonitor | null = null;
private initialized = false;
async initialize(): Promise<void> {
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<void> {
const { type, requestId, groupFolder: _groupFolder } = task;
const validatedRequestId = validateRequestId(requestId);
if (!validatedRequestId) {
logger.warn({ type, requestId }, 'Invalid integrity IPC request id');
return;
}
const safeTask = { ...task, requestId: validatedRequestId };
if (!deps.integrityService) {
logger.warn({ task }, 'IntegrityService not available');
writeResult(validatedRequestId, {
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');
writeResult(validatedRequestId, {
success: false,
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
});
return;
}
}
switch (type) {
case 'integrity_check':
await handleIntegrityCheck(safeTask, service, logger);
break;
case 'integrity_approve':
await handleIntegrityApprove(safeTask, service, logger);
break;
case 'integrity_status':
await handleIntegrityStatus(safeTask, service, logger);
break;
case 'integrity_verify_audit':
await handleIntegrityVerifyAudit(safeTask, service, logger);
break;
default:
logger.warn({ type }, 'Unknown integrity task type');
}
}
// ============================================================================
// Individual Handlers
// ============================================================================
async function handleIntegrityCheck(
task: any,
service: IntegrityService,
logger: any
): Promise<void> {
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<void> {
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<void> {
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<void> {
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 validateRequestId(requestId: unknown): string | null {
if (typeof requestId !== 'string') return null;
const normalized = requestId.trim();
if (!REQUEST_ID_PATTERN.test(normalized)) return null;
return normalized;
}
function resolveResultPath(requestId: string): string {
const safeRequestId = validateRequestId(requestId);
if (!safeRequestId) {
throw new Error('Invalid integrity IPC request id');
}
const resultDir = RESULT_DIR;
const normalizedResultDir = path.resolve(resultDir);
const resultPath = path.resolve(normalizedResultDir, `${safeRequestId}.json`);
const relativePath = path.relative(normalizedResultDir, resultPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new Error('Integrity IPC result path escapes result directory');
}
return resultPath;
}
function writeResult(requestId: string, result: any): void {
const resultPath = resolveResultPath(requestId);
const resultDir = path.dirname(resultPath);
// Ensure directory exists
if (!fs.existsSync(resultDir)) {
fs.mkdirSync(resultDir, { recursive: true });
}
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'
// });
/**
* 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<string, unknown>, msg?: string): void;
warn(obj: Record<string, unknown>, msg?: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IpcTask = Record<string, any>;
/**
* Placeholder for the host-side writeResponse function.
* The actual implementation lives in the NanoClaw host process.
*/
declare function writeResponse(requestId: string, data: Record<string, unknown>): Promise<void>;
/**
* 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<void> {
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 } = 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,
});
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;
}
}
}
/**
* 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;
}
const ALLOWED_PACKAGE_ROOTS = [
'/tmp',
'/var/tmp',
'/workspace/ipc',
'/workspace/project/data',
'/workspace/project/tmp',
'/workspace/project/downloads',
] as const;
const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const;
function isWithinAllowedRoots(filePath: string): boolean {
return ALLOWED_PACKAGE_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`));
}
function hasAllowedPackageExtension(filePath: string): boolean {
return ALLOWED_PACKAGE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
}
function normalizeAndValidatePath(rawPath: string, kind: 'package' | 'signature'): string {
if (!path.isAbsolute(rawPath)) {
throw new SecurityPolicyError(`${kind} path must be absolute`);
}
const resolved = path.resolve(rawPath);
if (!isWithinAllowedRoots(resolved)) {
throw new SecurityPolicyError(
`${kind} path must be under allowed roots: ${ALLOWED_PACKAGE_ROOTS.join(', ')}`
);
}
if (kind === 'package' && !hasAllowedPackageExtension(resolved)) {
throw new SecurityPolicyError(
`package path must use one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}`
);
}
if (kind === 'signature' && !resolved.endsWith('.sig')) {
throw new SecurityPolicyError('signature path must end with .sig');
}
return resolved;
}
function ensureExistingRegularFile(filePath: string, kind: 'package' | 'signature'): string {
if (!fs.existsSync(filePath)) {
throw new SecurityPolicyError(`${kind} file not found: ${filePath}`);
}
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
throw new SecurityPolicyError(`${kind} path cannot be a symlink`);
}
if (!stat.isFile()) {
throw new SecurityPolicyError(`${kind} path must be a regular file`);
}
const realPath = fs.realpathSync(filePath);
if (!isWithinAllowedRoots(realPath)) {
throw new SecurityPolicyError(`${kind} real path escapes allowed roots`);
}
return realPath;
}
function validatePackagePath(rawPackagePath: string): string {
const resolved = normalizeAndValidatePath(rawPackagePath, 'package');
return ensureExistingRegularFile(resolved, 'package');
}
function validateSignaturePath(rawSignaturePath: string): string {
const resolved = normalizeAndValidatePath(rawSignaturePath, 'signature');
return ensureExistingRegularFile(resolved, 'signature');
}
/**
* 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<VerificationResult> {
const {
packagePath,
signaturePath,
} = params;
let validatedPackagePath: string;
let validatedSignaturePath: string;
try {
validatedPackagePath = validatePackagePath(packagePath);
validatedSignaturePath = validateSignaturePath(signaturePath);
} catch (error) {
return {
valid: false,
signer: null,
packageHash: '',
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: error instanceof Error ? error.message : String(error),
};
}
// Load pinned ClawSec key only
let keyPem: string;
try {
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(validatedPackagePath);
} 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(
validatedPackagePath,
validatedSignaturePath,
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;
}
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
- Exploitability Context: Advisories include exploitability score and rationale for triage
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:
# 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
// 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: src/ipc.ts
// Add these imports at the top
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
import { SkillSignatureVerifier } from '../skills/clawsec-nanoclaw/host-services/skill-signature-handler.js';
// Initialize these once in host startup and pass through deps
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
const signatureVerifier = new SkillSignatureVerifier();
// In processTaskIpc switch:
case 'refresh_advisory_cache':
case 'verify_skill_signature':
await handleAdvisoryIpc(
data,
{ advisoryCacheManager, signatureVerifier },
logger,
sourceGroup
);
break;
default:
// existing task handling
}4. Start Advisory Cache Service
Add the advisory cache manager to your host services.
File: src/index.ts (or your main entry point)
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
// Start the service when your host process starts
async function main() {
// ... your existing initialization ...
// Initialize cache manager and prime it at startup
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
await advisoryCacheManager.initialize();
// Recommended refresh cadence (6h)
setInterval(() => {
advisoryCacheManager.refresh().catch((error) => {
logger.error({ error }, 'Periodic advisory cache refresh failed');
});
}, 6 * 60 * 60 * 1000);
// ... rest of your startup ...
}5. Restart NanoClaw
Restart your NanoClaw instance to load the new MCP tools and services:
# Stop NanoClaw
docker-compose down
# Start with new configuration
docker-compose up -dVerification
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 vulnerabilitiesclawsec_check_skill_safety- Pre-installation safety checkclawsec_list_advisories- List all advisories with filteringclawsec_refresh_cache- Request immediate advisory cache refresh
Signature Verification (mcp-tools/signature-verification.ts):
clawsec_verify_skill_package- Verify Ed25519 signature on skill packages- Uses pinned ClawSec public key (no runtime key override)
- Accepts staged package/signature paths only under
/tmp,/var/tmp,/workspace/ipc,/workspace/project/data,/workspace/project/tmp,/workspace/project/downloads
Integrity Monitoring (mcp-tools/integrity-tools.ts):
clawsec_check_integrity- Check protected files for unauthorized changesclawsec_approve_change- Approve intentional file modification as new baselineclawsec_integrity_status- View current baseline statusclawsec_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 advisoriesThe agent should use the clawsec_check_advisories tool and report results.
3. Check Advisory Cache
Verify the cache file was created:
cat /workspace/project/data/clawsec-advisory-cache.jsonYou should see:
feed: Array of advisoriesfetchedAt: Timestamp of last updateverified: Should betruepublicKeyFingerprint: SHA-256 fingerprint of the pinned signing key
Usage Examples
Agent Commands
Once installed, your NanoClaw agents can:
Check for vulnerabilities:
Scan my installed skills for security issuesPre-installation check:
Is it safe to install skill-name@1.0.0?List all advisories:
Show me all ClawSec security advisoriesManual Tool Invocation
You can also call the MCP tools directly from agent code:
// Check all installed skills
const result = await tools.clawsec_check_advisories({
installRoot: '/home/node/.claude/skills'
});
// Check specific skill before installation
const safetyCheck = await tools.clawsec_check_skill_safety({
skillName: 'risky-skill',
skillVersion: '1.0.0'
});Configuration
Cache Location
Default: /workspace/project/data/clawsec-advisory-cache.json
To change, pass a different data directory path to new AdvisoryCacheManager(dataDir, logger).
Refresh Interval
Default: 6 hours
To change, update the setInterval(...) duration (in milliseconds) in host startup.
Feed URL
Default: https://clawsec.prompt.security/advisories/feed.json
To use a mirror or custom feed, update FEED_URL in skills/clawsec-nanoclaw/host-services/advisory-cache.ts.
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
Platform metadata is preserved in advisory records and can be filtered by your policy layer.
Security
Signature Verification
All advisory feeds are Ed25519 signed. The public key is pinned in:
skills/clawsec-nanoclaw/advisories/feed-signing-public.pemFeeds 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 AdvisoryCacheManager.initialize() 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 src/ipc.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 src/ipc.ts 3. Remove AdvisoryCacheManager initialization 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.
/**
* 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<string, unknown>;
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<string, unknown>;
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
type ParsedVersion = {
major: number;
minor: number;
patch: number;
prerelease: string[];
};
const semverPattern = String.raw`v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
const semverRegex = new RegExp(
String.raw`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`
);
const parseVersion = (ver: string): ParsedVersion | null => {
const match = ver.match(semverRegex);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4] ? match[4].split('.') : [],
};
};
const comparePrereleaseIdentifiers = (left: string, right: string): number => {
const leftIsNumeric = /^\d+$/.test(left);
const rightIsNumeric = /^\d+$/.test(right);
if (leftIsNumeric && rightIsNumeric) {
const leftValue = parseInt(left, 10);
const rightValue = parseInt(right, 10);
if (leftValue > rightValue) return 1;
if (leftValue < rightValue) return -1;
return 0;
}
if (leftIsNumeric) return -1;
if (rightIsNumeric) return 1;
if (left > right) return 1;
if (left < right) return -1;
return 0;
};
const compareVersions = (left: ParsedVersion, right: ParsedVersion): number => {
if (left.major > right.major) return 1;
if (left.major < right.major) return -1;
if (left.minor > right.minor) return 1;
if (left.minor < right.minor) return -1;
if (left.patch > right.patch) return 1;
if (left.patch < right.patch) return -1;
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
if (left.prerelease.length === 0) return 1;
if (right.prerelease.length === 0) return -1;
const identifierCount = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < identifierCount; index += 1) {
const leftIdentifier = left.prerelease[index];
const rightIdentifier = right.prerelease[index];
if (leftIdentifier === undefined) return -1;
if (rightIdentifier === undefined) return 1;
const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
if (comparison !== 0) return comparison;
}
return 0;
};
const evaluateComparator = (comparator: string): boolean => {
const match = comparator.trim().match(new RegExp(`^(<=|>=|<|>|=)?\\s*(${semverPattern})$`));
if (!match) return false;
const operator = match[1] || '=';
const comparatorParts = parseVersion(match[2]);
if (!comparatorParts) return false;
const comparison = compareVersions(vParts, comparatorParts);
if (operator === '<') return comparison < 0;
if (operator === '<=') return comparison <= 0;
if (operator === '>') return comparison > 0;
if (operator === '>=') return comparison >= 0;
return comparison === 0;
};
const extractComparatorTokens = (range: string): string[] | null => {
const tokenPattern = new RegExp(`(?:<=|>=|<|>|=)?\\s*${semverPattern}`, 'g');
const tokens: string[] = [];
let cursor = 0;
let match = tokenPattern.exec(range);
while (match) {
const gap = range.slice(cursor, match.index);
if (!/^[\s,]*$/.test(gap)) return null;
tokens.push(match[0].trim());
cursor = match.index + match[0].length;
match = tokenPattern.exec(range);
}
if (!/^[\s,]*$/.test(range.slice(cursor))) return null;
return tokens.length > 0 ? tokens : null;
};
const vParts = parseVersion(v);
if (!vParts) return true;
if (/(?:<=|>=|<|>|=)/.test(spec)) {
const comparatorTokens = extractComparatorTokens(spec);
if (!comparatorTokens) return false;
return comparatorTokens.every((token) => evaluateComparator(token));
}
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
if (!specParts) return true;
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
if (spec.startsWith('^')) {
const upperBound =
specParts.major > 0
? { major: specParts.major + 1, minor: 0, patch: 0, prerelease: [] }
: specParts.minor > 0
? { major: 0, minor: specParts.minor + 1, patch: 0, prerelease: [] }
: { major: 0, minor: 0, patch: specParts.patch + 1, prerelease: [] };
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
}
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
if (spec.startsWith('~')) {
const upperBound = { major: specParts.major, minor: specParts.minor + 1, patch: 0, prerelease: [] };
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
}
if (new RegExp(`^${semverPattern}$`).test(spec)) {
return compareVersions(vParts, specParts) === 0;
}
return true;
}
/**
* Checks whether an affected specifier matches a skill name/version.
* Optionally matches against a skill directory name as alias.
*/
export function matchesAffectedSpecifier(
affected: string,
skillName: string,
skillVersion: string | null,
skillDirName?: string
): boolean {
const parsed = parseAffectedSpecifier(affected);
if (!parsed) return false;
const normalizedTarget = normalizeSkillName(parsed.name);
const normalizedSkillName = normalizeSkillName(skillName);
const normalizedDirName = skillDirName ? normalizeSkillName(skillDirName) : null;
if (normalizedTarget !== normalizedSkillName && normalizedTarget !== normalizedDirName) {
return false;
}
if (!skillVersion) {
return true;
}
return versionMatches(skillVersion, parsed.versionSpec);
}
/**
* Loads advisory feed from a remote URL with signature verification.
*/
export async function loadRemoteFeed(
feedUrl: string,
options: SignatureVerificationOptions
): Promise<AdvisoryFeed | null> {
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<AdvisoryFeed> {
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 exploitability = (advisory.exploitability_score || 'unknown').toLowerCase();
const combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase();
if (type.includes('malicious')) return true;
if (severity === 'critical') return true;
if (exploitability === 'high') 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) {
if (!matchesAffectedSpecifier(specifier, skillName, version)) {
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));
}
import fs from 'fs';
export function fileExists(filePath: string): boolean {
return fs.existsSync(filePath);
}
export function loadBinaryFile(filePath: string): Buffer {
return fs.readFileSync(filePath);
}
export function loadUtf8File(filePath: string): string {
return fs.readFileSync(filePath, 'utf8');
}
/**
* Shared advisory risk evaluation for NanoClaw host + MCP layers.
*/
export type SkillSafetyRecommendation = 'install' | 'block' | 'review';
export interface AdvisoryRiskInput {
severity?: string;
type?: string;
action?: string;
exploitability_score?: string;
}
export interface AdvisoryRiskEvaluation {
safe: boolean;
recommendation: SkillSafetyRecommendation;
reason: string;
}
export function normalizeExploitabilityScore(score: unknown): 'high' | 'medium' | 'low' | 'unknown' {
const value = String(score || '').toLowerCase().trim();
if (value === 'high' || value === 'medium' || value === 'low') {
return value;
}
return 'unknown';
}
export function evaluateAdvisoryRisk(advisories: AdvisoryRiskInput[]): AdvisoryRiskEvaluation {
if (advisories.length === 0) {
return { safe: true, recommendation: 'install', reason: 'No advisories found' };
}
const hasMalicious = advisories.some((a) => String(a.type || '').toLowerCase().includes('malicious'));
const hasRemoveAction = advisories.some((a) =>
/\b(remove|uninstall|disable|quarantine|block)\b/i.test(String(a.action || ''))
);
const hasCritical = advisories.some((a) => String(a.severity || '').toLowerCase() === 'critical');
const hasHigh = advisories.some((a) => String(a.severity || '').toLowerCase() === 'high');
const hasHighExploitability = advisories.some(
(a) => normalizeExploitabilityScore(a.exploitability_score) === 'high'
);
if (hasMalicious || hasRemoveAction) {
return {
safe: false,
recommendation: 'block',
reason: 'Malicious skill or removal recommended by ClawSec',
};
}
if (hasCritical && hasHighExploitability) {
return {
safe: false,
recommendation: 'block',
reason: 'Critical advisory with high exploitability context - do not install',
};
}
if (hasCritical) {
return {
safe: false,
recommendation: 'block',
reason: 'Critical security advisory - do not install',
};
}
if (hasHighExploitability) {
return {
safe: false,
recommendation: 'review',
reason: 'High exploitability advisory - urgent user review strongly recommended',
};
}
if (hasHigh) {
return {
safe: false,
recommendation: 'review',
reason: 'High severity advisory - user review strongly recommended',
};
}
return {
safe: false,
recommendation: 'review',
reason: 'Advisory found - review details before installing',
};
}
/**
* TypeScript types for NanoClaw Skill Installer
* Adapted from ClawSec's guarded skill installer
*/
export interface Advisory {
id: string;
ghsa_id?: string;
cve_id?: string | null;
status?: 'active' | 'matured' | 'stale' | string;
stale?: boolean;
source_feed?: 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;
cvss_vector?: string | null;
nvd_url?: string;
github_advisory_url?: string;
platforms?: string[];
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown';
exploitability_rationale?: 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<string, string>; // 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;
}
/**
* 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
}