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