diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index a0b9bf8..77f234b 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -399,6 +399,7 @@ jobs: (if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end) + (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end) + (if ($blob | test("github\\.com/softwarepub/hermes|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|software publication with rich metadata")) then ["hermes@*"] else [] end) + + (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end) ) ); @@ -414,14 +415,15 @@ jobs: [ (if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end), (if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end), - (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end) + (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end), + (if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end) ] ); def normalized_affected: ( matched_targets - | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*"] else . end + | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end ); def normalized_platforms: @@ -432,7 +434,7 @@ jobs: else matched_targets as $targets | platforms_from_targets($targets) as $from_targets - | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes"] end + | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end end ); @@ -639,6 +641,7 @@ jobs: (if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end) + (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end) + (if ($blob | test("github\\.com/softwarepub/hermes|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|software publication with rich metadata")) then ["hermes@*"] else [] end) + + (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end) ) ); @@ -654,14 +657,15 @@ jobs: [ (if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end), (if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end), - (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end) + (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end), + (if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end) ] ); def normalized_affected: ( matched_targets - | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*"] else . end + | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end ); def normalized_platforms: @@ -672,7 +676,7 @@ jobs: else matched_targets as $targets | platforms_from_targets($targets) as $from_targets - | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes"] end + | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end end ); diff --git a/README.md b/README.md index 5d26bfa..b0d6d84 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid - **OpenClaw** (MoltBot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits - **NanoClaw** - Containerized WhatsApp bot security with MCP tools for advisory monitoring, signature verification, and file integrity - **Hermes** - Hermes-native security skills for signed advisory feed verification, advisory-aware guarded verification, deterministic attestation generation, fail-closed verification, and baseline drift detection +- **Picoclaw** - Lightweight AI gateway security posture checks with advisory awareness, config drift detection, release-artifact verification, and an optional separate self-pen-testing package ### Skill Feature Matrix @@ -54,6 +55,8 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid | clawtributor | OpenClaw | Yes | No | No | No | | hermes-attestation-guardian | Hermes | Yes (signed advisory feed verification) | Yes | No | Limited (advisory preflight gating only; no artifact signature/provenance install verification) | | openclaw-audit-watchdog | OpenClaw | No | No | Yes | No | +| picoclaw-security-guardian | Picoclaw | Yes | Yes | No | Yes | +| picoclaw-self-pen-testing | Picoclaw | No | No | Yes | No | | soul-guardian | OpenClaw | No | Yes | No | No | ### Core Capabilities @@ -135,12 +138,16 @@ Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/... Detailed platform and suite docs live in the wiki modules: - NanoClaw: [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md) - Hermes: [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md) +- Picoclaw: [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md) +- Picoclaw self-pen-testing: [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md) - ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md) - CI/CD pipelines: [wiki/modules/automation-release.md](wiki/modules/automation-release.md) Quick install links: - NanoClaw install: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md) - Hermes skill package: `skills/hermes-attestation-guardian/` +- Picoclaw guardian package: `skills/picoclaw-security-guardian/` +- Picoclaw self-pen-testing package: `skills/picoclaw-self-pen-testing/` - Suite package: `skills/clawsec-suite/` --- @@ -164,6 +171,7 @@ Compatibility mirror (legacy): `https://clawsec.prompt.security/releases/latest/ The feed polls CVEs related to: - **OpenClaw Platform**: `OpenClaw`, `clawdbot`, `Moltbot` - **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys` +- **Picoclaw Platform**: `Picoclaw`, `picoclaw`, lightweight AI gateways, MCP gateway exposure - Prompt injection patterns - Agent security vulnerabilities @@ -219,7 +227,9 @@ This feature helps agents prioritize vulnerabilities that pose immediate threats **Platform values:** - `"openclaw"` - OpenClaw/Clawdbot/MoltBot only - `"nanoclaw"` - NanoClaw only -- `["openclaw", "nanoclaw"]` - Both platforms +- `"hermes"` - Hermes only +- `"picoclaw"` - Picoclaw only +- `["openclaw", "nanoclaw", "hermes", "picoclaw"]` - All core platforms - (empty/missing) - All platforms (backward compatible) --- @@ -340,6 +350,8 @@ npm run build โ”‚ โ”œโ”€โ”€ clawtributor/ # ๐Ÿค Community reporting skill โ”‚ โ”œโ”€โ”€ hermes-attestation-guardian/ # ๐Ÿ›ก๏ธ Hermes attestation + drift verification โ”‚ โ”œโ”€โ”€ openclaw-audit-watchdog/ # ๐Ÿ”ญ Automated audit skill +โ”‚ โ”œโ”€โ”€ picoclaw-security-guardian/ # ๐Ÿฆ Picoclaw posture/advisory/drift/supply-chain checks +โ”‚ โ”œโ”€โ”€ picoclaw-self-pen-testing/ # ๐Ÿงช Picoclaw self-pen-testing checks (separate package) โ”‚ โ””โ”€โ”€ soul-guardian/ # ๐Ÿ‘ป File integrity skill โ”œโ”€โ”€ utils/ โ”‚ โ”œโ”€โ”€ package_skill.py # Skill packager utility diff --git a/pages/FeedSetup.tsx b/pages/FeedSetup.tsx index 65942c1..577227d 100644 --- a/pages/FeedSetup.tsx +++ b/pages/FeedSetup.tsx @@ -29,6 +29,7 @@ const PLATFORM_TABS = [ { value: 'openclaw', label: 'OpenClaw', active: 'bg-clawd-accent/20 text-clawd-accent border-2 border-clawd-accent', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' }, { value: 'nanoclaw', label: 'NanoClaw', active: 'bg-clawd-secondary/20 text-clawd-secondary border-2 border-clawd-secondary', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-secondary/50' }, { value: 'hermes', label: 'Hermes', active: 'bg-emerald-500/20 text-emerald-300 border-2 border-emerald-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-emerald-400/50' }, + { value: 'picoclaw', label: 'Picoclaw', active: 'bg-cyan-500/20 text-cyan-300 border-2 border-cyan-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-cyan-400/50' }, { value: 'other', label: 'Other', active: 'bg-clawd-600/40 text-gray-100 border-2 border-clawd-500', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-500/50' }, ] as const satisfies ReadonlyArray>; @@ -157,7 +158,7 @@ export const FeedSetup: React.FC = () => {

Security Hardening Feed

A continuous stream of security advisories from NVD CVE data and staff-approved community reports. - This feed is automatically updated with OpenClaw, NanoClaw, and Hermes-related vulnerabilities and verified security incidents. + This feed is automatically updated with OpenClaw, NanoClaw, Hermes, and Picoclaw-related vulnerabilities and verified security incidents.

{lastUpdated && (

diff --git a/scripts/feed-utils.sh b/scripts/feed-utils.sh index 7df2c98..ccf5b00 100644 --- a/scripts/feed-utils.sh +++ b/scripts/feed-utils.sh @@ -45,20 +45,22 @@ keyword|NanoClaw keyword|WhatsApp-bot keyword|baileys keyword|hermes workflow +keyword|Picoclaw virtualMatchString|cpe:2.3:a:software-metadata.pub:hermes +virtualMatchString|cpe:2.3:a:picoclaw:picoclaw EOF } nvd_keyword_pattern() { - echo 'OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys|HERMES workflow|software publication with rich metadata' + echo 'OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys|HERMES workflow|software publication with rich metadata|Picoclaw|picoclaw' } nvd_github_ref_pattern() { - echo 'github\.com/openclaw/openclaw|github\.com/qwibitai/nanoclaw|github\.com/softwarepub/hermes' + echo 'github\.com/openclaw/openclaw|github\.com/qwibitai/nanoclaw|github\.com/softwarepub/hermes|github\.com/[^/]+/picoclaw' } nvd_cpe_pattern() { - echo 'cpe:2\.3:a:software-metadata\.pub:hermes(?::|$)' + echo 'cpe:2\.3:a:software-metadata\.pub:hermes(?::|$)|cpe:2\.3:[aho]:[^:]*:picoclaw(?::|$)' } nvd_query_slug() { diff --git a/scripts/populate-local-feed.sh b/scripts/populate-local-feed.sh index 4b25616..b60bdde 100755 --- a/scripts/populate-local-feed.sh +++ b/scripts/populate-local-feed.sh @@ -271,6 +271,7 @@ jq --slurpfile existing "$TEMP_DIR/existing_ids.json" ' (if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end) + (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end) + (if ($blob | test("github\\.com/softwarepub/hermes|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|software publication with rich metadata")) then ["hermes@*"] else [] end) + + (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end) ) ); @@ -286,14 +287,15 @@ jq --slurpfile existing "$TEMP_DIR/existing_ids.json" ' [ (if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end), (if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end), - (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end) + (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end), + (if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end) ] ); def normalized_affected: ( matched_targets - | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*"] else . end + | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end ); def normalized_platforms: @@ -304,7 +306,7 @@ jq --slurpfile existing "$TEMP_DIR/existing_ids.json" ' else matched_targets as $targets | platforms_from_targets($targets) as $from_targets - | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes"] end + | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end end ); @@ -406,7 +408,7 @@ else jq -n --slurpfile advisories "$TEMP_DIR/new_advisories.json" --arg now "$NOW" '{ version: "1.0.0", updated: $now, - description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw, NanoClaw, and Hermes-related CVEs from NVD.", + description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw, NanoClaw, Hermes, and Picoclaw-related CVEs from NVD.", advisories: (($advisories[0] // []) | sort_by(.published) | reverse) }' > "$TEMP_DIR/updated_feed.json" fi diff --git a/skills/picoclaw-security-guardian/CHANGELOG.md b/skills/picoclaw-security-guardian/CHANGELOG.md new file mode 100644 index 0000000..a15b7aa --- /dev/null +++ b/skills/picoclaw-security-guardian/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## [0.0.1] - 2026-04-26 + +### Added +- Initial Picoclaw-specific ClawSec skill package for advisory awareness, deterministic profile generation, drift detection, and supply-chain verification. +- Picoclaw-native Docker pre-release install regression harness using `find_skills` / `install_skill` and skill-loader validation. + +### Changed +- Split optional posture-review checks into separate `picoclaw-self-pen-testing` package so this package remains the core public guardian lane. +- Updated metadata/docs/regression expectations to keep this package focused on advisory, drift, and supply-chain checks. diff --git a/skills/picoclaw-security-guardian/README.md b/skills/picoclaw-security-guardian/README.md new file mode 100644 index 0000000..669f92f --- /dev/null +++ b/skills/picoclaw-security-guardian/README.md @@ -0,0 +1,51 @@ +# picoclaw-security-guardian + +Picoclaw security posture skill for ClawSec. + +Status: implemented (v0.0.1), Picoclaw-specific. + +Detailed architecture/operator docs: `wiki/modules/picoclaw-security-guardian.md`. + +## Support matrix mapping + +| Skill name | supported platform | security feed | config drift | agent posture-review lane | chain of supply verification | +|---|---|---|---|---|---| +| picoclaw-security-guardian | Picoclaw | Yes | Yes | Separate package | Yes | + +## Capabilities + +- Picoclaw-aware advisory filtering from a verified ClawSec feed/cache. +- Deterministic local posture profile generation for configs, gateway exposure, tools, MCP, credentials/security files, and release artifacts. +- Baseline drift comparison with critical/high/medium/low/info findings. +- Supply-chain verification for release artifacts using SHA-256 manifests plus required Ed25519 detached signatures for passing provenance verdicts. + +## Quickstart + +```bash +node scripts/generate_profile.mjs --output ~/.picoclaw/security/clawsec/current-profile.json +node scripts/check_drift.mjs --baseline ~/.picoclaw/security/clawsec/baseline-profile.json --current ~/.picoclaw/security/clawsec/current-profile.json +node scripts/verify_supply_chain.mjs --artifact ./picoclaw --checksums ./checksums.json --signature ./checksums.json.sig --public-key ./feed-signing-public.pem +node scripts/check_advisories.mjs --feed ~/.picoclaw/security/clawsec/feed.json --state ~/.picoclaw/security/clawsec/feed-verification-state.json +``` + +All scripts are read-only except profile/report outputs explicitly requested by `--output`. + +## Tests + +```bash +node test/profile.test.mjs +node test/drift.test.mjs +node test/supply_chain.test.mjs +bash -n test/picoclaw_security_guardian_sandbox_regression.sh +``` + +## Pre-release install regression + +Run this before cutting v0.0.1 release artifacts: + +```bash +test/picoclaw_security_guardian_sandbox_regression.sh +``` + +It uses Docker to publish the skill through a local ClawHub-compatible registry, installs it with Picoclaw's own `find_skills` / `install_skill` flow into an isolated Picoclaw workspace, confirms Picoclaw's skill loader can list/load it, then verifies the installed copy's profile, drift, advisory, and supply-chain paths. + diff --git a/skills/picoclaw-security-guardian/SKILL.md b/skills/picoclaw-security-guardian/SKILL.md new file mode 100644 index 0000000..7088d1e --- /dev/null +++ b/skills/picoclaw-security-guardian/SKILL.md @@ -0,0 +1,107 @@ +--- +name: picoclaw-security-guardian +version: 0.0.1 +description: Picoclaw security posture skill with advisory awareness, configuration drift detection, and supply-chain verification guidance. +homepage: https://clawsec.prompt.security +author: prompt-security +license: AGPL-3.0-or-later +picoclaw: + emoji: "๐Ÿฆ" + category: "security" + requires: + bins: [node] + test_requires: + bins: [bash, docker, python3, node, openssl, zip] +--- + +# Picoclaw Security Guardian + +Detailed architecture/operator docs: `wiki/modules/picoclaw-security-guardian.md`. + +## Goal + +Provide Picoclaw with the same support-matrix security capabilities ClawSec tracks for mature platform modules: + +| Skill name | supported platform | security feed | config drift | agent posture-review lane | chain of supply verification | +|---|---|---|---|---|---| +| picoclaw-security-guardian | Picoclaw | Yes | Yes | Separate package | Yes | + +## Threat model + +Picoclaw is a lightweight AI gateway that can expose chat channels, a Web UI, tool execution, MCP servers, credentials, schedulers, and embedded/router deployments. This skill focuses on the trust boundaries where those features become security-sensitive. + +## Default safety posture + +- Read-only by default. +- No scheduler creation in v0.0.1. +- No outbound network by default. +- Writes only explicit report/profile outputs under `$PICOCLAW_HOME/security/clawsec/` unless the operator supplies test-local temporary paths. +- Advisory checks fail closed when verification state is not verified unless the operator passes `--allow-unsigned` for a documented emergency/offline window. + +## Security advisory awareness + +Use `scripts/check_advisories.mjs` with a local feed/cache and verification state: + +```bash +node scripts/check_advisories.mjs --feed ~/.picoclaw/security/clawsec/feed.json --state ~/.picoclaw/security/clawsec/feed-verification-state.json +``` + +The script filters advisories for `picoclaw`, `ai-gateway`, empty/all-platform advisories, or affected package entries containing `picoclaw`. + +## Drift protection + +Generate a deterministic profile: + +```bash +node scripts/generate_profile.mjs --output ~/.picoclaw/security/clawsec/current-profile.json +``` + +Compare against an approved baseline: + +```bash +node scripts/check_drift.mjs --baseline ~/.picoclaw/security/clawsec/baseline-profile.json --current ~/.picoclaw/security/clawsec/current-profile.json --fail-on critical +``` + +Critical drift includes public Web UI enablement, Web UI auth disablement, workspace restriction disablement, unsigned/insecure verification mode, verified-feed regression, and watched-file/release-artifact fingerprint changes. + +## Chain-of-supply verification + +Verify a Picoclaw release artifact against a checksum manifest plus detached signature. Signed manifest verification is required for a passing supply-chain verdict: + +```bash +node scripts/verify_supply_chain.mjs \ + --artifact ./picoclaw \ + --checksums ./checksums.json \ + --signature ./checksums.json.sig \ + --public-key ./feed-signing-public.pem +``` + +Checksum-only mode is integrity-only, not provenance. Use `--allow-unsigned-checksums` only for short, documented offline triage windows; it should not satisfy production install verification. + +## Operator review notes + +- Treat public UI binding (`0.0.0.0`, `-public`) as a critical review item until auth and network allowlists are proven. +- Treat MCP servers as separate trust boundaries; review each server's filesystem, network, and credential access. +- Treat third-party OpenWrt/LuCI wrappers as separate supply-chain artifacts. Verify provenance before installing them on routers. +- Never leave unsigned advisory mode enabled in recurring or production checks. + +## Validation + +```bash +python utils/validate_skill.py skills/picoclaw-security-guardian +node skills/picoclaw-security-guardian/test/profile.test.mjs +node skills/picoclaw-security-guardian/test/drift.test.mjs +node skills/picoclaw-security-guardian/test/supply_chain.test.mjs +bash -n skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh +``` + +## Pre-release install regression + +Before publishing v0.0.1 release artifacts, run the isolated install lane from the repo root: + +```bash +skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh +``` + +The regression installs the skill through Picoclaw's own `find_skills` / `install_skill` path from a local ClawHub-compatible registry into an isolated Docker-hosted Picoclaw workspace with isolated `HOME`, `PICOCLAW_HOME`, and `PICOCLAW_WORKSPACE`. It verifies signed release-artifact preflight inputs, confirms Picoclaw's skill loader can list/load the installed skill, then runs the installed copy's profile, drift, advisory fail-closed, advisory filtering, and supply-chain verification paths against Picoclaw-style `config.json` and `launcher-config.json` files. + diff --git a/skills/picoclaw-security-guardian/lib/advisories.mjs b/skills/picoclaw-security-guardian/lib/advisories.mjs new file mode 100644 index 0000000..fae3cf9 --- /dev/null +++ b/skills/picoclaw-security-guardian/lib/advisories.mjs @@ -0,0 +1,17 @@ +import fs from "node:fs"; + +export function loadAdvisoryFeed(feedPath) { return JSON.parse(fs.readFileSync(feedPath, "utf8")); } +export function loadFeedState(statePath) { if (!statePath || !fs.existsSync(statePath)) return { status: "unknown" }; return JSON.parse(fs.readFileSync(statePath, "utf8")); } +export function isPicoclawAdvisory(advisory) { + const platforms = Array.isArray(advisory?.platforms) ? advisory.platforms.map(x=>String(x).toLowerCase()) : []; + const affected = Array.isArray(advisory?.affected) ? advisory.affected.map(x=>String(x).toLowerCase()) : []; + const blob = `${advisory?.title || ""} ${advisory?.description || ""} ${advisory?.type || ""}`.toLowerCase(); + return platforms.length === 0 || platforms.includes("picoclaw") || platforms.includes("ai-gateway") || affected.some(x=>x.includes("picoclaw")) || blob.includes("picoclaw"); +} +export function checkPicoclawAdvisories({ feedPath, statePath, allowUnsigned = false }) { + const state = loadFeedState(statePath); + if (!allowUnsigned && state.status !== "verified") throw new Error(`advisory feed state is not verified: ${state.status || "missing"}`); + const feed = loadAdvisoryFeed(feedPath); + const advisories = (feed.advisories || []).filter(isPicoclawAdvisory); + return { status: "ok", feed_version: feed.version || null, verified_state: state.status || "unknown", count: advisories.length, advisories }; +} diff --git a/skills/picoclaw-security-guardian/lib/drift.mjs b/skills/picoclaw-security-guardian/lib/drift.mjs new file mode 100644 index 0000000..8de440e --- /dev/null +++ b/skills/picoclaw-security-guardian/lib/drift.mjs @@ -0,0 +1,45 @@ +const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]; + +function bump(summary, sev) { summary[sev] = (summary[sev] || 0) + 1; } +function bool(value) { return !!value; } +function add(findings, summary, severity, code, path, message, details = undefined) { + const finding = { severity, code, path, message }; + if (details) finding.details = details; + findings.push(finding); bump(summary, severity); +} +function byPath(entries) { const m = new Map(); for (const e of Array.isArray(entries) ? entries : []) if (e?.path) m.set(e.path, e); return m; } +function compareBool({ before, after, path, codeOnEnable, codeOnDisable, enableSeverity, findings, summary }) { + if (bool(before) === bool(after)) return; + if (!before && after) add(findings, summary, enableSeverity, codeOnEnable, path, `${path} changed false -> true`); + else add(findings, summary, "info", codeOnDisable, path, `${path} changed true -> false`); +} +function compareHashSet(beforeEntries, afterEntries, changedCode, removedCode, findings, summary) { + const b = byPath(beforeEntries); const a = byPath(afterEntries); + for (const [p, before] of b.entries()) { + const after = a.get(p); + if (!after) { add(findings, summary, "high", removedCode, p, `${p} missing from current profile`); continue; } + if ((before.sha256 || null) !== (after.sha256 || null)) add(findings, summary, "critical", changedCode, p, `${p} fingerprint changed`); + } + for (const [p] of a.entries()) if (!b.has(p)) add(findings, summary, "low", "NEW_INTEGRITY_SCOPE", p, `${p} added to integrity tracking scope`); +} +export function diffPicoclawProfiles(baseline, current) { + const findings=[]; const summary={critical:0, high:0, medium:0, low:0, info:0}; + const b=baseline||{}; const c=current||{}; + if (b.platform !== c.platform) add(findings, summary, "critical", "PLATFORM_MISMATCH", "platform", `platform changed ${b.platform} -> ${c.platform}`); + if (b.schema_version !== c.schema_version) add(findings, summary, "high", "SCHEMA_VERSION_CHANGED", "schema_version", `schema_version changed ${b.schema_version} -> ${c.schema_version}`); + const br=b.posture?.runtime||{}; const cr=c.posture?.runtime||{}; + compareBool({before: br.ui?.public_web_ui, after: cr.ui?.public_web_ui, path:"posture.runtime.ui.public_web_ui", codeOnEnable:"PUBLIC_WEB_UI_ENABLED", codeOnDisable:"PUBLIC_WEB_UI_DISABLED", enableSeverity:"critical", findings, summary}); + compareBool({before: br.ui?.auth_disabled, after: cr.ui?.auth_disabled, path:"posture.runtime.ui.auth_disabled", codeOnEnable:"WEB_UI_AUTH_DISABLED", codeOnDisable:"WEB_UI_AUTH_REENABLED", enableSeverity:"critical", findings, summary}); + compareBool({before: br.tools?.unrestricted_workspace, after: cr.tools?.unrestricted_workspace, path:"posture.runtime.tools.unrestricted_workspace", codeOnEnable:"WORKSPACE_RESTRICTION_DISABLED", codeOnDisable:"WORKSPACE_RESTRICTION_RESTORED", enableSeverity:"critical", findings, summary}); + compareBool({before: br.risky_toggles?.allow_unsigned_mode, after: cr.risky_toggles?.allow_unsigned_mode, path:"posture.runtime.risky_toggles.allow_unsigned_mode", codeOnEnable:"UNSIGNED_MODE_ENABLED", codeOnDisable:"UNSIGNED_MODE_DISABLED", enableSeverity:"critical", findings, summary}); + compareBool({before: br.mcp?.enabled, after: cr.mcp?.enabled, path:"posture.runtime.mcp.enabled", codeOnEnable:"MCP_ENABLED", codeOnDisable:"MCP_DISABLED", enableSeverity:"high", findings, summary}); + compareBool({before: br.scheduler?.enabled, after: cr.scheduler?.enabled, path:"posture.runtime.scheduler.enabled", codeOnEnable:"SCHEDULER_ENABLED", codeOnDisable:"SCHEDULER_DISABLED", enableSeverity:"medium", findings, summary}); + if ((br.secrets?.config_secret_markers||0) < (cr.secrets?.config_secret_markers||0)) add(findings, summary, "high", "SECRET_MARKERS_INCREASED", "posture.runtime.secrets.config_secret_markers", "config secret markers increased", { before: br.secrets?.config_secret_markers||0, after: cr.secrets?.config_secret_markers||0 }); + if (b.posture?.feed_verification?.status === "verified" && c.posture?.feed_verification?.status !== "verified") add(findings, summary, "critical", "FEED_VERIFICATION_REGRESSION", "posture.feed_verification.status", `Feed verification regressed verified -> ${c.posture?.feed_verification?.status || "unknown"}`); + compareHashSet(b.posture?.integrity?.watched_files, c.posture?.integrity?.watched_files, "WATCHED_FILE_DRIFT", "WATCHED_FILE_REMOVED", findings, summary); + compareHashSet(b.posture?.integrity?.release_artifacts, c.posture?.integrity?.release_artifacts, "RELEASE_ARTIFACT_DRIFT", "RELEASE_ARTIFACT_REMOVED", findings, summary); + findings.sort((x,y)=>SEVERITY_ORDER.indexOf(x.severity)-SEVERITY_ORDER.indexOf(y.severity)||String(x.code).localeCompare(String(y.code))||String(x.path).localeCompare(String(y.path))); + return { summary, findings }; +} +export function highestSeverity(findings=[]) { return SEVERITY_ORDER.find(s => findings.some(f => f?.severity===s)) || null; } +export function severityAtOrAbove(severity, threshold) { if (!threshold || threshold === "none") return false; const a=SEVERITY_ORDER.indexOf(severity), b=SEVERITY_ORDER.indexOf(threshold); return a >= 0 && b >= 0 && a <= b; } diff --git a/skills/picoclaw-security-guardian/lib/profile.mjs b/skills/picoclaw-security-guardian/lib/profile.mjs new file mode 100644 index 0000000..6e4944c --- /dev/null +++ b/skills/picoclaw-security-guardian/lib/profile.mjs @@ -0,0 +1,270 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const SCHEMA_VERSION = "picoclaw-profile/v1"; +export const PROFILE_VERSION = "0.0.1"; + +export function stableStringify(value, space = 2) { + return JSON.stringify(sortDeep(value), null, space); +} + +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (!value || typeof value !== "object") return value; + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = sortDeep(value[key]); + return out; +} + +export function sha256Hex(content) { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +export function sha256FileHex(filePath) { + return sha256Hex(fs.readFileSync(filePath)); +} + +export function defaultPicoclawHome() { + return path.resolve(process.env.PICOCLAW_HOME || path.join(os.homedir(), ".picoclaw")); +} + +export function defaultOutputPath(picoclawHome = defaultPicoclawHome()) { + return path.join(picoclawHome, "security", "clawsec", "current-profile.json"); +} + +export function expandUserPath(raw, base = defaultPicoclawHome()) { + if (!raw) return ""; + const value = String(raw).trim(); + if (!value) return ""; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); + if (value.startsWith("$PICOCLAW_HOME/")) return path.join(base, value.slice("$PICOCLAW_HOME/".length)); + return path.resolve(value); +} + +export function isPathInside(childPath, parentPath) { + const child = path.resolve(childPath); + const parent = path.resolve(parentPath); + const rel = path.relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function nearestExistingAncestor(candidatePath) { + let candidate = path.resolve(candidatePath); + while (!fs.existsSync(candidate)) { + const parent = path.dirname(candidate); + if (parent === candidate) return candidate; + candidate = parent; + } + return candidate; +} + +function realpathWithMissingTail(candidatePath) { + const resolved = path.resolve(candidatePath); + const ancestor = nearestExistingAncestor(resolved); + const realAncestor = fs.realpathSync.native ? fs.realpathSync.native(ancestor) : fs.realpathSync(ancestor); + const rel = path.relative(ancestor, resolved); + return rel ? path.join(realAncestor, rel) : realAncestor; +} + +export function confineOutputToPicoclawHome(candidatePath, picoclawHome = defaultPicoclawHome()) { + const root = path.resolve(picoclawHome); + const resolved = path.resolve(candidatePath); + if (!isPathInside(resolved, root)) throw new Error(`output path must stay under ${root}`); + const rootReal = realpathWithMissingTail(root); + const resolvedReal = realpathWithMissingTail(resolved); + if (!isPathInside(resolvedReal, rootReal)) throw new Error(`output path must stay under ${rootReal}`); + if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) { + throw new Error(`output path must not be a symlink: ${resolved}`); + } + return resolved; +} + +export function parseJsonFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +export function detectConfigPaths(picoclawHome = defaultPicoclawHome(), extraConfig = null) { + const candidates = [ + process.env.PICOCLAW_CONFIG, + extraConfig, + path.join(picoclawHome, "config.yaml"), + path.join(picoclawHome, "config.yml"), + path.join(picoclawHome, "config.json"), + path.join(picoclawHome, "launcher-config.json"), + path.join(picoclawHome, ".security.yml"), + path.join(picoclawHome, "security.yml"), + ].filter(Boolean).map((p) => expandUserPath(p, picoclawHome)); + return [...new Set(candidates)]; +} + +function safeReadText(filePath, maxBytes = 1024 * 1024) { + try { + const st = fs.statSync(filePath); + if (!st.isFile() || st.size > maxBytes) return ""; + return fs.readFileSync(filePath, "utf8"); + } catch { + return ""; + } +} + +function fingerprintPath(filePath) { + const exists = fs.existsSync(filePath); + if (!exists) return { path: filePath, exists: false }; + const st = fs.statSync(filePath); + return { + path: filePath, + exists: true, + type: st.isDirectory() ? "directory" : st.isFile() ? "file" : "other", + size: st.isFile() ? st.size : null, + mode: (st.mode & 0o777).toString(8).padStart(3, "0"), + sha256: st.isFile() ? sha256FileHex(filePath) : null, + }; +} + +function truthyFromText(text, patterns) { + const low = text.toLowerCase(); + return patterns.some((p) => low.includes(p)); +} + +function truthyRegex(text, patterns) { + return patterns.some((p) => p.test(text)); +} + +function jsonBoolPattern(key, expected) { + return new RegExp(`"${key}"\\s*:\\s*${expected ? "true" : "false"}`, "i"); +} + +function jsonEmptyStringPattern(key) { + return new RegExp(`"${key}"\\s*:\\s*"\\s*"`, "i"); +} + +function jsonStringPattern(key, value) { + return new RegExp(`"${key}"\\s*:\\s*"${value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}"`, "i"); +} + +function analyzeConfigText(text) { + return { + public_web_ui: truthyFromText(text, [ + "public: true", + "bind: 0.0.0.0", + "host: 0.0.0.0", + "-public", + '"public": true', + '"bind": "0.0.0.0"', + '"host": "0.0.0.0"', + '"listen": "0.0.0.0"', + ]) || truthyRegex(text, [ + jsonBoolPattern("public", true), + jsonStringPattern("bind", "0.0.0.0"), + jsonStringPattern("host", "0.0.0.0"), + jsonStringPattern("listen", "0.0.0.0"), + ]), + auth_disabled: truthyFromText(text, [ + "auth: false", + "disable_auth: true", + "no_auth: true", + "password: ''", + 'password: ""', + '"auth": false', + '"disable_auth": true', + '"no_auth": true', + '"require_auth": false', + '"dashboard_auth": false', + '"password": ""', + '"dashboard_password_hash": ""', + '"launcher_token": ""', + ]) || truthyRegex(text, [ + jsonBoolPattern("auth", false), + jsonBoolPattern("disable_auth", true), + jsonBoolPattern("no_auth", true), + jsonBoolPattern("require_auth", false), + jsonBoolPattern("dashboard_auth", false), + jsonEmptyStringPattern("password"), + jsonEmptyStringPattern("dashboard_password_hash"), + jsonEmptyStringPattern("launcher_token"), + ]), + allow_unsigned: truthyFromText(text, [ + "allow_unsigned", + "skip_signature", + "disable_signature", + "insecure_skip_verify", + ]), + unrestricted_workspace: truthyFromText(text, [ + "restrict_to_workspace: false", + "workspace_restriction: false", + "sandbox: false", + '"restrict_to_workspace": false', + '"workspace_restriction": false', + '"sandbox": false', + ]) || truthyRegex(text, [ + jsonBoolPattern("restrict_to_workspace", false), + jsonBoolPattern("workspace_restriction", false), + jsonBoolPattern("sandbox", false), + ]), + mcp_enabled: truthyFromText(text, ["mcp:", "mcp_servers", "modelcontextprotocol", '"mcp"', '"mcp_servers"']), + tools_enabled: truthyFromText(text, ["tools:", "code_execution", "shell", "filesystem", '"tools"', '"exec"', '"shell"']), + scheduler_enabled: truthyFromText(text, ["cron", "schedule", "scheduler"]), + secret_markers: (text.match(/(api[_-]?key|token|secret|password)\s*[":=]+\s*['"]?[^\s'"]{8,}/gi) || []).length, + }; +} + +function mergeConfigSignals(paths) { + const signals = { + public_web_ui: false, + auth_disabled: false, + allow_unsigned: false, + unrestricted_workspace: false, + mcp_enabled: false, + tools_enabled: false, + scheduler_enabled: false, + secret_markers: 0, + }; + for (const p of paths) { + const text = safeReadText(p); + const found = analyzeConfigText(text); + for (const [k, v] of Object.entries(found)) { + if (typeof v === "boolean") signals[k] = signals[k] || v; + else signals[k] += v; + } + } + return signals; +} + +export function buildPicoclawProfile(options = {}) { + const picoclawHome = path.resolve(options.picoclawHome || defaultPicoclawHome()); + const generatedAt = options.generatedAt || new Date().toISOString(); + const configPaths = detectConfigPaths(picoclawHome, options.configPath); + const watchedFiles = [...new Set([...(options.watchFiles || []), ...configPaths].filter(Boolean).map((p) => expandUserPath(p, picoclawHome)))]; + const releaseArtifacts = [...new Set((options.releaseArtifacts || []).filter(Boolean).map((p) => expandUserPath(p, picoclawHome)))]; + const signals = options.signals || mergeConfigSignals(watchedFiles); + const profile = { + schema_version: SCHEMA_VERSION, + platform: "picoclaw", + generated_at: generatedAt, + generator: { name: "picoclaw-security-guardian", version: PROFILE_VERSION }, + posture: { + runtime: { + home: picoclawHome, + config_paths: configPaths, + gateways: options.gateways || {}, + ui: { public_web_ui: !!signals.public_web_ui, auth_disabled: !!signals.auth_disabled }, + tools: { enabled: !!signals.tools_enabled, unrestricted_workspace: !!signals.unrestricted_workspace }, + mcp: { enabled: !!signals.mcp_enabled }, + scheduler: { enabled: !!signals.scheduler_enabled }, + risky_toggles: { allow_unsigned_mode: !!signals.allow_unsigned }, + secrets: { config_secret_markers: signals.secret_markers || 0 }, + }, + integrity: { + watched_files: watchedFiles.map(fingerprintPath), + release_artifacts: releaseArtifacts.map(fingerprintPath), + }, + feed_verification: options.feedVerification || { status: "unknown" }, + }, + }; + profile.digests = { canonical_sha256: sha256Hex(stableStringify({ ...profile, digests: undefined }, 0)) }; + return profile; +} diff --git a/skills/picoclaw-security-guardian/lib/supply_chain.mjs b/skills/picoclaw-security-guardian/lib/supply_chain.mjs new file mode 100644 index 0000000..a01a865 --- /dev/null +++ b/skills/picoclaw-security-guardian/lib/supply_chain.mjs @@ -0,0 +1,99 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { sha256FileHex } from "./profile.mjs"; + +function normalizeManifestPath(value) { + return String(value || "").trim().replace(/^\.\//, ""); +} + +function parseChecksums(raw) { + const text = String(raw || ""); + const trimmed = text.trim(); + if (!trimmed) throw new Error("checksum manifest is empty"); + + if (trimmed.startsWith("{")) { + const parsed = JSON.parse(trimmed); + const source = parsed.files && typeof parsed.files === "object" ? parsed.files : parsed; + const out = {}; + for (const [manifestPath, entry] of Object.entries(source)) { + const normalized = normalizeManifestPath(manifestPath); + const hash = typeof entry === "string" ? entry : entry?.sha256; + if (typeof hash === "string" && /^[a-fA-F0-9]{64}$/.test(hash.trim())) { + if (out[normalized]) throw new Error(`duplicate checksum entry: ${normalized}`); + out[normalized] = hash.trim().toLowerCase(); + } + } + return out; + } + + const out = {}; + const basenameCounts = new Map(); + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (!m) continue; + const manifestPath = normalizeManifestPath(m[2]); + if (out[manifestPath]) throw new Error(`duplicate checksum entry: ${manifestPath}`); + out[manifestPath] = m[1].toLowerCase(); + const base = path.basename(manifestPath); + basenameCounts.set(base, (basenameCounts.get(base) || 0) + 1); + } + for (const [base, count] of basenameCounts.entries()) { + if (count > 1) throw new Error(`ambiguous duplicate checksum basename: ${base}`); + } + return out; +} + +function expectedForArtifact(files, artifactPath, manifestName = null) { + const candidates = [manifestName, artifactPath, path.basename(artifactPath)] + .filter(Boolean) + .map(normalizeManifestPath); + for (const candidate of candidates) { + if (files[candidate]) return files[candidate]; + } + return null; +} + +export function verifyChecksums({ artifactPath, checksumsPath, manifestName = null }) { + const files = parseChecksums(fs.readFileSync(checksumsPath, "utf8")); + const expected = expectedForArtifact(files, artifactPath, manifestName); + if (!expected) { + return { ok: false, status: "missing", artifact: artifactPath, message: "artifact not present in checksum manifest" }; + } + const actual = sha256FileHex(artifactPath); + return { ok: actual === expected, status: actual === expected ? "verified" : "mismatch", artifact: artifactPath, expected, actual }; +} + +export function verifyDetachedSignature({ manifestPath, signaturePath, publicKeyPath }) { + const manifestBytes = fs.readFileSync(manifestPath); + const signatureText = fs.readFileSync(signaturePath, "utf8").trim(); + const sig = Buffer.from(signatureText.replace(/\s+/g, ""), "base64"); + const key = crypto.createPublicKey(fs.readFileSync(publicKeyPath, "utf8")); + const ok = crypto.verify(null, manifestBytes, key, sig); + return { ok, status: ok ? "verified" : "mismatch", manifest: manifestPath, signature: signaturePath }; +} + +export function verifySupplyChain(options) { + const checksum = verifyChecksums(options); + if (!options.allowUnsignedChecksums && (!options.signaturePath || !options.publicKeyPath)) { + return { + checksum, + signature: { ok: false, status: "missing" }, + ok: false, + message: "detached signature and trusted public key are required for supply-chain verification", + }; + } + const result = { checksum, signature: { ok: null, status: "not_checked" }, ok: checksum.ok }; + if (options.signaturePath && options.publicKeyPath) { + result.signature = verifyDetachedSignature({ + manifestPath: options.checksumsPath, + signaturePath: options.signaturePath, + publicKeyPath: options.publicKeyPath, + }); + result.ok = checksum.ok && result.signature.ok; + } else { + result.signature = { ok: null, status: "unsigned_checksum_only" }; + result.ok = checksum.ok; + } + return result; +} diff --git a/skills/picoclaw-security-guardian/scripts/check_advisories.mjs b/skills/picoclaw-security-guardian/scripts/check_advisories.mjs new file mode 100644 index 0000000..2527844 --- /dev/null +++ b/skills/picoclaw-security-guardian/scripts/check_advisories.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { checkPicoclawAdvisories } from "../lib/advisories.mjs"; import { stableStringify } from "../lib/profile.mjs"; +function parse(argv){const a={allowUnsigned:false}; for(let i=0;if.code==="PUBLIC_WEB_UI_ENABLED")); assert.ok(d.findings.some(f=>f.code==="FEED_VERIFICATION_REGRESSION")); assert.ok(d.findings.some(f=>f.code==="WATCHED_FILE_DRIFT")); console.log("drift.test.mjs PASS"); diff --git a/skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh b/skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh new file mode 100755 index 0000000..afda2c3 --- /dev/null +++ b/skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Picoclaw-oriented sandbox regression for picoclaw-security-guardian. +# +# This is deliberately NOT a Hermes install test. It boots a disposable Docker +# sandbox, mounts a Picoclaw source tree, publishes this skill through a local +# ClawHub-compatible registry, installs it with Picoclaw's own install_skill tool, +# verifies Picoclaw's skill loader can see/load it, then runs the installed copy's +# Picoclaw security workflows against an isolated PICOCLAW_HOME. +# +# Usage from the ClawSec repo root: +# skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh +# +# Optional env overrides: +# IMAGE=golang:1.25-bookworm +# PICOCLAW_SRC=/home/davida/picoclaw_research/picoclaw +# SKILL_SRC=/home/davida/clawsec/skills/picoclaw-security-guardian +# CLAWHUB_PORT=8767 + +IMAGE="${IMAGE:-golang:1.25-bookworm}" +PICOCLAW_SRC="${PICOCLAW_SRC:-$HOME/picoclaw_research/picoclaw}" +SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +CLAWHUB_PORT="${CLAWHUB_PORT:-8767}" +SKILL_VERSION="${SKILL_VERSION:-$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["version"])' "$SKILL_SRC/skill.json")}" + +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: docker is required." >&2 + exit 1 +fi +if [[ ! -d "$PICOCLAW_SRC" ]]; then + echo "ERROR: PICOCLAW_SRC not found: $PICOCLAW_SRC" >&2 + exit 1 +fi +if [[ ! -f "$PICOCLAW_SRC/go.mod" ]]; then + echo "ERROR: PICOCLAW_SRC does not look like a Picoclaw Go module: $PICOCLAW_SRC" >&2 + exit 1 +fi +if [[ ! -d "$SKILL_SRC" ]]; then + echo "ERROR: SKILL_SRC not found: $SKILL_SRC" >&2 + exit 1 +fi + +echo "[sandbox] image=$IMAGE" +echo "[sandbox] picoclaw-src=$PICOCLAW_SRC" +echo "[sandbox] skill-src=$SKILL_SRC" +echo "[sandbox] skill-version=$SKILL_VERSION" + +docker run --rm \ + -e HOME=/tmp/picoclaw-user-home \ + -e PICOCLAW_HOME=/tmp/picoclaw-instance-home \ + -e PICOCLAW_WORKSPACE=/tmp/picoclaw-workspace \ + -e SKILL_VERSION="$SKILL_VERSION" \ + -e CLAWHUB_PORT="$CLAWHUB_PORT" \ + -v "$PICOCLAW_SRC":/opt/picoclaw-src:ro \ + -v "$SKILL_SRC":/opt/skill-src:ro \ + "$IMAGE" bash -lc ' +set -euo pipefail +export PATH="/usr/local/go/bin:$PATH" +export DEBIAN_FRONTEND=noninteractive +apt-get update >/dev/null +apt-get install -y --no-install-recommends ca-certificates curl nodejs npm openssl zip >/dev/null + +mkdir -p "$HOME" "$PICOCLAW_HOME/security/clawsec" "$PICOCLAW_WORKSPACE" /tmp/clawhub /tmp/registry-src + +echo "INSIDE_HOME=$HOME" +echo "INSIDE_PICOCLAW_HOME=$PICOCLAW_HOME" +echo "INSIDE_PICOCLAW_WORKSPACE=$PICOCLAW_WORKSPACE" + +# Build a ClawHub-style archive with SKILL.md at the archive root, because +# Picoclaw extracts registry ZIPs directly into workspace/skills//. +cp /opt/skill-src/SKILL.md /opt/skill-src/README.md /opt/skill-src/CHANGELOG.md /opt/skill-src/skill.json /tmp/registry-src/ +cp -a /opt/skill-src/lib /opt/skill-src/scripts /tmp/registry-src/ +( + cd /tmp/registry-src + zip -qr /tmp/clawhub/picoclaw-security-guardian.zip . +) + +ZIP_SHA=$(sha256sum /tmp/clawhub/picoclaw-security-guardian.zip | awk "{print \$1}") +cat > /tmp/checksums.json </dev/null 2>&1 +openssl pkey -in /tmp/release-sign.key -pubout -out /tmp/signing-public.pem >/dev/null 2>&1 +node - <<"NODE" +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const privateKey = crypto.createPrivateKey(fs.readFileSync("/tmp/release-sign.key")); +const manifestBytes = fs.readFileSync("/tmp/checksums.json"); +fs.writeFileSync("/tmp/checksums.json.sig", crypto.sign(null, manifestBytes, privateKey).toString("base64") + "\n"); +NODE + +# Release artifact verification preflight: checksum + detached Ed25519 signature. +node /opt/skill-src/scripts/verify_supply_chain.mjs \ + --artifact /tmp/clawhub/picoclaw-security-guardian.zip \ + --checksums /tmp/checksums.json \ + --signature /tmp/checksums.json.sig \ + --public-key /tmp/signing-public.pem >/tmp/release-verify.log + +cat > /tmp/clawhub_server.py <<"PY" +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +SKILL = "picoclaw-security-guardian" +VERSION = os.environ["SKILL_VERSION"] +ZIP_PATH = "/tmp/clawhub/picoclaw-security-guardian.zip" +SUMMARY = "Picoclaw security posture checks: advisory awareness, config drift, and supply-chain verification." + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def send_json(self, obj): + body = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/api/v1/search": + self.send_json({"results": [{"score": 1.0, "slug": SKILL, "displayName": "Picoclaw Security Guardian", "summary": SUMMARY, "version": VERSION}]}) + return + if parsed.path == f"/api/v1/skills/{SKILL}": + self.send_json({"slug": SKILL, "displayName": "Picoclaw Security Guardian", "summary": SUMMARY, "latestVersion": {"version": VERSION}, "moderation": {"isMalwareBlocked": False, "isSuspicious": False}}) + return + if parsed.path == "/api/v1/download": + qs = parse_qs(parsed.query) + if qs.get("slug", [""])[0] != SKILL: + self.send_error(404, "unknown skill") + return + data = open(ZIP_PATH, "rb").read() + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + self.send_error(404, "not found") + +ThreadingHTTPServer(("127.0.0.1", int(os.environ["CLAWHUB_PORT"])), Handler).serve_forever() +PY +python3 /tmp/clawhub_server.py >/tmp/clawhub.log 2>&1 & +SERVER_PID=$! +trap "kill $SERVER_PID >/dev/null 2>&1 || true; wait $SERVER_PID 2>/dev/null || true" EXIT +REGISTRY_READY=0 +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:$CLAWHUB_PORT/api/v1/skills/picoclaw-security-guardian" >/dev/null; then + REGISTRY_READY=1 + break + fi + sleep 0.2 +done +if [ "$REGISTRY_READY" -ne 1 ]; then + echo "ERROR: local ClawHub-compatible registry did not become ready" >&2 + cat /tmp/clawhub.log >&2 || true + exit 1 +fi + +# Exercise Picoclaw itself: registry search -> install_skill -> skill loader. +cat > /tmp/picoclaw_skill_harness.go <<"GO" +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/skills" + integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" +) + +func must(ok bool, msg string, args ...any) { + if !ok { + fmt.Fprintf(os.Stderr, msg+"\n", args...) + os.Exit(1) + } +} + +func main() { + workspace := os.Getenv("PICOCLAW_WORKSPACE") + baseURL := "http://127.0.0.1:" + os.Getenv("CLAWHUB_PORT") + version := os.Getenv("SKILL_VERSION") + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(skills.NewClawHubRegistry(skills.ClawHubConfig{Enabled: true, BaseURL: baseURL, Timeout: 10})) + + findTool := integrationtools.NewFindSkillsTool(registryMgr, skills.NewSearchCache(50, 5*time.Minute)) + findResult := findTool.Execute(context.Background(), map[string]any{"query": "picoclaw security", "limit": float64(5)}) + fmt.Println(findResult.ForLLM) + must(!findResult.IsError, "find_skills failed: %s", findResult.ForLLM) + must(strings.Contains(findResult.ForLLM, "picoclaw-security-guardian"), "find_skills did not return picoclaw-security-guardian") + + installTool := integrationtools.NewInstallSkillTool(registryMgr, workspace) + installResult := installTool.Execute(context.Background(), map[string]any{ + "slug": "picoclaw-security-guardian", + "registry": "clawhub", + "version": version, + }) + fmt.Println(installResult.ForLLM) + must(!installResult.IsError, "install_skill failed: %s", installResult.ForLLM) + must(strings.Contains(installResult.ForLLM, "Successfully installed skill"), "install_skill did not report success") + + installed := filepath.Join(workspace, "skills", "picoclaw-security-guardian") + for _, rel := range []string{"SKILL.md", "skill.json", "scripts/generate_profile.mjs", "scripts/check_drift.mjs", "scripts/check_advisories.mjs", "scripts/verify_supply_chain.mjs"} { + if _, err := os.Stat(filepath.Join(installed, rel)); err != nil { + fmt.Fprintf(os.Stderr, "missing installed file %s: %v\n", rel, err) + os.Exit(1) + } + } + + loader := skills.NewSkillsLoader(workspace, filepath.Join(os.Getenv("PICOCLAW_HOME"), "skills"), "") + found := false + for _, skill := range loader.ListSkills() { + if skill.Name == "picoclaw-security-guardian" && skill.Source == "workspace" { + found = true + break + } + } + must(found, "Picoclaw SkillsLoader did not list installed picoclaw-security-guardian workspace skill") + content, ok := loader.LoadSkill("picoclaw-security-guardian") + must(ok, "Picoclaw SkillsLoader could not load installed skill content") + must(strings.Contains(content, "Picoclaw Security Guardian"), "loaded skill content is not Picoclaw Security Guardian") + + fmt.Println("picoclaw_find_skill=PASS") + fmt.Println("picoclaw_install_skill=PASS") + fmt.Println("picoclaw_skill_loader=PASS") +} +GO +( + cd /opt/picoclaw-src + go run /tmp/picoclaw_skill_harness.go >/tmp/picoclaw-install.log +) +cat /tmp/picoclaw-install.log + +SKILL_DIR="$PICOCLAW_WORKSPACE/skills/picoclaw-security-guardian" + +# Use Picoclaw-native config paths and shapes: config.json + launcher-config.json. +cat > "$PICOCLAW_HOME/config.json" < "$PICOCLAW_HOME/launcher-config.json" </tmp/profile-baseline.log + +cp "$PICOCLAW_HOME/security/clawsec/baseline-profile.json" "$PICOCLAW_HOME/security/clawsec/current-profile.json" +node "$SKILL_DIR/scripts/check_drift.mjs" \ + --baseline "$PICOCLAW_HOME/security/clawsec/baseline-profile.json" \ + --current "$PICOCLAW_HOME/security/clawsec/current-profile.json" \ + --fail-on critical >/tmp/drift-clean.log + +cat > "$PICOCLAW_HOME/config.json" < "$PICOCLAW_HOME/launcher-config.json" </tmp/profile-current.log + +set +e +DRIFT_OUT=$(node "$SKILL_DIR/scripts/check_drift.mjs" \ + --baseline "$PICOCLAW_HOME/security/clawsec/baseline-profile.json" \ + --current "$PICOCLAW_HOME/security/clawsec/current-profile.json" \ + --fail-on critical 2>&1) +DRIFT_CODE=$? +set -e +[ "$DRIFT_CODE" -ne 0 ] +echo "$DRIFT_OUT" | grep -Eq "PUBLIC_WEB_UI_ENABLED|WEB_UI_AUTH_DISABLED|WORKSPACE_RESTRICTION_DISABLED" + +cat > /tmp/picoclaw-feed.json < /tmp/feed-state-unknown.json <&1) +ADVISORY_UNKNOWN_CODE=$? +set -e +if [ "$ADVISORY_UNKNOWN_CODE" -eq 0 ]; then + echo "ERROR: advisory check unexpectedly allowed unknown feed state" >&2 + exit 1 +fi +echo "$ADVISORY_UNKNOWN_OUT" | grep -q "advisory feed state is not verified" +cat > /tmp/feed-state-verified.json </tmp/advisory-verified.log +grep -q "CLAW-PICO-TEST" /tmp/advisory-verified.log + +node "$SKILL_DIR/scripts/verify_supply_chain.mjs" \ + --artifact /tmp/clawhub/picoclaw-security-guardian.zip \ + --checksums /tmp/checksums.json \ + --signature /tmp/checksums.json.sig \ + --public-key /tmp/signing-public.pem >/tmp/installed-supply-chain.log + +echo "=== PICOCLAW SANDBOX FEATURE TEST SUMMARY ===" +echo "picoclaw_find_skill=PASS" +echo "picoclaw_install_skill=PASS" +echo "picoclaw_skill_loader=PASS" +echo "release_verify_triad=PASS" +echo "generate_profile=PASS" +echo "picoclaw_json_config_detection=PASS" +echo "clean_drift_pass=PASS" +echo "baseline_drift_fail_closed=PASS" +echo "advisory_unknown_state_fail_closed=PASS" +echo "advisory_verified_filter=PASS" +echo "installed_supply_chain_verify=PASS" +echo "[sandbox] completed successfully" +' diff --git a/skills/picoclaw-security-guardian/test/profile.test.mjs b/skills/picoclaw-security-guardian/test/profile.test.mjs new file mode 100644 index 0000000..7b67f0b --- /dev/null +++ b/skills/picoclaw-security-guardian/test/profile.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildPicoclawProfile, confineOutputToPicoclawHome } from "../lib/profile.mjs"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "picoclaw-profile-")); +fs.writeFileSync(path.join(dir, "config.yaml"), "bind: 0.0.0.0\nauth: false\nmcp:\n", "utf8"); +const profile = buildPicoclawProfile({ picoclawHome: dir, generatedAt: "2026-04-25T00:00:00.000Z" }); +assert.equal(profile.platform, "picoclaw"); +assert.equal(profile.posture.runtime.ui.public_web_ui, true); +assert.equal(profile.posture.runtime.ui.auth_disabled, true); +assert.equal(profile.posture.runtime.mcp.enabled, true); +assert.match(profile.digests.canonical_sha256, /^[a-f0-9]{64}$/); +assert.throws(() => confineOutputToPicoclawHome(path.join(dir, "..", "escape.json"), dir), /must stay under/); +console.log("profile.test.mjs PASS"); diff --git a/skills/picoclaw-security-guardian/test/supply_chain.test.mjs b/skills/picoclaw-security-guardian/test/supply_chain.test.mjs new file mode 100644 index 0000000..020a169 --- /dev/null +++ b/skills/picoclaw-security-guardian/test/supply_chain.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { verifyChecksums, verifyDetachedSignature, verifySupplyChain } from "../lib/supply_chain.mjs"; +import { sha256FileHex } from "../lib/profile.mjs"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "picoclaw-supply-")); +const artifact = path.join(dir, "picoclaw"); +fs.writeFileSync(artifact, "binary", "utf8"); +const manifest = path.join(dir, "checksums.json"); +fs.writeFileSync(manifest, JSON.stringify({ files: { picoclaw: { sha256: sha256FileHex(artifact) } } }), "utf8"); +assert.equal(verifyChecksums({ artifactPath: artifact, checksumsPath: manifest }).ok, true); +assert.equal(verifySupplyChain({ artifactPath: artifact, checksumsPath: manifest }).ok, false); + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +const sig = crypto.sign(null, fs.readFileSync(manifest), privateKey).toString("base64"); +const pub = path.join(dir, "pub.pem"); +const sigPath = path.join(dir, "checksums.json.sig"); +fs.writeFileSync(pub, publicKey.export({ type: "spki", format: "pem" })); +fs.writeFileSync(sigPath, sig); +assert.equal(verifyDetachedSignature({ manifestPath: manifest, signaturePath: sigPath, publicKeyPath: pub }).ok, true); +assert.equal(verifySupplyChain({ artifactPath: artifact, checksumsPath: manifest, signaturePath: sigPath, publicKeyPath: pub }).ok, true); +console.log("supply_chain.test.mjs PASS"); diff --git a/skills/picoclaw-self-pen-testing/CHANGELOG.md b/skills/picoclaw-self-pen-testing/CHANGELOG.md new file mode 100644 index 0000000..dae4324 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.0.1] - 2026-04-26 + +### Added +- Initial extraction from `picoclaw-security-guardian` to isolate self-pen-testing checks as a standalone Picoclaw skill. +- Local read-only finding engine (`lib/self_pen_test.mjs`). +- CLI runner (`scripts/self_pen_test.mjs`) and unit test (`test/self_pen_test.test.mjs`). diff --git a/skills/picoclaw-self-pen-testing/README.md b/skills/picoclaw-self-pen-testing/README.md new file mode 100644 index 0000000..d3282f4 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/README.md @@ -0,0 +1,21 @@ +# picoclaw-self-pen-testing + +Picoclaw-only local posture-review findings package for ClawSec. + +Status: implemented (v0.0.1), Picoclaw-specific. + +## What it does + +Given a generated Picoclaw posture profile, it emits severity-ranked findings and a summary count for local operator review. + +## Quickstart + +```bash +node scripts/self_pen_test.mjs --profile ~/.picoclaw/security/clawsec/current-profile.json +``` + +## Test + +```bash +node test/self_pen_test.test.mjs +``` diff --git a/skills/picoclaw-self-pen-testing/SKILL.md b/skills/picoclaw-self-pen-testing/SKILL.md new file mode 100644 index 0000000..55c2c3c --- /dev/null +++ b/skills/picoclaw-self-pen-testing/SKILL.md @@ -0,0 +1,46 @@ +--- +name: picoclaw-self-pen-testing +version: 0.0.1 +description: Picoclaw-only local posture-review skill focused on read-only findings and safe operator remediation guidance. +homepage: https://clawsec.prompt.security +author: prompt-security +license: AGPL-3.0-or-later +picoclaw: + emoji: "๐Ÿฆ" + category: "security" + requires: + bins: [node] + test_requires: + bins: [node] +--- + +# Picoclaw Posture Review (separate package) + +Purpose: keep Picoclaw posture-review checks isolated from the broader guardian package so moderation-sensitive checks can be versioned/published independently. + +## Scope + +This skill only performs local, read-only posture-review analysis against an existing Picoclaw posture profile. + +It flags: +- public Web UI exposure +- disabled UI auth +- unrestricted workspace/tooling +- unsigned verification mode +- MCP trust-boundary review needs +- scheduler persistence review +- plaintext secret markers +- multi-channel auth review + +## Usage + +```bash +node scripts/self_pen_test.mjs --profile ~/.picoclaw/security/clawsec/current-profile.json +``` + +## Validation + +```bash +python utils/validate_skill.py skills/picoclaw-self-pen-testing +node skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs +``` diff --git a/skills/picoclaw-self-pen-testing/lib/format.mjs b/skills/picoclaw-self-pen-testing/lib/format.mjs new file mode 100644 index 0000000..a84e180 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/lib/format.mjs @@ -0,0 +1,11 @@ +export function stableStringify(value, space = 2) { + return JSON.stringify(sortDeep(value), null, space); +} + +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (!value || typeof value !== "object") return value; + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = sortDeep(value[key]); + return out; +} diff --git a/skills/picoclaw-self-pen-testing/lib/self_pen_test.mjs b/skills/picoclaw-self-pen-testing/lib/self_pen_test.mjs new file mode 100644 index 0000000..258ce0a --- /dev/null +++ b/skills/picoclaw-self-pen-testing/lib/self_pen_test.mjs @@ -0,0 +1,16 @@ +function add(findings, severity, code, title, evidence, recommendation) { findings.push({ severity, code, title, evidence, recommendation }); } +export function runPicoclawSelfPenTest(profile, _options = {}) { + const findings=[]; const rt=profile?.posture?.runtime || {}; + if (rt.ui?.public_web_ui) add(findings,"critical","PUBLIC_WEB_UI_EXPOSED","Web UI appears bound publicly","public_web_ui=true or equivalent detected","Bind to localhost or enforce password auth + CIDR allowlist before exposure."); + if (rt.ui?.auth_disabled) add(findings,"critical","WEB_UI_AUTH_DISABLED","Web UI auth appears disabled","auth_disabled=true or empty password marker detected","Require password/session auth for any gateway controller UI."); + if (rt.tools?.unrestricted_workspace) add(findings,"critical","WORKSPACE_UNRESTRICTED","Tool workspace restriction appears disabled","restrict_to_workspace=false or sandbox=false marker detected","Enable workspace confinement and deny symlink/absolute-path escapes."); + if (rt.risky_toggles?.allow_unsigned_mode) add(findings,"critical","UNSIGNED_MODE_ALLOWED","Unsigned or insecure verification mode appears enabled","allow_unsigned/skip_signature marker detected","Disable unsigned mode except short audited break-glass windows."); + if (rt.mcp?.enabled) add(findings,"high","MCP_REVIEW_REQUIRED","MCP servers enabled","mcp marker detected","Review each MCP server as a separate trust boundary with least privilege and secrets isolation."); + if (rt.tools?.enabled) add(findings,"medium","TOOLING_REVIEW_REQUIRED","Agent tools appear enabled","tools/code_execution/shell/filesystem marker detected","Require per-tool allowlists and operator approval for dangerous tools."); + if (rt.scheduler?.enabled) add(findings,"medium","SCHEDULER_REVIEW_REQUIRED","Scheduler/persistence features appear enabled","cron/schedule marker detected","Inventory jobs and alert on new persistent actions."); + if ((rt.secrets?.config_secret_markers || 0) > 0) add(findings,"high","PLAINTEXT_SECRET_MARKERS","Config contains secret-like markers",`${rt.secrets.config_secret_markers} marker(s) found`,`Move secrets to supported encrypted/secure storage and redact logs/exports.`); + const enabledGateways = Object.entries(rt.gateways || {}).filter(([,v])=>!!v).map(([k])=>k); + if (enabledGateways.length > 1) add(findings,"medium","MULTI_CHANNEL_AUTH_REVIEW","Multiple chat gateways appear enabled",enabledGateways.join(", "),"Pin immutable user IDs per channel and reject group/forwarded-message ambiguity."); + return { summary: summarize(findings), findings }; +} +function summarize(findings) { const out={critical:0, high:0, medium:0, low:0, info:0}; for (const f of findings) out[f.severity]=(out[f.severity]||0)+1; return out; } diff --git a/skills/picoclaw-self-pen-testing/scripts/self_pen_test.mjs b/skills/picoclaw-self-pen-testing/scripts/self_pen_test.mjs new file mode 100644 index 0000000..b757d61 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/scripts/self_pen_test.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { runPicoclawSelfPenTest } from "../lib/self_pen_test.mjs"; +import { stableStringify } from "../lib/format.mjs"; + +const idx = process.argv.indexOf("--profile"); +if (idx < 0 || !process.argv[idx + 1]) throw new Error("--profile is required"); + +const profile = JSON.parse(fs.readFileSync(process.argv[idx + 1], "utf8")); +const result = runPicoclawSelfPenTest(profile); +console.log(stableStringify(result)); diff --git a/skills/picoclaw-self-pen-testing/skill.json b/skills/picoclaw-self-pen-testing/skill.json new file mode 100644 index 0000000..bfb5405 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/skill.json @@ -0,0 +1,96 @@ +{ + "name": "picoclaw-self-pen-testing", + "version": "0.0.1", + "description": "Picoclaw-only local posture-review skill focused on read-only findings and safe operator remediation guidance.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "homepage": "https://clawsec.prompt.security/", + "platform": "picoclaw", + "keywords": [ + "security", + "picoclaw", + "posture-review", + "read-only-audit", + "mcp", + "auth" + ], + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true, + "description": "Skill documentation and operator guidance" + }, + { + "path": "README.md", + "required": true, + "description": "Quickstart overview" + }, + { + "path": "CHANGELOG.md", + "required": true, + "description": "Version history" + }, + { + "path": "lib/self_pen_test.mjs", + "required": true, + "description": "Local posture-review finding engine" + }, + { + "path": "lib/format.mjs", + "required": true, + "description": "Stable JSON formatter for deterministic output" + }, + { + "path": "scripts/self_pen_test.mjs", + "required": true, + "description": "Run posture-review checks on a profile" + }, + { + "path": "test/self_pen_test.test.mjs", + "required": false, + "description": "Finding classification tests" + } + ] + }, + "picoclaw": { + "emoji": "๐Ÿฆ", + "category": "security", + "requires": { + "bins": [ + "node" + ] + }, + "runtime": { + "required_env": [], + "optional_env": [ + "PICOCLAW_HOME" + ] + }, + "capabilities": { + "security_feed": false, + "config_drift": false, + "agent_self_pen_testing": true, + "supply_chain_install_verification": false + }, + "execution": { + "always": false, + "persistence": "Read-only/on-demand; no scheduler is installed.", + "network_egress": "None" + }, + "operator_review": [ + "This package is intentionally isolated so posture-review checks can be independently published or withheld.", + "Treat findings as operator review guidance; do not auto-remediate without explicit approval." + ], + "triggers": [ + "picoclaw posture review", + "picoclaw local security review", + "picoclaw auth exposure review" + ], + "test_requires": { + "bins": [ + "node" + ] + } + } +} diff --git a/skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs b/skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs new file mode 100644 index 0000000..19006a6 --- /dev/null +++ b/skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs @@ -0,0 +1,3 @@ +import assert from "node:assert/strict"; import { runPicoclawSelfPenTest } from "../lib/self_pen_test.mjs"; +const result=runPicoclawSelfPenTest({posture:{runtime:{ui:{public_web_ui:true,auth_disabled:true},tools:{enabled:true,unrestricted_workspace:true},mcp:{enabled:true},scheduler:{enabled:true},risky_toggles:{allow_unsigned_mode:true},secrets:{config_secret_markers:2},gateways:{telegram:true,discord:true}}}}); +assert.ok(result.summary.critical>=4); assert.ok(result.findings.some(f=>f.code==="MCP_REVIEW_REQUIRED")); assert.ok(result.findings.some(f=>f.code==="MULTI_CHANNEL_AUTH_REVIEW")); console.log("self_pen_test.test.mjs PASS"); diff --git a/types.ts b/types.ts index 4616b6d..fc19c69 100644 --- a/types.ts +++ b/types.ts @@ -28,7 +28,7 @@ export type AdvisoryType = // Keep this open for new categories without requiring type updates. | string; -export const CORE_PLATFORM_SLUGS = ['openclaw', 'nanoclaw', 'hermes'] as const; +export const CORE_PLATFORM_SLUGS = ['openclaw', 'nanoclaw', 'hermes', 'picoclaw'] as const; export type CorePlatformSlug = (typeof CORE_PLATFORM_SLUGS)[number]; export type AdvisoryPlatformSlug = CorePlatformSlug | (string & {}); export type AdvisoryPlatformFilter = 'all' | CorePlatformSlug | 'other'; diff --git a/utils/advisoryPlatforms.ts b/utils/advisoryPlatforms.ts index 74829f5..83fa1f9 100644 --- a/utils/advisoryPlatforms.ts +++ b/utils/advisoryPlatforms.ts @@ -20,6 +20,10 @@ const PLATFORM_DESCRIPTOR_BY_SLUG: Record = { label: 'Hermes', classes: 'bg-emerald-500/20 text-emerald-300 border border-emerald-400/40', }, + picoclaw: { + label: 'Picoclaw', + classes: 'bg-cyan-500/20 text-cyan-300 border border-cyan-400/40', + }, }; const CORE_PLATFORM_SET = new Set(CORE_PLATFORM_SLUGS); diff --git a/wiki/GENERATION.md b/wiki/GENERATION.md index bf327fb..e4f14da 100644 --- a/wiki/GENERATION.md +++ b/wiki/GENERATION.md @@ -16,6 +16,8 @@ - Added a dedicated module page for `clawsec-scanner` and linked it from `wiki/INDEX.md`. - Future updates should preserve existing headings and append `Update Notes` sections when making deltas. - 2026-04-15: Expanded `wiki/modules/hermes-attestation-guardian.md` into full narrative claim breakdowns (people-speak + wiring + verification + scenario) and moved draft-plan context into `wiki/modules/hermes-attestation-guardian-draft-history.md`. +- 2026-04-26: Split Picoclaw self-pen-testing into dedicated `wiki/modules/picoclaw-self-pen-testing.md`, and updated `wiki/modules/picoclaw-security-guardian.md` to cover advisory/drift/supply-chain scope only. +- 2026-04-25: Added DeepWiki-friendly `wiki/modules/picoclaw-security-guardian.md` with support-matrix claims, threat model, default safety posture, frontend/advisory-board wiring, verification commands, and source references. Regenerated `public/wiki/**/llms.txt` exports with `npm run gen:wiki-llms`. ## Source References - README.md @@ -24,6 +26,8 @@ - wiki/overview.md - wiki/architecture.md - wiki/modules/clawsec-scanner.md +- wiki/modules/picoclaw-security-guardian.md +- wiki/modules/picoclaw-self-pen-testing.md - wiki/dependencies.md - wiki/data-flow.md - wiki/glossary.md diff --git a/wiki/INDEX.md b/wiki/INDEX.md index 0571344..a9ffb32 100644 --- a/wiki/INDEX.md +++ b/wiki/INDEX.md @@ -32,6 +32,8 @@ - [Hermes Attestation Guardian](modules/hermes-attestation-guardian.md) - [Hermes Attestation Guardian Draft History (Archived)](modules/hermes-attestation-guardian-draft-history.md) - [NanoClaw Integration](modules/nanoclaw-integration.md) +- [Picoclaw Security Guardian](modules/picoclaw-security-guardian.md) +- [Picoclaw Self Pen Testing](modules/picoclaw-self-pen-testing.md) - [Automation and Release Pipelines](modules/automation-release.md) - [Local Validation and Packaging Tools](modules/local-tooling.md) @@ -42,6 +44,8 @@ - [Generation Metadata](GENERATION.md) ## Update Notes +- 2026-04-26: Split Picoclaw self-pen-testing into standalone `picoclaw-self-pen-testing`; updated Picoclaw module docs and references. +- 2026-04-25: Added Picoclaw Security Guardian module for advisory awareness, config drift detection, and chain-of-supply verification. - 2026-04-19: Moved NanoClaw platform-support and CI/CD pipeline detail sections out of `README.md` into module pages (`modules/nanoclaw-integration.md`, `modules/automation-release.md`) and left README pointers. - 2026-04-16: Added install-guard compatibility note for Hermes Attestation Guardian (community-source install now SAFE without `--force`; behavior unchanged). - 2026-04-15: Expanded Hermes Attestation Guardian module page into full narrative, claim-by-claim operator guidance (no claim tables), and added archived draft-history module page. @@ -58,7 +62,11 @@ - skills/clawsec-suite/skill.json - skills/clawsec-scanner/skill.json - skills/hermes-attestation-guardian/skill.json +- skills/picoclaw-security-guardian/skill.json +- skills/picoclaw-self-pen-testing/skill.json - wiki/modules/clawsec-scanner.md - wiki/modules/hermes-attestation-guardian.md - wiki/modules/hermes-attestation-guardian-draft-history.md +- wiki/modules/picoclaw-security-guardian.md +- wiki/modules/picoclaw-self-pen-testing.md - .github/workflows/ci.yml diff --git a/wiki/modules/picoclaw-security-guardian.md b/wiki/modules/picoclaw-security-guardian.md new file mode 100644 index 0000000..baa4d07 --- /dev/null +++ b/wiki/modules/picoclaw-security-guardian.md @@ -0,0 +1,63 @@ +# Picoclaw Security Guardian + +## Summary + +Current package version: `v0.0.1`. + +`picoclaw-security-guardian` is the core Picoclaw package for: +1. advisory awareness (fail-closed on unverified feed state), +2. deterministic profile generation + drift detection, +3. release artifact supply-chain verification. + +Self-pen-testing checks were intentionally split out into `picoclaw-self-pen-testing` so moderation-sensitive logic can be published/managed independently. + +## Responsibilities + +- Filter Picoclaw-relevant advisories from verified ClawSec feed state/cache. +- Build deterministic posture profiles from Picoclaw config/security files and optional release artifacts. +- Compare baseline vs current profile with severity-ranked findings. +- Verify release artifacts with checksum manifest + required detached signature for passing provenance verdicts. + +## Default safety posture + +- Read-only by default +- No scheduler creation +- No outbound network by default +- Advisory checks fail closed unless verification state is `verified` (or explicit `--allow-unsigned` override) +- Supply-chain verification requires detached-signature verification for a passing provenance result + +## Verification commands + +```bash +python utils/validate_skill.py skills/picoclaw-security-guardian +node skills/picoclaw-security-guardian/test/profile.test.mjs +node skills/picoclaw-security-guardian/test/drift.test.mjs +node skills/picoclaw-security-guardian/test/supply_chain.test.mjs +bash -n skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh +``` + +## Picoclaw-native sandbox regression + +`skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh` publishes the package via a local ClawHub-compatible registry, installs through Picoclaw `find_skills` / `install_skill`, validates skill-loader visibility, and runs installed profile/drift/advisory/supply-chain flows against isolated Picoclaw fixtures. + +## Related package + +- `skills/picoclaw-self-pen-testing/` (optional separate self-pen-testing package) + +## Source references + +- `skills/picoclaw-security-guardian/skill.json` +- `skills/picoclaw-security-guardian/SKILL.md` +- `skills/picoclaw-security-guardian/README.md` +- `skills/picoclaw-security-guardian/lib/profile.mjs` +- `skills/picoclaw-security-guardian/lib/drift.mjs` +- `skills/picoclaw-security-guardian/lib/advisories.mjs` +- `skills/picoclaw-security-guardian/lib/supply_chain.mjs` +- `skills/picoclaw-security-guardian/scripts/generate_profile.mjs` +- `skills/picoclaw-security-guardian/scripts/check_drift.mjs` +- `skills/picoclaw-security-guardian/scripts/check_advisories.mjs` +- `skills/picoclaw-security-guardian/scripts/verify_supply_chain.mjs` +- `skills/picoclaw-security-guardian/test/profile.test.mjs` +- `skills/picoclaw-security-guardian/test/drift.test.mjs` +- `skills/picoclaw-security-guardian/test/supply_chain.test.mjs` +- `skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regression.sh` diff --git a/wiki/modules/picoclaw-self-pen-testing.md b/wiki/modules/picoclaw-self-pen-testing.md new file mode 100644 index 0000000..c45df6a --- /dev/null +++ b/wiki/modules/picoclaw-self-pen-testing.md @@ -0,0 +1,44 @@ +# Picoclaw Self Pen Testing + +## Summary + +Current package version: `v0.0.1`. + +`picoclaw-self-pen-testing` is a standalone Picoclaw package that runs local, read-only self-pen-testing style checks from a generated Picoclaw posture profile. + +This package is intentionally separate from `picoclaw-security-guardian` so moderation-sensitive findings can be shipped independently. + +## What it checks + +- Public Web UI exposure +- Disabled Web UI auth +- Unrestricted workspace/tooling posture +- Unsafely unsigned verification mode +- MCP trust-boundary review needs +- Scheduler persistence review +- Plaintext secret markers +- Multi-channel auth review + +## Usage + +```bash +node skills/picoclaw-self-pen-testing/scripts/self_pen_test.mjs \ + --profile ~/.picoclaw/security/clawsec/current-profile.json +``` + +## Validation + +```bash +python utils/validate_skill.py skills/picoclaw-self-pen-testing +node skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs +``` + +## Source references + +- `skills/picoclaw-self-pen-testing/skill.json` +- `skills/picoclaw-self-pen-testing/SKILL.md` +- `skills/picoclaw-self-pen-testing/README.md` +- `skills/picoclaw-self-pen-testing/lib/self_pen_test.mjs` +- `skills/picoclaw-self-pen-testing/lib/format.mjs` +- `skills/picoclaw-self-pen-testing/scripts/self_pen_test.mjs` +- `skills/picoclaw-self-pen-testing/test/self_pen_test.test.mjs`