Add Picoclaw guardian + posture-review skills at v0.0.1 with wiki docs (#208)

* Add Picoclaw guardian + posture-review skills at v0.0.1 with wiki docs

* fix(feed): add picoclaw to core platform taxonomy and filters

* fix(picoclaw): resolve eslint errors in new skills

* chore(nvd): include picoclaw in CVE polling and cleanup report

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
This commit is contained in:
David Abutbul
2026-04-26 14:19:18 +03:00
committed by GitHub
parent c53463c445
commit 7bcac2d366
35 changed files with 1618 additions and 16 deletions
+10 -6
View File
@@ -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
);
+13 -1
View File
@@ -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
+2 -1
View File
@@ -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<FilterTabOption<AdvisoryPlatformFilter>>;
@@ -157,7 +158,7 @@ export const FeedSetup: React.FC = () => {
<h1 className="text-3xl md:text-4xl text-white">Security Hardening Feed</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
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.
</p>
{lastUpdated && (
<p className="text-xs text-gray-500">
+5 -3
View File
@@ -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() {
+6 -4
View File
@@ -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
@@ -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.
@@ -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.
+107
View File
@@ -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.
@@ -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 };
}
@@ -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; }
@@ -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;
}
@@ -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;
}
@@ -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;i<argv.length;i++){const t=argv[i]; if(t==="--feed") a.feedPath=argv[++i]; else if(t==="--state") a.statePath=argv[++i]; else if(t==="--allow-unsigned") a.allowUnsigned=true; else throw new Error(`Unknown argument: ${t}`);} if(!a.feedPath) throw new Error("--feed is required"); return a;}
const result=checkPicoclawAdvisories(parse(process.argv.slice(2))); console.log(stableStringify(result));
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import fs from "node:fs"; import { diffPicoclawProfiles, highestSeverity, severityAtOrAbove } from "../lib/drift.mjs"; import { stableStringify } from "../lib/profile.mjs";
function parse(argv){const a={failOn:"critical"}; for(let i=0;i<argv.length;i++){const t=argv[i]; if(t==="--baseline") a.baseline=argv[++i]; else if(t==="--current") a.current=argv[++i]; else if(t==="--fail-on") a.failOn=argv[++i]; else throw new Error(`Unknown argument: ${t}`);} if(!a.baseline||!a.current) throw new Error("--baseline and --current are required"); return a;}
const a=parse(process.argv.slice(2)); const result=diffPicoclawProfiles(JSON.parse(fs.readFileSync(a.baseline,"utf8")), JSON.parse(fs.readFileSync(a.current,"utf8"))); console.log(stableStringify(result)); const hi=highestSeverity(result.findings); if(severityAtOrAbove(hi,a.failOn)) process.exit(2);
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { buildPicoclawProfile, confineOutputToPicoclawHome, defaultOutputPath, defaultPicoclawHome, stableStringify } from "../lib/profile.mjs";
function parse(argv) {
const args = { watch: [], artifact: [], output: null, home: defaultPicoclawHome(), generatedAt: null, config: null };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--output") args.output = argv[++i];
else if (token === "--home") args.home = argv[++i];
else if (token === "--watch") args.watch.push(argv[++i]);
else if (token === "--artifact") args.artifact.push(argv[++i]);
else if (token === "--generated-at") args.generatedAt = argv[++i];
else if (token === "--config") args.config = argv[++i];
else if (token === "--help") {
console.log("Usage: node scripts/generate_profile.mjs [--output path] [--home path] [--config path] [--watch path] [--artifact path]");
process.exit(0);
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
if (!args.output) args.output = defaultOutputPath(args.home);
return args;
}
function writeNoFollow(outPath, body) {
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
const fd = fs.openSync(outPath, flags, 0o600);
try {
fs.writeFileSync(fd, body, "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
const args = parse(process.argv.slice(2));
const profile = buildPicoclawProfile({
picoclawHome: args.home,
generatedAt: args.generatedAt,
configPath: args.config,
watchFiles: args.watch,
releaseArtifacts: args.artifact,
});
const out = confineOutputToPicoclawHome(args.output, args.home);
fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
const checkedOut = confineOutputToPicoclawHome(out, args.home);
writeNoFollow(checkedOut, `${stableStringify(profile)}\n`);
console.log(stableStringify({ message: "picoclaw profile generated", output: checkedOut, canonical_sha256: profile.digests.canonical_sha256 }, 0));
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { verifySupplyChain } from "../lib/supply_chain.mjs";
import { stableStringify } from "../lib/profile.mjs";
function parse(argv) {
const args = { allowUnsignedChecksums: false, manifestName: null };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--artifact") args.artifactPath = argv[++i];
else if (token === "--checksums") args.checksumsPath = argv[++i];
else if (token === "--signature") args.signaturePath = argv[++i];
else if (token === "--public-key") args.publicKeyPath = argv[++i];
else if (token === "--manifest-name") args.manifestName = argv[++i];
else if (token === "--allow-unsigned-checksums") args.allowUnsignedChecksums = true;
else throw new Error(`Unknown argument: ${token}`);
}
if (!args.artifactPath || !args.checksumsPath) throw new Error("--artifact and --checksums are required");
return args;
}
const result = verifySupplyChain(parse(process.argv.slice(2)));
console.log(stableStringify(result));
if (!result.ok) process.exit(2);
@@ -0,0 +1,150 @@
{
"name": "picoclaw-security-guardian",
"version": "0.0.1",
"description": "Picoclaw security posture skill with advisory awareness, configuration drift detection, and supply-chain verification guidance.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
"homepage": "https://clawsec.prompt.security/",
"platform": "picoclaw",
"keywords": [
"security",
"picoclaw",
"ai-gateway",
"advisory",
"drift-detection",
"supply-chain"
],
"sbom": {
"files": [
{
"path": "SKILL.md",
"required": true,
"description": "Skill documentation and Picoclaw operator playbook"
},
{
"path": "README.md",
"required": true,
"description": "Human-oriented overview and quickstart"
},
{
"path": "CHANGELOG.md",
"required": true,
"description": "Version history and release notes"
},
{
"path": "lib/profile.mjs",
"required": true,
"description": "Picoclaw posture profile and path-confinement helpers"
},
{
"path": "lib/drift.mjs",
"required": true,
"description": "Baseline comparison and severity mapping helpers"
},
{
"path": "lib/supply_chain.mjs",
"required": true,
"description": "Release artifact checksum/signature verification helpers"
},
{
"path": "lib/advisories.mjs",
"required": true,
"description": "Picoclaw advisory feed filtering helpers"
},
{
"path": "scripts/generate_profile.mjs",
"required": true,
"description": "Generate deterministic Picoclaw security posture profile"
},
{
"path": "scripts/check_drift.mjs",
"required": true,
"description": "Compare Picoclaw profile against an approved baseline"
},
{
"path": "scripts/verify_supply_chain.mjs",
"required": true,
"description": "Verify release artifact checksums and required detached signatures for provenance"
},
{
"path": "scripts/check_advisories.mjs",
"required": true,
"description": "Check Picoclaw-relevant advisories from a signed/verified feed state"
},
{
"path": "test/profile.test.mjs",
"required": false,
"description": "Profile generation and path-safety tests"
},
{
"path": "test/drift.test.mjs",
"required": false,
"description": "Drift severity tests"
},
{
"path": "test/supply_chain.test.mjs",
"required": false,
"description": "Checksum and required-signature verification tests"
},
{
"path": "test/picoclaw_security_guardian_sandbox_regression.sh",
"required": false,
"description": "Isolated Docker/Picoclaw install regression harness using Picoclaw find_skills/install_skill and skill-loader validation for pre-release checks"
}
]
},
"picoclaw": {
"emoji": "\ud83e\udd90",
"category": "security",
"requires": {
"bins": [
"node"
]
},
"runtime": {
"required_env": [],
"optional_env": [
"PICOCLAW_HOME",
"PICOCLAW_CONFIG",
"PICOCLAW_PROFILE_OUTPUT_DIR",
"PICOCLAW_BASELINE",
"PICOCLAW_ADVISORY_FEED_STATE_PATH",
"PICOCLAW_ADVISORY_CACHED_FEED",
"PICOCLAW_ALLOW_UNSIGNED_FEED"
]
},
"capabilities": {
"security_feed": true,
"config_drift": true,
"agent_self_pen_testing": false,
"supply_chain_install_verification": true
},
"execution": {
"always": false,
"persistence": "Read-only/on-demand in v0.0.1; no scheduler is installed.",
"network_egress": "None by default. Advisory checks consume local verified feed state/cache unless the operator supplies a feed file."
},
"operator_review": [
"Picoclaw-specific skill: use for Picoclaw gateways and lightweight AI gateway deployments, not OpenClaw hook execution.",
"Treat public Web UI binding and broad chat-channel enablement as review findings until explicitly justified.",
"Keep unsigned advisory mode temporary and documented; default workflows expect verified feed state.",
"Supply-chain verification requires manifests/signatures from a trusted release source; third-party LuCI wrappers need separate provenance review."
],
"triggers": [
"picoclaw security profile",
"picoclaw drift detection",
"picoclaw advisory check",
"picoclaw supply chain verification"
],
"test_requires": {
"bins": [
"bash",
"docker",
"python3",
"node",
"openssl",
"zip"
]
}
}
}
@@ -0,0 +1,4 @@
import assert from "node:assert/strict"; import { diffPicoclawProfiles, highestSeverity } from "../lib/drift.mjs";
const base={platform:"picoclaw",schema_version:"picoclaw-profile/v1",posture:{runtime:{ui:{public_web_ui:false,auth_disabled:false},tools:{unrestricted_workspace:false},mcp:{enabled:false},scheduler:{enabled:false},risky_toggles:{allow_unsigned_mode:false},secrets:{config_secret_markers:0}},feed_verification:{status:"verified"},integrity:{watched_files:[{path:"a",sha256:"1"}],release_artifacts:[]}}};
const cur=JSON.parse(JSON.stringify(base)); cur.posture.runtime.ui.public_web_ui=true; cur.posture.feed_verification.status="unknown"; cur.posture.integrity.watched_files[0].sha256="2";
const d=diffPicoclawProfiles(base,cur); assert.equal(highestSeverity(d.findings),"critical"); assert.ok(d.findings.some(f=>f.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");
@@ -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/<slug>/.
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 <<EOF
{"files":{"picoclaw-security-guardian.zip":{"sha256":"$ZIP_SHA"}}}
EOF
openssl genpkey -algorithm ed25519 -out /tmp/release-sign.key >/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" <<EOF
{
"version": 3,
"agents": {
"defaults": {
"workspace": "$PICOCLAW_WORKSPACE",
"restrict_to_workspace": true,
"model_name": "sandbox-model"
}
},
"tools": {
"exec": {"enabled": false},
"cron": {"enabled": false},
"find_skills": {"enabled": true},
"install_skill": {"enabled": true}
}
}
EOF
cat > "$PICOCLAW_HOME/launcher-config.json" <<EOF
{
"port": 18800,
"public": false,
"allowed_cidrs": ["127.0.0.1/32"],
"dashboard_password_hash": "argon2id-test-hash"
}
EOF
node "$SKILL_DIR/scripts/generate_profile.mjs" \
--home "$PICOCLAW_HOME" \
--output "$PICOCLAW_HOME/security/clawsec/baseline-profile.json" \
--generated-at 2026-04-25T00:00:00.000Z >/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" <<EOF
{
"version": 3,
"agents": {
"defaults": {
"workspace": "/",
"restrict_to_workspace": false,
"allow_read_outside_workspace": true,
"model_name": "sandbox-model"
}
},
"tools": {
"exec": {"enabled": true, "allow_remote": true},
"cron": {"enabled": true, "allow_command": true},
"mcp": {
"enabled": true,
"servers": {
"dangerous-local": {"command": "node", "args": ["server.js"]}
}
},
"web": {"brave": {"enabled": true, "api_keys": ["test-secret-value"]}}
}
}
EOF
cat > "$PICOCLAW_HOME/launcher-config.json" <<EOF
{
"port": 18800,
"public": true,
"allowed_cidrs": ["0.0.0.0/0"],
"dashboard_password_hash": ""
}
EOF
node "$SKILL_DIR/scripts/generate_profile.mjs" \
--home "$PICOCLAW_HOME" \
--output "$PICOCLAW_HOME/security/clawsec/current-profile.json" \
--generated-at 2026-04-25T00:10:00.000Z >/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 <<EOF
{"version":"1.0.0","updated":"2026-04-25T00:00:00Z","advisories":[{"id":"CLAW-PICO-TEST","severity":"high","type":"prompt_injection","platforms":["picoclaw"],"affected":["picoclaw-security-guardian@$SKILL_VERSION"],"title":"Picoclaw test advisory","description":"Picoclaw gateway review","published":"2026-04-25T00:00:00Z","action":"Review before release"}]}
EOF
cat > /tmp/feed-state-unknown.json <<EOF
{"status":"unknown"}
EOF
set +e
ADVISORY_UNKNOWN_OUT=$(node "$SKILL_DIR/scripts/check_advisories.mjs" --feed /tmp/picoclaw-feed.json --state /tmp/feed-state-unknown.json 2>&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 <<EOF
{"status":"verified"}
EOF
node "$SKILL_DIR/scripts/check_advisories.mjs" --feed /tmp/picoclaw-feed.json --state /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"
'
@@ -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");
@@ -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");
@@ -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`).
@@ -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
```
+46
View File
@@ -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
```
@@ -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;
}
@@ -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; }
@@ -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));
@@ -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"
]
}
}
}
@@ -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");
+1 -1
View File
@@ -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';
+4
View File
@@ -20,6 +20,10 @@ const PLATFORM_DESCRIPTOR_BY_SLUG: Record<string, PlatformDescriptor> = {
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<string>(CORE_PLATFORM_SLUGS);
+4
View File
@@ -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
+8
View File
@@ -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
@@ -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`
+44
View File
@@ -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`