diff --git a/pages/Home.tsx b/pages/Home.tsx
index c840f48..7d80f77 100644
--- a/pages/Home.tsx
+++ b/pages/Home.tsx
@@ -3,7 +3,7 @@ import { User, Bot, Copy, Check, Lock } from 'lucide-react';
import { Footer } from '../components/Footer';
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;
export const Home: React.FC = () => {
@@ -97,7 +97,7 @@ export const Home: React.FC = () => {
agents
- 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{' '}
/dev/null 2>&1; then
+ echo "ERROR: docker is required." >&2
+ exit 1
+fi
+if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
+ echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_SRC" >&2
+ exit 1
+fi
+if [[ ! -d "$SKILL_SRC" ]]; then
+ echo "ERROR: SKILL_SRC not found: $SKILL_SRC" >&2
+ exit 1
+fi
+
+echo "[sandbox] image=$IMAGE"
+echo "[sandbox] hermes-agent-src=$HERMES_AGENT_SRC"
+echo "[sandbox] skill-src=$SKILL_SRC"
+
+docker run --rm \
+ -e HOME=/tmp/hermes-sandbox-home \
+ -e HERMES_HOME=/tmp/hermes-sandbox-home \
+ -v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
+ -v "$SKILL_SRC":/opt/skill-src:ro \
+ "$IMAGE" bash -lc "
+set -euo pipefail
+export DEBIAN_FRONTEND=noninteractive
+apt-get update >/dev/null
+apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm >/dev/null
+
+cp -a /opt/hermes-agent /tmp/hermes-agent-src
+python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
+mkdir -p \"\$HOME\"
+
+echo \"INSIDE_HOME=\$HOME\"
+echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
+
+mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
+cp -a /opt/skill-src/. /tmp/well/.well-known/skills/hermes-attestation-guardian/
+python3 - <<'PY'
+import os,json
+root='/tmp/well/.well-known/skills'
+sk='hermes-attestation-guardian'
+base=os.path.join(root,sk)
+files=[]
+for dp,_,fns in os.walk(base):
+ for fn in fns:
+ files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
+idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':'0.0.1','description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
+with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
+PY
+python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
+HPID=\$!
+sleep 1
+
+INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
+echo \"\$INSTALL_OUT\"
+
+echo \"\$INSTALL_OUT\" | grep -q \"Verdict: SAFE\"
+echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"
+
+SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
+mkdir -p \"\$HERMES_HOME/security/attestations\"
+echo \"alpha\" > /tmp/watch.txt
+echo \"anchor-v1\" > /tmp/anchor.pem
+cat > /tmp/policy.json </tmp/generate.log
+DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
+node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
+
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
+openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
+openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
+node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
+
+cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
+BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
+echo \"beta\" > /tmp/watch.txt
+echo \"anchor-v2\" > /tmp/anchor.pem
+node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
+set +e
+DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
+DRIFT_CODE=\$?
+set -e
+[ \"\$DRIFT_CODE\" -ne 0 ]
+echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
+
+node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
+grep -q \"Preflight review:\" /tmp/cron-preview.log
+grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
+
+echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
+echo \"install_safe_allowed=PASS\"
+echo \"generate_with_policy=PASS\"
+echo \"verify_expected_sha=PASS\"
+echo \"verify_signature=PASS\"
+echo \"baseline_drift_fail_closed=PASS\"
+echo \"scheduler_preview=PASS\"
+
+kill \$HPID >/dev/null 2>&1 || true
+wait \$HPID 2>/dev/null || true
+"
+
+echo "[sandbox] completed successfully"
\ No newline at end of file
diff --git a/skills/hermes-attestation-guardian/CHANGELOG.md b/skills/hermes-attestation-guardian/CHANGELOG.md
new file mode 100644
index 0000000..75181c4
--- /dev/null
+++ b/skills/hermes-attestation-guardian/CHANGELOG.md
@@ -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.
diff --git a/skills/hermes-attestation-guardian/README.md b/skills/hermes-attestation-guardian/README.md
new file mode 100644
index 0000000..90c2be9
--- /dev/null
+++ b/skills/hermes-attestation-guardian/README.md
@@ -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
+```
diff --git a/skills/hermes-attestation-guardian/SKILL.md b/skills/hermes-attestation-guardian/SKILL.md
new file mode 100644
index 0000000..1cdbe06
--- /dev/null
+++ b/skills/hermes-attestation-guardian/SKILL.md
@@ -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 \
+ --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
diff --git a/skills/hermes-attestation-guardian/lib/attestation.mjs b/skills/hermes-attestation-guardian/lib/attestation.mjs
new file mode 100644
index 0000000..b8e4401
--- /dev/null
+++ b/skills/hermes-attestation-guardian/lib/attestation.mjs
@@ -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;
+}
diff --git a/skills/hermes-attestation-guardian/lib/diff.mjs b/skills/hermes-attestation-guardian/lib/diff.mjs
new file mode 100644
index 0000000..59bb5cf
--- /dev/null
+++ b/skills/hermes-attestation-guardian/lib/diff.mjs
@@ -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;
+}
diff --git a/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs b/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs
new file mode 100644
index 0000000..931eaa7
--- /dev/null
+++ b/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs
@@ -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 Output file path (default: ~/.hermes/security/attestations/current.json)",
+ " --policy JSON policy file with watch_files and trust_anchor_files arrays",
+ " --watch Extra watched file path (repeatable)",
+ " --trust-anchor Extra trust anchor file path (repeatable)",
+ " --generated-at Override generated_at for deterministic testing",
+ " --write-sha256 Also write