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