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:
David Abutbul
2026-04-16 17:59:18 +03:00
committed by GitHub
parent caad6f698c
commit 600c945fe2
19 changed files with 3001 additions and 2 deletions
+2 -2
View File
@@ -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
View File
@@ -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");
+1
View File
@@ -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
+7
View File
@@ -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/`
+292
View File
@@ -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:
- Yesterdays 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 modules 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