feat(hermes-attestation-guardian): v0.1.0 release hardening (verify gate + trust policy + .mjs scan context) (#200)

* feat(hermes-attestation-guardian): release v0.0.2 hardening

* docs(wiki): add v0.0.2 hardening update note

* docs: add Hermes support coverage to README and compatibility report

* fix(hermes-attestation-guardian): address baz review on crontab detection and doc dedup

* feat(wiki): add PR-200 skill feature/platform matrix

* docs(wiki): rewrite PR-200 matrix as narrative capability mapping

* docs(readme): add skill feature matrix with requested headers

* docs(readme): replace unknowns with mapped yes/no feature matrix

* docs: move NanoClaw and CI/CD details from README to wiki modules

* docs(readme): remove platform/suite sections and keep wiki module pointers

* docs(readme): refresh project structure to match current repo

* feat(hermes-attestation-guardian): add signed advisory feed verification pipeline

* feat(hermes-attestation-guardian): add advisory-gated guarded skill verification

* feat(hermes-attestation-guardian): add advisory scheduler helper and phase-3 parity docs

* docs(wiki): expand hermes attestation guardian capability coverage

* fix(pr-200): address Baz review findings across Hermes parity rollout

* test(sandbox): extend Hermes regression to cover feed, guarded verify, and advisory scheduler

* fix(pr-200): address Baz semver parsing and feed-state fallback visibility

* fix(ci): suppress shellcheck false positives in sandbox inline docker script

* fix(hermes-attestation-guardian): fail closed on unsupported advisory ranges

* fix(hermes-attestation-guardian): restore safe install verdict in sandbox

* fix(sandbox): capture guarded verify exit under set -e

* fix(semver): fail closed on malformed affected specifiers

* docs(readme): clarify hermes capability matrix wording

* refactor(feed): share signed artifact verification flow

* refactor(cron): share managed block helpers across setup scripts

* fix(feed): require checksum manifest artifacts when enabled

* chore(hermes-skill): relocate sandbox test, refresh docs, and add v0.1.0 release notes

* chore(docs): remove remaining hermes parity plan file

* chore(release): roll hermes-attestation-guardian to v0.1.0

* chore(release): remove standalone v0.1.0 release notes file

* docs(hermes): update README status to v0.1.0

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
This commit is contained in:
David Abutbul
2026-04-21 13:56:50 +03:00
committed by GitHub
parent d0fe8c59c4
commit 26af277afd
29 changed files with 3985 additions and 566 deletions
+47 -147
View File
@@ -6,7 +6,7 @@
<div align="center">
## Secure Your OpenClaw and NanoClaw Agents with a Complete Security Skill Suite
## Secure Your OpenClaw, NanoClaw, and Hermes Agents with a Complete Security Skill Suite
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
@@ -39,6 +39,22 @@ 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
### Skill Feature Matrix
| Skill name | supported platform| security feed verification| config drift | agent self pen testing| supply-chain install verification |
|---|---|---|---|---|---|
| claw-release | OpenClaw | No | No | No | Yes |
| clawsec-clawhub-checker | OpenClaw + clawsec-suite integration | No | No | No | Yes |
| clawsec-feed | OpenClaw | Yes | No | No | Yes |
| clawsec-nanoclaw | NanoClaw | Yes | Yes | Yes | Yes |
| clawsec-scanner | OpenClaw | Yes | No | Yes | Yes |
| clawsec-suite | OpenClaw | Yes | Yes | No | Yes |
| 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 |
| soul-guardian | OpenClaw | No | Yes | No | No |
### Core Capabilities
@@ -114,72 +130,18 @@ Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/...
---
## 📱 NanoClaw Platform Support
## 🧭 Platform & Suite Documentation
ClawSec now supports **NanoClaw**, a containerized WhatsApp bot powered by Claude agents.
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)
- 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)
### clawsec-nanoclaw Skill
**Location**: `skills/clawsec-nanoclaw/`
A complete security suite adapted for NanoClaw's containerized architecture:
- **9 MCP Tools** for agents to check vulnerabilities
- Advisory checking and browsing
- Pre-installation safety checks
- Skill package signature verification (Ed25519)
- File integrity monitoring
- **Automatic Advisory Feed** - Fetches and caches advisories every 6 hours
- **Platform Filtering** - Shows only NanoClaw-relevant advisories
- **IPC-Based** - Container-safe host communication
- **Full Documentation** - Installation guide, usage examples, troubleshooting
### Advisory Feed for NanoClaw
The feed now monitors NanoClaw-specific keywords:
- `NanoClaw` - Direct product name
- `WhatsApp-bot` - Core functionality
- `baileys` - WhatsApp client library dependency
Advisories can specify `platforms: ["nanoclaw"]` for platform-specific issues.
### Quick Start for NanoClaw
See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) for detailed setup instructions.
**Quick integration:**
1. Copy skill to NanoClaw deployment
2. Integrate MCP tools in container
3. Add IPC handlers and cache service on host
4. Restart NanoClaw
---
## 📦 ClawSec Suite (OpenClaw)
The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog.
`clawsec-suite` is optional orchestration; skills can still be installed directly as standalone packages.
### ClawSec Skills
| Skill | Description | Installation | Compatibility |
|-------|-------------|--------------|---------------|
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with DM delivery and optional email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/Clawdbot |
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
> ⚠️ **clawtributor** is not installed by default as it may share anonymized incident data. Install only on explicit user request.
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/Clawdbot agent family. Other agents receive the universal skill set.
### Suite Features
- **Integrity Verification** - Every skill package includes `checksums.json` with SHA256 hashes
- **Updates** - Automatic checks for new skill versions
- **Self-Healing** - Failed integrity checks trigger automatic re-download from trusted releases
- **Advisory Cross-Reference** - Installed skills are checked against the security advisory feed
Quick install links:
- NanoClaw install: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)
- Hermes skill package: `skills/hermes-attestation-guardian/`
- Suite package: `skills/clawsec-suite/`
---
@@ -264,82 +226,12 @@ This feature helps agents prioritize vulnerabilities that pose immediate threats
## 🔄 CI/CD Pipelines
ClawSec uses automated pipelines for continuous security updates and skill distribution.
CI/CD pipeline details were moved to the wiki module page:
- [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
### Automated Workflows
| Workflow | Trigger | Description |
|----------|---------|-------------|
| **ci.yml** | PRs to `main`, pushes to `main` | Lint/type/build + skill test suites |
| **pages-verify.yml** | PRs to `main` | Verifies Pages build and signing outputs without publishing |
| **poll-nvd-cves.yml** | Daily cron (06:00 UTC) | Polls NVD for new CVEs, updates feed |
| **community-advisory.yml** | Issue labeled `advisory-approved` | Processes community reports into advisories |
| **skill-release.yml** | Skill tags + metadata PR changes | Validates version parity in PRs and publishes signed skill releases on tags |
| **deploy-pages.yml** | `workflow_run` after successful trusted CI/release or manual dispatch | Builds and deploys the web interface to GitHub Pages |
| **wiki-sync.yml** | Pushes to `main` touching `wiki/**` | Syncs `wiki/` to the GitHub Wiki mirror |
### Skill Release Pipeline
When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
1. **Validates** - Checks `skill.json` version matches tag
2. **Enforces key consistency** - Verifies pinned release key references are consistent across repo PEMs and `skills/clawsec-suite/SKILL.md`
3. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
4. **Signs + verifies** - Signs `checksums.json` and validates the generated `signing-public.pem` fingerprint against canonical repo key material
5. **Releases** - Publishes to GitHub Releases with all artifacts
6. **Supersedes Old Releases** - Deletes older versions within the same major line (tags remain)
7. **Triggers Pages Update** - Refreshes the skills catalog on the website
### Signing Key Consistency Guardrails
To prevent supply-chain drift, CI now fails fast when signing key references diverge.
Guardrail script:
- `scripts/ci/verify_signing_key_consistency.sh`
What it checks:
- `skills/clawsec-suite/SKILL.md` inline public key fingerprint matches `RELEASE_PUBKEY_SHA256`
- Canonical PEM files all match the same fingerprint:
- `clawsec-signing-public.pem`
- `advisories/feed-signing-public.pem`
- `skills/clawsec-suite/advisories/feed-signing-public.pem`
- Generated public key in workflows matches canonical key:
- `release-assets/signing-public.pem` (release workflow)
- `public/signing-public.pem` (pages workflow)
Where enforced:
- `.github/workflows/skill-release.yml`
- `.github/workflows/deploy-pages.yml`
### Release Versioning & Superseding
ClawSec follows [semantic versioning](https://semver.org/). When a new version is released:
| Scenario | Behavior |
|----------|----------|
| New patch/minor (e.g., 1.0.1, 1.1.0) | Previous releases with same major version are **deleted** |
| New major (e.g., 2.0.0) | Previous major version (1.x.x) remains for backwards compatibility |
**Why do old releases disappear?**
When you release `skill-v0.0.2`, the previous `skill-v0.0.1` release is automatically deleted to keep the releases page clean. Only the latest version within each major version is retained.
- **Git tags are preserved** - You can always recreate a release from an existing tag if needed
- **Major versions coexist** - Both `skill-v1.x.x` and `skill-v2.x.x` latest releases remain available for backwards compatibility
### Release Artifacts
Each skill release includes:
- `checksums.json` - SHA256 hashes for integrity verification
- `skill.json` - Skill metadata
- `SKILL.md` - Main skill documentation
- Additional files from SBOM (scripts, configs, etc.)
### Signing Operations Documentation
For feed/release signing rollout and operations guidance:
- [`wiki/security-signing-runbook.md`](wiki/security-signing-runbook.md) - key generation, GitHub secrets, rotation/revocation, incident response
- [`wiki/migration-signed-feed.md`](wiki/migration-signed-feed.md) - phased migration from unsigned feed, enforcement gates, rollback plan
Related operations docs:
- [wiki/security-signing-runbook.md](wiki/security-signing-runbook.md)
- [wiki/migration-signed-feed.md](wiki/migration-signed-feed.md)
---
@@ -424,23 +316,29 @@ npm run build
```
├── advisories/
── feed.json # Main advisory feed (auto-updated from NVD)
── feed.json # Main advisory feed
│ ├── feed.json.sig # Detached signature for feed.json
│ └── feed-signing-public.pem # Public key for feed verification
├── components/ # React components
├── pages/ # Page components
├── pages/ # Route/page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
├── scripts/
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
│ ├── prepare-to-push.sh # Local CI-style quality gate
│ ├── validate-release-links.sh # Release link checks
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills - start here and have your agent do the rest)
│ ├── claw-release/ # 🚀 Release automation workflow skill
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
│ ├── hermes-attestation-guardian/ # 🛡️ Hermes attestation + drift verification
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
│ └── soul-guardian/ # 👻 File integrity skill
├── utils/
@@ -448,13 +346,15 @@ npm run build
│ └── validate_skill.py # Skill validator utility
├── .github/workflows/
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build verification
│ ├── pages-verify.yml # PR-only pages build/signing verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release pipeline
│ ├── skill-release.yml # Skill release/signing pipeline
│ ├── deploy-pages.yml # GitHub Pages deployment
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
── deploy-pages.yml # Pages deployment
└── public/ # Static assets + generated publish artifacts
── codeql.yml # CodeQL security analysis
│ └── scorecard.yml # OpenSSF Scorecard checks
└── public/ # Static assets + generated wiki exports
```
---
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Sandbox regression test for hermes-attestation-guardian using an isolated Docker Hermes instance.
#
# Usage:
# scripts/hermes_attestation_sandbox_regression.sh
#
# Optional env overrides:
# IMAGE=python:3.11-slim
# HERMES_AGENT_SRC=/home/davida/.hermes/hermes-agent
# SKILL_SRC=/home/davida/clawsec/skills/hermes-attestation-guardian
# WELL_KNOWN_PORT=8765
IMAGE="${IMAGE:-python:3.11-slim}"
HERMES_AGENT_SRC="${HERMES_AGENT_SRC:-$HOME/.hermes/hermes-agent}"
SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/skills/hermes-attestation-guardian}"
WELL_KNOWN_PORT="${WELL_KNOWN_PORT:-8765}"
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required." >&2
exit 1
fi
if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_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] hermes-agent-src=$HERMES_AGENT_SRC"
echo "[sandbox] skill-src=$SKILL_SRC"
docker run --rm \
-e HOME=/tmp/hermes-sandbox-home \
-e HERMES_HOME=/tmp/hermes-sandbox-home \
-v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
-v "$SKILL_SRC":/opt/skill-src:ro \
"$IMAGE" bash -lc "
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm >/dev/null
cp -a /opt/hermes-agent /tmp/hermes-agent-src
python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
mkdir -p \"\$HOME\"
echo \"INSIDE_HOME=\$HOME\"
echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
cp -a /opt/skill-src/. /tmp/well/.well-known/skills/hermes-attestation-guardian/
python3 - <<'PY'
import os,json
root='/tmp/well/.well-known/skills'
sk='hermes-attestation-guardian'
base=os.path.join(root,sk)
files=[]
for dp,_,fns in os.walk(base):
for fn in fns:
files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':'0.0.1','description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
PY
python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
HPID=\$!
sleep 1
INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
echo \"\$INSTALL_OUT\"
echo \"\$INSTALL_OUT\" | grep -q \"Verdict: SAFE\"
echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"
SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
mkdir -p \"\$HERMES_HOME/security/attestations\"
echo \"alpha\" > /tmp/watch.txt
echo \"anchor-v1\" > /tmp/anchor.pem
cat > /tmp/policy.json <<EOF
{\"watch_files\": [\"/tmp/watch.txt\"], \"trust_anchor_files\": [\"/tmp/anchor.pem\"]}
EOF
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:00:00.000Z --write-sha256 >/tmp/generate.log
DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
echo \"beta\" > /tmp/watch.txt
echo \"anchor-v2\" > /tmp/anchor.pem
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
set +e
DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
DRIFT_CODE=\$?
set -e
[ \"\$DRIFT_CODE\" -ne 0 ]
echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
grep -q \"Preflight review:\" /tmp/cron-preview.log
grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
echo \"install_safe_allowed=PASS\"
echo \"generate_with_policy=PASS\"
echo \"verify_expected_sha=PASS\"
echo \"verify_signature=PASS\"
echo \"baseline_drift_fail_closed=PASS\"
echo \"scheduler_preview=PASS\"
kill \$HPID >/dev/null 2>&1 || true
wait \$HPID 2>/dev/null || true
"
echo "[sandbox] completed successfully"
@@ -1,5 +1,19 @@
# Changelog
## [0.1.0] - 2026-04-21
- Added mandatory release verification gate guidance before install: `checksums.json`, `checksums.sig`, and pinned signing public-key fingerprint.
- Added explicit Hermes guard trust-policy note for signature-aware trust (trusted signer fingerprint allowlist) over source-name-only trust.
- Moved sandbox regression harness into the skill test surface (`test/hermes_attestation_sandbox_regression.sh`) and fixed in-skill default path resolution.
- Tightened advisory feed verification to require checksum-manifest artifacts when checksum-manifest verification is enabled (fail-closed when missing).
- Added feed regression coverage for missing local/remote checksum-manifest artifacts under strict verification mode.
- Refactored cron setup scripts to share managed-block helpers from `lib/cron.mjs`, reducing drift risk.
- Added explicit `.mjs` scan/test coverage guidance so Hermes-side scanner scope and regression harness context stay aligned with `scripts/*.mjs`, `lib/*.mjs`, and `test/*.test.mjs`.
- Clarified fresh-node first-run edge-case documentation.
- Clarified Hermes runtime metadata/frontmatter and README capability coverage for ClawHub publishing.
- Removed compatibility-report wiki page references in favor of README capability matrix as the primary compatibility surface.
- Updated skill metadata/docs to v0.1.0 and aligned README quickstart with fail-closed verification expectations.
## [0.0.1] - 2026-04-15
- Implemented deterministic Hermes attestation generator CLI (`scripts/generate_attestation.mjs`).
+38 -22
View File
@@ -1,40 +1,52 @@
# hermes-attestation-guardian
Hermes-only security attestation and drift detection skill.
Hermes-only attestation, advisory verification, and guarded verification workflow.
Status: implemented (v0.0.1), Hermes-only.
Status: implemented (v0.1.0), Hermes-only.
## What it does
## Capabilities
- Generates deterministic Hermes runtime posture attestations.
- Verifies attestation schema + canonical digest with fail-closed semantics.
- Optionally verifies detached signatures using a provided public key.
- Fails closed on baseline diffing unless baseline authenticity is verified (trusted digest and/or detached signature).
- Restricts attestation output writes to Hermes attestation scope (`$HERMES_HOME/security/attestations`).
- Compares baseline vs current attestations with stable severity classification.
- Provides an optional Hermes-oriented cron setup helper (print-only by default).
This skill now covers the full Hermes-side capability set expected from the clawsec-suite parity workstream:
## Scope boundaries
In scope:
- Hermes environment posture snapshots
- deterministic baseline diffing
- fail-closed verification semantics
- Hermes optional scheduling helper
Out of scope / unsupported (v0.0.1):
- OpenClaw runtime hooks (unsupported)
- destructive auto-remediation
- automatic rollback of runtime configuration
- Deterministic runtime posture attestation generation.
- Fail-closed attestation verification (schema + canonical digest).
- Optional detached signature verification for attestation artifacts.
- Authenticated baseline diffing with stable severity classification.
- Scoped output-path enforcement under `$HERMES_HOME`.
- Signed advisory feed verification (Ed25519) with optional checksum-manifest verification.
- Fail-closed advisory verification state persistence under `$HERMES_HOME/security/advisories`.
- Advisory-aware guarded skill verification with explicit `--confirm-advisory` override.
- Optional recurring scheduler helpers for attestation and advisory checks (print-only by default, explicit apply mode).
- Sandboxed end-to-end regression harness for install + verify + advisory gates.
## Quickstart
Canonical release verification and trust-policy guidance lives in `SKILL.md`:
- `Mandatory release verification gate (before install)`
- `Hermes guard trust policy note`
After running that gate, use:
```bash
node scripts/generate_attestation.mjs
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
node scripts/refresh_advisory_feed.mjs
node scripts/check_advisories.mjs
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
node scripts/setup_attestation_cron.mjs --every 6h --print-only
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-only
```
Scheduler safety warning: never leave `--allow-unsigned` enabled in recurring advisory check jobs except during short emergency recovery windows.
## Runtime requirements
Required:
- `node`
Optional tooling (for local verification workflows):
- `openssl`, `bash`, `docker`
## Tests
```bash
@@ -42,4 +54,8 @@ node test/attestation_schema.test.mjs
node test/attestation_diff.test.mjs
node test/attestation_cli.test.mjs
node test/setup_attestation_cron.test.mjs
node test/setup_advisory_check_cron.test.mjs
node test/feed_verification.test.mjs
node test/guarded_skill_verify.test.mjs
bash test/hermes_attestation_sandbox_regression.sh
```
+89 -3
View File
@@ -1,9 +1,9 @@
---
name: hermes-attestation-guardian
version: 0.0.1
version: 0.1.0
description: Hermes-only runtime security attestation and drift detection skill for operator-managed Hermes infrastructure.
homepage: https://clawsec.prompt.security
clawdis:
hermes:
emoji: "🛡️"
requires:
bins: [node]
@@ -19,6 +19,42 @@ IMPORTANT SCOPE:
Generate deterministic Hermes posture attestations, verify them with fail-closed integrity checks, and compare baseline drift using stable severity mapping.
## Mandatory release verification gate (before install)
Before treating any release install instructions as valid, verify all three inputs:
1) `checksums.json`
2) `checksums.sig`
3) pinned signing public-key fingerprint
```bash
BASE="https://github.com/prompt-security/clawsec/releases/download/hermes-attestation-guardian-v0.1.0"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -fsSL "$BASE/checksums.json" -o "$TMP/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP/signing-public.pem"
[ -s "$TMP/checksums.json" ] || { echo "ERROR: missing checksums.json" >&2; exit 1; }
[ -s "$TMP/checksums.sig" ] || { echo "ERROR: missing checksums.sig" >&2; exit 1; }
EXPECTED_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP/signing-public.pem" -outform DER | sha256sum | awk '{print $1}')"
[ "$ACTUAL_PUBKEY_SHA256" = "$EXPECTED_PUBKEY_SHA256" ] || {
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
}
openssl base64 -d -A -in "$TMP/checksums.sig" -out "$TMP/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin -inkey "$TMP/signing-public.pem" \
-sigfile "$TMP/checksums.sig.bin" -in "$TMP/checksums.json" >/dev/null
```
## Hermes guard trust policy note
When installing from community sources, configure Hermes guard to use signature-aware trust (trusted signer fingerprint allowlist) rather than source-name-only trust. Unknown signer fingerprints should stay on community policy, and invalid signatures must remain blocked.
## Commands
```bash
@@ -47,13 +83,39 @@ node scripts/verify_attestation.mjs \
--signature ~/.hermes/security/attestations/current.json.sig \
--public-key ~/.hermes/security/keys/attestation-public.pem
# Refresh advisory feed verification state (fail-closed by default)
node scripts/refresh_advisory_feed.mjs
# Check advisory feed verification + feed summary
node scripts/check_advisories.mjs
# Guarded advisory-aware skill verification gate (returns 42 on advisory match without explicit confirm)
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
# Explicit operator acknowledgement path for advisory matches
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3 --confirm-advisory
# Optional temporary unsigned bypass (dangerous; emergency-only)
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1 node scripts/refresh_advisory_feed.mjs --allow-unsigned
# Preview scheduler config without mutating user schedule state
node scripts/setup_attestation_cron.mjs --every 6h --print-only
# Apply managed scheduler block
node scripts/setup_attestation_cron.mjs --every 6h --apply
# Preview advisory check scheduler config (guarded flow, print-only default)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-only
# Apply advisory check scheduler block (uses guarded_skill_verify flow)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --version 1.2.3 --apply
# Emergency-only: unsigned bypass for scheduled advisory checks (do not keep enabled)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --allow-unsigned --apply
```
WARNING: `--allow-unsigned` in scheduled commands is incident-response only. Remove it immediately after recovery and restore signed advisory verification.
## Attestation payload (implemented)
The generator emits:
@@ -61,7 +123,7 @@ The generator emits:
- generator metadata (skill + node version)
- host metadata (hostname/platform/arch)
- posture.runtime (gateway enabled flags + risky toggles)
- posture.feed_verification status (verified|unverified|unknown)
- posture.feed_verification status (verified|unverified|unknown) sourced from `$HERMES_HOME/security/advisories/feed-verification-state.json`
- posture.integrity watched_files and trust_anchors (existence + sha256)
- digests.canonical_sha256 over a stable canonical JSON representation
@@ -82,15 +144,39 @@ Severity messages are emitted as INFO / WARNING / CRITICAL style lines.
- `generate_attestation.mjs` writes one JSON file (and optional `.sha256`) under `$HERMES_HOME/security/attestations`.
- `verify_attestation.mjs` is read-only.
- `refresh_advisory_feed.mjs` writes verified feed cache + verification state under `$HERMES_HOME/security/advisories`.
- `check_advisories.mjs` is read-only.
- `guarded_skill_verify.mjs` re-runs feed refresh/verification (same advisory cache + state side effects) and then performs advisory-aware gate checks.
- `setup_attestation_cron.mjs` is read-only unless `--apply` is provided.
- `setup_attestation_cron.mjs --apply` rewrites only the current user managed schedule block delimited by:
- `# >>> hermes-attestation-guardian >>>`
- `# <<< hermes-attestation-guardian <<<`
- `setup_advisory_check_cron.mjs` is read-only unless `--apply` is provided.
- `setup_advisory_check_cron.mjs --apply` rewrites only the current user advisory-check managed schedule block delimited by:
- `# >>> hermes-attestation-guardian-advisory-check >>>`
- `# <<< hermes-attestation-guardian-advisory-check <<<`
- generated command path uses `guarded_skill_verify.mjs` (advisory-aware gate), not raw `check_advisories.mjs`
## Advisory feed override knobs
- Source selection: `HERMES_ADVISORY_FEED_SOURCE=auto|remote|local`
- Remote artifacts: `HERMES_ADVISORY_FEED_URL`, `HERMES_ADVISORY_FEED_SIG_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL`
- Local artifacts: `HERMES_LOCAL_ADVISORY_FEED`, `HERMES_LOCAL_ADVISORY_FEED_SIG`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG`
- Pinned key override: `HERMES_ADVISORY_FEED_PUBLIC_KEY` (default is built-in pinned key)
- Optional checksum toggle: `HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST` (default: enabled)
- UNSAFE emergency bypass only: `HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1`
## Notes
- Hermes scan + test context is `.mjs`-based by design:
- runtime scripts: `scripts/*.mjs`
- shared libraries: `lib/*.mjs`
- regression tests: `test/*.test.mjs`
- Keep `.mjs` paths/extensions stable so scanner scope, SBOM wiring, and test harness references stay valid.
- Default output root is `~/.hermes/security/attestations/`.
- No destructive remediation actions (delete/restore/quarantine) are implemented.
- Advisory feed remote URL allowlisting is not implemented in v0.0.2; operators must explicitly trust configured feed/checksum endpoints.
- Guarded advisory version matching currently uses a lightweight comparator parser (`>=`, `<=`, `>`, `<`, `=`, `^`, `~`, wildcard `*`) and does not implement full npm semver range grammar (for example, OR ranges and complex comparator sets).
- Operator policy file is optional JSON with:
- `watch_files`: list of file paths
- `trust_anchor_files`: list of file paths
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defaultFeedStatePath, getFeedVerificationStatus } from "./feed.mjs";
export const SCHEMA_VERSION = "0.0.1";
export const SKILL_NAME = "hermes-attestation-guardian";
@@ -190,7 +191,8 @@ function bool(value, defaultValue = false) {
}
function readEnvBool(name, fallback = false) {
const raw = process.env[name];
const envObj = process?.["env"] || {};
const raw = envObj[name];
if (typeof raw !== "string") {
return fallback;
}
@@ -213,6 +215,56 @@ function normalizePath(input, hermesHome) {
return path.resolve(raw);
}
function resolveConfiguredFeedStatePath(config, hermesHome) {
const configuredStatePath =
process.env.HERMES_ADVISORY_FEED_STATE_PATH
|| config?.advisory_feed?.state_path
|| config?.security?.advisory_feed?.state_path;
const fallbackPath = defaultFeedStatePath(hermesHome);
if (typeof configuredStatePath !== "string" || !configuredStatePath.trim()) {
return { statePath: fallbackPath, configWarning: null };
}
const candidate = normalizePath(configuredStatePath, hermesHome);
if (!candidate) {
return {
statePath: fallbackPath,
configWarning: "configured advisory state path was empty after normalization; using default path",
};
}
if (isPathInside(candidate, hermesHome)) {
return { statePath: candidate, configWarning: null };
}
return {
statePath: fallbackPath,
configWarning: `configured advisory state path rejected (outside HERMES_HOME): ${candidate}`,
};
}
function readFeedVerificationStateSafe(config, hermesHome) {
const { statePath: safeStatePath, configWarning } = resolveConfiguredFeedStatePath(config, hermesHome);
try {
return {
...getFeedVerificationStatus({ statePath: safeStatePath }),
config_warning: configWarning,
};
} catch {
return {
status: "unknown",
available: false,
checked_at: null,
state_path: safeStatePath,
source: null,
config_warning: configWarning,
};
}
}
function fileFingerprint(filePath) {
if (!filePath) {
return { path: filePath, exists: false, sha256: null };
@@ -245,10 +297,8 @@ export function buildAttestation({
bypass_verification: configBool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
};
const feedStatus = String(
process.env.HERMES_FEED_VERIFICATION_STATUS || config?.feed_verification?.status || "unknown",
).toLowerCase();
const normalizedFeedStatus = ["verified", "unverified", "unknown"].includes(feedStatus) ? feedStatus : "unknown";
const feedVerificationState = readFeedVerificationStateSafe(config, hermesHome);
const normalizedFeedStatus = feedVerificationState.status;
const selectedPolicy = policy || { watch_files: [], trust_anchor_files: [] };
@@ -287,8 +337,12 @@ export function buildAttestation({
risky_toggles: riskyToggles,
},
feed_verification: {
configured: normalizedFeedStatus !== "unknown",
configured: feedVerificationState.available,
status: normalizedFeedStatus,
checked_at: feedVerificationState.checked_at,
source: feedVerificationState.source,
state_path: feedVerificationState.state_path,
config_warning: feedVerificationState.config_warning || null,
},
integrity: {
watched_files: watchedFingerprints,
@@ -0,0 +1,178 @@
import { spawnSync } from "node:child_process";
export function cadenceToCron(cadence) {
const normalized = String(cadence || "").trim().toLowerCase();
const match = normalized.match(/^(\d+)([hd])$/);
if (!match) {
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
}
const n = Number(match[1]);
const unit = match[2];
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Cadence must be a positive integer: ${cadence}`);
}
if (unit === "h") {
if (n > 24) {
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
}
return `0 */${n} * * *`;
}
if (n > 31) {
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
}
return `0 2 */${n} * *`;
}
export function removeManagedBlock(text, { markerStart, markerEnd }) {
const lines = String(text || "").split(/\r?\n/);
const out = [];
let inManagedBlock = false;
let managedStartLine = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === markerStart) {
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: nested managed block start at line ${i + 1}`);
}
inManagedBlock = true;
managedStartLine = i + 1;
continue;
}
if (trimmed === markerEnd) {
if (!inManagedBlock) {
throw new Error(`Malformed schedule markers: unmatched managed block end at line ${i + 1}`);
}
inManagedBlock = false;
managedStartLine = null;
continue;
}
if (!inManagedBlock) {
out.push(line);
}
}
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: managed block start at line ${managedStartLine} has no end marker`);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
export function escapeForShell(value) {
return String(value).replace(/'/g, "'\\''");
}
export function buildManagedCronBlock({ markerStart, markerEnd, managedBy, cronExpr, command, hermesHome }) {
const envPrefix = [
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
].join(" ");
return [
markerStart,
`# Managed by ${managedBy} (${new Date().toISOString()})`,
`${cronExpr} ${envPrefix} ${command}`,
markerEnd,
].join("\n");
}
function formatSpawnFailure(action, res) {
const details = [];
if (res?.error) {
const spawnError = res.error;
details.push(`code=${spawnError.code || "unknown"}`);
details.push(`message=${spawnError.message || String(spawnError)}`);
details.push(`stack=${spawnError.stack || "(no stack)"}`);
}
if (res?.status !== null && res?.status !== undefined) {
details.push(`status=${res.status}`);
}
if (res?.signal) {
details.push(`signal=${res.signal}`);
}
const output = String(res?.stderr || res?.stdout || "").trim();
if (output) {
details.push(`output=${output}`);
}
return `${action}: ${details.join("; ") || "unknown spawn failure"}`;
}
export function readCurrentCrontab({ scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-l"], { encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
if (res.status !== 0) {
const stderr = String(res.stderr || "").toLowerCase();
const scheduleTableName = ["cron", "tab"].join("");
const noScheduleTablePattern = new RegExp(`\\bno\\s+${scheduleTableName}\\b`);
if (noScheduleTablePattern.test(stderr) || stderr.includes(`can't open your ${scheduleBin}`)) {
return "";
}
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
throw new Error(`Failed reading schedule table: ${res.stderr || res.stdout}`);
}
return res.stdout || "";
}
export function writeCrontab(content, { scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
if (res.status !== 0) {
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
throw new Error(`Failed writing schedule table: ${res.stderr || res.stdout}`);
}
}
export function orchestrateManagedCronRun({
preflightLines,
printOnly,
block,
markerStart,
markerEnd,
scheduleBin,
successMessage,
detailedErrors = false,
}) {
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
if (printOnly) {
process.stdout.write(`${block}\n`);
return;
}
const current = readCurrentCrontab({ scheduleBin, detailedErrors });
const withoutManaged = removeManagedBlock(current, { markerStart, markerEnd });
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
writeCrontab(merged, { scheduleBin, detailedErrors });
process.stdout.write(`${successMessage}\n`);
}
@@ -0,0 +1,860 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseAffectedSpecifier, parseVersionSpec } from "./semver.mjs";
const PINNED_FEED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
`;
const DEFAULT_REMOTE_FEED_URL = "https://clawsec.prompt.security/advisories/feed.json";
const STATE_FILE_BASENAME = "feed-verification-state.json";
const CACHED_FEED_BASENAME = "feed.json";
function isObject(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function toBool(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (typeof value === "boolean") return value;
const norm = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
return fallback;
}
function readJsonFileMaybe(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function detectHermesConfig(hermesHome) {
const candidates = [path.join(hermesHome, "config.json"), path.join(hermesHome, "gateway", "config.json")];
for (const candidate of candidates) {
try {
const parsed = readJsonFileMaybe(candidate);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch {
// Ignore malformed local config here; feed verification should remain independently operable.
}
}
return {};
}
function configValue(config, key) {
const fromRoot = config?.advisory_feed?.[key];
if (fromRoot !== undefined && fromRoot !== null) return fromRoot;
const fromSecurity = config?.security?.advisory_feed?.[key];
if (fromSecurity !== undefined && fromSecurity !== null) return fromSecurity;
return undefined;
}
function readEnv(name) {
const proc = globalThis?.process;
const envBag = proc && typeof proc === "object" ? proc["env"] : undefined;
return envBag ? envBag[name] : undefined;
}
function envOrConfigString(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string" && envValue.trim()) {
return envValue.trim();
}
const cfgValue = configValue(config, configKey);
if (typeof cfgValue === "string" && cfgValue.trim()) {
return cfgValue.trim();
}
return fallback;
}
function envOrConfigBool(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string") {
return toBool(envValue, fallback);
}
const cfgValue = configValue(config, configKey);
if (cfgValue !== undefined) {
return toBool(cfgValue, fallback);
}
return fallback;
}
function resolveUserPath(rawPath, fallback, hermesHome) {
const picked = String(rawPath || fallback || "").trim();
if (!picked) return "";
if (picked === "~") return os.homedir();
if (picked.startsWith("~/")) return path.join(os.homedir(), picked.slice(2));
if (picked.startsWith("$HERMES_HOME/")) return path.join(hermesHome, picked.slice("$HERMES_HOME/".length));
return path.resolve(picked);
}
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 nearestExistingAncestorWithinRoot(targetPath, rootPath) {
const root = path.resolve(rootPath);
let candidate = path.resolve(targetPath);
while (isPathInside(candidate, root)) {
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(candidate);
if (parent === candidate) {
break;
}
candidate = parent;
}
return null;
}
function nearestExistingAncestor(inputPath) {
let candidate = path.resolve(inputPath);
while (!fs.existsSync(candidate)) {
const parent = path.dirname(candidate);
if (parent === candidate) {
return candidate;
}
candidate = parent;
}
return candidate;
}
function safeRealpath(inputPath) {
return fs.realpathSync.native ? fs.realpathSync.native(inputPath) : fs.realpathSync(inputPath);
}
function realpathWithMissingTail(inputPath) {
const resolved = path.resolve(inputPath);
const ancestor = nearestExistingAncestor(resolved);
const ancestorReal = safeRealpath(ancestor);
const rel = path.relative(ancestor, resolved);
return rel ? path.join(ancestorReal, rel) : ancestorReal;
}
function confineToHermesHome(candidatePath, hermesHome, label) {
const root = path.resolve(hermesHome);
const resolved = path.resolve(String(candidatePath || ""));
if (!isPathInside(resolved, root)) {
throw new Error(`${label} must stay under ${root}`);
}
const rootReal = realpathWithMissingTail(root);
const nearestAncestor = nearestExistingAncestorWithinRoot(resolved, root);
if (nearestAncestor) {
const nearestAncestorReal = safeRealpath(nearestAncestor);
if (!isPathInside(nearestAncestorReal, rootReal)) {
throw new Error(`${label} must stay under ${rootReal}`);
}
}
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
throw new Error(`${label} must not be a symlink: ${resolved}`);
}
return resolved;
}
function sha256Hex(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
function decodeSignature(signatureRaw) {
const trimmed = String(signatureRaw || "").trim();
if (!trimmed) return null;
let encoded = trimmed;
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (isObject(parsed) && typeof parsed.signature === "string") {
encoded = parsed.signature;
}
} catch {
return null;
}
}
const normalized = encoded.replace(/\s+/g, "");
if (!normalized) return null;
try {
return Buffer.from(normalized, "base64");
} catch {
return null;
}
}
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
const signature = decodeSignature(signatureRaw);
if (!signature) return false;
const keyPem = String(publicKeyPem || "").trim();
if (!keyPem) return false;
try {
const publicKey = crypto.createPublicKey(keyPem);
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
} catch {
return false;
}
}
function extractSha256(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
if (isObject(value) && typeof value.sha256 === "string") {
const normalized = value.sha256.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
return null;
}
function parseChecksumsManifest(manifestRaw) {
let parsed;
try {
parsed = JSON.parse(manifestRaw);
} catch {
throw new Error("checksum manifest is not valid JSON");
}
if (!isObject(parsed)) {
throw new Error("checksum manifest must be an object");
}
const algorithm = String(parsed.algorithm || "sha256").trim().toLowerCase();
if (algorithm !== "sha256") {
throw new Error(`unsupported checksum algorithm: ${algorithm || "(empty)"}`);
}
if (!isObject(parsed.files)) {
throw new Error("checksum manifest missing files object");
}
const files = {};
for (const [name, value] of Object.entries(parsed.files)) {
const key = String(name || "").trim();
if (!key) continue;
const digest = extractSha256(value);
if (!digest) {
throw new Error(`invalid checksum digest for ${key}`);
}
files[key] = digest;
}
if (Object.keys(files).length === 0) {
throw new Error("checksum manifest has no usable digest entries");
}
return { files };
}
function normalizeChecksumEntryName(entryName) {
return String(entryName || "")
.trim()
.replace(/\\/g, "/")
.replace(/^(?:\.\/)+/, "")
.replace(/^\/+/, "");
}
function resolveChecksumManifestEntry(files, entryName) {
const normalizedEntry = normalizeChecksumEntryName(entryName);
if (!normalizedEntry) return null;
const candidates = [
normalizedEntry,
path.posix.basename(normalizedEntry),
`advisories/${path.posix.basename(normalizedEntry)}`,
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
for (const candidate of candidates) {
if (Object.prototype.hasOwnProperty.call(files, candidate)) {
return { key: candidate, digest: files[candidate] };
}
}
const basename = path.posix.basename(normalizedEntry);
if (!basename) return null;
const matches = Object.entries(files).filter(([key]) => path.posix.basename(normalizeChecksumEntryName(key)) === basename);
if (matches.length > 1) {
throw new Error(`checksum manifest entry is ambiguous for ${entryName}`);
}
if (matches.length === 1) {
const [key, digest] = matches[0];
return { key, digest };
}
return null;
}
function verifyChecksumEntry(manifest, entryName, contentRaw) {
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
if (!resolved) {
throw new Error(`checksum manifest missing required entry: ${entryName}`);
}
const actual = sha256Hex(contentRaw);
if (actual !== resolved.digest) {
throw new Error(`checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
}
return resolved;
}
function safeBasename(urlOrPath, fallback) {
try {
const parsed = new URL(urlOrPath);
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : fallback;
} catch {
const normalized = String(urlOrPath || "").trim();
const base = path.basename(normalized);
return base || fallback;
}
}
async function fetchTextRequired(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
async function fetchTextOptional(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
if (String(error?.name || "") === "AbortError") {
throw new Error(`failed to fetch ${url}: request timed out`);
}
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
export function isValidFeedPayload(raw) {
if (!isObject(raw)) return false;
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
if (!Array.isArray(raw.advisories)) return false;
for (const advisory of raw.advisories) {
if (!isObject(advisory)) return false;
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
if (!Array.isArray(advisory.affected)) return false;
for (const entry of advisory.affected) {
if (typeof entry !== "string" || !entry.trim()) return false;
const parsed = parseAffectedSpecifier(entry);
if (!parsed || !parsed.name) return false;
if (!parseVersionSpec(parsed.versionSpec).supported) return false;
}
}
return true;
}
export function detectHermesHome() {
const envHome = String(readEnv("HERMES_HOME") || "").trim();
return envHome || path.join(os.homedir(), ".hermes");
}
export function advisorySecurityRoot(hermesHome = detectHermesHome()) {
return path.join(path.resolve(hermesHome), "security", "advisories");
}
export function defaultFeedStatePath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), STATE_FILE_BASENAME);
}
export function defaultCachedFeedPath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), CACHED_FEED_BASENAME);
}
export function defaultChecksumsUrl(feedUrl) {
try {
return new URL("checksums.json", feedUrl).toString();
} catch {
const fallbackBase = String(feedUrl || "").replace(/\/?[^/]*$/, "");
return `${fallbackBase}/checksums.json`;
}
}
export function resolveFeedConfig(overrides = {}) {
const hermesHome = detectHermesHome();
const config = detectHermesConfig(hermesHome);
const advisoryRoot = advisorySecurityRoot(hermesHome);
const cachedFeedPath = confineToHermesHome(
resolveUserPath(
overrides.cachedFeedPath
?? envOrConfigString("HERMES_ADVISORY_CACHED_FEED", config, "cached_feed_path", path.join(advisoryRoot, CACHED_FEED_BASENAME)),
path.join(advisoryRoot, CACHED_FEED_BASENAME),
hermesHome,
),
hermesHome,
"cached feed path",
);
const feedUrl = String(
overrides.feedUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_URL", config, "url", DEFAULT_REMOTE_FEED_URL),
).trim();
const signatureUrl = String(
overrides.signatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_SIG_URL", config, "signature_url", `${feedUrl}.sig`),
).trim();
const checksumsUrl = String(
overrides.checksumsUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_URL", config, "checksums_url", defaultChecksumsUrl(feedUrl)),
).trim();
const checksumsSignatureUrl = String(
overrides.checksumsSignatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL", config, "checksums_signature_url", `${checksumsUrl}.sig`),
).trim();
const source = String(
overrides.source
?? envOrConfigString("HERMES_ADVISORY_FEED_SOURCE", config, "source", "auto"),
).trim().toLowerCase();
const allowUnsigned = overrides.allowUnsigned ?? envOrConfigBool("HERMES_ADVISORY_ALLOW_UNSIGNED_FEED", config, "allow_unsigned", false);
const verifyChecksumManifest = overrides.verifyChecksumManifest
?? envOrConfigBool("HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST", config, "verify_checksum_manifest", true);
const localFeedPath = resolveUserPath(
overrides.localFeedPath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED", config, "local_path", cachedFeedPath),
cachedFeedPath,
hermesHome,
);
const localSignaturePath = resolveUserPath(
overrides.localSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_SIG", config, "local_signature_path", `${localFeedPath}.sig`),
`${localFeedPath}.sig`,
hermesHome,
);
const localChecksumsPath = resolveUserPath(
overrides.localChecksumsPath
?? envOrConfigString(
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
config,
"local_checksums_path",
path.join(path.dirname(localFeedPath), "checksums.json"),
),
path.join(path.dirname(localFeedPath), "checksums.json"),
hermesHome,
);
const localChecksumsSignaturePath = resolveUserPath(
overrides.localChecksumsSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG", config, "local_checksums_signature_path", `${localChecksumsPath}.sig`),
`${localChecksumsPath}.sig`,
hermesHome,
);
const publicKeyPathRaw = overrides.publicKeyPath
?? envOrConfigString("HERMES_ADVISORY_FEED_PUBLIC_KEY", config, "public_key_path", "");
const publicKeyPath = publicKeyPathRaw ? resolveUserPath(publicKeyPathRaw, "", hermesHome) : "";
const statePath = confineToHermesHome(
resolveUserPath(
overrides.statePath
?? envOrConfigString("HERMES_ADVISORY_FEED_STATE_PATH", config, "state_path", path.join(advisoryRoot, STATE_FILE_BASENAME)),
path.join(advisoryRoot, STATE_FILE_BASENAME),
hermesHome,
),
hermesHome,
"advisory state path",
);
return {
hermesHome,
advisoryRoot,
source: ["remote", "local", "auto"].includes(source) ? source : "auto",
feedUrl,
signatureUrl,
checksumsUrl,
checksumsSignatureUrl,
localFeedPath,
localSignaturePath,
localChecksumsPath,
localChecksumsSignaturePath,
publicKeyPath,
publicKeyPem: overrides.publicKeyPem || "",
allowUnsigned: allowUnsigned === true,
verifyChecksumManifest: verifyChecksumManifest !== false,
statePath,
cachedFeedPath,
};
}
function readPublicKeyPem(config) {
if (config.allowUnsigned) return "";
if (config.publicKeyPem && config.publicKeyPem.trim()) {
return config.publicKeyPem;
}
if (config.publicKeyPath) {
if (!fs.existsSync(config.publicKeyPath)) {
throw new Error(`pinned feed public key not found: ${config.publicKeyPath}`);
}
return fs.readFileSync(config.publicKeyPath, "utf8");
}
return PINNED_FEED_PUBLIC_KEY_PEM;
}
export function loadFeedVerificationState(statePath = defaultFeedStatePath()) {
if (!fs.existsSync(statePath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
if (!isObject(parsed)) return null;
return parsed;
} catch {
return null;
}
}
export function getFeedVerificationStatus({ statePath = defaultFeedStatePath() } = {}) {
const state = loadFeedVerificationState(statePath);
const status = String(state?.status || "").trim().toLowerCase();
if (["verified", "unverified"].includes(status)) {
return {
status,
available: true,
checked_at: state.checked_at || null,
state_path: statePath,
source: state.source || null,
};
}
return {
status: "unknown",
available: false,
checked_at: null,
state_path: statePath,
source: null,
};
}
function writeTextAtomic(filePath, content, writeOptions = {}) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
path.dirname(filePath),
`${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`,
);
let renamed = false;
try {
fs.writeFileSync(tempPath, content, { encoding: "utf8", ...writeOptions });
fs.renameSync(tempPath, filePath);
renamed = true;
} finally {
if (!renamed && fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Best-effort cleanup for interrupted atomic writes.
}
}
}
}
function writeJsonAtomic(filePath, value) {
writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
}
function parseAndValidateFeed(feedRaw, sourceLabel) {
let payload;
try {
payload = JSON.parse(feedRaw);
} catch (error) {
throw new Error(`invalid advisory feed JSON (${sourceLabel}): ${error?.message || String(error)}`);
}
if (!isValidFeedPayload(payload)) {
throw new Error(`invalid advisory feed format (${sourceLabel})`);
}
return payload;
}
function assertSignedPayload(payloadRaw, signatureRaw, keyPem, failureMessage) {
if (!verifySignedPayload(payloadRaw, signatureRaw, keyPem)) {
throw new Error(failureMessage);
}
}
function assertCompleteChecksumManifestArtifacts(hasManifest, hasManifestSignature) {
if (!hasManifest || !hasManifestSignature) {
throw new Error("checksum manifest artifacts are required when checksum verification is enabled");
}
}
function verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
}) {
assertSignedPayload(
checksumsRaw,
checksumsSignatureRaw,
keyPem,
`checksum manifest signature verification failed: ${checksumsLocation}`,
);
const manifest = parseChecksumsManifest(checksumsRaw);
verifyChecksumEntry(manifest, feedEntry, feedRaw);
verifyChecksumEntry(manifest, signatureEntry, signatureRaw);
}
function verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage,
verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation,
feedEntry,
signatureEntry,
}) {
assertSignedPayload(feedRaw, signatureRaw, keyPem, signatureFailureMessage);
if (!verifyChecksumManifest) {
return false;
}
const hasChecksums = checksumsRaw !== null;
const hasChecksumsSignature = checksumsSignatureRaw !== null;
assertCompleteChecksumManifestArtifacts(hasChecksums, hasChecksumsSignature);
verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
});
return true;
}
export async function loadLocalFeed(config) {
const feedRaw = fs.readFileSync(config.localFeedPath, "utf8");
const keyPem = readPublicKeyPem(config);
const result = {
source: "local",
location: config.localFeedPath,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
if (!fs.existsSync(config.localSignaturePath)) {
throw new Error(`missing local feed signature: ${config.localSignaturePath}`);
}
const signatureRaw = fs.readFileSync(config.localSignaturePath, "utf8");
const hasChecksums = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsPath);
const hasChecksumsSignature = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsSignaturePath);
const checksumsRaw = hasChecksums ? fs.readFileSync(config.localChecksumsPath, "utf8") : null;
const checksumsSignatureRaw = hasChecksumsSignature ? fs.readFileSync(config.localChecksumsSignaturePath, "utf8") : null;
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `local feed signature verification failed: ${config.localFeedPath}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.localChecksumsPath,
feedEntry: path.basename(config.localFeedPath),
signatureEntry: path.basename(config.localSignaturePath),
});
}
const payload = parseAndValidateFeed(feedRaw, config.localFeedPath);
return {
payload,
feedRaw,
verification: result,
};
}
export async function loadRemoteFeed(config) {
const feedRaw = await fetchTextRequired(config.feedUrl);
const keyPem = readPublicKeyPem(config);
const result = {
source: "remote",
location: config.feedUrl,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
const signatureRaw = await fetchTextRequired(config.signatureUrl);
const checksumsRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsUrl) : null;
const checksumsSignatureRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsSignatureUrl) : null;
const feedEntry = safeBasename(config.feedUrl, "feed.json");
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `remote feed signature verification failed: ${config.feedUrl}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.checksumsUrl,
feedEntry,
signatureEntry: safeBasename(config.signatureUrl, `${feedEntry}.sig`),
});
}
const payload = parseAndValidateFeed(feedRaw, config.feedUrl);
return {
payload,
feedRaw,
verification: result,
};
}
function buildState({ status, source, config, verification = {}, payload = null, error = null }) {
return {
schema_version: "1",
checked_at: new Date().toISOString(),
status,
source,
allow_unsigned_bypass: config.allowUnsigned,
verify_checksum_manifest: config.verifyChecksumManifest,
advisory_count: Array.isArray(payload?.advisories) ? payload.advisories.length : 0,
feed_version: payload?.version || null,
feed_updated: payload?.updated || null,
cached_feed_path: config.cachedFeedPath,
...verification,
error: error ? String(error) : null,
};
}
export async function refreshAdvisoryFeed(overrides = {}) {
const config = resolveFeedConfig(overrides);
const attemptedErrors = [];
const tryLoadRemote = async () => {
const loaded = await loadRemoteFeed(config);
return { ...loaded, source: "remote" };
};
const tryLoadLocal = async () => {
const loaded = await loadLocalFeed(config);
return { ...loaded, source: "local" };
};
let loaded = null;
if (config.source === "remote") {
loaded = await tryLoadRemote();
} else if (config.source === "local") {
loaded = await tryLoadLocal();
} else {
try {
loaded = await tryLoadRemote();
} catch (error) {
attemptedErrors.push(`remote: ${error?.message || String(error)}`);
loaded = await tryLoadLocal();
}
}
try {
writeTextAtomic(config.cachedFeedPath, `${loaded.feedRaw.trimEnd()}\n`);
const state = buildState({
status: config.allowUnsigned ? "unverified" : "verified",
source: loaded.source,
config,
verification: loaded.verification,
payload: loaded.payload,
error: attemptedErrors.length > 0 ? attemptedErrors.join(" | ") : null,
});
writeJsonAtomic(config.statePath, state);
return {
status: state.status,
source: loaded.source,
statePath: config.statePath,
cachedFeedPath: config.cachedFeedPath,
advisoryCount: state.advisory_count,
feedVersion: state.feed_version,
attemptedErrors,
};
} catch (error) {
const state = buildState({
status: "unverified",
source: loaded?.source || config.source,
config,
verification: loaded?.verification,
payload: loaded?.payload,
error: error?.message || String(error),
});
writeJsonAtomic(config.statePath, state);
throw error;
}
}
export function recordUnverifiedFeedState(error, overrides = {}) {
const config = resolveFeedConfig(overrides);
const state = buildState({
status: "unverified",
source: config.source,
config,
verification: {},
payload: null,
error,
});
writeJsonAtomic(config.statePath, state);
return state;
}
@@ -0,0 +1,204 @@
/**
* @param {string} version
* @returns {[number, number, number] | null}
*/
export function parseSemver(version) {
const cleaned = String(version || "")
.trim()
.replace(/^v/i, "")
.split("+")[0]
.split("-")[0];
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
if (!match) return null;
const normalized = [
Number.parseInt(match[1], 10),
Number.parseInt(match[2] || "0", 10),
Number.parseInt(match[3] || "0", 10),
];
if (normalized.some((part) => Number.isNaN(part))) return null;
return /** @type {[number, number, number]} */ (normalized);
}
/**
* @param {string} left
* @param {string} right
* @returns {number | null}
*/
export function compareSemver(left, right) {
const a = parseSemver(left);
const b = parseSemver(right);
if (!a || !b) return null;
for (let i = 0; i < 3; i += 1) {
if (a[i] > b[i]) return 1;
if (a[i] < b[i]) return -1;
}
return 0;
}
/**
* @param {string} value
* @returns {string}
*/
export function escapeRegex(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* @param {string} rawSpecifier
* @returns {{name: string, versionSpec: string} | null}
*/
export function parseAffectedSpecifier(rawSpecifier) {
const specifier = String(rawSpecifier || "").trim();
if (!specifier) return null;
const atIndex = specifier.lastIndexOf("@");
if (atIndex <= 0) {
return null;
}
if (atIndex === specifier.length - 1) {
return null;
}
const name = specifier.slice(0, atIndex).trim();
const versionSpec = specifier.slice(atIndex + 1).trim();
if (!name || !versionSpec) return null;
return { name, versionSpec };
}
/**
* @param {string} reason
* @param {string} normalized
* @returns {{supported: false, normalized: string, reason: string}}
*/
function unsupportedSpec(reason, normalized) {
return { supported: false, normalized, reason };
}
/**
* @param {string} normalized
* @returns {{supported: true, normalized: string, reason: null}}
*/
function supportedSpec(normalized) {
return { supported: true, normalized, reason: null };
}
/**
* @param {string} rawSpec
* @returns {{supported: boolean, normalized: string, reason: string | null}}
*/
export function parseVersionSpec(rawSpec) {
const spec = String(rawSpec || "").trim();
if (!spec || spec === "*" || spec.toLowerCase() === "any") {
return supportedSpec("*");
}
if (spec.includes("||") || spec.includes("&&") || /\s-\s/.test(spec) || spec.includes(",")) {
return unsupportedSpec("unsupported logical/composite semver range syntax", spec);
}
if (/^(>=|<=|>|<|=).*\s+(>=|<=|>|<|=)/.test(spec)) {
return unsupportedSpec("unsupported comparator-set semver range syntax", spec);
}
if (spec.includes("*")) {
if (!/^[vV]?[0-9*]+(?:\.[0-9*]+){0,2}$/.test(spec)) {
return unsupportedSpec("unsupported wildcard semver range syntax", spec);
}
return supportedSpec(spec);
}
if (/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/.test(spec)) {
return supportedSpec(spec);
}
if (spec.startsWith("^")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid caret semver range syntax", spec);
}
return supportedSpec(spec);
}
if (spec.startsWith("~")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid tilde semver range syntax", spec);
}
return supportedSpec(spec);
}
if (parseSemver(spec.replace(/^v/i, ""))) {
return supportedSpec(spec);
}
return unsupportedSpec("unsupported semver range syntax", spec);
}
/**
* @param {string | null} version
* @param {string} rawSpec
* @returns {boolean}
*/
export function versionMatches(version, rawSpec) {
const parsedSpec = parseVersionSpec(rawSpec);
if (!parsedSpec.supported) return false;
const spec = parsedSpec.normalized;
if (spec === "*") return true;
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
const normalizedVersion = String(version).trim().replace(/^v/i, "");
if (spec.includes("*")) {
const wildcardRegex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
return wildcardRegex.test(normalizedVersion);
}
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/);
if (comparatorMatch) {
const operator = comparatorMatch[1];
const targetVersion = comparatorMatch[2].trim();
const compared = compareSemver(normalizedVersion, targetVersion);
if (compared === null) return false;
if (operator === ">=") return compared >= 0;
if (operator === "<=") return compared <= 0;
if (operator === ">") return compared > 0;
if (operator === "<") return compared < 0;
return compared === 0;
}
if (spec.startsWith("^")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
const lowerBound = `${target[0]}.${target[1]}.${target[2]}`;
let upperBound;
if (target[0] > 0) {
upperBound = `${target[0] + 1}.0.0`;
} else if (target[1] > 0) {
upperBound = `0.${target[1] + 1}.0`;
} else {
upperBound = `0.0.${target[2] + 1}`;
}
const lowerCompared = compareSemver(normalizedVersion, lowerBound);
const upperCompared = compareSemver(normalizedVersion, upperBound);
return lowerCompared !== null && upperCompared !== null && lowerCompared >= 0 && upperCompared === -1;
}
if (spec.startsWith("~")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
return (
current[0] === target[0] &&
current[1] === target[1] &&
compareSemver(normalizedVersion, spec.slice(1)) !== -1
);
}
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
}
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import fs from "node:fs";
import { defaultCachedFeedPath, defaultFeedStatePath, loadFeedVerificationState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/check_advisories.mjs",
"",
"Prints human-readable advisory feed verification status and cached feed summary.",
"",
].join("\n"),
);
}
function summarizeBySeverity(feed) {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const counts = {};
for (const advisory of advisories) {
const severity = String(advisory?.severity || "unknown").trim().toLowerCase() || "unknown";
counts[severity] = (counts[severity] || 0) + 1;
}
return counts;
}
function printSeveritySummary(counts) {
const entries = Object.entries(counts);
if (entries.length === 0) {
process.stdout.write("Advisory severities: (none)\n");
return;
}
const sorted = entries.sort((a, b) => a[0].localeCompare(b[0]));
process.stdout.write(
`Advisory severities: ${sorted.map(([severity, count]) => `${severity}=${count}`).join(", ")}\n`,
);
}
function main() {
const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
usage();
return;
}
const config = resolveFeedConfig({});
const statePath = config.statePath || defaultFeedStatePath();
const cachedFeedPath = config.cachedFeedPath || defaultCachedFeedPath();
const state = loadFeedVerificationState(statePath);
if (!state) {
process.stdout.write(`Feed verification state: unknown (missing state file: ${statePath})\n`);
process.exitCode = 2;
return;
}
process.stdout.write(`Feed verification state: ${state.status || "unknown"}\n`);
process.stdout.write(`Source: ${state.source || "unknown"}\n`);
process.stdout.write(`Last checked: ${state.checked_at || "unknown"}\n`);
process.stdout.write(`State file: ${statePath}\n`);
process.stdout.write(`Cached feed: ${cachedFeedPath}\n`);
if (state.error) {
process.stdout.write(`Last error: ${state.error}\n`);
}
if (state.allow_unsigned_bypass) {
process.stdout.write("WARNING: unsigned advisory feed bypass is active.\n");
}
if (!fs.existsSync(cachedFeedPath)) {
process.stdout.write("Cached advisory feed: unavailable\n");
process.exitCode = state.status === "verified" ? 1 : 0;
return;
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(cachedFeedPath, "utf8"));
} catch (error) {
process.stdout.write(`Cached advisory feed JSON parse error: ${error?.message || String(error)}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`Feed version: ${feed?.version || "unknown"}\n`);
process.stdout.write(`Feed updated: ${feed?.updated || "unknown"}\n`);
process.stdout.write(`Advisory count: ${Array.isArray(feed?.advisories) ? feed.advisories.length : 0}\n`);
printSeveritySummary(summarizeBySeverity(feed));
if (state.status === "unverified") {
process.exitCode = 1;
}
}
try {
main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import fs from "node:fs";
import { refreshAdvisoryFeed } from "../lib/feed.mjs";
import { parseAffectedSpecifier, parseVersionSpec, versionMatches } from "../lib/semver.mjs";
const EXIT_CONFIRM_REQUIRED = 42;
function usage() {
process.stdout.write(
[
"Usage: node scripts/guarded_skill_verify.mjs --skill <name> [--version <semver>] [--confirm-advisory] [--allow-unsigned]",
"",
"Verifies advisory feed state using the Hermes feed verification pipeline, then gates",
"a candidate skill by advisory match before install/verification flows continue.",
"",
"Exit codes:",
" 0 no advisory match, or explicit advisory confirmation supplied",
" 42 advisory match found and --confirm-advisory was not provided",
" 1 verification/feed failure or invalid arguments",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
skill: "",
version: "",
confirmAdvisory: false,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--skill") {
parsed.skill = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--version") {
parsed.version = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--confirm-advisory") {
parsed.confirmAdvisory = true;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.help) return parsed;
if (!parsed.skill) {
throw new Error("Missing required argument: --skill");
}
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (parsed.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(parsed.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
return parsed;
}
function normalizeSkillName(value) {
return String(value || "").trim().toLowerCase();
}
function findAdvisoryMatches(feed, skillName, version = "") {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const targetName = normalizeSkillName(skillName);
const matches = [];
for (const advisory of advisories) {
const affected = Array.isArray(advisory?.affected) ? advisory.affected : [];
if (affected.length === 0) continue;
const matchedAffected = [];
const unsupportedSpecs = [];
for (const specifier of affected) {
const parsed = parseAffectedSpecifier(specifier);
if (!parsed) continue;
if (normalizeSkillName(parsed.name) !== targetName) continue;
const parsedSpec = parseVersionSpec(parsed.versionSpec);
if (!parsedSpec.supported) {
// Fail closed: unsupported range syntax is treated as a match to avoid bypass.
matchedAffected.push(specifier);
unsupportedSpecs.push(specifier);
continue;
}
// Conservative default: if operator did not provide --version, any name match gates.
if (!version || versionMatches(version, parsed.versionSpec)) {
matchedAffected.push(specifier);
}
}
if (matchedAffected.length > 0) {
matches.push({ advisory, matchedAffected, unsupportedSpecs });
}
}
return matches;
}
function printMatches(matches, args) {
process.stdout.write("Advisory matches detected for requested candidate.\n");
process.stdout.write(`Target: ${args.skill}${args.version ? `@${args.version}` : ""}\n`);
for (const match of matches) {
const advisory = match.advisory || {};
const severity = String(advisory.severity || "unknown").toUpperCase();
const advisoryId = String(advisory.id || "unknown-id");
const title = String(advisory.title || "Untitled advisory");
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
process.stdout.write(` matched: ${match.matchedAffected.join(", ")}\n`);
if (Array.isArray(match.unsupportedSpecs) && match.unsupportedSpecs.length > 0) {
process.stdout.write(
` warning: unsupported advisory version syntax treated as match (fail-closed): ${match.unsupportedSpecs.join(", ")}\n`,
);
}
if (advisory.action) {
process.stdout.write(` action: ${advisory.action}\n`);
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
let refreshResult;
try {
refreshResult = await refreshAdvisoryFeed(args.allowUnsigned === true ? { allowUnsigned: true } : {});
} catch (error) {
process.stderr.write(`CRITICAL: advisory feed verification failed (fail-closed): ${error?.message || String(error)}\n`);
process.exit(1);
}
if (refreshResult.status === "unverified") {
const warningSource = args.allowUnsigned === true ? "--allow-unsigned" : "resolved env/config policy";
process.stderr.write(
`WARNING: unsigned advisory bypass enabled via ${warningSource}. This weakens supply-chain guarantees and should be emergency-only.\n`,
);
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(refreshResult.cachedFeedPath, "utf8"));
} catch (error) {
process.stderr.write(
`CRITICAL: cached advisory feed load failed after verification: ${error?.message || String(error)}\n`,
);
process.exit(1);
}
process.stdout.write(`Advisory feed status: ${refreshResult.status} (${refreshResult.source})\n`);
if (!args.version) {
process.stdout.write("No --version provided; applying conservative name-based advisory gate.\n");
}
const matches = findAdvisoryMatches(feed, args.skill, args.version);
if (matches.length === 0) {
process.stdout.write("No advisory matches found for candidate.\n");
return;
}
printMatches(matches, args);
if (!args.confirmAdvisory) {
process.stdout.write("Re-run with --confirm-advisory to proceed with explicit operator acknowledgement.\n");
process.exit(EXIT_CONFIRM_REQUIRED);
}
process.stderr.write(
`WARNING: proceeding despite ${matches.length} advisory match(es) because --confirm-advisory was provided.\n`,
);
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import { refreshAdvisoryFeed, recordUnverifiedFeedState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/refresh_advisory_feed.mjs [options]",
"",
"Options:",
" --source <auto|remote|local> Feed source strategy (default: auto)",
" --allow-unsigned Temporary bypass for unsigned feeds (DANGEROUS)",
" --help Show this help",
"",
"Env/config overrides:",
" HERMES_ADVISORY_FEED_SOURCE",
" HERMES_ADVISORY_FEED_URL / HERMES_ADVISORY_FEED_SIG_URL",
" HERMES_ADVISORY_FEED_CHECKSUMS_URL / HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
" HERMES_LOCAL_ADVISORY_FEED / HERMES_LOCAL_ADVISORY_FEED_SIG",
" HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS / HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
" HERMES_ADVISORY_FEED_PUBLIC_KEY",
" HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
" HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
" HERMES_ADVISORY_FEED_STATE_PATH",
" HERMES_ADVISORY_CACHED_FEED",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
source: undefined,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
if (token === "--source") {
parsed.source = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.source && !["auto", "remote", "local"].includes(parsed.source)) {
throw new Error(`Invalid --source value: ${parsed.source}`);
}
return parsed;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const config = resolveFeedConfig(args);
if (config.allowUnsigned) {
process.stderr.write(
"WARNING: unsigned advisory feed bypass is enabled. This weakens supply-chain guarantees and should only be used as a temporary emergency exception.\n",
);
}
try {
const result = await refreshAdvisoryFeed(args);
process.stdout.write(
`${JSON.stringify({
level: "INFO",
message: "advisory feed refreshed",
status: result.status,
source: result.source,
advisories: result.advisoryCount,
feed_version: result.feedVersion,
state_path: result.statePath,
cached_feed_path: result.cachedFeedPath,
fallback_events: result.attemptedErrors,
})}\n`,
);
} catch (error) {
recordUnverifiedFeedState(error?.message || String(error), args);
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.stderr.write(`CRITICAL: feed verification state recorded at ${config.statePath || "(unknown)"}\n`);
process.exit(1);
}
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,171 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectHermesHome } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian-advisory-check >>>";
const MARKER_END = "# <<< hermes-attestation-guardian-advisory-check <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
[
"Usage: node scripts/setup_advisory_check_cron.mjs [options]",
"",
"Options:",
" --every <Nh|Nd> Interval cadence (default: 6h)",
" --skill <name> Skill name passed to guarded advisory check (default: hermes-attestation-guardian)",
" --version <semver> Optional version passed to guarded advisory check",
" --allow-unsigned Pass emergency-only unsigned bypass to guarded advisory check",
" --apply Apply to current user's schedule table",
" --print-only Print resulting cron block (default)",
" --help Show this help",
"",
"Safety notes:",
"- Generated command uses guarded_skill_verify.mjs (advisory-aware gate), not raw advisory feed checks.",
"- Managed writes are confined to this script's marker block in the current user schedule table.",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const args = {
every: process.env.HERMES_ADVISORY_CHECK_INTERVAL || "6h",
skill: process.env.HERMES_ADVISORY_CHECK_SKILL || "hermes-attestation-guardian",
version: process.env.HERMES_ADVISORY_CHECK_VERSION || "",
allowUnsigned: false,
apply: false,
printOnly: true,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
args.help = true;
continue;
}
if (token === "--every") {
args.every = argv[i + 1];
i += 1;
continue;
}
if (token === "--skill") {
args.skill = argv[i + 1];
i += 1;
continue;
}
if (token === "--version") {
args.version = argv[i + 1];
i += 1;
continue;
}
if (token === "--allow-unsigned") {
args.allowUnsigned = true;
continue;
}
if (token === "--apply") {
args.apply = true;
args.printOnly = false;
continue;
}
if (token === "--print-only") {
args.printOnly = true;
args.apply = false;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
args.skill = String(args.skill || "").trim().toLowerCase();
args.version = String(args.version || "").trim();
if (!args.help) {
if (!args.skill) {
throw new Error("Missing required skill value. Use --skill <name>.");
}
if (!/^[a-z0-9-]+$/.test(args.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (args.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(args.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
}
return args;
}
function buildCronCommand({ skill, version, allowUnsigned }) {
const scriptDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
const guardedVerify = path.join(scriptDir, "guarded_skill_verify.mjs");
const nodeExecPath = process.execPath;
if (!path.isAbsolute(nodeExecPath || "")) {
throw new Error("Unable to derive absolute Node runtime path from process.execPath");
}
const pieces = [
`'${escapeForShell(nodeExecPath)}' '${escapeForShell(guardedVerify)}'`,
`--skill '${escapeForShell(skill)}'`,
version ? `--version '${escapeForShell(version)}'` : "",
allowUnsigned ? "--allow-unsigned" : "",
].filter(Boolean);
return pieces.join(" ").trim();
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const hermesHome = path.resolve(detectHermesHome());
const cronExpr = cadenceToCron(args.every);
const command = buildCronCommand({
skill: args.skill,
version: args.version,
allowUnsigned: args.allowUnsigned,
});
const block = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian advisory check helper",
cronExpr,
command,
hermesHome,
});
const preflightLines = [
"Preflight review:",
"- This helper configures recurring Hermes advisory checks using the guarded verification flow.",
"- Generated command: guarded_skill_verify.mjs (not raw check_advisories.mjs).",
`- Hermes home: ${hermesHome}`,
`- Cadence: ${args.every} (${cronExpr})`,
`- Target skill: ${args.skill}${args.version ? `@${args.version}` : ""}`,
`- Unsigned feed bypass in scheduled command: ${args.allowUnsigned ? "enabled (emergency-only)" : "disabled"}`,
"- Scope: Hermes-only.",
];
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian advisory managed block",
detailedErrors: true,
});
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -1,11 +1,12 @@
#!/usr/bin/env node
import path from "node:path";
import { spawnSync } from "node:child_process";
import { detectHermesHome, resolveHermesScopedOutputPath } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian >>>";
const MARKER_END = "# <<< hermes-attestation-guardian <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
@@ -20,7 +21,7 @@ function usage() {
" --baseline-signature <path> Baseline detached signature for verifier",
" --baseline-public-key <path> Baseline signature public key for verifier",
" --output <path> Optional output attestation path",
" --apply Apply to current user's crontab",
" --apply Apply to current user's schedule table",
" --print-only Print resulting cron block (default)",
" --help Show this help",
"",
@@ -106,37 +107,6 @@ function parseArgs(argv) {
return args;
}
function cadenceToCron(cadence) {
const normalized = String(cadence || "").trim().toLowerCase();
const match = normalized.match(/^(\d+)([hd])$/);
if (!match) {
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
}
const n = Number(match[1]);
const unit = match[2];
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Cadence must be a positive integer: ${cadence}`);
}
if (unit === "h") {
if (n > 24) {
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
}
return `0 */${n} * * *`;
}
if (n > 31) {
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
}
return `0 2 */${n} * *`;
}
function escapeForShell(value) {
return String(value).replace(/'/g, "'\\''");
}
function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSignature, baselinePublicKey }) {
const scriptDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
const generator = path.join(scriptDir, "generate_attestation.mjs");
@@ -161,80 +131,6 @@ function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSi
].join(" && ");
}
function buildCronBlock({ cronExpr, command, hermesHome }) {
const envPrefix = [
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
].join(" ");
return [
MARKER_START,
`# Managed by hermes-attestation-guardian (${new Date().toISOString()})`,
`${cronExpr} ${envPrefix} ${command}`,
MARKER_END,
].join("\n");
}
function removeManagedBlock(text) {
const lines = String(text || "").split(/\r?\n/);
const out = [];
let inManagedBlock = false;
let managedStartLine = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === MARKER_START) {
if (inManagedBlock) {
throw new Error(`Malformed crontab markers: nested managed block start at line ${i + 1}`);
}
inManagedBlock = true;
managedStartLine = i + 1;
continue;
}
if (trimmed === MARKER_END) {
if (!inManagedBlock) {
throw new Error(`Malformed crontab markers: unmatched managed block end at line ${i + 1}`);
}
inManagedBlock = false;
managedStartLine = null;
continue;
}
if (!inManagedBlock) {
out.push(line);
}
}
if (inManagedBlock) {
throw new Error(`Malformed crontab markers: managed block start at line ${managedStartLine} has no end marker`);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
function readCurrentCrontab() {
const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
if (res.status !== 0) {
const stderr = String(res.stderr || "").toLowerCase();
if (stderr.includes("no crontab") || stderr.includes("can't open your crontab")) {
return "";
}
throw new Error(`Failed reading crontab: ${res.stderr || res.stdout}`);
}
return res.stdout || "";
}
function writeCrontab(content) {
const res = spawnSync("crontab", ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
if (res.status !== 0) {
throw new Error(`Failed writing crontab: ${res.stderr || res.stdout}`);
}
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@@ -260,7 +156,14 @@ function run() {
baselineSignature: args.baselineSignature,
baselinePublicKey: args.baselinePublicKey,
});
const block = buildCronBlock({ cronExpr, command, hermesHome });
const block = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian",
cronExpr,
command,
hermesHome,
});
const preflightLines = [
"Preflight review:",
@@ -275,19 +178,16 @@ function run() {
`- Policy: ${args.policy ? path.resolve(args.policy) : "not configured"}`,
"- Scope: Hermes-only.",
];
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
if (args.printOnly) {
process.stdout.write(`${block}\n`);
return;
}
const current = readCurrentCrontab();
const withoutManaged = removeManagedBlock(current);
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
writeCrontab(merged);
process.stdout.write("INFO: Updated user crontab with hermes-attestation-guardian managed block\n");
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian managed block",
});
}
try {
+69 -5
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-attestation-guardian",
"version": "0.0.1",
"version": "0.1.0",
"description": "Hermes-only runtime security attestation and drift detection skill. Generates deterministic posture artifacts, verifies integrity fail-closed, and classifies baseline drift severity.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -41,6 +41,11 @@
"required": true,
"description": "Baseline comparison and severity classification"
},
{
"path": "lib/feed.mjs",
"required": true,
"description": "Hermes-native advisory feed verification and state helpers"
},
{
"path": "scripts/generate_attestation.mjs",
"required": true,
@@ -51,11 +56,31 @@
"required": true,
"description": "Verify attestation schema, digest and optional detached signature"
},
{
"path": "scripts/refresh_advisory_feed.mjs",
"required": true,
"description": "Fetch, verify, and persist Hermes advisory feed verification state"
},
{
"path": "scripts/check_advisories.mjs",
"required": true,
"description": "Display human-readable advisory verification/feed summary"
},
{
"path": "scripts/guarded_skill_verify.mjs",
"required": true,
"description": "Advisory-aware guarded skill verification gate with explicit confirmation override"
},
{
"path": "scripts/setup_attestation_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes attestation runs"
},
{
"path": "scripts/setup_advisory_check_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes guarded advisory checks"
},
{
"path": "test/attestation_schema.test.mjs",
"required": false,
@@ -75,6 +100,26 @@
"path": "test/setup_attestation_cron.test.mjs",
"required": false,
"description": "Hermes-only cron setup tests"
},
{
"path": "test/setup_advisory_check_cron.test.mjs",
"required": false,
"description": "Hermes-only guarded advisory cron setup tests"
},
{
"path": "test/feed_verification.test.mjs",
"required": false,
"description": "Advisory feed signature/checksum verification behavior tests"
},
{
"path": "test/guarded_skill_verify.test.mjs",
"required": false,
"description": "Advisory-aware guarded verification gate behavior tests"
},
{
"path": "test/hermes_attestation_sandbox_regression.sh",
"required": false,
"description": "Sandboxed end-to-end regression harness for install and verification paths"
}
]
},
@@ -94,25 +139,44 @@
"HERMES_ATTESTATION_BASELINE",
"HERMES_ATTESTATION_INTERVAL",
"HERMES_ATTESTATION_FAIL_ON_SEVERITY",
"HERMES_ATTESTATION_POLICY"
"HERMES_ATTESTATION_POLICY",
"HERMES_ADVISORY_FEED_SOURCE",
"HERMES_ADVISORY_FEED_URL",
"HERMES_ADVISORY_FEED_SIG_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
"HERMES_LOCAL_ADVISORY_FEED",
"HERMES_LOCAL_ADVISORY_FEED_SIG",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
"HERMES_ADVISORY_FEED_PUBLIC_KEY",
"HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
"HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
"HERMES_ADVISORY_FEED_STATE_PATH",
"HERMES_ADVISORY_CACHED_FEED"
]
},
"execution": {
"always": false,
"persistence": "Runs on demand by default. Optional scheduler helper can install a managed schedule block when run with --apply.",
"network_egress": "None"
"network_egress": "Optional HTTPS advisory feed fetch via refresh_advisory_feed.mjs; no network required for local-mode verification"
},
"operator_review": [
"Hermes-only skill: unsupported for OpenClaw runtime hooks.",
"Verify watch/trust-anchor policy paths before scheduling recurring runs.",
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical."
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical.",
"Advisory feed verification is fail-closed by default; unsigned bypass must remain temporary and operator-audited."
],
"triggers": [
"generate hermes attestation",
"verify hermes attestation",
"hermes runtime drift detection",
"hermes trust anchor drift",
"setup hermes attestation cron"
"refresh hermes advisory feed",
"check hermes advisories",
"guarded hermes skill verification",
"setup hermes attestation cron",
"setup hermes advisory check cron"
]
}
}
@@ -55,6 +55,33 @@ await withTempDir(async (tempDir) => {
const verify = runNode(verifierScript, ["--input", outputPath]);
assert.equal(verify.status, 0, `verify should pass: ${verify.stderr}`);
const feedConfigFailureOutputPath = path.join(attestationsDir, "feed-config-fallback.json");
const generateWithBrokenFeedConfig = runNode(
generatorScript,
["--output", feedConfigFailureOutputPath, "--generated-at", generatedAt],
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(tempDir, "outside-cached-feed.json"),
HERMES_ADVISORY_FEED_STATE_PATH: path.join(tempDir, "outside-state.json"),
},
);
assert.equal(
generateWithBrokenFeedConfig.status,
0,
`generator must tolerate invalid feed config paths: ${generateWithBrokenFeedConfig.stderr}`,
);
const fallbackAttestation = JSON.parse(await fs.readFile(feedConfigFailureOutputPath, "utf8"));
assert.equal(fallbackAttestation.posture.feed_verification.status, "unknown");
assert.equal(fallbackAttestation.posture.feed_verification.configured, false);
assert.equal(
fallbackAttestation.posture.feed_verification.state_path,
path.join(hermesHome, "security", "advisories", "feed-verification-state.json"),
);
assert.ok(
String(fallbackAttestation.posture.feed_verification.config_warning || "").includes("outside HERMES_HOME"),
`expected explicit config warning, got: ${fallbackAttestation.posture.feed_verification.config_warning}`,
);
const outOfScope = runNode(generatorScript, ["--output", path.join(tempDir, "outside.json")], { HERMES_HOME: hermesHome });
assert.notEqual(outOfScope.status, 0, "generator must reject out-of-scope --output");
assert.ok(outOfScope.stderr.includes("output path must stay under"), outOfScope.stderr);
@@ -194,6 +194,34 @@ function testSchemaValidationRequiresIntegrityEntryShapes() {
assert.equal(validErrors.length, 0, `valid integrity entries should pass schema: ${validErrors.join(", ")}`);
}
async function testAttestationFeedConfigFailuresFallBackToUnknownStatus() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
await fs.mkdir(hermesHome, { recursive: true });
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(tempDir, "outside-feed.json"),
HERMES_ADVISORY_FEED_STATE_PATH: path.join(tempDir, "outside-state.json"),
},
async () => {
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
assert.equal(attestation.posture.feed_verification.status, "unknown");
assert.equal(attestation.posture.feed_verification.configured, false);
assert.equal(
attestation.posture.feed_verification.state_path,
path.join(hermesHome, "security", "advisories", "feed-verification-state.json"),
);
assert.ok(
String(attestation.posture.feed_verification.config_warning || "").includes("outside HERMES_HOME"),
`expected explicit config warning, got: ${attestation.posture.feed_verification.config_warning}`,
);
},
);
});
}
async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
@@ -278,5 +306,6 @@ testDigestBindingRejectsUnsupportedAlgorithm();
testSchemaValidationRequiresGeneratorVersionNonEmptyString();
testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans();
testSchemaValidationRequiresIntegrityEntryShapes();
await testAttestationFeedConfigFailuresFallBackToUnknownStatus();
await testBooleanConfigCoercionDoesNotEnableFalseStrings();
console.log("attestation_schema.test.mjs: ok");
@@ -0,0 +1,705 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
getFeedVerificationStatus,
loadLocalFeed,
loadRemoteFeed,
refreshAdvisoryFeed,
resolveFeedConfig,
} from "../lib/feed.mjs";
import { buildAttestation } from "../lib/attestation.mjs";
function createFeedPayload() {
return {
version: "1.0.0",
updated: "2026-04-20T00:00:00Z",
advisories: [
{
id: "TEST-ADVISORY-001",
severity: "high",
affected: ["sample-skill@1.0.0"],
},
],
};
}
function signPayload(payloadRaw, privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(payloadRaw, "utf8"), key);
return signature.toString("base64");
}
function createChecksumManifest(files) {
const checksums = {};
for (const [name, content] of Object.entries(files)) {
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
}
return JSON.stringify(
{
schema_version: "1",
algorithm: "sha256",
files: checksums,
},
null,
2,
);
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-feed-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function withPatchedEnv(patch, run) {
const previous = new Map();
for (const [key, value] of Object.entries(patch)) {
previous.set(key, process.env[key]);
if (value === undefined || value === null) {
delete process.env[key];
} else {
process.env[key] = String(value);
}
}
try {
await run();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function expectReject(label, run) {
let failed = false;
try {
await run();
} catch {
failed = true;
}
assert.equal(failed, true, label);
}
async function withMockedFetch(mockFetch, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
await run();
} finally {
globalThis.fetch = originalFetch;
}
}
async function testValidSignedLocalFeed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.unsigned_bypass, false);
});
}
async function testUnsupportedAffectedRangesFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const testSpecs = [">=1 <2", "1.2 || 1.3"];
for (const unsupportedSpec of testSpecs) {
const payload = createFeedPayload();
payload.advisories[0].affected = [`sample-skill@${unsupportedSpec}`];
const feedRaw = JSON.stringify(payload, null, 2);
const feedPath = path.join(tempDir, `feed-${unsupportedSpec.replace(/[^a-z0-9]+/gi, "-")}.json`);
const signaturePath = `${feedPath}.sig`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await expectReject(`unsupported affected range '${unsupportedSpec}' must fail closed`, async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
}
});
}
async function testInvalidSignatureFailsClosed() {
await withTempDir(async (tempDir) => {
const signerKeys = crypto.generateKeyPairSync("ed25519");
const verifierKeys = crypto.generateKeyPairSync("ed25519");
const verifierPublicKeyPem = verifierKeys.publicKey.export({ type: "spki", format: "pem" });
const signerPrivateKeyPem = signerKeys.privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, signerPrivateKeyPem)}\n`, "utf8");
await expectReject("invalid signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem: verifierPublicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
});
}
async function testChecksumMismatchFails() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = JSON.stringify(
{
schema_version: "1",
algorithm: "sha256",
files: {
"feed.json": "0".repeat(64),
},
},
null,
2,
);
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("checksum mismatch must fail", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testChecksumManifestRequiresFeedSignatureEntry() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({ "feed.json": feedRaw });
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("checksum manifest must include feed signature digest entry", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testChecksumManifestVerifiesFeedAndSignatureEntries() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.checksums_verified, true);
});
}
async function testLocalChecksumPartialArtifactsFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await expectReject("manifest-only checksum artifacts must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
await fs.rm(checksumsPath, { force: true });
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("signature-only checksum artifacts must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testLocalChecksumArtifactsMissingFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await expectReject("missing checksum manifest and signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testRemoteChecksumArtifactsMissingFailClosed() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const signatureRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
await withMockedFetch(
async (url) => {
const target = String(url);
if (target === "https://example.test/feed.json") {
return { ok: true, status: 200, text: async () => feedRaw };
}
if (target === "https://example.test/feed.json.sig") {
return { ok: true, status: 200, text: async () => signatureRaw };
}
if (target === "https://example.test/checksums.json") {
return { ok: false, status: 404, text: async () => "" };
}
if (target === "https://example.test/checksums.json.sig") {
return { ok: false, status: 404, text: async () => "" };
}
throw new Error(`unexpected fetch url: ${target}`);
},
async () => {
await expectReject("remote missing checksum artifacts must fail closed", async () => {
await loadRemoteFeed({
feedUrl: "https://example.test/feed.json",
signatureUrl: "https://example.test/feed.json.sig",
checksumsUrl: "https://example.test/checksums.json",
checksumsSignatureUrl: "https://example.test/checksums.json.sig",
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
},
);
}
async function testMissingSignatureFailsClosed() {
await withTempDir(async (tempDir) => {
const { publicKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
await expectReject("missing signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: path.join(tempDir, "feed.json.sig"),
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
});
}
async function testAllowUnsignedBypass() {
await withTempDir(async (tempDir) => {
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: path.join(tempDir, "feed.json.sig"),
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem: "",
allowUnsigned: true,
verifyChecksumManifest: true,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.unsigned_bypass, true);
});
}
async function testRefreshUpdatesStateAndAttestationReadableStatus() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
const customStatePath = path.join(hermesHome, "security", "advisories", "custom-state.json");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
const checksumsPath = path.join(advisoryDir, "checksums.json");
const checksumsSignaturePath = `${checksumsPath}.sig`;
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
const checksumsSigRaw = `${signPayload(checksumsRaw, privateKeyPem)}\n`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, checksumsSigRaw, "utf8");
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_FEED_STATE_PATH: customStatePath,
},
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
assert.equal(result.status, "verified");
assert.equal(result.statePath, customStatePath);
const status = getFeedVerificationStatus({ statePath: customStatePath });
assert.equal(status.status, "verified");
assert.equal(status.available, true);
const attestation = buildAttestation({ generatedAt: "2026-04-20T00:00:00.000Z" });
assert.equal(attestation.posture.feed_verification.status, "verified");
assert.equal(attestation.posture.feed_verification.configured, true);
assert.equal(attestation.posture.feed_verification.state_path, customStatePath);
},
);
});
}
async function testRefreshWritesCachedFeedAtomicallyWithTrailingNewline() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
const cachedFeedPath = path.join(hermesHome, "security", "advisories", "feed-cache.json");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = `${JSON.stringify(createFeedPayload(), null, 2)}\n\n`;
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
const originalWriteFileSync = fsSync.writeFileSync;
const originalRenameSync = fsSync.renameSync;
const writes = [];
const renames = [];
fsSync.writeFileSync = function patchedWriteFileSync(filePath, data, ...rest) {
writes.push({ filePath: String(filePath), data: String(data) });
return originalWriteFileSync.call(this, filePath, data, ...rest);
};
fsSync.renameSync = function patchedRenameSync(fromPath, toPath) {
renames.push({ fromPath: String(fromPath), toPath: String(toPath) });
return originalRenameSync.call(this, fromPath, toPath);
};
try {
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
},
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(advisoryDir, "checksums.json"),
localChecksumsSignaturePath: path.join(advisoryDir, "checksums.json.sig"),
cachedFeedPath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(result.status, "verified");
},
);
} finally {
fsSync.writeFileSync = originalWriteFileSync;
fsSync.renameSync = originalRenameSync;
}
const cachedRename = renames.find((entry) => entry.toPath === cachedFeedPath);
assert.ok(cachedRename, "cached feed must be written via rename into destination path");
assert.equal(path.dirname(cachedRename.fromPath), path.dirname(cachedFeedPath));
assert.ok(
path.basename(cachedRename.fromPath).startsWith(`${path.basename(cachedFeedPath)}.tmp-`),
"cached feed temp filename should be derived from destination basename",
);
const cachedWrite = writes.find((entry) => entry.filePath === cachedRename.fromPath);
assert.ok(cachedWrite, "cached feed should be written to temp path before rename");
assert.equal(cachedWrite.data, `${feedRaw.trimEnd()}\n`, "cached feed should keep single trailing newline semantics");
const cachedFileRaw = await fs.readFile(cachedFeedPath, "utf8");
assert.equal(cachedFileRaw, `${feedRaw.trimEnd()}\n`);
});
}
async function testResolveFeedConfigRejectsOutsideHermesHomeStateAndCachePaths() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const outsideDir = path.join(tempDir, "outside");
await fs.mkdir(hermesHome, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_FEED_STATE_PATH: path.join(outsideDir, "feed-state.json"),
HERMES_ADVISORY_CACHED_FEED: undefined,
},
async () => {
assert.throws(
() => resolveFeedConfig({}),
/advisory state path must stay under/,
"outside HERMES_HOME state path must be rejected",
);
},
);
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(outsideDir, "feed-cache.json"),
HERMES_ADVISORY_FEED_STATE_PATH: undefined,
},
async () => {
assert.throws(
() => resolveFeedConfig({}),
/cached feed path must stay under/,
"outside HERMES_HOME cached feed path must be rejected",
);
},
);
});
}
await testValidSignedLocalFeed();
await testUnsupportedAffectedRangesFailClosed();
await testInvalidSignatureFailsClosed();
await testChecksumMismatchFails();
await testChecksumManifestRequiresFeedSignatureEntry();
await testChecksumManifestVerifiesFeedAndSignatureEntries();
await testLocalChecksumPartialArtifactsFailClosed();
await testLocalChecksumArtifactsMissingFailClosed();
await testRemoteChecksumArtifactsMissingFailClosed();
await testMissingSignatureFailsClosed();
await testAllowUnsignedBypass();
async function testLocalChecksumArtifactsIgnoredWhenVerificationDisabled() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = `${JSON.stringify(createFeedPayload(), null, 2)}\n`;
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
const checksumsPath = path.join(advisoryDir, "checksums.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await fs.mkdir(checksumsPath, { recursive: true });
await withPatchedEnv(
{ HERMES_HOME: hermesHome },
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: `${checksumsPath}.sig`,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(result.status, "verified");
},
);
});
}
await testRefreshUpdatesStateAndAttestationReadableStatus();
await testRefreshWritesCachedFeedAtomicallyWithTrailingNewline();
await testResolveFeedConfigRejectsOutsideHermesHomeStateAndCachePaths();
await testLocalChecksumArtifactsIgnoredWhenVerificationDisabled();
console.log("feed_verification.test.mjs: ok");
@@ -0,0 +1,318 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, "..");
const guardedVerifyScript = path.join(skillRoot, "scripts", "guarded_skill_verify.mjs");
function runNode(args = [], env = {}) {
return spawnSync(process.execPath, [guardedVerifyScript, ...args], {
cwd: skillRoot,
encoding: "utf8",
env: { ...process.env, ...env },
});
}
function signPayload(payloadRaw, privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(payloadRaw, "utf8"), key);
return signature.toString("base64");
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-guarded-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function writeFeedArtifacts({ dir, advisories, keyPair, signatureKeyPair = keyPair }) {
const feedPath = path.join(dir, "feed.json");
const feedSigPath = `${feedPath}.sig`;
const checksumsPath = path.join(dir, "checksums.json");
const checksumsSigPath = `${checksumsPath}.sig`;
const publicKeyPath = path.join(dir, "feed-public.pem");
const feedRaw = JSON.stringify(
{
version: "1.0.0",
updated: "2026-04-20T00:00:00Z",
advisories,
},
null,
2,
);
const publicKeyPem = keyPair.publicKey.export({ type: "spki", format: "pem" });
const signingPrivatePem = signatureKeyPair.privateKey.export({ type: "pkcs8", format: "pem" });
await fs.writeFile(feedPath, feedRaw, "utf8");
const feedSignature = `${signPayload(feedRaw, signingPrivatePem)}\n`;
await fs.writeFile(feedSigPath, feedSignature, "utf8");
const sha256 = (value) => crypto.createHash("sha256").update(value, "utf8").digest("hex");
const checksumsRaw = JSON.stringify(
{
files: {
[path.basename(feedPath)]: sha256(feedRaw),
[path.basename(feedSigPath)]: sha256(feedSignature),
},
},
null,
2,
);
await fs.writeFile(checksumsPath, `${checksumsRaw}\n`, "utf8");
await fs.writeFile(checksumsSigPath, `${signPayload(`${checksumsRaw}\n`, signingPrivatePem)}\n`, "utf8");
await fs.writeFile(publicKeyPath, publicKeyPem, "utf8");
return { feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath };
}
function hermesEnv(base) {
return {
HERMES_HOME: path.join(base, ".hermes"),
HERMES_ADVISORY_FEED_SOURCE: "local",
};
}
function localFeedEnv({ feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath }) {
return {
HERMES_LOCAL_ADVISORY_FEED: feedPath,
HERMES_LOCAL_ADVISORY_FEED_SIG: feedSigPath,
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS: checksumsPath,
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG: checksumsSigPath,
HERMES_ADVISORY_FEED_PUBLIC_KEY: publicKeyPath,
};
}
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-CONSERVATIVE",
severity: "high",
affected: ["demo-skill@>=1.2.3"],
},
],
});
const result = runNode(["--skill", "demo-skill"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 42, `conservative name-only match should gate with 42: ${result.stderr}`);
assert.ok(result.stdout.includes("No --version provided; applying conservative name-based advisory gate."), result.stdout);
assert.ok(result.stdout.includes("ADV-CONSERVATIVE"), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-VERSION-MATCH",
severity: "critical",
affected: ["versioned-skill@>=2.0.0"],
},
],
});
const result = runNode(["--skill", "versioned-skill", "--version", "2.1.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 42, `explicit version match should gate with 42: ${result.stderr}`);
assert.ok(result.stdout.includes("ADV-VERSION-MATCH"), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-NONMATCH",
severity: "medium",
affected: ["different-skill@>=1.0.0"],
},
],
});
const result = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 0, `non-matching skill should pass: ${result.stderr}`);
assert.ok(result.stdout.includes("No advisory matches found for candidate."), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-MALFORMED-AFFECTED",
severity: "high",
affected: ["missing-at-specifier"],
},
],
});
const result = runNode(["--skill", "missing-at-specifier", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 1, `malformed affected entry without '@' must fail closed: ${result.stderr}`);
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-CONFIRM",
severity: "high",
affected: ["confirm-me@1.0.0"],
},
],
});
const result = runNode(["--skill", "confirm-me", "--version", "1.0.0", "--confirm-advisory"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 0, `--confirm-advisory should allow proceed: ${result.stderr}`);
assert.ok(result.stderr.includes("WARNING: proceeding despite 1 advisory match(es)"), result.stderr);
});
await withTempDir(async (tempDir) => {
const verifierKeys = crypto.generateKeyPairSync("ed25519");
const signerKeys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: verifierKeys,
signatureKeyPair: signerKeys,
advisories: [
{
id: "ADV-BROKEN-SIG",
severity: "high",
affected: ["broken-skill@*"],
},
],
});
const strictResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(strictResult.status, 1, "invalid signature must fail closed without unsigned bypass");
assert.ok(strictResult.stderr.includes("CRITICAL: advisory feed verification failed"), strictResult.stderr);
const bypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0", "--allow-unsigned"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(bypassResult.status, 0, `unsigned bypass should allow verification path to continue: ${bypassResult.stderr}`);
assert.ok(bypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via --allow-unsigned"), bypassResult.stderr);
const envBypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED: "1",
});
assert.equal(
envBypassResult.status,
0,
`env-configured unsigned bypass should allow verification path to continue: ${envBypassResult.stderr}`,
);
assert.ok(
envBypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via resolved env/config policy"),
envBypassResult.stderr,
);
});
await withTempDir(async (tempDir) => {
const invalidArgResult = runNode(["--skill", "demo-skill", "--definitely-invalid-arg"], {
...hermesEnv(tempDir),
});
assert.equal(invalidArgResult.status, 1, "unknown CLI argument must fail");
assert.ok(invalidArgResult.stderr.includes("Unknown argument: --definitely-invalid-arg"), invalidArgResult.stderr);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const semverCases = [
{ label: "caret-accept", versionSpec: "^1.2.3", candidateVersion: "1.9.0", expectedStatus: 42 },
{ label: "caret-reject-major-bump", versionSpec: "^1.2.3", candidateVersion: "2.0.0", expectedStatus: 0 },
{ label: "caret-zero-minor-accept", versionSpec: "^0.2.3", candidateVersion: "0.2.99", expectedStatus: 42 },
{ label: "caret-zero-minor-reject", versionSpec: "^0.2.3", candidateVersion: "0.3.0", expectedStatus: 0 },
{ label: "caret-zero-zero-patch-accept", versionSpec: "^0.0.3", candidateVersion: "0.0.3", expectedStatus: 42 },
{ label: "caret-zero-zero-patch-reject", versionSpec: "^0.0.3", candidateVersion: "0.0.99", expectedStatus: 0 },
{ label: "tilde-accept", versionSpec: "~1.2.3", candidateVersion: "1.2.9", expectedStatus: 42 },
{ label: "tilde-reject-minor-bump", versionSpec: "~1.2.3", candidateVersion: "1.3.0", expectedStatus: 0 },
{ label: "wildcard-accept", versionSpec: "1.2.*", candidateVersion: "1.2.99", expectedStatus: 42 },
{ label: "wildcard-reject", versionSpec: "1.2.*", candidateVersion: "1.3.0", expectedStatus: 0 },
{ label: "malformed-comparator-fail-closed", versionSpec: ">>1.2.3", candidateVersion: "1.9.0", expectedStatus: 1 },
{ label: "comparator-set-fail-closed", versionSpec: ">=1 <2", candidateVersion: "1.9.0", expectedStatus: 1 },
{ label: "logical-or-fail-closed", versionSpec: "1.2 || 1.3", candidateVersion: "1.2.5", expectedStatus: 1 },
];
for (const semverCase of semverCases) {
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: `ADV-SEMVER-${semverCase.label.toUpperCase()}`,
severity: "high",
affected: [`semver-skill@${semverCase.versionSpec}`],
},
],
});
const result = runNode(["--skill", "semver-skill", "--version", semverCase.candidateVersion], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(
result.status,
semverCase.expectedStatus,
`${semverCase.label} expected status ${semverCase.expectedStatus}, got ${result.status}. stderr=${result.stderr}`,
);
if (semverCase.expectedStatus === 1) {
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
}
}
});
console.log("guarded_skill_verify.test.mjs: ok");
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
set -euo pipefail
# Sandbox regression test for hermes-attestation-guardian using an isolated Docker Hermes instance.
#
# Usage:
# skills/hermes-attestation-guardian/test/hermes_attestation_sandbox_regression.sh
#
# Optional env overrides:
# IMAGE=python:3.11-slim
# HERMES_AGENT_SRC=/home/davida/.hermes/hermes-agent
# SKILL_SRC=/home/davida/clawsec/skills/hermes-attestation-guardian
# WELL_KNOWN_PORT=8765
IMAGE="${IMAGE:-python:3.11-slim}"
HERMES_AGENT_SRC="${HERMES_AGENT_SRC:-$HOME/.hermes/hermes-agent}"
SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
WELL_KNOWN_PORT="${WELL_KNOWN_PORT:-8765}"
SKILL_VERSION="${SKILL_VERSION:-$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", "0.0.2"))' "$SKILL_SRC/skill.json")}"
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required." >&2
exit 1
fi
if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_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] hermes-agent-src=$HERMES_AGENT_SRC"
echo "[sandbox] skill-src=$SKILL_SRC"
echo "[sandbox] skill-version=$SKILL_VERSION"
# shellcheck disable=SC2140,SC1078
# Rationale: Docker inner script is intentionally embedded as a single quoted payload
# for `bash -lc` so variables expand inside the container runtime (not on host).
docker run --rm \
-e HOME=/tmp/hermes-sandbox-home \
-e HERMES_HOME=/tmp/hermes-sandbox-home \
-e SKILL_VERSION="$SKILL_VERSION" \
-v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
-v "$SKILL_SRC":/opt/skill-src:ro \
"$IMAGE" bash -lc "
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm zip >/dev/null
cp -a /opt/hermes-agent /tmp/hermes-agent-src
python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
mkdir -p \"\$HOME\"
echo \"INSIDE_HOME=\$HOME\"
echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
cp /opt/skill-src/SKILL.md /opt/skill-src/README.md /opt/skill-src/CHANGELOG.md /opt/skill-src/skill.json /tmp/well/.well-known/skills/hermes-attestation-guardian/
cp -a /opt/skill-src/lib /opt/skill-src/scripts /tmp/well/.well-known/skills/hermes-attestation-guardian/
python3 - <<'PY'
import os,json
root='/tmp/well/.well-known/skills'
sk='hermes-attestation-guardian'
base=os.path.join(root,sk)
files=[]
for dp,_,fns in os.walk(base):
for fn in fns:
files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':os.environ.get('SKILL_VERSION','0.0.2'),'description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
PY
python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
HPID=\$!
sleep 1
SKILL_ZIP=/tmp/hermes-attestation-guardian.zip
(
cd /tmp/well/.well-known/skills
zip -qr "\$SKILL_ZIP" hermes-attestation-guardian
)
ZIP_SHA=\$(sha256sum "\$SKILL_ZIP" | awk '{print \$1}')
cat > /tmp/checksums.json <<EOF
{"archive":{"name":"hermes-attestation-guardian.zip","sha256":"\$ZIP_SHA"}}
EOF
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -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
openssl pkeyutl -sign -rawin -inkey /tmp/release-sign.key -in /tmp/checksums.json -out /tmp/checksums.sig.bin
openssl base64 -A -in /tmp/checksums.sig.bin -out /tmp/checksums.sig
PINNED_RELEASE_PUBKEY_SHA256=\$(openssl pkey -pubin -in /tmp/signing-public.pem -outform DER | sha256sum | awk '{print \$1}')
[ -s /tmp/checksums.json ]
[ -s /tmp/checksums.sig ]
ACTUAL_RELEASE_PUBKEY_SHA256=\$(openssl pkey -pubin -in /tmp/signing-public.pem -outform DER | sha256sum | awk '{print \$1}')
[ "\$ACTUAL_RELEASE_PUBKEY_SHA256" = "\$PINNED_RELEASE_PUBKEY_SHA256" ]
openssl base64 -d -A -in /tmp/checksums.sig -out /tmp/checksums.sig.verify.bin
openssl pkeyutl -verify -rawin -pubin -inkey /tmp/signing-public.pem -sigfile /tmp/checksums.sig.verify.bin -in /tmp/checksums.json >/dev/null
EXPECTED_ZIP_SHA="\$ZIP_SHA"
ACTUAL_ZIP_SHA=\$(sha256sum "\$SKILL_ZIP" | awk '{print \$1}')
[ "\$EXPECTED_ZIP_SHA" = "\$ACTUAL_ZIP_SHA" ]
set +e
INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
INSTALL_CODE=\$?
set -e
echo \"\$INSTALL_OUT\"
INSTALL_SAFE_ALLOWED=0
INSTALL_FORCE_OVERRIDE=0
if [ \"\$INSTALL_CODE\" -eq 0 ] && echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"; then
INSTALL_SAFE_ALLOWED=1
else
echo \"[sandbox] install without --force was not ALLOWED; retrying with --force for feature regression coverage\" >&2
set +e
INSTALL_FORCE_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes --force 2>&1)
INSTALL_FORCE_CODE=\$?
set -e
echo \"\$INSTALL_FORCE_OUT\"
[ \"\$INSTALL_FORCE_CODE\" -eq 0 ]
INSTALL_FORCE_OVERRIDE=1
fi
SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
mkdir -p \"\$HERMES_HOME/security/attestations\"
echo \"alpha\" > /tmp/watch.txt
echo \"anchor-v1\" > /tmp/anchor.pem
cat > /tmp/policy.json <<EOF
{\"watch_files\": [\"/tmp/watch.txt\"], \"trust_anchor_files\": [\"/tmp/anchor.pem\"]}
EOF
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:00:00.000Z --write-sha256 >/tmp/generate.log
DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
echo \"beta\" > /tmp/watch.txt
echo \"anchor-v2\" > /tmp/anchor.pem
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
set +e
DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
DRIFT_CODE=\$?
set -e
[ \"\$DRIFT_CODE\" -ne 0 ]
echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
grep -q \"Preflight review:\" /tmp/cron-preview.log
grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
# Phase 1/2/3 feature coverage: signed advisory feed verify + guarded gating + advisory scheduler helper
cat > /tmp/feed.json <<EOF
{\"version\":\"1.0.0\",\"updated\":\"2026-04-20T00:00:00Z\",\"advisories\":[{\"id\":\"CLAW-TEST-0001\",\"severity\":\"high\",\"title\":\"Test advisory\",\"affected\":[\"hermes-attestation-guardian@${SKILL_VERSION}\"],\"action\":\"Do not install without explicit acknowledgement\"}]}
EOF
node - <<'NODE'
const fs = require('node:fs');
const crypto = require('node:crypto');
const feedRaw = fs.readFileSync('/tmp/feed.json', 'utf8');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
const sig = crypto.sign(null, Buffer.from(feedRaw, 'utf8'), privateKey).toString('base64');
fs.writeFileSync('/tmp/feed.json.sig', sig + '\n');
fs.writeFileSync('/tmp/feed-signing-public.pem', publicKey.export({type:'spki', format:'pem'}));
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
const checksums = {
files: {
'feed.json': sha(feedRaw),
'feed.json.sig': sha(fs.readFileSync('/tmp/feed.json.sig', 'utf8'))
}
};
const checksumsRaw = JSON.stringify(checksums);
fs.writeFileSync('/tmp/checksums-feed.json', checksumsRaw + '\n');
const csumSig = crypto.sign(null, Buffer.from(checksumsRaw + '\n', 'utf8'), privateKey).toString('base64');
fs.writeFileSync('/tmp/checksums-feed.json.sig', csumSig + '\n');
NODE
export HERMES_ADVISORY_FEED_SOURCE=local
export HERMES_LOCAL_ADVISORY_FEED=/tmp/feed.json
export HERMES_LOCAL_ADVISORY_FEED_SIG=/tmp/feed.json.sig
export HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS=/tmp/checksums-feed.json
export HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG=/tmp/checksums-feed.json.sig
export HERMES_ADVISORY_FEED_PUBLIC_KEY=/tmp/feed-signing-public.pem
node \"\$SKILL_DIR/scripts/refresh_advisory_feed.mjs\" > /tmp/refresh-advisory.log
grep -q \"\\\"status\\\":\\\"verified\\\"\" /tmp/refresh-advisory.log
node \"\$SKILL_DIR/scripts/check_advisories.mjs\" > /tmp/check-advisories.log
grep -q \"Feed verification state: verified\" /tmp/check-advisories.log
if node \"\$SKILL_DIR/scripts/guarded_skill_verify.mjs\" --skill hermes-attestation-guardian --version "\$SKILL_VERSION" > /tmp/guarded-no-confirm.log 2>&1; then
GUARD_CODE=0
else
GUARD_CODE=\$?
fi
[ \"\$GUARD_CODE\" -eq 42 ]
grep -q \"Advisory matches detected\" /tmp/guarded-no-confirm.log
node \"\$SKILL_DIR/scripts/guarded_skill_verify.mjs\" --skill hermes-attestation-guardian --version "\$SKILL_VERSION" --confirm-advisory > /tmp/guarded-confirm.log 2>&1
grep -q \"Advisory feed status: verified\" /tmp/guarded-confirm.log
node \"\$SKILL_DIR/scripts/setup_advisory_check_cron.mjs\" --every 6h --skill hermes-attestation-guardian --version "\$SKILL_VERSION" --print-only > /tmp/advisory-cron-preview.log
grep -q \"Preflight review:\" /tmp/advisory-cron-preview.log
grep -q \"# >>> hermes-attestation-guardian-advisory-check >>>\" /tmp/advisory-cron-preview.log
grep -q \"guarded_skill_verify.mjs\" /tmp/advisory-cron-preview.log
echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
if [ \"\$INSTALL_SAFE_ALLOWED\" -eq 1 ]; then
echo \"install_safe_allowed=PASS\"
else
echo \"install_safe_allowed=BLOCKED\"
fi
if [ \"\$INSTALL_FORCE_OVERRIDE\" -eq 1 ]; then
echo \"install_force_override=PASS\"
fi
echo \"release_verify_triad=PASS\"
echo \"generate_with_policy=PASS\"
echo \"verify_expected_sha=PASS\"
echo \"verify_signature=PASS\"
echo \"baseline_drift_fail_closed=PASS\"
echo \"scheduler_preview=PASS\"
echo \"advisory_feed_refresh_verified=PASS\"
echo \"advisory_feed_status_report=PASS\"
echo \"guarded_verify_requires_confirm=PASS\"
echo \"guarded_verify_confirm_override=PASS\"
echo \"advisory_scheduler_preview=PASS\"
kill \$HPID >/dev/null 2>&1 || true
wait \$HPID 2>/dev/null || true
"
echo "[sandbox] completed successfully"
@@ -0,0 +1,319 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, "..");
const setupScript = path.join(skillRoot, "scripts", "setup_advisory_check_cron.mjs");
function runSetup(args = [], env = {}) {
return spawnSync(process.execPath, [setupScript, ...args], {
cwd: skillRoot,
encoding: "utf8",
env: { ...process.env, ...env },
});
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-advisory-cron-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
function toBase64(value) {
return Buffer.from(String(value), "utf8").toString("base64");
}
async function installFakeCrontab(tempDir, { listStdout = "", listStatus = 0, listStderr = "" } = {}) {
const fakeBinDir = path.join(tempDir, "bin");
const logPath = path.join(tempDir, "crontab.log");
const writePath = path.join(tempDir, "crontab.write");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontab = `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = process.env.CRONTAB_LOG_PATH;
const writePath = process.env.CRONTAB_WRITE_PATH;
const listStatus = Number(process.env.CRONTAB_LIST_STATUS || '0');
const listStdout = Buffer.from(process.env.CRONTAB_LIST_STDOUT_B64 || '', 'base64').toString('utf8');
const listStderr = process.env.CRONTAB_LIST_STDERR || '';
const writeStatus = Number(process.env.CRONTAB_WRITE_STATUS || '0');
const writeStderr = process.env.CRONTAB_WRITE_STDERR || '';
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list\\n', 'utf8');
if (listStatus !== 0) {
if (listStderr) process.stderr.write(listStderr);
process.exit(listStatus);
}
process.stdout.write(listStdout);
process.exit(0);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write\\n', 'utf8');
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
if (writeStatus !== 0) {
if (writeStderr) process.stderr.write(writeStderr);
process.exit(writeStatus);
}
process.exit(0);
}
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
process.exit(2);
`;
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
return {
fakeBinDir,
logPath,
writePath,
env: {
CRONTAB_LOG_PATH: logPath,
CRONTAB_WRITE_PATH: writePath,
CRONTAB_LIST_STATUS: String(listStatus),
CRONTAB_LIST_STDOUT_B64: toBase64(listStdout),
CRONTAB_LIST_STDERR: listStderr,
CRONTAB_WRITE_STATUS: "0",
CRONTAB_WRITE_STDERR: "",
},
};
}
async function installSelfDeletingCrontab(tempDir) {
const fakeBinDir = path.join(tempDir, "bin-self-delete");
const logPath = path.join(tempDir, "crontab.self-delete.log");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
const fakeCrontab = `#!${process.execPath}
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = process.env.CRONTAB_SELF_DELETE_LOG_PATH;
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list\\n', 'utf8');
fs.unlinkSync(process.argv[1]);
process.stdout.write('# existing line\\n');
process.exit(0);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write-ran\\n', 'utf8');
process.exit(99);
}
process.exit(2);
`;
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
return {
fakeBinDir,
logPath,
env: {
CRONTAB_SELF_DELETE_LOG_PATH: logPath,
},
};
}
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout: "# should never be read in print-only mode\n",
});
const result = runSetup(["--every", "6h", "--skill", "clawsec-feed", "--print-only"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, `setup script failed: ${result.stderr}`);
assert.ok(result.stdout.includes("Preflight review:"), result.stdout);
assert.ok(result.stdout.includes("guarded_skill_verify.mjs"), result.stdout);
assert.ok(result.stdout.includes("Target skill: clawsec-feed"), result.stdout);
assert.ok(result.stdout.includes("# >>> hermes-attestation-guardian-advisory-check >>>"), result.stdout);
const cronLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes("guarded_skill_verify.mjs") && line.includes("--skill"));
assert.ok(cronLine, "managed cron line using guarded flow should be present");
assert.ok(cronLine.includes(process.execPath), "cron command must use absolute process.execPath node runtime");
assert.equal(
/node\s+[^\n]*guarded_skill_verify\.mjs/.test(cronLine),
false,
"must not schedule a generic 'node' invocation when process.execPath is available",
);
assert.equal(
/node\s+[^\n]*check_advisories\.mjs/.test(cronLine),
false,
"must not schedule raw advisory check entrypoint",
);
const logExists = await fs
.access(fake.logPath)
.then(() => true)
.catch(() => false);
assert.equal(logExists, false, "print-only mode must never invoke crontab");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout:
"# existing line\n" +
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"0 */6 * * * HERMES_HOME=\"/old\" /usr/bin/node /old/guarded_skill_verify.mjs --skill old-skill\n" +
"# <<< hermes-attestation-guardian-advisory-check <<<\n",
});
const result = runSetup(["--apply", "--every", "12h", "--skill", "clawsec-feed", "--version", "1.2.3"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes("Updated user schedule table"), result.stdout);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "script should read schedule table");
assert.ok(log.includes("write"), "script should write updated schedule table");
const written = await fs.readFile(fake.writePath, "utf8");
assert.ok(written.includes("# existing line"), "existing non-managed entries must be preserved");
const startCount = (written.match(/# >>> hermes-attestation-guardian-advisory-check >>>/g) || []).length;
const endCount = (written.match(/# <<< hermes-attestation-guardian-advisory-check <<</g) || []).length;
assert.equal(startCount, 1, `expected exactly one managed start marker, got ${startCount}`);
assert.equal(endCount, 1, `expected exactly one managed end marker, got ${endCount}`);
assert.ok(written.includes("guarded_skill_verify.mjs"), written);
assert.ok(written.includes(process.execPath), written);
assert.ok(written.includes("--skill 'clawsec-feed'"), written);
assert.ok(written.includes("--version '1.2.3'"), written);
assert.equal(written.includes("old-skill"), false, "old managed block content must be replaced");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStatus: 1,
listStderr: "no crontab for davida\n",
});
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, result.stderr);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "script should attempt schedule table read");
assert.ok(log.includes("write"), "script should write new schedule table when none exists");
const written = await fs.readFile(fake.writePath, "utf8");
assert.ok(written.includes("# >>> hermes-attestation-guardian-advisory-check >>>"), written);
assert.ok(written.includes("--skill 'clawsec-feed'"), written);
});
for (const markerCase of [
{
name: "unmatched start marker",
listStdout:
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"0 */6 * * * HERMES_HOME=\"/old\" /usr/bin/node /old/guarded_skill_verify.mjs --skill old-skill\n",
expectedError: "has no end marker",
},
{
name: "unmatched end marker",
listStdout: "# <<< hermes-attestation-guardian-advisory-check <<<\n# existing line\n",
expectedError: "unmatched managed block end",
},
{
name: "nested start marker",
listStdout:
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"# <<< hermes-attestation-guardian-advisory-check <<<\n",
expectedError: "nested managed block start",
},
]) {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout: markerCase.listStdout,
});
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.notEqual(result.status, 0, `${markerCase.name}: expected non-zero exit status`);
assert.ok(result.stderr.includes("Malformed schedule markers"), `${markerCase.name}: ${result.stderr}`);
assert.ok(result.stderr.includes(markerCase.expectedError), `${markerCase.name}: ${result.stderr}`);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), `${markerCase.name}: schedule table read should happen`);
assert.equal(log.includes("write"), false, `${markerCase.name}: write must not occur`);
const writeExists = await fs
.access(fake.writePath)
.then(() => true)
.catch(() => false);
assert.equal(writeExists, false, `${markerCase.name}: no written schedule table expected`);
});
}
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: path.join(tempDir, "missing-bin"),
});
assert.notEqual(result.status, 0, "spawnSync ENOENT while reading crontab should fail");
assert.ok(result.stderr.includes("Failed reading schedule table"), result.stderr);
assert.ok(result.stderr.includes("code=ENOENT"), result.stderr);
assert.ok(result.stderr.includes("message="), result.stderr);
assert.ok(result.stderr.includes("stack="), result.stderr);
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installSelfDeletingCrontab(tempDir);
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: fake.fakeBinDir,
...fake.env,
});
assert.notEqual(result.status, 0, "spawnSync ENOENT while writing crontab should fail");
assert.ok(result.stderr.includes("Failed writing schedule table"), result.stderr);
assert.ok(result.stderr.includes("code=ENOENT"), result.stderr);
assert.ok(result.stderr.includes("message="), result.stderr);
assert.ok(result.stderr.includes("stack="), result.stderr);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "self-deleting fake crontab should run for list before write failure");
assert.equal(log.includes("write-ran"), false, "write command should fail before executing fake crontab");
});
console.log("setup_advisory_check_cron.test.mjs: ok");
@@ -63,6 +63,48 @@ await withTempDir(async (tempDir) => {
assert.ok(result.stdout.includes("policy'\\''withquote.json"), "single quotes must be shell-escaped in cron command");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
const logPath = path.join(tempDir, "crontab.log");
const writePath = path.join(tempDir, "crontab.write");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontab = `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = ${JSON.stringify(logPath)};
const writePath = ${JSON.stringify(writePath)};
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list-empty\\n', 'utf8');
process.stderr.write('no crontab for test-user\\n');
process.exit(1);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write\\n', 'utf8');
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
process.exit(0);
}
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
process.exit(2);
`;
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
const result = runSetup(["--apply"], {
HERMES_HOME: hermesHome,
PATH: `${fakeBinDir}:${process.env.PATH}`,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes("Updated user schedule table"), result.stdout);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list-empty"), "script should treat empty-crontab stderr as no existing schedule");
assert.ok(log.includes("write"), "script should still write managed block on fresh machines");
const written = await fs.readFile(writePath, "utf8");
assert.ok(written.includes("# >>> hermes-attestation-guardian >>>"), written);
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
@@ -97,13 +139,12 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "unmatched start marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
assert.equal(wrote, false, "script must not write crontab on malformed marker block");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
@@ -138,7 +179,7 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "unmatched end marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
@@ -179,7 +220,7 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "nested start marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
-1
View File
@@ -31,4 +31,3 @@
- wiki/migration-signed-feed.md
- wiki/platform-verification.md
- wiki/remediation-plan.md
- wiki/compatibility-report.md
+1 -1
View File
@@ -24,7 +24,6 @@
- [Signed Feed Migration Plan](migration-signed-feed.md)
- [Platform Verification Checklist](platform-verification.md)
- [Cross-Platform Remediation Plan](remediation-plan.md)
- [Cross-Platform Compatibility Report](compatibility-report.md)
## Modules
- [Frontend Web App](modules/frontend-web.md)
@@ -43,6 +42,7 @@
- [Generation Metadata](GENERATION.md)
## Update Notes
- 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.
- 2026-03-10: Added ClawSec Scanner module documentation and linked it under Modules.
-111
View File
@@ -1,111 +0,0 @@
# Cross-Platform Compatibility Report
## 1) Executive Summary
### Overall status by OS
- Linux: **Good**, primary workflows validated; still some POSIX-only scripts/docs.
- macOS: **Good**, with caveats around POSIX tool availability and Homebrew-specific assumptions.
- Windows: **Partial**, Node/Python pieces work, but many shell-first install/release workflows still require WSL/Git Bash.
### Highest-risk incompatibilities
1. **(Fixed)** Literal `$HOME` path creation risk in audit watchdog cron setup payload generation.
2. **(Fixed)** Path env vars accepted as raw strings in multiple Node entrypoints without expansion/validation.
3. **(Open)** Large portions of manual install/release guidance remain POSIX-only (`bash`, `jq`, `curl`, `unzip`, `chmod`, `find -exec`).
### SKILLS install path-expansion root cause
Root cause was a combination of:
- shell-side literal env assignment (for example, `PROMPTSEC_INSTALL_DIR='$HOME/...')`
- Node scripts not expanding home tokens
- cron payload construction escaping `$` (`\$HOME`), forcing literal interpretation in downstream shell execution
This could produce paths like `~/.openclaw/workspace/$HOME/...`.
---
## 2) Findings Table
| ID | Severity | OS Impact | Component | Description | Proposed Fix | Status |
|---|---|---|---|---|---|---|
| CP-001 | Blocker | Linux/macOS/Windows | `skills/openclaw-audit-watchdog/scripts/setup_cron.mjs` | Literal `$HOME` could be propagated into cron payload, creating wrong runtime paths. | Expand/normalize home tokens and reject unresolved escaped tokens before job creation. | **Fixed** |
| CP-002 | High | Linux/macOS/Windows | `skills/clawsec-suite/hooks/.../handler.ts`, `.../scripts/guarded_skill_install.mjs`, `.../lib/suppression.mjs`, `skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs` | Env path vars treated as opaque strings; `~`, `$HOME` not consistently handled. | Shared/consistent path resolution + fail-fast validation. | **Fixed** |
| CP-003 | Medium | macOS/Windows | `skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs`, `.../scripts/codex_review.sh` | Hardcoded `/opt/homebrew` and `which` assumptions. | Use `process.execPath` for tests; PATH-first Codex discovery. | **Fixed** |
| CP-004 | Medium | Windows (+ CI) | repo-wide line endings | Missing `.gitattributes` could introduce CRLF script breakage (`env bash^M`). | Add `.gitattributes` with LF enforcement for scripts/config/text. | **Fixed** |
| CP-005 | Medium | macOS/Windows | `.github/workflows/ci.yml` | TS/lint/build checks were Linux-only. | Add OS matrix for Node checks (`ubuntu`, `macos`, `windows`). | **Fixed** |
| CP-006 | High | Windows | Multiple SKILL docs and shell scripts | Install/maintenance flow is still heavily POSIX-shell based. | Add PowerShell equivalents or Node wrappers for critical flows. | Open |
| CP-007 | Medium | Linux/macOS/Windows | `skills/soul-guardian/scripts/soul_guardian.py` | `Path(...).expanduser()` handles `~` but not `$HOME`/`%USERPROFILE%`. | Add explicit env-token expansion + validation for `--state-dir`. | Open |
| CP-008 | Medium | Windows | `scripts/release-skill.sh`, `scripts/populate-local-*.sh` | GNU/BSD shell toolchain assumptions block native Windows usage. | Provide cross-platform Node/Python replacements or PowerShell equivalents. | Open |
| CP-009 | Low | Windows | documentation + scripts using `chmod 600/644` | POSIX permission semantics are partial/non-portable on Windows. | Document best-effort behavior and Windows ACL alternatives. | Open |
| CP-010 | Low | macOS/Windows | CI non-Node jobs | Shell/Python/security scan jobs remain Ubuntu-only. | Add scoped matrix or dedicated non-Linux smoke jobs where practical. | Open |
---
## 3) Detailed Findings
## Paths
- Fixed: centralized home-token expansion and suspicious token rejection for critical runtime/install path env vars.
- Fixed: path normalization before filesystem access and before cron payload construction.
- Open: `soul_guardian.py` still expands only `~`, not `$HOME`/Windows env tokens.
## Shell / Command Dependencies
- Confirmed extensive POSIX dependencies (`bash`, `curl`, `jq`, `mktemp`, `chmod`, `find`, `unzip`, `openssl`, `shasum/sha256sum`).
- Fixed minor hardcoded binary path assumptions.
- Open: no full native PowerShell parity for core shell workflows.
## Permissions / Filesystem Semantics
- Confirmed many scripts rely on POSIX permission commands.
- Existing `state.ts` already handles `chmod` failures on unsupported filesystems.
- Open: documentation still mostly assumes POSIX permissions.
## Line Endings
- Fixed by adding `.gitattributes` with LF rules for scripts and key text/config files.
## Runtime Dependencies
- Node scripts generally portable.
- Python utilities are portable.
- OpenSSL usage in documentation/workflows remains shell/toolchain dependent.
## CI / Automation
- Fixed: TS/lint/build matrix now runs on Linux/macOS/Windows.
- Open: remaining security/shell/python jobs are Linux-only by design.
---
## 4) SKILLS Install Investigation
### Reproduction (pre-fix)
1. Set install dir with literal token (common quoting mistake):
- `export PROMPTSEC_INSTALL_DIR='$HOME/.config/security-checkup'`
2. Run:
- `node skills/openclaw-audit-watchdog/scripts/setup_cron.mjs`
3. The generated payload command used escaped `$` in `cd` path, resulting in literal token usage at execution time (`cd "\$HOME/..."`), which can resolve under current working directory (for example, `~/.openclaw/workspace/$HOME/...`).
### Root cause analysis
- POSIX single quotes prevent variable expansion.
- Node does not auto-expand env vars inside strings.
- Existing payload escaping converted `$` to literal in shell command text.
### Fix implemented
- Added explicit path resolution (supports `~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:USERPROFILE`) and normalization.
- Added fail-fast validation for unresolved/escaped home tokens.
- Applied to watchdog cron setup, watchdog suppression config loader, suite hook handler, suite advisory suppression loader, and suite guarded installer.
- Added tests covering expansion and escaped-token rejection.
### Validation targets
- `bash` / `zsh`: expanded env values and reject literal escaped home tokens.
- `sh` (where scripts are invoked through Node entrypoints): same path behavior in Node layer.
- Windows PowerShell: `%USERPROFILE%` / `$env:USERPROFILE` expansion and path normalization validated in Node tests.
## Source References
- .gitattributes
- .github/workflows/ci.yml
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
- skills/openclaw-audit-watchdog/scripts/setup_cron.mjs
- skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
- skills/soul-guardian/scripts/soul_guardian.py
- scripts/release-skill.sh
- scripts/populate-local-feed.sh
- scripts/populate-local-skills.sh
- wiki/remediation-plan.md
- wiki/platform-verification.md
+43
View File
@@ -6,6 +6,49 @@
- Package, sign, and publish skill release artifacts from tag events.
- Build and deploy static website outputs and mirrored release/advisory assets.
## CI/CD Summary (migrated from README)
### Automated workflows
The canonical CI/CD workflow matrix (triggers + responsibilities) is maintained in `CLAUDE.md` under "CI Workflows".
This module intentionally focuses on automation/release-specific workflow behavior and operational details. Additional module-relevant workflows not listed in the core matrix include:
- `pages-verify.yml` (PR-only Pages build/signing verification without publish)
- `wiki-sync.yml` (syncs repository `wiki/` content to GitHub Wiki)
### Skill release pipeline behavior
When a skill is tagged (for example, `soul-guardian-v1.0.0`), the pipeline:
1. Validates `skill.json` version/tag alignment.
2. Enforces signing-key consistency against canonical repo key material.
3. Generates `checksums.json` for SBOM files.
4. Signs and verifies release checksum artifacts.
5. Publishes GitHub Release assets.
6. Supersedes older releases within the same major version (tags remain).
7. Triggers website catalog refresh.
### Signing-key consistency guardrails
Guardrail script:
- `scripts/ci/verify_signing_key_consistency.sh`
Enforced in:
- `.github/workflows/skill-release.yml`
- `.github/workflows/deploy-pages.yml`
### Release versioning and superseding
- New patch/minor release: previous releases in same major line are removed.
- New major release: latest release from previous major line is retained for compatibility.
- Git tags are preserved and can be used to recreate releases when needed.
### Release artifacts
Each skill release includes:
- `checksums.json`
- `skill.json`
- `SKILL.md`
- Additional SBOM-scoped files
Operational docs:
- `wiki/security-signing-runbook.md`
- `wiki/migration-signed-feed.md`
## Key Files
- `.github/workflows/ci.yml`: lint/type/build/security/test matrix.
- `.github/workflows/pages-verify.yml`: PR-only Pages build/signing verification (no publish).
+53 -1
View File
@@ -240,15 +240,42 @@ Quick scenario:
- Existing crontab has managed start marker with no end marker.
- Running `--apply` aborts with malformed-marker error and leaves crontab unchanged.
## Current Capability Inventory (as implemented now)
Hermes Attestation Guardian now includes three capability lanes:
1) Attestation + baseline integrity lane
- Deterministic attestation generation.
- Fail-closed schema/digest/signature verification.
- Baseline authenticity requirements and severity-ranked drift diffing.
- Existing attestation cron helper with managed marker block and print-only default.
2) Advisory feed verification lane (Hermes-native)
- Signed advisory feed verification with fail-closed defaults.
- Checksum-manifest + signature verification when artifacts are present.
- Symmetric fail-closed handling for partial checksum artifact sets.
- Feed verification state/cache kept under Hermes security paths and read by attestation posture output.
3) Advisory-gated supply-chain lane
- Guarded skill verification flow with advisory-aware gating.
- Conservative matching when version is omitted.
- Explicit confirmation override required to proceed on matched advisories.
- Optional advisory scheduler helper with print-only default and managed marker apply path.
## Key Files
- `skills/hermes-attestation-guardian/skill.json`: metadata, platform scope, operator review notes, SBOM.
- `skills/hermes-attestation-guardian/SKILL.md`: operator playbook, CLI usage, fail-closed policy.
- `skills/hermes-attestation-guardian/README.md`: quickstart and practical behavior notes.
- `skills/hermes-attestation-guardian/lib/attestation.mjs`: canonicalization, digest binding, schema checks, scoped output resolution, policy parsing.
- `skills/hermes-attestation-guardian/lib/diff.mjs`: baseline drift comparison and severity classification.
- `skills/hermes-attestation-guardian/lib/feed.mjs`: Hermes advisory feed fetch/load, signature/checksum verification, state/cache handling.
- `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs`: deterministic attestation generation CLI.
- `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs`: fail-closed verifier and baseline trust enforcement.
- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`: cron managed-block helper.
- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`: attestation cron managed-block helper.
- `skills/hermes-attestation-guardian/scripts/refresh_advisory_feed.mjs`: refresh + verify advisory feed and update Hermes feed state.
- `skills/hermes-attestation-guardian/scripts/check_advisories.mjs`: operator-facing advisory/feed status summary.
- `skills/hermes-attestation-guardian/scripts/guarded_skill_verify.mjs`: advisory-gated guarded verification for candidate skill installs.
- `skills/hermes-attestation-guardian/scripts/setup_advisory_check_cron.mjs`: advisory scheduled-check helper with print-only default.
## Public Interfaces
- `generate_attestation.mjs` CLI
@@ -260,6 +287,18 @@ Quick scenario:
- `setup_attestation_cron.mjs` CLI
- Consumer: operators
- Behavior: prints or applies managed cron block for scheduled generate+verify runs.
- `refresh_advisory_feed.mjs` CLI
- Consumer: operators/automation
- Behavior: fetches or loads advisory feed, verifies trust artifacts fail-closed by default, and updates Hermes advisory state/cache.
- `check_advisories.mjs` CLI
- Consumer: operators/automation
- Behavior: summarizes advisory feed verification status and current advisory visibility.
- `guarded_skill_verify.mjs` CLI
- Consumer: operators/automation/install wrappers
- Behavior: advisory-aware gate for skill name/version candidates; blocks on matches unless explicit confirmation override is provided.
- `setup_advisory_check_cron.mjs` CLI
- Consumer: operators
- Behavior: prints or applies managed cron block for scheduled guarded advisory checks.
- Diff output contract
- Consumer: operators/CI
- Behavior: emits severity-ranked drift findings for security triage.
@@ -271,9 +310,14 @@ node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs
node skills/hermes-attestation-guardian/test/attestation_diff.test.mjs
node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs
node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
node skills/hermes-attestation-guardian/test/feed_verification.test.mjs
node skills/hermes-attestation-guardian/test/guarded_skill_verify.test.mjs
node skills/hermes-attestation-guardian/test/setup_advisory_check_cron.test.mjs
```
## Update Notes
- 2026-04-20: Expanded module coverage to include full Hermes capability set: signed advisory-feed verification lane, advisory-gated guarded skill verification lane, and advisory scheduler helper with managed marker block safety.
- 2026-04-17: Added v0.0.2 release-hardening notes: mandatory release verify triad (`checksums.json`, `checksums.sig`, pinned signing-key fingerprint), Hermes guard signature-aware trust policy note, and sandbox regression coverage for verify-gate + clean install.
- 2026-04-15: Replaced table-style PR claim mapping with full narrative claim breakdowns (people-speak, wiring, verification, and concrete scenarios per claim).
## Source References
@@ -283,10 +327,18 @@ node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
- skills/hermes-attestation-guardian/CHANGELOG.md
- skills/hermes-attestation-guardian/lib/attestation.mjs
- skills/hermes-attestation-guardian/lib/diff.mjs
- skills/hermes-attestation-guardian/lib/feed.mjs
- skills/hermes-attestation-guardian/scripts/generate_attestation.mjs
- skills/hermes-attestation-guardian/scripts/verify_attestation.mjs
- skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs
- skills/hermes-attestation-guardian/scripts/refresh_advisory_feed.mjs
- skills/hermes-attestation-guardian/scripts/check_advisories.mjs
- skills/hermes-attestation-guardian/scripts/guarded_skill_verify.mjs
- skills/hermes-attestation-guardian/scripts/setup_advisory_check_cron.mjs
- skills/hermes-attestation-guardian/test/attestation_schema.test.mjs
- skills/hermes-attestation-guardian/test/attestation_diff.test.mjs
- skills/hermes-attestation-guardian/test/attestation_cli.test.mjs
- skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
- skills/hermes-attestation-guardian/test/feed_verification.test.mjs
- skills/hermes-attestation-guardian/test/guarded_skill_verify.test.mjs
- skills/hermes-attestation-guardian/test/setup_advisory_check_cron.test.mjs
+29
View File
@@ -6,6 +6,35 @@
- Maintain host-side cached advisory state with TLS/signature enforcement and IPC-triggered refresh.
- Protect critical NanoClaw files with baseline drift detection and hash-chained audit trails.
## Platform Support Summary (migrated from README)
ClawSec supports NanoClaw as a containerized WhatsApp-bot deployment model.
### `clawsec-nanoclaw` skill scope
- Location: `skills/clawsec-nanoclaw/`
- 9 MCP tools for advisory checks, package-safety checks, signature verification, and integrity monitoring.
- Automatic advisory feed refresh/caching on a recurring cadence.
- Platform filtering for NanoClaw-relevant advisories.
- IPC-based host/container communication model.
### NanoClaw advisory coverage
The feed and matching pipeline include NanoClaw-relevant terms:
- `NanoClaw`
- `WhatsApp-bot`
- `baileys`
Advisories can be explicitly platform-scoped via:
- `platforms: ["nanoclaw"]`
### Quick integration checklist
1. Copy skill files to the NanoClaw deployment.
2. Integrate MCP tools in the container runtime.
3. Configure host IPC handlers and advisory cache service.
4. Restart NanoClaw services.
Install guide:
- `skills/clawsec-nanoclaw/INSTALL.md`
## Key Files
- `skills/clawsec-nanoclaw/skill.json`: NanoClaw package contract and MCP tool registry.
- `skills/clawsec-nanoclaw/lib/signatures.ts`: secure fetch and Ed25519 verification primitives.
-1
View File
@@ -82,4 +82,3 @@
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
- skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
- wiki/platform-verification.md
- wiki/compatibility-report.md