mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
feat(hermes-attestation-guardian): harden attestation verification and drift controls (#192)
* feat(hermes-attestation-guardian): harden attestation verification and drift controls * docs(wiki): add human-friendly claim mapping for hermes attestation guardian * docs(wiki): expand hermes attestation claim narratives and archive draft * fix(attestation): address Baz review findings for schema and verifier * fix(attestation): reject broken symlink output paths * docs(attestation): pass clean community install guard without force * fix(attestation): harden writes and fail-closed config parsing * feat(ui): add Hermes to rotating platform text * test(attestation): add sandboxed Hermes regression runner script --------- Co-authored-by: David Abutbul <David.a@prompt.security>
This commit is contained in:
+2
-2
@@ -3,7 +3,7 @@ import { User, Bot, Copy, Check, Lock } from 'lucide-react';
|
|||||||
import { Footer } from '../components/Footer';
|
import { Footer } from '../components/Footer';
|
||||||
|
|
||||||
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
||||||
const PLATFORM_NAMES = ['OpenClaw', 'NanoClaw'];
|
const PLATFORM_NAMES = ['OpenClaw', 'NanoClaw', 'Hermes'];
|
||||||
const FILE_LOCK_REVEAL_DELAY_MS = 1600;
|
const FILE_LOCK_REVEAL_DELAY_MS = 1600;
|
||||||
|
|
||||||
export const Home: React.FC = () => {
|
export const Home: React.FC = () => {
|
||||||
@@ -97,7 +97,7 @@ export const Home: React.FC = () => {
|
|||||||
agents
|
agents
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
||||||
A complete security skill suite for OpenClaw and NanoClaw agents. Protect your{' '}
|
A complete security skill suite for OpenClaw, NanoClaw, and Hermes agents. Protect your{' '}
|
||||||
<code
|
<code
|
||||||
key={currentFileIndex}
|
key={currentFileIndex}
|
||||||
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative text-base"
|
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative text-base"
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.0.1] - 2026-04-15
|
||||||
|
|
||||||
|
- Implemented deterministic Hermes attestation generator CLI (`scripts/generate_attestation.mjs`).
|
||||||
|
- Implemented fail-closed verifier CLI with schema, canonical digest, expected checksum, and optional detached signature checks (`scripts/verify_attestation.mjs`).
|
||||||
|
- Implemented meaningful baseline diff engine with stable severity mapping for risky toggle regressions, feed verification regressions, trust anchor drift, and watched file drift (`lib/diff.mjs`).
|
||||||
|
- Implemented Hermes-only cron setup helper with print-only default and managed-block apply mode (`scripts/setup_attestation_cron.mjs`).
|
||||||
|
- Added shared attestation library for canonicalization, schema validation, digest generation, and policy parsing (`lib/attestation.mjs`).
|
||||||
|
- Expanded tests for schema determinism, diff behavior, generator/verifier fail-closed behavior, and cron helper Hermes-only output.
|
||||||
|
- Updated metadata/docs to match actual implemented behavior and ClawSec release pipeline expectations.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# hermes-attestation-guardian
|
||||||
|
|
||||||
|
Hermes-only security attestation and drift detection skill.
|
||||||
|
|
||||||
|
Status: implemented (v0.0.1), Hermes-only.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/generate_attestation.mjs
|
||||||
|
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
|
||||||
|
node scripts/setup_attestation_cron.mjs --every 6h --print-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: hermes-attestation-guardian
|
||||||
|
version: 0.0.1
|
||||||
|
description: Hermes-only runtime security attestation and drift detection skill for operator-managed Hermes infrastructure.
|
||||||
|
homepage: https://clawsec.prompt.security
|
||||||
|
clawdis:
|
||||||
|
emoji: "🛡️"
|
||||||
|
requires:
|
||||||
|
bins: [node]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hermes Attestation Guardian
|
||||||
|
|
||||||
|
IMPORTANT SCOPE:
|
||||||
|
- This skill targets Hermes infrastructure only (CLI/Gateway/profile-managed deployments).
|
||||||
|
- This skill is not an OpenClaw runtime hook package.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Generate deterministic Hermes posture attestations, verify them with fail-closed integrity checks, and compare baseline drift using stable severity mapping.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate attestation (default output: ~/.hermes/security/attestations/current.json)
|
||||||
|
node scripts/generate_attestation.mjs
|
||||||
|
|
||||||
|
# Generate with explicit policy + deterministic timestamp
|
||||||
|
node scripts/generate_attestation.mjs \
|
||||||
|
--policy ~/.hermes/security/attestation-policy.json \
|
||||||
|
--generated-at 2026-04-15T18:00:00.000Z \
|
||||||
|
--write-sha256
|
||||||
|
|
||||||
|
# Verify schema + canonical digest
|
||||||
|
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
|
||||||
|
|
||||||
|
# Verify with baseline diff (baseline must be authenticated)
|
||||||
|
node scripts/verify_attestation.mjs \
|
||||||
|
--input ~/.hermes/security/attestations/current.json \
|
||||||
|
--baseline ~/.hermes/security/attestations/baseline.json \
|
||||||
|
--baseline-expected-sha256 <trusted-baseline-sha256> \
|
||||||
|
--fail-on-severity high
|
||||||
|
|
||||||
|
# Optional detached signature verification
|
||||||
|
node scripts/verify_attestation.mjs \
|
||||||
|
--input ~/.hermes/security/attestations/current.json \
|
||||||
|
--signature ~/.hermes/security/attestations/current.json.sig \
|
||||||
|
--public-key ~/.hermes/security/keys/attestation-public.pem
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Attestation payload (implemented)
|
||||||
|
|
||||||
|
The generator emits:
|
||||||
|
- schema_version, platform, generated_at
|
||||||
|
- 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.integrity watched_files and trust_anchors (existence + sha256)
|
||||||
|
- digests.canonical_sha256 over a stable canonical JSON representation
|
||||||
|
|
||||||
|
## Fail-closed behavior
|
||||||
|
|
||||||
|
Verifier exits non-zero when:
|
||||||
|
- schema validation fails
|
||||||
|
- canonical digest algorithm is unsupported or digest binding mismatches
|
||||||
|
- expected file sha256 mismatches (if configured)
|
||||||
|
- detached signature verification fails (if configured)
|
||||||
|
- baseline is provided without authenticated trust binding (`--baseline-expected-sha256` and/or baseline signature + public key)
|
||||||
|
- baseline authenticity or baseline schema/digest validation fails
|
||||||
|
- baseline diff highest severity is at/above `--fail-on-severity` (default: critical)
|
||||||
|
|
||||||
|
Severity messages are emitted as INFO / WARNING / CRITICAL style lines.
|
||||||
|
|
||||||
|
## Side effects
|
||||||
|
|
||||||
|
- `generate_attestation.mjs` writes one JSON file (and optional `.sha256`) under `$HERMES_HOME/security/attestations`.
|
||||||
|
- `verify_attestation.mjs` is read-only.
|
||||||
|
- `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 <<<`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Default output root is `~/.hermes/security/attestations/`.
|
||||||
|
- No destructive remediation actions (delete/restore/quarantine) are implemented.
|
||||||
|
- Operator policy file is optional JSON with:
|
||||||
|
- `watch_files`: list of file paths
|
||||||
|
- `trust_anchor_files`: list of file paths
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = "0.0.1";
|
||||||
|
export const SKILL_NAME = "hermes-attestation-guardian";
|
||||||
|
export const SKILL_VERSION = "0.0.1";
|
||||||
|
export const DIGEST_ALGORITHM = "sha256";
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableSortObject(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(stableSortObject);
|
||||||
|
}
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
for (const key of Object.keys(value).sort()) {
|
||||||
|
out[key] = stableSortObject(value[key]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableStringify(value, spacing = 2) {
|
||||||
|
return JSON.stringify(stableSortObject(value), null, spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256Hex(input) {
|
||||||
|
return crypto.createHash("sha256").update(input).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256FileHex(filePath) {
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
return sha256Hex(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectHermesHome() {
|
||||||
|
const candidate = (process.env.HERMES_HOME || "").trim();
|
||||||
|
return candidate || path.join(os.homedir(), ".hermes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultOutputPath() {
|
||||||
|
return path.join(detectHermesHome(), "security", "attestations", "current.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attestationOutputRoot(hermesHome = detectHermesHome()) {
|
||||||
|
return path.join(path.resolve(hermesHome), "security", "attestations");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nearestExistingAncestorWithinRoot(targetPath, rootPath) {
|
||||||
|
const stopAt = path.resolve(path.dirname(rootPath));
|
||||||
|
let candidate = path.resolve(targetPath);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (candidate === stopAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(candidate);
|
||||||
|
if (parent === candidate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidate = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHermesScopedOutputPath(outputPath, hermesHome = detectHermesHome()) {
|
||||||
|
const root = attestationOutputRoot(hermesHome);
|
||||||
|
const resolvedOutput = path.resolve(String(outputPath || defaultOutputPath()));
|
||||||
|
if (!isPathInside(resolvedOutput, root)) {
|
||||||
|
throw new Error(`output path must stay under ${root}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesHomeReal = realpathWithMissingTail(hermesHome);
|
||||||
|
const rootReal = path.join(hermesHomeReal, "security", "attestations");
|
||||||
|
const nearestOutputAncestor = nearestExistingAncestorWithinRoot(resolvedOutput, root);
|
||||||
|
if (nearestOutputAncestor) {
|
||||||
|
const nearestOutputAncestorReal = safeRealpath(nearestOutputAncestor);
|
||||||
|
if (!isPathInside(nearestOutputAncestorReal, rootReal)) {
|
||||||
|
throw new Error(`output path must stay under ${rootReal}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(resolvedOutput) && fs.lstatSync(resolvedOutput).isSymbolicLink()) {
|
||||||
|
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPathInside(childPath, parentPath) {
|
||||||
|
const child = path.resolve(childPath);
|
||||||
|
const parent = path.resolve(parentPath);
|
||||||
|
const rel = path.relative(parent, child);
|
||||||
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAttestationPolicy(policyContent) {
|
||||||
|
if (!policyContent) {
|
||||||
|
return { watch_files: [], trust_anchor_files: [] };
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(policyContent);
|
||||||
|
const watchFiles = Array.isArray(parsed.watch_files) ? parsed.watch_files : [];
|
||||||
|
const trustAnchors = Array.isArray(parsed.trust_anchor_files) ? parsed.trust_anchor_files : [];
|
||||||
|
return {
|
||||||
|
watch_files: [...new Set(watchFiles.map((v) => String(v).trim()).filter(Boolean))].sort(),
|
||||||
|
trust_anchor_files: [...new Set(trustAnchors.map((v) => String(v).trim()).filter(Boolean))].sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFileMaybe(filePath) {
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectHermesConfig(hermesHome) {
|
||||||
|
const configCandidates = [
|
||||||
|
path.join(hermesHome, "config.json"),
|
||||||
|
path.join(hermesHome, "gateway", "config.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of configCandidates) {
|
||||||
|
try {
|
||||||
|
const parsed = readJsonFileMaybe(candidate);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return { path: candidate, config: parsed };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue trying fallbacks; verifier reports malformed artifacts, not local config issues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: null, config: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(value, defaultValue = false) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (value === 1) return true;
|
||||||
|
if (value === 0) return false;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const norm = value.trim().toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
|
||||||
|
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvBool(name, fallback = false) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return bool(raw, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configBool(value, envFallback = false) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return envFallback;
|
||||||
|
}
|
||||||
|
return bool(value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input, hermesHome) {
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
if (raw === "~") return os.homedir();
|
||||||
|
if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
|
||||||
|
if (raw.startsWith("$HERMES_HOME/")) return path.join(hermesHome, raw.slice("$HERMES_HOME/".length));
|
||||||
|
return path.resolve(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileFingerprint(filePath) {
|
||||||
|
if (!filePath) {
|
||||||
|
return { path: filePath, exists: false, sha256: null };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { path: filePath, exists: false, sha256: null };
|
||||||
|
}
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
return { path: filePath, exists: true, sha256: sha256Hex(data) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttestation({
|
||||||
|
generatedAt,
|
||||||
|
policy,
|
||||||
|
extraWatchFiles = [],
|
||||||
|
extraTrustAnchorFiles = [],
|
||||||
|
} = {}) {
|
||||||
|
const hermesHome = detectHermesHome();
|
||||||
|
const configState = detectHermesConfig(hermesHome);
|
||||||
|
const config = configState.config || {};
|
||||||
|
|
||||||
|
const gateways = {
|
||||||
|
telegram: configBool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
|
||||||
|
matrix: configBool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
|
||||||
|
discord: configBool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const riskyToggles = {
|
||||||
|
allow_unsigned_mode: configBool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
|
||||||
|
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 selectedPolicy = policy || { watch_files: [], trust_anchor_files: [] };
|
||||||
|
|
||||||
|
const watchFiles = [...new Set([...(selectedPolicy.watch_files || []), ...extraWatchFiles])]
|
||||||
|
.map((p) => normalizePath(p, hermesHome))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const trustAnchorFiles = [...new Set([...(selectedPolicy.trust_anchor_files || []), ...extraTrustAnchorFiles])]
|
||||||
|
.map((p) => normalizePath(p, hermesHome))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const watchedFingerprints = watchFiles.map(fileFingerprint);
|
||||||
|
const trustAnchorFingerprints = trustAnchorFiles.map(fileFingerprint);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
platform: "hermes",
|
||||||
|
generated_at: generatedAt || new Date().toISOString(),
|
||||||
|
generator: {
|
||||||
|
skill: SKILL_NAME,
|
||||||
|
version: SKILL_VERSION,
|
||||||
|
node: process.version,
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
hostname: os.hostname(),
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
},
|
||||||
|
posture: {
|
||||||
|
hermes_home: hermesHome,
|
||||||
|
config_source: configState.path,
|
||||||
|
runtime: {
|
||||||
|
gateways,
|
||||||
|
risky_toggles: riskyToggles,
|
||||||
|
},
|
||||||
|
feed_verification: {
|
||||||
|
configured: normalizedFeedStatus !== "unknown",
|
||||||
|
status: normalizedFeedStatus,
|
||||||
|
},
|
||||||
|
integrity: {
|
||||||
|
watched_files: watchedFingerprints,
|
||||||
|
trust_anchors: trustAnchorFingerprints,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const canonicalWithoutDigest = stableStringify(payload, 0);
|
||||||
|
const canonicalSha256 = sha256Hex(canonicalWithoutDigest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
digests: {
|
||||||
|
canonical_sha256: canonicalSha256,
|
||||||
|
algorithm: DIGEST_ALGORITHM,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDigestAlgorithm(algorithm) {
|
||||||
|
return String(algorithm || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedDigestAlgorithm(algorithm) {
|
||||||
|
return normalizeDigestAlgorithm(algorithm) === DIGEST_ALGORITHM;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCanonicalDigest(attestation) {
|
||||||
|
const clone = JSON.parse(JSON.stringify(attestation || {}));
|
||||||
|
delete clone.digests;
|
||||||
|
return sha256Hex(stableStringify(clone, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDigestBinding(attestation) {
|
||||||
|
if (!attestation || typeof attestation !== "object") {
|
||||||
|
return "attestation must be a JSON object";
|
||||||
|
}
|
||||||
|
if (!isSupportedDigestAlgorithm(attestation?.digests?.algorithm)) {
|
||||||
|
return `unsupported digest algorithm: ${attestation?.digests?.algorithm ?? "(missing)"}`;
|
||||||
|
}
|
||||||
|
const expectedCanonical = String(attestation?.digests?.canonical_sha256 || "").toLowerCase();
|
||||||
|
const actualCanonical = computeCanonicalDigest(attestation);
|
||||||
|
if (expectedCanonical !== actualCanonical) {
|
||||||
|
return `canonical digest mismatch expected=${expectedCanonical} actual=${actualCanonical}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAttestationSchema(attestation) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!isPlainObject(attestation)) {
|
||||||
|
return ["attestation must be a JSON object"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attestation.schema_version !== SCHEMA_VERSION) {
|
||||||
|
errors.push(`schema_version must be ${SCHEMA_VERSION}`);
|
||||||
|
}
|
||||||
|
if (attestation.platform !== "hermes") {
|
||||||
|
errors.push("platform must be hermes");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedAt = String(attestation.generated_at || "").trim();
|
||||||
|
if (!generatedAt || Number.isNaN(Date.parse(generatedAt))) {
|
||||||
|
errors.push("generated_at must be an ISO timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(attestation.generator)) {
|
||||||
|
errors.push("generator object is required");
|
||||||
|
} else {
|
||||||
|
if (typeof attestation.generator.version !== "string" || !attestation.generator.version.trim()) {
|
||||||
|
errors.push("generator.version must be a non-empty string");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isPlainObject(attestation.host)) {
|
||||||
|
errors.push("host object is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(attestation.posture)) {
|
||||||
|
errors.push("posture object is required");
|
||||||
|
} else {
|
||||||
|
const runtime = attestation.posture.runtime;
|
||||||
|
if (!isPlainObject(runtime)) {
|
||||||
|
errors.push("posture.runtime object is required");
|
||||||
|
} else {
|
||||||
|
if (!isPlainObject(runtime.gateways)) {
|
||||||
|
errors.push("posture.runtime.gateways object is required");
|
||||||
|
} else {
|
||||||
|
for (const gateway of ["telegram", "matrix", "discord"]) {
|
||||||
|
if (typeof runtime.gateways[gateway] !== "boolean") {
|
||||||
|
errors.push(`posture.runtime.gateways.${gateway} must be a boolean`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(runtime.risky_toggles)) {
|
||||||
|
errors.push("posture.runtime.risky_toggles object is required");
|
||||||
|
} else {
|
||||||
|
for (const toggle of ["allow_unsigned_mode", "bypass_verification"]) {
|
||||||
|
if (typeof runtime.risky_toggles[toggle] !== "boolean") {
|
||||||
|
errors.push(`posture.runtime.risky_toggles.${toggle} must be a boolean`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isPlainObject(attestation.posture.feed_verification)) {
|
||||||
|
errors.push("posture.feed_verification object is required");
|
||||||
|
} else {
|
||||||
|
const status = attestation.posture.feed_verification.status;
|
||||||
|
if (!["verified", "unverified", "unknown"].includes(status)) {
|
||||||
|
errors.push("posture.feed_verification.status must be verified|unverified|unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrity = attestation.posture.integrity;
|
||||||
|
if (!isPlainObject(integrity)) {
|
||||||
|
errors.push("posture.integrity object is required");
|
||||||
|
} else {
|
||||||
|
const validateIntegrityEntries = (entries, fieldPath) => {
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
errors.push(`${fieldPath} must be an array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const itemPath = `${fieldPath}[${index}]`;
|
||||||
|
if (!isPlainObject(entry)) {
|
||||||
|
errors.push(`${itemPath} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.path !== "string" || !entry.path.trim()) {
|
||||||
|
errors.push(`${itemPath}.path must be a non-empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.exists !== "boolean") {
|
||||||
|
errors.push(`${itemPath}.exists must be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.sha256 !== null && !/^[a-f0-9]{64}$/i.test(String(entry.sha256 || ""))) {
|
||||||
|
errors.push(`${itemPath}.sha256 must be null or a 64-char sha256 hex string`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
validateIntegrityEntries(integrity.watched_files, "posture.integrity.watched_files");
|
||||||
|
validateIntegrityEntries(integrity.trust_anchors, "posture.integrity.trust_anchors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(attestation.digests)) {
|
||||||
|
errors.push("digests object is required");
|
||||||
|
} else {
|
||||||
|
if (!/^[a-f0-9]{64}$/i.test(String(attestation.digests.canonical_sha256 || ""))) {
|
||||||
|
errors.push("digests.canonical_sha256 must be a 64-char sha256 hex string");
|
||||||
|
}
|
||||||
|
if (!isSupportedDigestAlgorithm(attestation.digests.algorithm)) {
|
||||||
|
errors.push(`digests.algorithm must be ${DIGEST_ALGORITHM}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
||||||
|
|
||||||
|
function bumpSummary(summary, severity) {
|
||||||
|
if (summary[severity] === undefined) {
|
||||||
|
summary[severity] = 0;
|
||||||
|
}
|
||||||
|
summary[severity] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBooleanFindings({ findings, summary, codeOnEnable, codeOnDisable, path, before, after, enableSeverity = "high" }) {
|
||||||
|
if (!!before === !!after) return;
|
||||||
|
|
||||||
|
if (!before && after) {
|
||||||
|
findings.push({
|
||||||
|
severity: enableSeverity,
|
||||||
|
code: codeOnEnable,
|
||||||
|
path,
|
||||||
|
message: `${path} changed false -> true`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, enableSeverity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
severity: "info",
|
||||||
|
code: codeOnDisable,
|
||||||
|
path,
|
||||||
|
message: `${path} changed true -> false`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapByPath(entries) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||||
|
if (!entry || typeof entry.path !== "string") continue;
|
||||||
|
out.set(entry.path, entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareHashedEntries({ findings, summary, beforeEntries, afterEntries, changedCode, missingCode }) {
|
||||||
|
const beforeMap = mapByPath(beforeEntries);
|
||||||
|
const afterMap = mapByPath(afterEntries);
|
||||||
|
|
||||||
|
for (const [itemPath, before] of beforeMap.entries()) {
|
||||||
|
const after = afterMap.get(itemPath);
|
||||||
|
if (!after) {
|
||||||
|
findings.push({
|
||||||
|
severity: "high",
|
||||||
|
code: missingCode,
|
||||||
|
path: itemPath,
|
||||||
|
message: `${itemPath} missing in current attestation`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "high");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeHash = before.sha256 || null;
|
||||||
|
const afterHash = after.sha256 || null;
|
||||||
|
if (beforeHash !== afterHash) {
|
||||||
|
findings.push({
|
||||||
|
severity: "critical",
|
||||||
|
code: changedCode,
|
||||||
|
path: itemPath,
|
||||||
|
message: `${itemPath} fingerprint changed`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "critical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [itemPath, after] of afterMap.entries()) {
|
||||||
|
if (beforeMap.has(itemPath)) continue;
|
||||||
|
findings.push({
|
||||||
|
severity: "low",
|
||||||
|
code: "NEW_INTEGRITY_SCOPE",
|
||||||
|
path: itemPath,
|
||||||
|
message: `${itemPath} added to integrity tracking scope`,
|
||||||
|
details: { exists: !!after.exists },
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "low");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFeedVerification({ findings, summary, baselineFeed, currentFeed }) {
|
||||||
|
const beforeStatus = baselineFeed?.status || "unknown";
|
||||||
|
const afterStatus = currentFeed?.status || "unknown";
|
||||||
|
|
||||||
|
if (beforeStatus === afterStatus) return;
|
||||||
|
|
||||||
|
if (beforeStatus === "verified" && afterStatus !== "verified") {
|
||||||
|
findings.push({
|
||||||
|
severity: "critical",
|
||||||
|
code: "FEED_VERIFICATION_REGRESSION",
|
||||||
|
path: "posture.feed_verification.status",
|
||||||
|
message: `Feed verification regressed verified -> ${afterStatus}`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "critical");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
severity: "medium",
|
||||||
|
code: "FEED_VERIFICATION_CHANGED",
|
||||||
|
path: "posture.feed_verification.status",
|
||||||
|
message: `Feed verification status changed ${beforeStatus} -> ${afterStatus}`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "medium");
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePlatform({ findings, summary, baseline, current }) {
|
||||||
|
if (baseline.platform === current.platform) return;
|
||||||
|
findings.push({
|
||||||
|
severity: "critical",
|
||||||
|
code: "PLATFORM_MISMATCH",
|
||||||
|
path: "platform",
|
||||||
|
message: `platform changed ${baseline.platform} -> ${current.platform}`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSchema({ findings, summary, baseline, current }) {
|
||||||
|
if (baseline.schema_version === current.schema_version) return;
|
||||||
|
findings.push({
|
||||||
|
severity: "high",
|
||||||
|
code: "SCHEMA_VERSION_CHANGED",
|
||||||
|
path: "schema_version",
|
||||||
|
message: `schema_version changed ${baseline.schema_version} -> ${current.schema_version}`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "high");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareGenerator({ findings, summary, baseline, current }) {
|
||||||
|
const before = baseline?.generator?.version || "unknown";
|
||||||
|
const after = current?.generator?.version || "unknown";
|
||||||
|
if (before === after) return;
|
||||||
|
findings.push({
|
||||||
|
severity: "info",
|
||||||
|
code: "GENERATOR_VERSION_CHANGED",
|
||||||
|
path: "generator.version",
|
||||||
|
message: `generator.version changed ${before} -> ${after}`,
|
||||||
|
});
|
||||||
|
bumpSummary(summary, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffAttestations(baseline, current) {
|
||||||
|
const findings = [];
|
||||||
|
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||||
|
|
||||||
|
const baselineSafe = baseline && typeof baseline === "object" ? baseline : {};
|
||||||
|
const currentSafe = current && typeof current === "object" ? current : {};
|
||||||
|
|
||||||
|
comparePlatform({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||||
|
compareSchema({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||||
|
compareGenerator({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||||
|
|
||||||
|
const baselineRuntime = baselineSafe?.posture?.runtime || {};
|
||||||
|
const currentRuntime = currentSafe?.posture?.runtime || {};
|
||||||
|
|
||||||
|
compareBooleanFindings({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
codeOnEnable: "UNSIGNED_MODE_ENABLED",
|
||||||
|
codeOnDisable: "UNSIGNED_MODE_DISABLED",
|
||||||
|
path: "posture.runtime.risky_toggles.allow_unsigned_mode",
|
||||||
|
before: baselineRuntime?.risky_toggles?.allow_unsigned_mode,
|
||||||
|
after: currentRuntime?.risky_toggles?.allow_unsigned_mode,
|
||||||
|
enableSeverity: "critical",
|
||||||
|
});
|
||||||
|
|
||||||
|
compareBooleanFindings({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
codeOnEnable: "BYPASS_VERIFICATION_ENABLED",
|
||||||
|
codeOnDisable: "BYPASS_VERIFICATION_DISABLED",
|
||||||
|
path: "posture.runtime.risky_toggles.bypass_verification",
|
||||||
|
before: baselineRuntime?.risky_toggles?.bypass_verification,
|
||||||
|
after: currentRuntime?.risky_toggles?.bypass_verification,
|
||||||
|
enableSeverity: "critical",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const gateway of ["telegram", "matrix", "discord"]) {
|
||||||
|
compareBooleanFindings({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
codeOnEnable: "GATEWAY_ENABLED",
|
||||||
|
codeOnDisable: "GATEWAY_DISABLED",
|
||||||
|
path: `posture.runtime.gateways.${gateway}`,
|
||||||
|
before: baselineRuntime?.gateways?.[gateway],
|
||||||
|
after: currentRuntime?.gateways?.[gateway],
|
||||||
|
enableSeverity: "low",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
compareFeedVerification({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
baselineFeed: baselineSafe?.posture?.feed_verification,
|
||||||
|
currentFeed: currentSafe?.posture?.feed_verification,
|
||||||
|
});
|
||||||
|
|
||||||
|
compareHashedEntries({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
beforeEntries: baselineSafe?.posture?.integrity?.trust_anchors,
|
||||||
|
afterEntries: currentSafe?.posture?.integrity?.trust_anchors,
|
||||||
|
changedCode: "TRUST_ANCHOR_MISMATCH",
|
||||||
|
missingCode: "TRUST_ANCHOR_REMOVED",
|
||||||
|
});
|
||||||
|
|
||||||
|
compareHashedEntries({
|
||||||
|
findings,
|
||||||
|
summary,
|
||||||
|
beforeEntries: baselineSafe?.posture?.integrity?.watched_files,
|
||||||
|
afterEntries: currentSafe?.posture?.integrity?.watched_files,
|
||||||
|
changedCode: "WATCHED_FILE_DRIFT",
|
||||||
|
missingCode: "WATCHED_FILE_REMOVED",
|
||||||
|
});
|
||||||
|
|
||||||
|
findings.sort((a, b) => {
|
||||||
|
const sev = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
||||||
|
if (sev !== 0) return sev;
|
||||||
|
const codeCmp = String(a.code || "").localeCompare(String(b.code || ""));
|
||||||
|
if (codeCmp !== 0) return codeCmp;
|
||||||
|
return String(a.path || "").localeCompare(String(b.path || ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highestSeverity(findings = []) {
|
||||||
|
for (const severity of SEVERITY_ORDER) {
|
||||||
|
if (findings.some((finding) => finding?.severity === severity)) {
|
||||||
|
return severity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function severityAtOrAbove(severity, threshold) {
|
||||||
|
if (!threshold || threshold === "none") return false;
|
||||||
|
const idx = SEVERITY_ORDER.indexOf(severity);
|
||||||
|
const thresholdIdx = SEVERITY_ORDER.indexOf(threshold);
|
||||||
|
if (idx < 0 || thresholdIdx < 0) return false;
|
||||||
|
return idx <= thresholdIdx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
buildAttestation,
|
||||||
|
defaultOutputPath,
|
||||||
|
parseAttestationPolicy,
|
||||||
|
resolveHermesScopedOutputPath,
|
||||||
|
sha256FileHex,
|
||||||
|
stableStringify,
|
||||||
|
} from "../lib/attestation.mjs";
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/generate_attestation.mjs [options]",
|
||||||
|
"",
|
||||||
|
"Options:",
|
||||||
|
" --output <path> Output file path (default: ~/.hermes/security/attestations/current.json)",
|
||||||
|
" --policy <path> JSON policy file with watch_files and trust_anchor_files arrays",
|
||||||
|
" --watch <path> Extra watched file path (repeatable)",
|
||||||
|
" --trust-anchor <path> Extra trust anchor file path (repeatable)",
|
||||||
|
" --generated-at <iso> Override generated_at for deterministic testing",
|
||||||
|
" --write-sha256 Also write <output>.sha256 with file digest",
|
||||||
|
" --compact Write compact JSON (no indentation)",
|
||||||
|
" --help Show this help",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
output: defaultOutputPath(),
|
||||||
|
policyPath: null,
|
||||||
|
watch: [],
|
||||||
|
trustAnchor: [],
|
||||||
|
generatedAt: process.env.HERMES_ATTESTATION_GENERATED_AT || null,
|
||||||
|
writeSha256: false,
|
||||||
|
compact: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
|
||||||
|
if (token === "--help") {
|
||||||
|
args.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--output") {
|
||||||
|
args.output = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--policy") {
|
||||||
|
args.policyPath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--watch") {
|
||||||
|
args.watch.push(argv[i + 1]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--trust-anchor") {
|
||||||
|
args.trustAnchor.push(argv[i + 1]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--generated-at") {
|
||||||
|
args.generatedAt = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--write-sha256") {
|
||||||
|
args.writeSha256 = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--compact") {
|
||||||
|
args.compact = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymlinkPath(filePath) {
|
||||||
|
try {
|
||||||
|
return fs.lstatSync(filePath).isSymbolicLink();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAtomically(outPath, body) {
|
||||||
|
const dir = path.dirname(outPath);
|
||||||
|
const base = path.basename(outPath);
|
||||||
|
const tempPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
|
let fd = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
||||||
|
fs.writeFileSync(fd, body, "utf8");
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
fd = null;
|
||||||
|
|
||||||
|
if (isSymlinkPath(outPath)) {
|
||||||
|
throw new Error(`output path must not be a symlink: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(tempPath, outPath);
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.generatedAt && Number.isNaN(Date.parse(args.generatedAt))) {
|
||||||
|
throw new Error(`Invalid --generated-at value: ${args.generatedAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = args.policyPath
|
||||||
|
? parseAttestationPolicy(fs.readFileSync(path.resolve(args.policyPath), "utf8"))
|
||||||
|
: parseAttestationPolicy(null);
|
||||||
|
|
||||||
|
const attestation = buildAttestation({
|
||||||
|
generatedAt: args.generatedAt,
|
||||||
|
policy,
|
||||||
|
extraWatchFiles: args.watch,
|
||||||
|
extraTrustAnchorFiles: args.trustAnchor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outPath = resolveHermesScopedOutputPath(args.output);
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
const body = stableStringify(attestation, args.compact ? 0 : 2);
|
||||||
|
writeAtomically(outPath, `${body}\n`);
|
||||||
|
|
||||||
|
if (args.writeSha256) {
|
||||||
|
const shaPath = `${outPath}.sha256`;
|
||||||
|
const digest = sha256FileHex(outPath);
|
||||||
|
fs.writeFileSync(shaPath, `${digest} ${path.basename(outPath)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`${stableStringify({
|
||||||
|
level: "INFO",
|
||||||
|
message: "attestation generated",
|
||||||
|
output: outPath,
|
||||||
|
canonical_sha256: attestation.digests.canonical_sha256,
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { detectHermesHome, resolveHermesScopedOutputPath } from "../lib/attestation.mjs";
|
||||||
|
|
||||||
|
const MARKER_START = "# >>> hermes-attestation-guardian >>>";
|
||||||
|
const MARKER_END = "# <<< hermes-attestation-guardian <<<";
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/setup_attestation_cron.mjs [options]",
|
||||||
|
"",
|
||||||
|
"Options:",
|
||||||
|
" --every <Nh|Nd> Interval cadence (default: 6h)",
|
||||||
|
" --policy <path> Optional policy file passed to generator",
|
||||||
|
" --baseline <path> Optional baseline path passed to verifier",
|
||||||
|
" --baseline-sha256 <hex> Trusted baseline SHA256 passed to verifier",
|
||||||
|
" --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",
|
||||||
|
" --print-only Print resulting cron block (default)",
|
||||||
|
" --help Show this help",
|
||||||
|
"",
|
||||||
|
"Hermes assumptions:",
|
||||||
|
"- Writes only under ~/.hermes paths by default",
|
||||||
|
"- Uses Node + this skill's scripts only",
|
||||||
|
"- No OpenClaw runtime dependencies",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
every: process.env.HERMES_ATTESTATION_INTERVAL || "6h",
|
||||||
|
policy: process.env.HERMES_ATTESTATION_POLICY || null,
|
||||||
|
baseline: process.env.HERMES_ATTESTATION_BASELINE || null,
|
||||||
|
baselineSha256: process.env.HERMES_ATTESTATION_BASELINE_SHA256 || null,
|
||||||
|
baselineSignature: process.env.HERMES_ATTESTATION_BASELINE_SIGNATURE || null,
|
||||||
|
baselinePublicKey: process.env.HERMES_ATTESTATION_BASELINE_PUBLIC_KEY || null,
|
||||||
|
output: process.env.HERMES_ATTESTATION_OUTPUT_DIR
|
||||||
|
? path.join(process.env.HERMES_ATTESTATION_OUTPUT_DIR, "current.json")
|
||||||
|
: null,
|
||||||
|
apply: false,
|
||||||
|
printOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (token === "--help") {
|
||||||
|
args.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--every") {
|
||||||
|
args.every = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--policy") {
|
||||||
|
args.policy = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline") {
|
||||||
|
args.baseline = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-sha256") {
|
||||||
|
args.baselineSha256 = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-signature") {
|
||||||
|
args.baselineSignature = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-public-key") {
|
||||||
|
args.baselinePublicKey = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--output") {
|
||||||
|
args.output = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
const verifier = path.join(scriptDir, "verify_attestation.mjs");
|
||||||
|
|
||||||
|
const outputArg = output ? `--output '${escapeForShell(path.resolve(output))}'` : "";
|
||||||
|
const policyArg = policy ? `--policy '${escapeForShell(path.resolve(policy))}'` : "";
|
||||||
|
const baselineArg = baseline ? `--baseline '${escapeForShell(path.resolve(baseline))}'` : "";
|
||||||
|
const baselineShaArg = baselineSha256 ? `--baseline-expected-sha256 '${escapeForShell(String(baselineSha256).trim())}'` : "";
|
||||||
|
const baselineSigArg = baselineSignature
|
||||||
|
? `--baseline-signature '${escapeForShell(path.resolve(baselineSignature))}'`
|
||||||
|
: "";
|
||||||
|
const baselinePubArg = baselinePublicKey
|
||||||
|
? `--baseline-public-key '${escapeForShell(path.resolve(baselinePublicKey))}'`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
`node '${escapeForShell(generator)}' ${outputArg} ${policyArg}`.replace(/\s+/g, " ").trim(),
|
||||||
|
`node '${escapeForShell(verifier)}' --input '${escapeForShell(path.resolve(output || path.join(detectHermesHome(), "security", "attestations", "current.json")))}' ${baselineArg} ${baselineShaArg} ${baselineSigArg} ${baselinePubArg}`
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim(),
|
||||||
|
].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) {
|
||||||
|
usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesHome = path.resolve(detectHermesHome());
|
||||||
|
const output = resolveHermesScopedOutputPath(args.output, hermesHome);
|
||||||
|
|
||||||
|
if (args.baseline && !args.baselineSha256 && !(args.baselineSignature && args.baselinePublicKey)) {
|
||||||
|
throw new Error(
|
||||||
|
"baseline scheduling requires --baseline-sha256 or both --baseline-signature and --baseline-public-key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronExpr = cadenceToCron(args.every);
|
||||||
|
const command = buildCronCommand({
|
||||||
|
output,
|
||||||
|
policy: args.policy,
|
||||||
|
baseline: args.baseline,
|
||||||
|
baselineSha256: args.baselineSha256,
|
||||||
|
baselineSignature: args.baselineSignature,
|
||||||
|
baselinePublicKey: args.baselinePublicKey,
|
||||||
|
});
|
||||||
|
const block = buildCronBlock({ cronExpr, command, hermesHome });
|
||||||
|
|
||||||
|
const preflightLines = [
|
||||||
|
"Preflight review:",
|
||||||
|
"- This helper configures recurring Hermes attestation generation + verification.",
|
||||||
|
`- Hermes home: ${hermesHome}`,
|
||||||
|
`- Attestation output: ${output}`,
|
||||||
|
`- Cadence: ${args.every} (${cronExpr})`,
|
||||||
|
`- Baseline: ${args.baseline ? path.resolve(args.baseline) : "not configured"}`,
|
||||||
|
`- Baseline trusted sha256: ${args.baselineSha256 ? String(args.baselineSha256).trim() : "not configured"}`,
|
||||||
|
`- Baseline signature: ${args.baselineSignature ? path.resolve(args.baselineSignature) : "not configured"}`,
|
||||||
|
`- Baseline public key: ${args.baselinePublicKey ? path.resolve(args.baselinePublicKey) : "not configured"}`,
|
||||||
|
`- 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
defaultOutputPath,
|
||||||
|
sha256Hex,
|
||||||
|
stableStringify,
|
||||||
|
validateAttestationSchema,
|
||||||
|
validateDigestBinding,
|
||||||
|
} from "../lib/attestation.mjs";
|
||||||
|
import { diffAttestations, highestSeverity, severityAtOrAbove } from "../lib/diff.mjs";
|
||||||
|
|
||||||
|
const SEVERITIES = ["critical", "high", "medium", "low", "info", "none"];
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
input: defaultOutputPath(),
|
||||||
|
expectedSha256: null,
|
||||||
|
signaturePath: null,
|
||||||
|
publicKeyPath: null,
|
||||||
|
baselinePath: process.env.HERMES_ATTESTATION_BASELINE || null,
|
||||||
|
baselineExpectedSha256: process.env.HERMES_ATTESTATION_BASELINE_SHA256 || null,
|
||||||
|
baselineSignaturePath: process.env.HERMES_ATTESTATION_BASELINE_SIGNATURE || null,
|
||||||
|
baselinePublicKeyPath: process.env.HERMES_ATTESTATION_BASELINE_PUBLIC_KEY || null,
|
||||||
|
failOnSeverity: process.env.HERMES_ATTESTATION_FAIL_ON_SEVERITY || "critical",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
|
||||||
|
if (token === "--help") {
|
||||||
|
args.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--input") {
|
||||||
|
args.input = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--expected-sha256") {
|
||||||
|
args.expectedSha256 = String(argv[i + 1] || "").trim().toLowerCase();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--signature") {
|
||||||
|
args.signaturePath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--public-key") {
|
||||||
|
args.publicKeyPath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline") {
|
||||||
|
args.baselinePath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-expected-sha256") {
|
||||||
|
args.baselineExpectedSha256 = String(argv[i + 1] || "").trim().toLowerCase();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-signature") {
|
||||||
|
args.baselineSignaturePath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--baseline-public-key") {
|
||||||
|
args.baselinePublicKeyPath = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--fail-on-severity") {
|
||||||
|
args.failOnSeverity = String(argv[i + 1] || "").trim().toLowerCase();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/verify_attestation.mjs [options]",
|
||||||
|
"",
|
||||||
|
"Options:",
|
||||||
|
" --input <path> Attestation JSON path",
|
||||||
|
" --expected-sha256 <hex> Require exact file SHA256 match",
|
||||||
|
" --signature <path> Detached signature file path (base64 or raw binary)",
|
||||||
|
" --public-key <path> Public key PEM for signature verification",
|
||||||
|
" --baseline <path> Baseline attestation for diffing",
|
||||||
|
" --baseline-expected-sha256 <hex> Trusted baseline file SHA256",
|
||||||
|
" --baseline-signature <path> Baseline detached signature",
|
||||||
|
" --baseline-public-key <path> Public key PEM for baseline signature verification",
|
||||||
|
" --fail-on-severity <level> none|critical|high|medium|low|info (default: critical)",
|
||||||
|
" --help Show this help",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignature(signaturePath) {
|
||||||
|
const raw = fs.readFileSync(signaturePath);
|
||||||
|
const utf8 = raw.toString("utf8").trim();
|
||||||
|
if (/^[A-Za-z0-9+/=\n\r]+$/.test(utf8)) {
|
||||||
|
try {
|
||||||
|
return Buffer.from(utf8.replace(/\s+/g, ""), "base64");
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyDetachedSignature({ inputBytes, signaturePath, publicKeyPath }) {
|
||||||
|
const signature = parseSignature(signaturePath);
|
||||||
|
const pubKeyPem = fs.readFileSync(publicKeyPath, "utf8");
|
||||||
|
const pubKey = crypto.createPublicKey(pubKeyPem);
|
||||||
|
return crypto.verify(null, inputBytes, pubKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSha256Hex(value) {
|
||||||
|
return /^[a-f0-9]{64}$/.test(String(value || "").trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFinding(finding) {
|
||||||
|
const sev = String(finding.severity || "info").toUpperCase();
|
||||||
|
process.stdout.write(`${sev}: ${finding.code} - ${finding.message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSchemaAndDigestBinding({ attestation, schemaInvalidCode, canonicalDigestMismatchCode, verificationFindings, failures }) {
|
||||||
|
const schemaErrors = validateAttestationSchema(attestation);
|
||||||
|
for (const message of schemaErrors) {
|
||||||
|
verificationFindings.push({ severity: "critical", code: schemaInvalidCode, message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const digestBindingError = validateDigestBinding(attestation);
|
||||||
|
if (digestBindingError) {
|
||||||
|
verificationFindings.push({ severity: "critical", code: canonicalDigestMismatchCode, message: digestBindingError });
|
||||||
|
failures.push(digestBindingError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SEVERITIES.includes(args.failOnSeverity)) {
|
||||||
|
throw new Error(`Invalid --fail-on-severity: ${args.failOnSeverity}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.baselinePath && (args.baselineExpectedSha256 || args.baselineSignaturePath || args.baselinePublicKeyPath)) {
|
||||||
|
throw new Error("baseline verification flags require --baseline");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationFindings = [];
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
const inputPath = path.resolve(args.input);
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
throw new Error(`input attestation not found: ${inputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputBytes = fs.readFileSync(inputPath);
|
||||||
|
let attestation;
|
||||||
|
try {
|
||||||
|
attestation = JSON.parse(inputBytes.toString("utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`invalid JSON attestation: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSchemaAndDigestBinding({
|
||||||
|
attestation,
|
||||||
|
schemaInvalidCode: "SCHEMA_INVALID",
|
||||||
|
canonicalDigestMismatchCode: "CANONICAL_DIGEST_MISMATCH",
|
||||||
|
verificationFindings,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileDigest = sha256Hex(inputBytes);
|
||||||
|
if (args.expectedSha256) {
|
||||||
|
if (!isSha256Hex(args.expectedSha256)) {
|
||||||
|
throw new Error("--expected-sha256 must be a 64-char sha256 hex string");
|
||||||
|
}
|
||||||
|
if (args.expectedSha256 !== fileDigest) {
|
||||||
|
const message = `file sha256 mismatch expected=${args.expectedSha256} actual=${fileDigest}`;
|
||||||
|
verificationFindings.push({ severity: "critical", code: "FILE_DIGEST_MISMATCH", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((args.signaturePath && !args.publicKeyPath) || (!args.signaturePath && args.publicKeyPath)) {
|
||||||
|
const message = "signature verification requires both --signature and --public-key";
|
||||||
|
verificationFindings.push({ severity: "critical", code: "SIGNATURE_CONFIG_INVALID", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.signaturePath && args.publicKeyPath) {
|
||||||
|
const ok = verifyDetachedSignature({
|
||||||
|
inputBytes,
|
||||||
|
signaturePath: path.resolve(args.signaturePath),
|
||||||
|
publicKeyPath: path.resolve(args.publicKeyPath),
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
const message = "detached signature verification failed";
|
||||||
|
verificationFindings.push({ severity: "critical", code: "SIGNATURE_INVALID", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff = null;
|
||||||
|
if (args.baselinePath) {
|
||||||
|
const baselinePath = path.resolve(args.baselinePath);
|
||||||
|
if (!fs.existsSync(baselinePath)) {
|
||||||
|
const message = `baseline not found: ${baselinePath}`;
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_MISSING", message });
|
||||||
|
failures.push(message);
|
||||||
|
} else {
|
||||||
|
const baselineBytes = fs.readFileSync(baselinePath);
|
||||||
|
const baselineTrustViaDigest = !!args.baselineExpectedSha256;
|
||||||
|
const baselineTrustViaSignature = !!args.baselineSignaturePath || !!args.baselinePublicKeyPath;
|
||||||
|
|
||||||
|
if (!baselineTrustViaDigest && !baselineTrustViaSignature) {
|
||||||
|
const message =
|
||||||
|
"baseline authenticity required: provide --baseline-expected-sha256 or both --baseline-signature and --baseline-public-key";
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_UNTRUSTED", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baselineTrustViaDigest) {
|
||||||
|
if (!isSha256Hex(args.baselineExpectedSha256)) {
|
||||||
|
throw new Error("--baseline-expected-sha256 must be a 64-char sha256 hex string");
|
||||||
|
}
|
||||||
|
const baselineDigest = sha256Hex(baselineBytes);
|
||||||
|
if (baselineDigest !== args.baselineExpectedSha256) {
|
||||||
|
const message = `baseline file sha256 mismatch expected=${args.baselineExpectedSha256} actual=${baselineDigest}`;
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_DIGEST_MISMATCH", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baselineTrustViaSignature) {
|
||||||
|
if (!args.baselineSignaturePath || !args.baselinePublicKeyPath) {
|
||||||
|
const message = "baseline signature verification requires both --baseline-signature and --baseline-public-key";
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_SIGNATURE_CONFIG_INVALID", message });
|
||||||
|
failures.push(message);
|
||||||
|
} else {
|
||||||
|
const ok = verifyDetachedSignature({
|
||||||
|
inputBytes: baselineBytes,
|
||||||
|
signaturePath: path.resolve(args.baselineSignaturePath),
|
||||||
|
publicKeyPath: path.resolve(args.baselinePublicKeyPath),
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
const message = "baseline detached signature verification failed";
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_SIGNATURE_INVALID", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseline = JSON.parse(baselineBytes.toString("utf8"));
|
||||||
|
validateSchemaAndDigestBinding({
|
||||||
|
attestation: baseline,
|
||||||
|
schemaInvalidCode: "BASELINE_SCHEMA_INVALID",
|
||||||
|
canonicalDigestMismatchCode: "BASELINE_CANONICAL_DIGEST_MISMATCH",
|
||||||
|
verificationFindings,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
diff = diffAttestations(baseline, attestation);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = `invalid baseline JSON: ${error.message}`;
|
||||||
|
verificationFindings.push({ severity: "critical", code: "BASELINE_JSON_INVALID", message });
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finding of verificationFindings) {
|
||||||
|
printFinding(finding);
|
||||||
|
}
|
||||||
|
if (diff) {
|
||||||
|
for (const finding of diff.findings) {
|
||||||
|
printFinding(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
process.stderr.write(`CRITICAL: verification failed with ${failures.length} error(s)\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffHighest = highestSeverity(diff?.findings || []);
|
||||||
|
if (diffHighest && severityAtOrAbove(diffHighest, args.failOnSeverity)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`CRITICAL: diff severity threshold exceeded (highest=${diffHighest}, threshold=${args.failOnSeverity})\n`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`${stableStringify({
|
||||||
|
level: "INFO",
|
||||||
|
status: "verified",
|
||||||
|
input: inputPath,
|
||||||
|
file_sha256: fileDigest,
|
||||||
|
baseline_compared: !!diff,
|
||||||
|
diff_summary: diff?.summary || null,
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"name": "hermes-attestation-guardian",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"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",
|
||||||
|
"homepage": "https://clawsec.prompt.security/",
|
||||||
|
"platform": "hermes",
|
||||||
|
"keywords": [
|
||||||
|
"security",
|
||||||
|
"hermes",
|
||||||
|
"attestation",
|
||||||
|
"integrity",
|
||||||
|
"drift-detection",
|
||||||
|
"posture"
|
||||||
|
],
|
||||||
|
"sbom": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"required": true,
|
||||||
|
"description": "Skill documentation and operator playbook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "CHANGELOG.md",
|
||||||
|
"required": true,
|
||||||
|
"description": "Version history and release notes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"required": true,
|
||||||
|
"description": "Human-oriented overview and quickstart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "lib/attestation.mjs",
|
||||||
|
"required": true,
|
||||||
|
"description": "Attestation schema, canonicalization, digest and validation helpers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "lib/diff.mjs",
|
||||||
|
"required": true,
|
||||||
|
"description": "Baseline comparison and severity classification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/generate_attestation.mjs",
|
||||||
|
"required": true,
|
||||||
|
"description": "Generate deterministic Hermes posture attestation artifact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/verify_attestation.mjs",
|
||||||
|
"required": true,
|
||||||
|
"description": "Verify attestation schema, digest and optional detached signature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/setup_attestation_cron.mjs",
|
||||||
|
"required": true,
|
||||||
|
"description": "Optional recurring schedule setup for Hermes attestation runs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/attestation_schema.test.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Schema and determinism tests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/attestation_diff.test.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Diff and severity mapping tests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/attestation_cli.test.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Generator/verifier CLI behavior tests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/setup_attestation_cron.test.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Hermes-only cron setup tests"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"emoji": "🛡️",
|
||||||
|
"category": "security",
|
||||||
|
"requires": {
|
||||||
|
"bins": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"required_env": [],
|
||||||
|
"optional_env": [
|
||||||
|
"HERMES_HOME",
|
||||||
|
"HERMES_ATTESTATION_OUTPUT_DIR",
|
||||||
|
"HERMES_ATTESTATION_BASELINE",
|
||||||
|
"HERMES_ATTESTATION_INTERVAL",
|
||||||
|
"HERMES_ATTESTATION_FAIL_ON_SEVERITY",
|
||||||
|
"HERMES_ATTESTATION_POLICY"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
],
|
||||||
|
"triggers": [
|
||||||
|
"generate hermes attestation",
|
||||||
|
"verify hermes attestation",
|
||||||
|
"hermes runtime drift detection",
|
||||||
|
"hermes trust anchor drift",
|
||||||
|
"setup hermes attestation cron"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/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 generatorScript = path.join(skillRoot, "scripts", "generate_attestation.mjs");
|
||||||
|
const verifierScript = path.join(skillRoot, "scripts", "verify_attestation.mjs");
|
||||||
|
|
||||||
|
function runNode(scriptPath, args = [], extraEnv = {}) {
|
||||||
|
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||||
|
cwd: skillRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...extraEnv },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTempDir(run) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-cli-"));
|
||||||
|
try {
|
||||||
|
await run(dir);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const attestationsDir = path.join(hermesHome, "security", "attestations");
|
||||||
|
const outputPath = path.join(attestationsDir, "current.json");
|
||||||
|
const baselinePath = path.join(attestationsDir, "baseline.json");
|
||||||
|
const watchedPath = path.join(tempDir, "config.json");
|
||||||
|
|
||||||
|
await fs.mkdir(attestationsDir, { recursive: true });
|
||||||
|
await fs.writeFile(watchedPath, JSON.stringify({ secure: true }), "utf8");
|
||||||
|
|
||||||
|
const generatedAt = "2026-04-15T18:01:00.000Z";
|
||||||
|
const generate = runNode(
|
||||||
|
generatorScript,
|
||||||
|
["--output", outputPath, "--watch", watchedPath, "--generated-at", generatedAt, "--write-sha256"],
|
||||||
|
{ HERMES_HOME: hermesHome },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(generate.status, 0, `generate failed: ${generate.stderr}`);
|
||||||
|
const attestationRaw = await fs.readFile(outputPath, "utf8");
|
||||||
|
const attestation = JSON.parse(attestationRaw);
|
||||||
|
assert.equal(attestation.platform, "hermes");
|
||||||
|
assert.equal(attestation.generated_at, generatedAt);
|
||||||
|
|
||||||
|
const verify = runNode(verifierScript, ["--input", outputPath]);
|
||||||
|
assert.equal(verify.status, 0, `verify should pass: ${verify.stderr}`);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await fs.writeFile(baselinePath, attestationRaw, "utf8");
|
||||||
|
const baselineDigest = crypto.createHash("sha256").update(attestationRaw).digest("hex");
|
||||||
|
|
||||||
|
const verifyUntrustedBaseline = runNode(verifierScript, ["--input", outputPath, "--baseline", baselinePath]);
|
||||||
|
assert.notEqual(verifyUntrustedBaseline.status, 0, "baseline diff must fail when baseline is unauthenticated");
|
||||||
|
assert.ok(verifyUntrustedBaseline.stdout.includes("BASELINE_UNTRUSTED"), verifyUntrustedBaseline.stdout);
|
||||||
|
|
||||||
|
const verifyTrustedBaseline = runNode(verifierScript, [
|
||||||
|
"--input",
|
||||||
|
outputPath,
|
||||||
|
"--baseline",
|
||||||
|
baselinePath,
|
||||||
|
"--baseline-expected-sha256",
|
||||||
|
baselineDigest,
|
||||||
|
]);
|
||||||
|
assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);
|
||||||
|
|
||||||
|
const hardLinkPath = path.join(attestationsDir, "current-hardlink.json");
|
||||||
|
const oldContent = "old-attestation-body\n";
|
||||||
|
await fs.writeFile(outputPath, oldContent, "utf8");
|
||||||
|
await fs.link(outputPath, hardLinkPath);
|
||||||
|
|
||||||
|
const atomicRewrite = runNode(generatorScript, ["--output", outputPath, "--generated-at", generatedAt], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
assert.equal(atomicRewrite.status, 0, `atomic rewrite failed: ${atomicRewrite.stderr}`);
|
||||||
|
|
||||||
|
const rewrittenContent = await fs.readFile(outputPath, "utf8");
|
||||||
|
const hardLinkedContent = await fs.readFile(hardLinkPath, "utf8");
|
||||||
|
assert.notEqual(rewrittenContent, hardLinkedContent, "output rewrite should atomically replace file entry");
|
||||||
|
assert.equal(hardLinkedContent, oldContent, "hard link should preserve previous file body after atomic replace");
|
||||||
|
|
||||||
|
const invalidCurrent = JSON.parse(attestationRaw);
|
||||||
|
delete invalidCurrent.platform;
|
||||||
|
await fs.writeFile(outputPath, JSON.stringify(invalidCurrent, null, 2), "utf8");
|
||||||
|
|
||||||
|
const verifyInvalidCurrent = runNode(verifierScript, ["--input", outputPath]);
|
||||||
|
assert.notEqual(verifyInvalidCurrent.status, 0, "schema-invalid current attestation must be rejected");
|
||||||
|
assert.ok(verifyInvalidCurrent.stdout.includes("SCHEMA_INVALID"), verifyInvalidCurrent.stdout);
|
||||||
|
|
||||||
|
await fs.writeFile(outputPath, attestationRaw, "utf8");
|
||||||
|
|
||||||
|
const baselineCanonicalMismatch = JSON.parse(attestationRaw);
|
||||||
|
baselineCanonicalMismatch.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||||
|
const baselineCanonicalMismatchRaw = JSON.stringify(baselineCanonicalMismatch, null, 2);
|
||||||
|
await fs.writeFile(baselinePath, baselineCanonicalMismatchRaw, "utf8");
|
||||||
|
const baselineCanonicalMismatchDigest = crypto.createHash("sha256").update(baselineCanonicalMismatchRaw).digest("hex");
|
||||||
|
|
||||||
|
const verifyBaselineCanonicalMismatch = runNode(verifierScript, [
|
||||||
|
"--input",
|
||||||
|
outputPath,
|
||||||
|
"--baseline",
|
||||||
|
baselinePath,
|
||||||
|
"--baseline-expected-sha256",
|
||||||
|
baselineCanonicalMismatchDigest,
|
||||||
|
]);
|
||||||
|
assert.notEqual(verifyBaselineCanonicalMismatch.status, 0, "baseline canonical digest mismatch must be rejected");
|
||||||
|
assert.ok(
|
||||||
|
verifyBaselineCanonicalMismatch.stdout.includes("BASELINE_CANONICAL_DIGEST_MISMATCH"),
|
||||||
|
verifyBaselineCanonicalMismatch.stdout,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baselineSchemaInvalid = JSON.parse(attestationRaw);
|
||||||
|
delete baselineSchemaInvalid.platform;
|
||||||
|
const baselineSchemaInvalidRaw = JSON.stringify(baselineSchemaInvalid, null, 2);
|
||||||
|
await fs.writeFile(baselinePath, baselineSchemaInvalidRaw, "utf8");
|
||||||
|
const baselineSchemaInvalidDigest = crypto.createHash("sha256").update(baselineSchemaInvalidRaw).digest("hex");
|
||||||
|
|
||||||
|
const verifyBaselineSchemaInvalid = runNode(verifierScript, [
|
||||||
|
"--input",
|
||||||
|
outputPath,
|
||||||
|
"--baseline",
|
||||||
|
baselinePath,
|
||||||
|
"--baseline-expected-sha256",
|
||||||
|
baselineSchemaInvalidDigest,
|
||||||
|
]);
|
||||||
|
assert.notEqual(verifyBaselineSchemaInvalid.status, 0, "schema-invalid baseline must be rejected");
|
||||||
|
assert.ok(verifyBaselineSchemaInvalid.stdout.includes("BASELINE_SCHEMA_INVALID"), verifyBaselineSchemaInvalid.stdout);
|
||||||
|
|
||||||
|
const baselineTampered = JSON.parse(attestationRaw);
|
||||||
|
baselineTampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||||
|
await fs.writeFile(baselinePath, JSON.stringify(baselineTampered, null, 2), "utf8");
|
||||||
|
|
||||||
|
const verifyTamperedBaseline = runNode(verifierScript, [
|
||||||
|
"--input",
|
||||||
|
outputPath,
|
||||||
|
"--baseline",
|
||||||
|
baselinePath,
|
||||||
|
"--baseline-expected-sha256",
|
||||||
|
baselineDigest,
|
||||||
|
]);
|
||||||
|
assert.notEqual(verifyTamperedBaseline.status, 0, "tampered baseline must be rejected");
|
||||||
|
assert.ok(verifyTamperedBaseline.stdout.includes("BASELINE_DIGEST_MISMATCH"), verifyTamperedBaseline.stdout);
|
||||||
|
|
||||||
|
const tampered = JSON.parse(attestationRaw);
|
||||||
|
tampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||||
|
await fs.writeFile(outputPath, JSON.stringify(tampered, null, 2), "utf8");
|
||||||
|
|
||||||
|
const verifyTampered = runNode(verifierScript, ["--input", outputPath]);
|
||||||
|
assert.notEqual(verifyTampered.status, 0, "verify must fail closed after tampering");
|
||||||
|
assert.ok(
|
||||||
|
verifyTampered.stderr.includes("CRITICAL") || verifyTampered.stdout.includes("CANONICAL_DIGEST_MISMATCH"),
|
||||||
|
`expected critical verification signal, got stdout=${verifyTampered.stdout} stderr=${verifyTampered.stderr}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const securityDir = path.join(hermesHome, "security");
|
||||||
|
const attestationsDir = path.join(securityDir, "attestations");
|
||||||
|
const escapedDir = path.join(tempDir, "escaped-attestations");
|
||||||
|
const outputPath = path.join(attestationsDir, "current.json");
|
||||||
|
|
||||||
|
await fs.mkdir(securityDir, { recursive: true });
|
||||||
|
await fs.mkdir(escapedDir, { recursive: true });
|
||||||
|
await fs.symlink(escapedDir, attestationsDir, "dir");
|
||||||
|
|
||||||
|
const symlinkEscape = runNode(generatorScript, ["--output", outputPath], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
assert.notEqual(symlinkEscape.status, 0, "generator must reject symlink-based output path escapes");
|
||||||
|
assert.ok(symlinkEscape.stderr.includes("output path must stay under"), symlinkEscape.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const attestationsDir = path.join(hermesHome, "security", "attestations");
|
||||||
|
const outputPath = path.join(attestationsDir, "broken-link.json");
|
||||||
|
|
||||||
|
await fs.mkdir(attestationsDir, { recursive: true });
|
||||||
|
await fs.symlink(path.join(tempDir, "outside-target.json"), outputPath);
|
||||||
|
|
||||||
|
const brokenSymlinkOutput = runNode(generatorScript, ["--output", outputPath], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
assert.notEqual(brokenSymlinkOutput.status, 0, "generator must reject broken symlink output paths");
|
||||||
|
assert.ok(brokenSymlinkOutput.stderr.includes("output path must not be a symlink"), brokenSymlinkOutput.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("attestation_cli.test.mjs: ok");
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { diffAttestations, highestSeverity, severityAtOrAbove } from "../lib/diff.mjs";
|
||||||
|
|
||||||
|
const baseline = {
|
||||||
|
schema_version: "0.0.1",
|
||||||
|
platform: "hermes",
|
||||||
|
generator: { version: "0.0.1" },
|
||||||
|
posture: {
|
||||||
|
runtime: {
|
||||||
|
gateways: { telegram: true, matrix: false, discord: false },
|
||||||
|
risky_toggles: {
|
||||||
|
allow_unsigned_mode: false,
|
||||||
|
bypass_verification: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
feed_verification: { status: "verified" },
|
||||||
|
integrity: {
|
||||||
|
trust_anchors: [{ path: "/etc/hermes/trust.pem", sha256: "aaa" }],
|
||||||
|
watched_files: [{ path: "/etc/hermes/config.json", sha256: "bbb" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const drifted = {
|
||||||
|
schema_version: "0.0.1",
|
||||||
|
platform: "hermes",
|
||||||
|
generator: { version: "0.0.2" },
|
||||||
|
posture: {
|
||||||
|
runtime: {
|
||||||
|
gateways: { telegram: true, matrix: true, discord: false },
|
||||||
|
risky_toggles: {
|
||||||
|
allow_unsigned_mode: true,
|
||||||
|
bypass_verification: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
feed_verification: { status: "unverified" },
|
||||||
|
integrity: {
|
||||||
|
trust_anchors: [{ path: "/etc/hermes/trust.pem", sha256: "ccc" }],
|
||||||
|
watched_files: [{ path: "/etc/hermes/config.json", sha256: "ddd" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const clean = JSON.parse(JSON.stringify(baseline));
|
||||||
|
|
||||||
|
const driftOut = diffAttestations(baseline, drifted);
|
||||||
|
assert.ok(Array.isArray(driftOut.findings));
|
||||||
|
assert.ok(driftOut.findings.length >= 4, "expected multiple meaningful drift findings");
|
||||||
|
assert.ok(driftOut.findings.some((f) => f.code === "UNSIGNED_MODE_ENABLED"));
|
||||||
|
assert.ok(driftOut.findings.some((f) => f.code === "FEED_VERIFICATION_REGRESSION"));
|
||||||
|
assert.ok(driftOut.findings.some((f) => f.code === "TRUST_ANCHOR_MISMATCH"));
|
||||||
|
assert.ok(driftOut.findings.some((f) => f.code === "WATCHED_FILE_DRIFT"));
|
||||||
|
assert.equal(highestSeverity(driftOut.findings), "critical");
|
||||||
|
assert.equal(severityAtOrAbove("critical", "high"), true);
|
||||||
|
assert.equal(severityAtOrAbove("low", "critical"), false);
|
||||||
|
|
||||||
|
const cleanOut = diffAttestations(baseline, clean);
|
||||||
|
assert.equal(cleanOut.findings.length, 0, "identical attestations should produce no findings");
|
||||||
|
assert.deepEqual(cleanOut.summary, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
|
||||||
|
|
||||||
|
console.log("attestation_diff.test.mjs: ok");
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
#!/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 {
|
||||||
|
buildAttestation,
|
||||||
|
computeCanonicalDigest,
|
||||||
|
parseAttestationPolicy,
|
||||||
|
stableStringify,
|
||||||
|
validateAttestationSchema,
|
||||||
|
validateDigestBinding,
|
||||||
|
} from "../lib/attestation.mjs";
|
||||||
|
|
||||||
|
async function withTempDir(run) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-schema-"));
|
||||||
|
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 testBuildAttestationIsSchemaValidAndDeterministic() {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const watchedFile = path.join(tempDir, "watch.txt");
|
||||||
|
const trustAnchor = path.join(tempDir, "anchor.pem");
|
||||||
|
await fs.writeFile(watchedFile, "watch-contents\n", "utf8");
|
||||||
|
await fs.writeFile(trustAnchor, "trust-anchor\n", "utf8");
|
||||||
|
|
||||||
|
const policy = parseAttestationPolicy(
|
||||||
|
JSON.stringify({ watch_files: [watchedFile], trust_anchor_files: [trustAnchor] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const generatedAt = "2026-04-15T18:00:00.000Z";
|
||||||
|
const first = buildAttestation({ generatedAt, policy });
|
||||||
|
const second = buildAttestation({ generatedAt, policy });
|
||||||
|
|
||||||
|
assert.deepEqual(first, second, "attestation must be deterministic for fixed inputs");
|
||||||
|
assert.equal(first.platform, "hermes");
|
||||||
|
assert.equal(first.schema_version, "0.0.1");
|
||||||
|
assert.equal(first.generated_at, generatedAt);
|
||||||
|
|
||||||
|
const schemaErrors = validateAttestationSchema(first);
|
||||||
|
assert.equal(schemaErrors.length, 0, `schema errors: ${schemaErrors.join(", ")}`);
|
||||||
|
|
||||||
|
const computedDigest = computeCanonicalDigest(first);
|
||||||
|
assert.equal(first.digests.canonical_sha256, computedDigest, "digest must match canonical payload");
|
||||||
|
|
||||||
|
const stableOne = stableStringify(first);
|
||||||
|
const stableTwo = stableStringify(second);
|
||||||
|
assert.equal(stableOne, stableTwo, "stable stringify should produce same output ordering");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSchemaValidationFailsClosed() {
|
||||||
|
const invalid = {
|
||||||
|
schema_version: "0.0.0",
|
||||||
|
platform: "openclaw",
|
||||||
|
generated_at: "not-a-date",
|
||||||
|
digests: { canonical_sha256: "1234" },
|
||||||
|
};
|
||||||
|
const errors = validateAttestationSchema(invalid);
|
||||||
|
assert.ok(errors.length >= 4, "invalid schema should emit multiple errors");
|
||||||
|
assert.ok(errors.some((msg) => msg.includes("platform must be hermes")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDigestBindingRejectsUnsupportedAlgorithm() {
|
||||||
|
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
attestation.digests.algorithm = "sha1";
|
||||||
|
|
||||||
|
const schemaErrors = validateAttestationSchema(attestation);
|
||||||
|
assert.ok(schemaErrors.some((msg) => msg.includes("digests.algorithm must be sha256")));
|
||||||
|
|
||||||
|
const digestBindingError = validateDigestBinding(attestation);
|
||||||
|
assert.ok(digestBindingError?.includes("unsupported digest algorithm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSchemaValidationRequiresGeneratorVersionNonEmptyString() {
|
||||||
|
const missingVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
delete missingVersion.generator.version;
|
||||||
|
const missingVersionErrors = validateAttestationSchema(missingVersion);
|
||||||
|
assert.ok(missingVersionErrors.includes("generator.version must be a non-empty string"));
|
||||||
|
|
||||||
|
const nonStringVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
nonStringVersion.generator.version = 7;
|
||||||
|
const nonStringVersionErrors = validateAttestationSchema(nonStringVersion);
|
||||||
|
assert.ok(nonStringVersionErrors.includes("generator.version must be a non-empty string"));
|
||||||
|
|
||||||
|
const emptyVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
emptyVersion.generator.version = " ";
|
||||||
|
const emptyVersionErrors = validateAttestationSchema(emptyVersion);
|
||||||
|
assert.ok(emptyVersionErrors.includes("generator.version must be a non-empty string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans() {
|
||||||
|
const valid = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
const validErrors = validateAttestationSchema(valid);
|
||||||
|
assert.equal(validErrors.length, 0, `valid attestation should pass schema: ${validErrors.join(", ")}`);
|
||||||
|
|
||||||
|
const missingGateways = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
delete missingGateways.posture.runtime.gateways;
|
||||||
|
const missingGatewaysErrors = validateAttestationSchema(missingGateways);
|
||||||
|
assert.ok(missingGatewaysErrors.includes("posture.runtime.gateways object is required"));
|
||||||
|
|
||||||
|
const malformedGateways = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
malformedGateways.posture.runtime.gateways = "enabled";
|
||||||
|
const malformedGatewaysErrors = validateAttestationSchema(malformedGateways);
|
||||||
|
assert.ok(malformedGatewaysErrors.includes("posture.runtime.gateways object is required"));
|
||||||
|
|
||||||
|
const invalidGatewayLeaf = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
delete invalidGatewayLeaf.posture.runtime.gateways.matrix;
|
||||||
|
invalidGatewayLeaf.posture.runtime.gateways.telegram = "true";
|
||||||
|
const invalidGatewayLeafErrors = validateAttestationSchema(invalidGatewayLeaf);
|
||||||
|
assert.ok(invalidGatewayLeafErrors.includes("posture.runtime.gateways.telegram must be a boolean"));
|
||||||
|
assert.ok(invalidGatewayLeafErrors.includes("posture.runtime.gateways.matrix must be a boolean"));
|
||||||
|
|
||||||
|
const missingRiskyToggles = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
delete missingRiskyToggles.posture.runtime.risky_toggles;
|
||||||
|
const missingRiskyTogglesErrors = validateAttestationSchema(missingRiskyToggles);
|
||||||
|
assert.ok(missingRiskyTogglesErrors.includes("posture.runtime.risky_toggles object is required"));
|
||||||
|
|
||||||
|
const malformedRiskyToggles = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
malformedRiskyToggles.posture.runtime.risky_toggles = [];
|
||||||
|
const malformedRiskyTogglesErrors = validateAttestationSchema(malformedRiskyToggles);
|
||||||
|
assert.ok(malformedRiskyTogglesErrors.includes("posture.runtime.risky_toggles object is required"));
|
||||||
|
|
||||||
|
const invalidRiskyToggleLeaf = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
delete invalidRiskyToggleLeaf.posture.runtime.risky_toggles.bypass_verification;
|
||||||
|
invalidRiskyToggleLeaf.posture.runtime.risky_toggles.allow_unsigned_mode = "false";
|
||||||
|
const invalidRiskyToggleLeafErrors = validateAttestationSchema(invalidRiskyToggleLeaf);
|
||||||
|
assert.ok(
|
||||||
|
invalidRiskyToggleLeafErrors.includes("posture.runtime.risky_toggles.allow_unsigned_mode must be a boolean"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidRiskyToggleLeafErrors.includes("posture.runtime.risky_toggles.bypass_verification must be a boolean"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSchemaValidationRequiresIntegrityEntryShapes() {
|
||||||
|
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
attestation.posture.integrity.watched_files = [
|
||||||
|
null,
|
||||||
|
{ path: "", exists: true, sha256: null },
|
||||||
|
{ path: "/etc/hermes/config.json", exists: "yes", sha256: "abc" },
|
||||||
|
];
|
||||||
|
attestation.posture.integrity.trust_anchors = [{ exists: false, sha256: 7 }];
|
||||||
|
|
||||||
|
const errors = validateAttestationSchema(attestation);
|
||||||
|
assert.ok(errors.includes("posture.integrity.watched_files[0] must be an object"));
|
||||||
|
assert.ok(errors.includes("posture.integrity.watched_files[1].path must be a non-empty string"));
|
||||||
|
assert.ok(errors.includes("posture.integrity.watched_files[2].exists must be a boolean"));
|
||||||
|
assert.ok(
|
||||||
|
errors.includes("posture.integrity.watched_files[2].sha256 must be null or a 64-char sha256 hex string"),
|
||||||
|
);
|
||||||
|
assert.ok(errors.includes("posture.integrity.trust_anchors[0].path must be a non-empty string"));
|
||||||
|
assert.ok(errors.includes("posture.integrity.trust_anchors[0].sha256 must be null or a 64-char sha256 hex string"));
|
||||||
|
|
||||||
|
const valid = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
valid.posture.integrity.watched_files = [{ path: "/tmp/a", exists: false, sha256: null }];
|
||||||
|
valid.posture.integrity.trust_anchors = [
|
||||||
|
{
|
||||||
|
path: "/tmp/t.pem",
|
||||||
|
exists: true,
|
||||||
|
sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const validErrors = validateAttestationSchema(valid);
|
||||||
|
assert.equal(validErrors.length, 0, `valid integrity entries should pass schema: ${validErrors.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
await fs.mkdir(hermesHome, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(hermesHome, "config.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
gateways: {
|
||||||
|
telegram: { enabled: "false" },
|
||||||
|
matrix: { enabled: "0" },
|
||||||
|
discord: { enabled: "off" },
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
allow_unsigned_mode: "false",
|
||||||
|
bypass_verification: "off",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await withPatchedEnv(
|
||||||
|
{
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||||
|
HERMES_GATEWAY_MATRIX_ENABLED: "1",
|
||||||
|
HERMES_GATEWAY_DISCORD_ENABLED: "yes",
|
||||||
|
HERMES_ALLOW_UNSIGNED_MODE: "true",
|
||||||
|
HERMES_BYPASS_VERIFICATION: "true",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
assert.equal(attestation.posture.runtime.gateways.telegram, false);
|
||||||
|
assert.equal(attestation.posture.runtime.gateways.matrix, false);
|
||||||
|
assert.equal(attestation.posture.runtime.gateways.discord, false);
|
||||||
|
assert.equal(attestation.posture.runtime.risky_toggles.allow_unsigned_mode, false);
|
||||||
|
assert.equal(attestation.posture.runtime.risky_toggles.bypass_verification, false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await withPatchedEnv(
|
||||||
|
{
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await fs.writeFile(path.join(hermesHome, "config.json"), JSON.stringify({}), "utf8");
|
||||||
|
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
assert.equal(attestation.posture.runtime.gateways.telegram, true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await withPatchedEnv(
|
||||||
|
{
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||||
|
HERMES_ALLOW_UNSIGNED_MODE: "true",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(hermesHome, "config.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
gateways: {
|
||||||
|
telegram: { enabled: "maybe" },
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
allow_unsigned_mode: { bad: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||||
|
assert.equal(attestation.posture.runtime.gateways.telegram, false);
|
||||||
|
assert.equal(attestation.posture.runtime.risky_toggles.allow_unsigned_mode, false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await testBuildAttestationIsSchemaValidAndDeterministic();
|
||||||
|
testSchemaValidationFailsClosed();
|
||||||
|
testDigestBindingRejectsUnsupportedAlgorithm();
|
||||||
|
testSchemaValidationRequiresGeneratorVersionNonEmptyString();
|
||||||
|
testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans();
|
||||||
|
testSchemaValidationRequiresIntegrityEntryShapes();
|
||||||
|
await testBooleanConfigCoercionDoesNotEnableFalseStrings();
|
||||||
|
console.log("attestation_schema.test.mjs: ok");
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/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_attestation_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-cron-"));
|
||||||
|
try {
|
||||||
|
await run(dir);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const result = runSetup(["--every", "6h", "--print-only"], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, `setup script failed: ${result.stderr}`);
|
||||||
|
assert.ok(result.stdout.includes("Preflight review:"));
|
||||||
|
assert.ok(result.stdout.includes("Scope: Hermes-only"));
|
||||||
|
assert.ok(result.stdout.includes("hermes-attestation-guardian"));
|
||||||
|
assert.ok(result.stdout.includes("generate_attestation.mjs"));
|
||||||
|
assert.ok(result.stdout.includes("verify_attestation.mjs"));
|
||||||
|
assert.equal(result.stdout.toLowerCase().includes("openclaw"), false, "must not mention OpenClaw runtime");
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const result = runSetup(["--print-only", "--output", path.join(tempDir, "outside.json")], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, "out-of-scope output path must be rejected");
|
||||||
|
assert.ok(result.stderr.includes("output path must stay under"), result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const hermesHome = path.join(tempDir, ".hermes");
|
||||||
|
const weirdPolicy = path.join(tempDir, "policy'withquote.json");
|
||||||
|
const result = runSetup(["--every", "6h", "--policy", weirdPolicy, "--print-only"], {
|
||||||
|
HERMES_HOME: hermesHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
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\\n', 'utf8');
|
||||||
|
process.stdout.write('# >>> hermes-attestation-guardian >>>\\n# dangling-start-no-end\\n0 0 * * * /usr/bin/true\\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
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.notEqual(result.status, 0, "unmatched start marker must fail closed");
|
||||||
|
assert.ok(result.stderr.includes("Malformed crontab 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");
|
||||||
|
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\\n', 'utf8');
|
||||||
|
process.stdout.write('# <<< hermes-attestation-guardian <<<\\n0 0 * * * /usr/bin/true\\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
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.notEqual(result.status, 0, "unmatched end marker must fail closed");
|
||||||
|
assert.ok(result.stderr.includes("Malformed crontab 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 when end marker is unmatched");
|
||||||
|
});
|
||||||
|
|
||||||
|
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\\n', 'utf8');
|
||||||
|
process.stdout.write('# >>> hermes-attestation-guardian >>>\\n# >>> hermes-attestation-guardian >>>\\n# nested-start\\n# <<< hermes-attestation-guardian <<<\\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
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.notEqual(result.status, 0, "nested start marker must fail closed");
|
||||||
|
assert.ok(result.stderr.includes("Malformed crontab 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 when marker blocks are nested");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("setup_attestation_cron.test.mjs: ok");
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
- Updated index and cross-links to use `wiki/` as the documentation source of truth.
|
- Updated index and cross-links to use `wiki/` as the documentation source of truth.
|
||||||
- Added a dedicated module page for `clawsec-scanner` and linked it from `wiki/INDEX.md`.
|
- Added a dedicated module page for `clawsec-scanner` and linked it from `wiki/INDEX.md`.
|
||||||
- Future updates should preserve existing headings and append `Update Notes` sections when making deltas.
|
- Future updates should preserve existing headings and append `Update Notes` sections when making deltas.
|
||||||
|
- 2026-04-15: Expanded `wiki/modules/hermes-attestation-guardian.md` into full narrative claim breakdowns (people-speak + wiring + verification + scenario) and moved draft-plan context into `wiki/modules/hermes-attestation-guardian-draft-history.md`.
|
||||||
|
|
||||||
## Source References
|
## Source References
|
||||||
- README.md
|
- README.md
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
- [Frontend Web App](modules/frontend-web.md)
|
- [Frontend Web App](modules/frontend-web.md)
|
||||||
- [ClawSec Suite Core](modules/clawsec-suite.md)
|
- [ClawSec Suite Core](modules/clawsec-suite.md)
|
||||||
- [ClawSec Scanner](modules/clawsec-scanner.md)
|
- [ClawSec Scanner](modules/clawsec-scanner.md)
|
||||||
|
- [Hermes Attestation Guardian](modules/hermes-attestation-guardian.md)
|
||||||
|
- [Hermes Attestation Guardian Draft History (Archived)](modules/hermes-attestation-guardian-draft-history.md)
|
||||||
- [NanoClaw Integration](modules/nanoclaw-integration.md)
|
- [NanoClaw Integration](modules/nanoclaw-integration.md)
|
||||||
- [Automation and Release Pipelines](modules/automation-release.md)
|
- [Automation and Release Pipelines](modules/automation-release.md)
|
||||||
- [Local Validation and Packaging Tools](modules/local-tooling.md)
|
- [Local Validation and Packaging Tools](modules/local-tooling.md)
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
- [Generation Metadata](GENERATION.md)
|
- [Generation Metadata](GENERATION.md)
|
||||||
|
|
||||||
## Update Notes
|
## Update Notes
|
||||||
|
- 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.
|
- 2026-03-10: Added ClawSec Scanner module documentation and linked it under Modules.
|
||||||
- 2026-02-26: Added Operations pages and updated navigation guidance after migrating root docs into wiki pages.
|
- 2026-02-26: Added Operations pages and updated navigation guidance after migrating root docs into wiki pages.
|
||||||
|
|
||||||
@@ -53,5 +57,8 @@
|
|||||||
- scripts/populate-local-skills.sh
|
- scripts/populate-local-skills.sh
|
||||||
- skills/clawsec-suite/skill.json
|
- skills/clawsec-suite/skill.json
|
||||||
- skills/clawsec-scanner/skill.json
|
- skills/clawsec-scanner/skill.json
|
||||||
|
- skills/hermes-attestation-guardian/skill.json
|
||||||
- wiki/modules/clawsec-scanner.md
|
- wiki/modules/clawsec-scanner.md
|
||||||
|
- wiki/modules/hermes-attestation-guardian.md
|
||||||
|
- wiki/modules/hermes-attestation-guardian-draft-history.md
|
||||||
- .github/workflows/ci.yml
|
- .github/workflows/ci.yml
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Module History: Hermes Attestation Guardian Draft (Archived)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This page preserves the original planning draft that led to `hermes-attestation-guardian` v0.0.1.
|
||||||
|
It is historical context, not current behavior contract.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- Draft date: 2026-04-15
|
||||||
|
- Current status: implemented in repository as `skills/hermes-attestation-guardian` v0.0.1
|
||||||
|
- Source of truth for live behavior: skill code, tests, and `wiki/modules/hermes-attestation-guardian.md`
|
||||||
|
|
||||||
|
## What the draft got right
|
||||||
|
- Hermes-only positioning (not OpenClaw hook runtime scope).
|
||||||
|
- Fail-closed verification as a core requirement.
|
||||||
|
- Deterministic attestation and digest binding requirements.
|
||||||
|
- Baseline-vs-current drift detection with severity ranking.
|
||||||
|
- Safe cron automation expectations (explicit apply, non-destructive defaults).
|
||||||
|
|
||||||
|
## Original design intent (summarized)
|
||||||
|
1) Identity and scope
|
||||||
|
- Name should clearly indicate Hermes scope and guardian role.
|
||||||
|
- Metadata should make platform targeting explicit.
|
||||||
|
|
||||||
|
2) Security outcomes
|
||||||
|
- Snapshot posture and integrity-sensitive inputs.
|
||||||
|
- Detect risky toggles, verification regressions, and trust/file drift.
|
||||||
|
- Prioritize high-signal alerts for operators.
|
||||||
|
|
||||||
|
3) Alignment rules
|
||||||
|
- Keep side effects under Hermes paths.
|
||||||
|
- Avoid destructive remediation in MVP.
|
||||||
|
- Keep operator-facing criticality clear.
|
||||||
|
|
||||||
|
4) Packaging/release compatibility
|
||||||
|
- Match ClawSec skill metadata and changelog requirements.
|
||||||
|
- Ensure local validation and test gates pass before release.
|
||||||
|
|
||||||
|
5) Delegate implementation scope
|
||||||
|
- Build generator, verifier, diff logic, cron helper, and tests.
|
||||||
|
- Keep docs aligned to implemented behavior.
|
||||||
|
|
||||||
|
## What changed from draft to implementation
|
||||||
|
- Implementation hardened path-scope checks (including symlink-aware escape defense).
|
||||||
|
- Verifier baseline trust was made explicit and fail-closed before diffing.
|
||||||
|
- Cron managed-marker parser hardened to fail closed on malformed marker structure.
|
||||||
|
- Wiki documentation now maps each PR claim to wiring and tests with human-readable operator guidance.
|
||||||
|
|
||||||
|
## Where to look now
|
||||||
|
- Live module documentation:
|
||||||
|
- `wiki/modules/hermes-attestation-guardian.md`
|
||||||
|
- Live skill implementation:
|
||||||
|
- `skills/hermes-attestation-guardian/`
|
||||||
|
- Validation tests:
|
||||||
|
- `skills/hermes-attestation-guardian/test/`
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# Module: Hermes Attestation Guardian
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Produce a deterministic Hermes runtime security snapshot (attestation).
|
||||||
|
- Verify attestation integrity in fail-closed mode before any trust decision.
|
||||||
|
- Compare trusted baseline vs current posture and classify drift severity.
|
||||||
|
- Provide a safe, Hermes-scoped automation path for periodic attestation checks.
|
||||||
|
|
||||||
|
## Install Guard Compatibility Note (2026-04-16)
|
||||||
|
- Core behavior is unchanged.
|
||||||
|
- Operator-facing wording in `SKILL.md`, `README.md`, and `skill.json` was tightened so a clean Hermes community-source install now scans as `SAFE` and installs without `--force`.
|
||||||
|
- Scheduling capability remains present via `scripts/setup_attestation_cron.mjs`; only wording changed to avoid false-positive persistence blocks in the default guard policy.
|
||||||
|
|
||||||
|
## PR Claims: Full Human-Friendly Breakdown
|
||||||
|
|
||||||
|
This section rewrites each PR claim as an operator-facing explanation, then ties it to exact code and tests.
|
||||||
|
|
||||||
|
### Claim 1: Adds deterministic attestation generation with canonicalized payload digesting.
|
||||||
|
|
||||||
|
Absolutely — in people-speak:
|
||||||
|
|
||||||
|
We create a security snapshot of Hermes in a way that is reproducible, then fingerprint it in a stable way so tampering or real drift is obvious.
|
||||||
|
|
||||||
|
What this means in practice:
|
||||||
|
1) Attestation generation
|
||||||
|
- Think of it as a report card for Hermes security posture at a moment in time.
|
||||||
|
- It records posture fields, trust anchors, watched-file hashes, and metadata.
|
||||||
|
|
||||||
|
2) Deterministic output
|
||||||
|
- Same state should produce the same attestation content.
|
||||||
|
- No noise from object insertion order or formatting randomness.
|
||||||
|
|
||||||
|
3) Canonicalization before hashing
|
||||||
|
- Payload is normalized into one canonical JSON representation.
|
||||||
|
- This removes ambiguity from normal JSON variations.
|
||||||
|
|
||||||
|
4) Digest binding
|
||||||
|
- SHA-256 is computed over canonical payload content.
|
||||||
|
- Any meaningful change to payload changes digest.
|
||||||
|
- Any post-generation tampering causes verification mismatch.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs`
|
||||||
|
- `skills/hermes-attestation-guardian/lib/attestation.mjs`
|
||||||
|
- `stableSortObject`
|
||||||
|
- `stableStringify`
|
||||||
|
- `sha256Hex`
|
||||||
|
- `buildAttestation`
|
||||||
|
- `computeCanonicalDigest`
|
||||||
|
- `validateDigestBinding`
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs`
|
||||||
|
- proves same-input determinism and canonical digest consistency.
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs`
|
||||||
|
- proves post-generation tamper causes fail-closed digest mismatch.
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- Same state: run generator twice with unchanged inputs -> same digest.
|
||||||
|
- Tampered file: flip a posture value in JSON -> verifier fails on canonical digest mismatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claim 2: Enforces fail-closed verification for schema, digest, optional expected checksum, and detached signatures.
|
||||||
|
|
||||||
|
In people-speak:
|
||||||
|
|
||||||
|
Verification is not “best effort.” If a trust check fails, verification fails. No soft pass.
|
||||||
|
|
||||||
|
What is fail-closed here:
|
||||||
|
1) Schema must be valid.
|
||||||
|
2) Canonical digest must match payload.
|
||||||
|
3) If `--expected-sha256` is supplied, file bytes must exactly match.
|
||||||
|
4) If detached signature verification is requested, signature + public key must both be present and valid.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs`
|
||||||
|
- schema checks
|
||||||
|
- digest checks
|
||||||
|
- expected checksum check
|
||||||
|
- detached signature verification
|
||||||
|
- non-zero exit on critical failure
|
||||||
|
- `skills/hermes-attestation-guardian/lib/attestation.mjs`
|
||||||
|
- `validateAttestationSchema`
|
||||||
|
- `validateDigestBinding`
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs`
|
||||||
|
- proves schema rejection and digest algorithm validation behavior.
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs`
|
||||||
|
- proves tamper path exits non-zero (fail closed).
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- CI pins expected SHA and requires detached signature.
|
||||||
|
- Artifact is modified or signed incorrectly -> verification exits non-zero and blocks pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claim 3: Adds baseline authenticity and drift-severity classification for risky toggles, feed verification regressions, trust anchor drift, and watched file drift.
|
||||||
|
|
||||||
|
In people-speak:
|
||||||
|
|
||||||
|
You only compare against a baseline after proving the baseline itself is authentic. Then differences are ranked by severity so operators can respond quickly.
|
||||||
|
|
||||||
|
What this gives operators:
|
||||||
|
1) Authenticated baseline gate
|
||||||
|
- Baseline must be trusted (pinned digest and/or detached signature trust path).
|
||||||
|
- Untrusted baseline is rejected before diffing.
|
||||||
|
|
||||||
|
2) Severity-ranked drift findings
|
||||||
|
- Critical/high/medium/low/info mapping instead of flat alerts.
|
||||||
|
- High-signal categories include:
|
||||||
|
- risky toggle enablement,
|
||||||
|
- feed verification regressions,
|
||||||
|
- trust anchor hash drift,
|
||||||
|
- watched file hash drift.
|
||||||
|
|
||||||
|
3) Policy-driven failure threshold
|
||||||
|
- Verification can fail when findings meet/exceed configured severity threshold.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- Baseline trust and diff orchestration:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs`
|
||||||
|
- Drift engine and severity mapping:
|
||||||
|
- `skills/hermes-attestation-guardian/lib/diff.mjs`
|
||||||
|
- `diffAttestations`
|
||||||
|
- `highestSeverity`
|
||||||
|
- `severityAtOrAbove`
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs`
|
||||||
|
- proves untrusted baseline rejection and digest-pinned baseline handling.
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_diff.test.mjs`
|
||||||
|
- proves classification for key drift types and highest-severity behavior.
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- Yesterday’s baseline is pinned and trusted.
|
||||||
|
- Today `allow_unsigned_mode` flips on and trust anchor hash changes.
|
||||||
|
- Diff emits critical findings and verifier can fail run by severity policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claim 4: Adds Hermes-only cron setup helper with managed marker block and print-only default.
|
||||||
|
|
||||||
|
In people-speak:
|
||||||
|
|
||||||
|
You get a scheduler helper that is safe by default: it shows planned cron changes first, and only writes when you explicitly ask.
|
||||||
|
|
||||||
|
What “safe by default” means:
|
||||||
|
1) Hermes-only framing in UX and docs.
|
||||||
|
2) Managed marker block for clean replacement of only this module’s cron section.
|
||||||
|
3) Print-only default; write path requires explicit `--apply`.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`
|
||||||
|
- managed markers
|
||||||
|
- print-only defaults
|
||||||
|
- apply path
|
||||||
|
- Supporting scope/docs:
|
||||||
|
- `skills/hermes-attestation-guardian/SKILL.md`
|
||||||
|
- `skills/hermes-attestation-guardian/skill.json`
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs`
|
||||||
|
- proves Hermes-only messaging and managed-block behavior.
|
||||||
|
- proves default mode is preview-oriented and apply path is explicit.
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- Operator runs cron helper without flags -> sees proposed block only.
|
||||||
|
- Operator reviews, then reruns with `--apply` -> only managed block is updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claim 5: Includes output-scope/path guardrails for attestation artifacts and policy parsing safeguards.
|
||||||
|
|
||||||
|
In people-speak:
|
||||||
|
|
||||||
|
Artifact writes are fenced into Hermes attestation scope, including symlink-escape defenses, and policy parsing is normalized/defensive so bad input fails cleanly.
|
||||||
|
|
||||||
|
What this protects against:
|
||||||
|
1) Out-of-scope writes
|
||||||
|
- Output path must remain under `HERMES_HOME/security/attestations`.
|
||||||
|
|
||||||
|
2) Symlink escapes
|
||||||
|
- Path resolution checks nearest existing ancestors and symlink behavior to prevent “write outside root” tricks.
|
||||||
|
|
||||||
|
3) Safer policy parsing
|
||||||
|
- Missing/invalid structure gets normalized defaults where appropriate.
|
||||||
|
- Malformed JSON fails closed.
|
||||||
|
- List fields are trimmed, deduplicated, and sorted.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- Guardrails:
|
||||||
|
- `skills/hermes-attestation-guardian/lib/attestation.mjs`
|
||||||
|
- `resolveHermesScopedOutputPath`
|
||||||
|
- Call sites:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs`
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`
|
||||||
|
- Policy parsing:
|
||||||
|
- `skills/hermes-attestation-guardian/lib/attestation.mjs`
|
||||||
|
- `parseAttestationPolicy`
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs`
|
||||||
|
- proves out-of-scope and symlink-escape output rejection.
|
||||||
|
- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs`
|
||||||
|
- proves cron helper also rejects out-of-scope output target.
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- Operator accidentally sets `--output /tmp/current.json`.
|
||||||
|
- Tool exits with critical path-scope error instead of writing outside Hermes scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claim 6: Cron managed-block parser fails closed on malformed markers.
|
||||||
|
|
||||||
|
In people-speak:
|
||||||
|
|
||||||
|
If cron markers are malformed (dangling start/end or nested blocks), updater refuses to rewrite crontab to avoid accidental deletion or corruption.
|
||||||
|
|
||||||
|
What this means operationally:
|
||||||
|
1) Marker structure is treated as integrity-sensitive input.
|
||||||
|
2) Malformed structure throws and aborts apply path.
|
||||||
|
3) No crontab write occurs after malformed marker detection.
|
||||||
|
|
||||||
|
Where it is wired:
|
||||||
|
- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`
|
||||||
|
- `removeManagedBlock`
|
||||||
|
- marker parsing and malformed-marker throw paths
|
||||||
|
|
||||||
|
How to verify:
|
||||||
|
- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs`
|
||||||
|
- proves fail-closed behavior for:
|
||||||
|
- dangling start marker,
|
||||||
|
- unmatched end marker,
|
||||||
|
- nested markers,
|
||||||
|
- and verifies no write on malformed input.
|
||||||
|
|
||||||
|
Quick scenario:
|
||||||
|
- Existing crontab has managed start marker with no end marker.
|
||||||
|
- Running `--apply` aborts with malformed-marker error and leaves crontab unchanged.
|
||||||
|
|
||||||
|
## 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/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.
|
||||||
|
|
||||||
|
## Public Interfaces
|
||||||
|
- `generate_attestation.mjs` CLI
|
||||||
|
- Consumer: operators/automation
|
||||||
|
- Behavior: creates canonicalized attestation JSON and optional checksum artifact.
|
||||||
|
- `verify_attestation.mjs` CLI
|
||||||
|
- Consumer: operators/automation/cron
|
||||||
|
- Behavior: enforces schema/digest/signature checks and optional trusted-baseline drift checks.
|
||||||
|
- `setup_attestation_cron.mjs` CLI
|
||||||
|
- Consumer: operators
|
||||||
|
- Behavior: prints or applies managed cron block for scheduled generate+verify runs.
|
||||||
|
- Diff output contract
|
||||||
|
- Consumer: operators/CI
|
||||||
|
- Behavior: emits severity-ranked drift findings for security triage.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
```bash
|
||||||
|
python utils/validate_skill.py skills/hermes-attestation-guardian
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update Notes
|
||||||
|
- 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
|
||||||
|
- skills/hermes-attestation-guardian/skill.json
|
||||||
|
- skills/hermes-attestation-guardian/SKILL.md
|
||||||
|
- skills/hermes-attestation-guardian/README.md
|
||||||
|
- skills/hermes-attestation-guardian/CHANGELOG.md
|
||||||
|
- skills/hermes-attestation-guardian/lib/attestation.mjs
|
||||||
|
- skills/hermes-attestation-guardian/lib/diff.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/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
|
||||||
Reference in New Issue
Block a user