diff --git a/README.md b/README.md
index 331fc60..5d26bfa 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-## 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
Brought to you by Prompt Security, the Platform for AI Security
@@ -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,37 +316,45 @@ npm run build
```
โโโ advisories/
-โ โโโ feed.json # Main advisory feed (auto-updated from NVD)
-โโโ components/ # React components
-โโโ pages/ # Page components
-โโโ wiki/ # Source-of-truth docs (synced to GitHub Wiki)
+โ โโโ 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/ # 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
-โ โโโ release-skill.sh # Manual skill release helper
+โ โโโ 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)
-โ โโโ 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
-โ โโโ openclaw-audit-watchdog/ # ๐ญ Automated audit skill
-โ โโโ soul-guardian/ # ๐ป File integrity skill
+โ โโโ 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/
-โ โโโ package_skill.py # Skill packager utility
-โ โโโ validate_skill.py # Skill validator utility
+โ โโโ package_skill.py # Skill packager utility
+โ โโโ validate_skill.py # Skill validator utility
โโโ .github/workflows/
-โ โโโ ci.yml # Cross-platform lint/type/build + tests
-โ โโโ pages-verify.yml # PR-only pages build verification
-โ โโโ poll-nvd-cves.yml # CVE polling pipeline
-โ โโโ community-advisory.yml # Approved issue -> advisory PR
-โ โโโ skill-release.yml # Skill release pipeline
-โ โโโ wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
-โ โโโ deploy-pages.yml # Pages deployment
-โโโ public/ # Static assets + generated publish artifacts
+โ โโโ ci.yml # Cross-platform lint/type/build + tests
+โ โโโ 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/signing pipeline
+โ โโโ deploy-pages.yml # GitHub Pages deployment
+โ โโโ wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
+โ โโโ codeql.yml # CodeQL security analysis
+โ โโโ scorecard.yml # OpenSSF Scorecard checks
+โโโ public/ # Static assets + generated wiki exports
```
---
diff --git a/scripts/hermes_attestation_sandbox_regression.sh b/scripts/hermes_attestation_sandbox_regression.sh
deleted file mode 100755
index 9570de8..0000000
--- a/scripts/hermes_attestation_sandbox_regression.sh
+++ /dev/null
@@ -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 <
/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"
\ No newline at end of file
diff --git a/skills/hermes-attestation-guardian/CHANGELOG.md b/skills/hermes-attestation-guardian/CHANGELOG.md
index 75181c4..e2602a1 100644
--- a/skills/hermes-attestation-guardian/CHANGELOG.md
+++ b/skills/hermes-attestation-guardian/CHANGELOG.md
@@ -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`).
diff --git a/skills/hermes-attestation-guardian/README.md b/skills/hermes-attestation-guardian/README.md
index 90c2be9..9c69974 100644
--- a/skills/hermes-attestation-guardian/README.md
+++ b/skills/hermes-attestation-guardian/README.md
@@ -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
```
diff --git a/skills/hermes-attestation-guardian/SKILL.md b/skills/hermes-attestation-guardian/SKILL.md
index 1cdbe06..002f640 100644
--- a/skills/hermes-attestation-guardian/SKILL.md
+++ b/skills/hermes-attestation-guardian/SKILL.md
@@ -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
diff --git a/skills/hermes-attestation-guardian/lib/attestation.mjs b/skills/hermes-attestation-guardian/lib/attestation.mjs
index b8e4401..c7c8cc0 100644
--- a/skills/hermes-attestation-guardian/lib/attestation.mjs
+++ b/skills/hermes-attestation-guardian/lib/attestation.mjs
@@ -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,
diff --git a/skills/hermes-attestation-guardian/lib/cron.mjs b/skills/hermes-attestation-guardian/lib/cron.mjs
new file mode 100644
index 0000000..c111541
--- /dev/null
+++ b/skills/hermes-attestation-guardian/lib/cron.mjs
@@ -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 h or 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`);
+}
diff --git a/skills/hermes-attestation-guardian/lib/feed.mjs b/skills/hermes-attestation-guardian/lib/feed.mjs
new file mode 100644
index 0000000..2309374
--- /dev/null
+++ b/skills/hermes-attestation-guardian/lib/feed.mjs
@@ -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;
+}
diff --git a/skills/hermes-attestation-guardian/lib/semver.mjs b/skills/hermes-attestation-guardian/lib/semver.mjs
new file mode 100644
index 0000000..9f33008
--- /dev/null
+++ b/skills/hermes-attestation-guardian/lib/semver.mjs
@@ -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, "");
+}
diff --git a/skills/hermes-attestation-guardian/scripts/check_advisories.mjs b/skills/hermes-attestation-guardian/scripts/check_advisories.mjs
new file mode 100644
index 0000000..ff020b6
--- /dev/null
+++ b/skills/hermes-attestation-guardian/scripts/check_advisories.mjs
@@ -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);
+}
diff --git a/skills/hermes-attestation-guardian/scripts/guarded_skill_verify.mjs b/skills/hermes-attestation-guardian/scripts/guarded_skill_verify.mjs
new file mode 100644
index 0000000..24d3a32
--- /dev/null
+++ b/skills/hermes-attestation-guardian/scripts/guarded_skill_verify.mjs
@@ -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 [--version ] [--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);
+}
diff --git a/skills/hermes-attestation-guardian/scripts/refresh_advisory_feed.mjs b/skills/hermes-attestation-guardian/scripts/refresh_advisory_feed.mjs
new file mode 100644
index 0000000..6fd4685
--- /dev/null
+++ b/skills/hermes-attestation-guardian/scripts/refresh_advisory_feed.mjs
@@ -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 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);
+}
diff --git a/skills/hermes-attestation-guardian/scripts/setup_advisory_check_cron.mjs b/skills/hermes-attestation-guardian/scripts/setup_advisory_check_cron.mjs
new file mode 100644
index 0000000..8317886
--- /dev/null
+++ b/skills/hermes-attestation-guardian/scripts/setup_advisory_check_cron.mjs
@@ -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 Interval cadence (default: 6h)",
+ " --skill Skill name passed to guarded advisory check (default: hermes-attestation-guardian)",
+ " --version 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 .");
+ }
+ 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);
+}
diff --git a/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs b/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs
index 01ec801..e5407b4 100644
--- a/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs
+++ b/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs
@@ -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 Baseline detached signature for verifier",
" --baseline-public-key Baseline signature public key for verifier",
" --output 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 h or 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 {
diff --git a/skills/hermes-attestation-guardian/skill.json b/skills/hermes-attestation-guardian/skill.json
index 6deb66e..0ef3ee7 100644
--- a/skills/hermes-attestation-guardian/skill.json
+++ b/skills/hermes-attestation-guardian/skill.json
@@ -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"
]
}
}
diff --git a/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs
index cf74ff3..2638ea7 100644
--- a/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs
+++ b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs
@@ -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);
diff --git a/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs
index f6005f5..be28836 100644
--- a/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs
+++ b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs
@@ -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");
diff --git a/skills/hermes-attestation-guardian/test/feed_verification.test.mjs b/skills/hermes-attestation-guardian/test/feed_verification.test.mjs
new file mode 100644
index 0000000..3ff17a1
--- /dev/null
+++ b/skills/hermes-attestation-guardian/test/feed_verification.test.mjs
@@ -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");
diff --git a/skills/hermes-attestation-guardian/test/guarded_skill_verify.test.mjs b/skills/hermes-attestation-guardian/test/guarded_skill_verify.test.mjs
new file mode 100644
index 0000000..1d4f325
--- /dev/null
+++ b/skills/hermes-attestation-guardian/test/guarded_skill_verify.test.mjs
@@ -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");
diff --git a/skills/hermes-attestation-guardian/test/hermes_attestation_sandbox_regression.sh b/skills/hermes-attestation-guardian/test/hermes_attestation_sandbox_regression.sh
new file mode 100755
index 0000000..7247a3d
--- /dev/null
+++ b/skills/hermes-attestation-guardian/test/hermes_attestation_sandbox_regression.sh
@@ -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 </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 </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 < 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"
\ No newline at end of file
diff --git a/skills/hermes-attestation-guardian/test/setup_advisory_check_cron.test.mjs b/skills/hermes-attestation-guardian/test/setup_advisory_check_cron.test.mjs
new file mode 100644
index 0000000..31b4899
--- /dev/null
+++ b/skills/hermes-attestation-guardian/test/setup_advisory_check_cron.test.mjs
@@ -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 << {
+ 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");
diff --git a/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs b/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
index 508b5e9..ef2ed53 100644
--- a/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
+++ b/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs
@@ -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);
diff --git a/wiki/GENERATION.md b/wiki/GENERATION.md
index 3c2424d..bf327fb 100644
--- a/wiki/GENERATION.md
+++ b/wiki/GENERATION.md
@@ -31,4 +31,3 @@
- wiki/migration-signed-feed.md
- wiki/platform-verification.md
- wiki/remediation-plan.md
-- wiki/compatibility-report.md
diff --git a/wiki/INDEX.md b/wiki/INDEX.md
index d5f042c..0571344 100644
--- a/wiki/INDEX.md
+++ b/wiki/INDEX.md
@@ -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.
diff --git a/wiki/compatibility-report.md b/wiki/compatibility-report.md
deleted file mode 100644
index 458ee85..0000000
--- a/wiki/compatibility-report.md
+++ /dev/null
@@ -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
diff --git a/wiki/modules/automation-release.md b/wiki/modules/automation-release.md
index 4d1e009..58522ac 100644
--- a/wiki/modules/automation-release.md
+++ b/wiki/modules/automation-release.md
@@ -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).
diff --git a/wiki/modules/hermes-attestation-guardian.md b/wiki/modules/hermes-attestation-guardian.md
index 53e70ab..6f79438 100644
--- a/wiki/modules/hermes-attestation-guardian.md
+++ b/wiki/modules/hermes-attestation-guardian.md
@@ -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
diff --git a/wiki/modules/nanoclaw-integration.md b/wiki/modules/nanoclaw-integration.md
index 7b887d9..ccab42e 100644
--- a/wiki/modules/nanoclaw-integration.md
+++ b/wiki/modules/nanoclaw-integration.md
@@ -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.
diff --git a/wiki/remediation-plan.md b/wiki/remediation-plan.md
index 8d59b40..33039a5 100644
--- a/wiki/remediation-plan.md
+++ b/wiki/remediation-plan.md
@@ -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