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..8d23011 --- /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 user-crontab 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..5a68825 --- /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 cron config without mutating crontab +node scripts/setup_attestation_cron.mjs --every 6h --print-only + +# Apply managed cron 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 crontab managed 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..8906acf --- /dev/null +++ b/skills/hermes-attestation-guardian/lib/attestation.mjs @@ -0,0 +1,451 @@ +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; + } + const norm = raw.trim().toLowerCase(); + if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true; + if (["0", "false", "no", "off", "disabled"].includes(norm)) return false; + return fallback; +} + +function 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: bool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)), + matrix: bool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)), + discord: bool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)), + }; + + const riskyToggles = { + allow_unsigned_mode: bool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)), + bypass_verification: bool(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..9db2782 --- /dev/null +++ b/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs @@ -0,0 +1,139 @@ +#!/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 .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 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); + fs.writeFileSync(outPath, `${body}\n`, "utf8"); + + 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); +} diff --git a/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs b/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs new file mode 100644 index 0000000..01ec801 --- /dev/null +++ b/skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs @@ -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 Interval cadence (default: 6h)", + " --policy Optional policy file passed to generator", + " --baseline Optional baseline path passed to verifier", + " --baseline-sha256 Trusted baseline SHA256 passed to verifier", + " --baseline-signature Baseline detached signature for verifier", + " --baseline-public-key Baseline signature public key for verifier", + " --output Optional output attestation path", + " --apply Apply to current user's crontab", + " --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 h or d.`); + } + + const n = Number(match[1]); + const unit = match[2]; + + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`Cadence must be a positive integer: ${cadence}`); + } + + if (unit === "h") { + if (n > 24) { + throw new Error("Hourly cadence cannot exceed 24h for cron expression generation."); + } + return `0 */${n} * * *`; + } + + if (n > 31) { + throw new Error("Daily cadence cannot exceed 31d for cron expression generation."); + } + return `0 2 */${n} * *`; +} + +function escapeForShell(value) { + return String(value).replace(/'/g, "'\\''"); +} + +function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSignature, baselinePublicKey }) { + const scriptDir = path.resolve(path.dirname(new URL(import.meta.url).pathname)); + const generator = path.join(scriptDir, "generate_attestation.mjs"); + 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); +} diff --git a/skills/hermes-attestation-guardian/scripts/verify_attestation.mjs b/skills/hermes-attestation-guardian/scripts/verify_attestation.mjs new file mode 100644 index 0000000..1aea656 --- /dev/null +++ b/skills/hermes-attestation-guardian/scripts/verify_attestation.mjs @@ -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 Attestation JSON path", + " --expected-sha256 Require exact file SHA256 match", + " --signature Detached signature file path (base64 or raw binary)", + " --public-key Public key PEM for signature verification", + " --baseline Baseline attestation for diffing", + " --baseline-expected-sha256 Trusted baseline file SHA256", + " --baseline-signature Baseline detached signature", + " --baseline-public-key Public key PEM for baseline signature verification", + " --fail-on-severity 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); +} diff --git a/skills/hermes-attestation-guardian/skill.json b/skills/hermes-attestation-guardian/skill.json new file mode 100644 index 0000000..59f9f46 --- /dev/null +++ b/skills/hermes-attestation-guardian/skill.json @@ -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": "No persistence by default. scripts/setup_attestation_cron.mjs can install a user crontab 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" + ] + } +} diff --git a/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs new file mode 100644 index 0000000..3f36bdc --- /dev/null +++ b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs @@ -0,0 +1,171 @@ +#!/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 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); +}); + +console.log("attestation_cli.test.mjs: ok"); diff --git a/skills/hermes-attestation-guardian/test/attestation_diff.test.mjs b/skills/hermes-attestation-guardian/test/attestation_diff.test.mjs new file mode 100644 index 0000000..aa57532 --- /dev/null +++ b/skills/hermes-attestation-guardian/test/attestation_diff.test.mjs @@ -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"); diff --git a/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs new file mode 100644 index 0000000..d2d1e5d --- /dev/null +++ b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs @@ -0,0 +1,257 @@ +#!/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 testBuildAttestationIsSchemaValidAndDeterministic(); +testSchemaValidationFailsClosed(); +testDigestBindingRejectsUnsupportedAlgorithm(); +testSchemaValidationRequiresGeneratorVersionNonEmptyString(); +testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans(); +testSchemaValidationRequiresIntegrityEntryShapes(); +await testBooleanConfigCoercionDoesNotEnableFalseStrings(); +console.log("attestation_schema.test.mjs: ok"); diff --git a/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs b/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs new file mode 100644 index 0000000..508b5e9 --- /dev/null +++ b/skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs @@ -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"); diff --git a/wiki/GENERATION.md b/wiki/GENERATION.md index cc9ac28..3c2424d 100644 --- a/wiki/GENERATION.md +++ b/wiki/GENERATION.md @@ -15,6 +15,7 @@ - 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`. - 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 - README.md diff --git a/wiki/INDEX.md b/wiki/INDEX.md index 809591b..aa93daf 100644 --- a/wiki/INDEX.md +++ b/wiki/INDEX.md @@ -30,6 +30,8 @@ - [Frontend Web App](modules/frontend-web.md) - [ClawSec Suite Core](modules/clawsec-suite.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) - [Automation and Release Pipelines](modules/automation-release.md) - [Local Validation and Packaging Tools](modules/local-tooling.md) @@ -41,6 +43,7 @@ - [Generation Metadata](GENERATION.md) ## Update Notes +- 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-02-26: Added Operations pages and updated navigation guidance after migrating root docs into wiki pages. @@ -53,5 +56,8 @@ - scripts/populate-local-skills.sh - skills/clawsec-suite/skill.json - skills/clawsec-scanner/skill.json +- skills/hermes-attestation-guardian/skill.json - wiki/modules/clawsec-scanner.md +- wiki/modules/hermes-attestation-guardian.md +- wiki/modules/hermes-attestation-guardian-draft-history.md - .github/workflows/ci.yml diff --git a/wiki/modules/hermes-attestation-guardian-draft-history.md b/wiki/modules/hermes-attestation-guardian-draft-history.md new file mode 100644 index 0000000..7b43f3f --- /dev/null +++ b/wiki/modules/hermes-attestation-guardian-draft-history.md @@ -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/` diff --git a/wiki/modules/hermes-attestation-guardian.md b/wiki/modules/hermes-attestation-guardian.md new file mode 100644 index 0000000..9b810c9 --- /dev/null +++ b/wiki/modules/hermes-attestation-guardian.md @@ -0,0 +1,287 @@ +# 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. + +## PR Claims: Full Human-Friendly Breakdown + +This section rewrites each PR claim as an operator-facing explanation, then ties it to exact code and tests. + +### Claim 1: Adds deterministic attestation generation with canonicalized payload digesting. + +Absolutely — in people-speak: + +We create a security snapshot of Hermes in a way that is reproducible, then fingerprint it in a stable way so tampering or real drift is obvious. + +What this means in practice: +1) Attestation generation +- Think of it as a report card for Hermes security posture at a moment in time. +- It records posture fields, trust anchors, watched-file hashes, and metadata. + +2) Deterministic output +- Same state should produce the same attestation content. +- No noise from object insertion order or formatting randomness. + +3) Canonicalization before hashing +- Payload is normalized into one canonical JSON representation. +- This removes ambiguity from normal JSON variations. + +4) Digest binding +- SHA-256 is computed over canonical payload content. +- Any meaningful change to payload changes digest. +- Any post-generation tampering causes verification mismatch. + +Where it is wired: +- `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs` +- `skills/hermes-attestation-guardian/lib/attestation.mjs` + - `stableSortObject` + - `stableStringify` + - `sha256Hex` + - `buildAttestation` + - `computeCanonicalDigest` + - `validateDigestBinding` + +How to verify: +- `node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs` + - proves same-input determinism and canonical digest consistency. +- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs` + - proves post-generation tamper causes fail-closed digest mismatch. + +Quick scenario: +- Same state: run generator twice with unchanged inputs -> same digest. +- Tampered file: flip a posture value in JSON -> verifier fails on canonical digest mismatch. + +--- + +### Claim 2: Enforces fail-closed verification for schema, digest, optional expected checksum, and detached signatures. + +In people-speak: + +Verification is not “best effort.” If a trust check fails, verification fails. No soft pass. + +What is fail-closed here: +1) Schema must be valid. +2) Canonical digest must match payload. +3) If `--expected-sha256` is supplied, file bytes must exactly match. +4) If detached signature verification is requested, signature + public key must both be present and valid. + +Where it is wired: +- `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs` + - schema checks + - digest checks + - expected checksum check + - detached signature verification + - non-zero exit on critical failure +- `skills/hermes-attestation-guardian/lib/attestation.mjs` + - `validateAttestationSchema` + - `validateDigestBinding` + +How to verify: +- `node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs` + - proves schema rejection and digest algorithm validation behavior. +- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs` + - proves tamper path exits non-zero (fail closed). + +Quick scenario: +- CI pins expected SHA and requires detached signature. +- Artifact is modified or signed incorrectly -> verification exits non-zero and blocks pipeline. + +--- + +### Claim 3: Adds baseline authenticity and drift-severity classification for risky toggles, feed verification regressions, trust anchor drift, and watched file drift. + +In people-speak: + +You only compare against a baseline after proving the baseline itself is authentic. Then differences are ranked by severity so operators can respond quickly. + +What this gives operators: +1) Authenticated baseline gate +- Baseline must be trusted (pinned digest and/or detached signature trust path). +- Untrusted baseline is rejected before diffing. + +2) Severity-ranked drift findings +- Critical/high/medium/low/info mapping instead of flat alerts. +- High-signal categories include: + - risky toggle enablement, + - feed verification regressions, + - trust anchor hash drift, + - watched file hash drift. + +3) Policy-driven failure threshold +- Verification can fail when findings meet/exceed configured severity threshold. + +Where it is wired: +- Baseline trust and diff orchestration: + - `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs` +- Drift engine and severity mapping: + - `skills/hermes-attestation-guardian/lib/diff.mjs` + - `diffAttestations` + - `highestSeverity` + - `severityAtOrAbove` + +How to verify: +- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs` + - proves untrusted baseline rejection and digest-pinned baseline handling. +- `node skills/hermes-attestation-guardian/test/attestation_diff.test.mjs` + - proves classification for key drift types and highest-severity behavior. + +Quick scenario: +- Yesterday’s baseline is pinned and trusted. +- Today `allow_unsigned_mode` flips on and trust anchor hash changes. +- Diff emits critical findings and verifier can fail run by severity policy. + +--- + +### Claim 4: Adds Hermes-only cron setup helper with managed marker block and print-only default. + +In people-speak: + +You get a scheduler helper that is safe by default: it shows planned cron changes first, and only writes when you explicitly ask. + +What “safe by default” means: +1) Hermes-only framing in UX and docs. +2) Managed marker block for clean replacement of only this module’s cron section. +3) Print-only default; write path requires explicit `--apply`. + +Where it is wired: +- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs` + - managed markers + - print-only defaults + - apply path +- Supporting scope/docs: + - `skills/hermes-attestation-guardian/SKILL.md` + - `skills/hermes-attestation-guardian/skill.json` + +How to verify: +- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs` + - proves Hermes-only messaging and managed-block behavior. + - proves default mode is preview-oriented and apply path is explicit. + +Quick scenario: +- Operator runs cron helper without flags -> sees proposed block only. +- Operator reviews, then reruns with `--apply` -> only managed block is updated. + +--- + +### Claim 5: Includes output-scope/path guardrails for attestation artifacts and policy parsing safeguards. + +In people-speak: + +Artifact writes are fenced into Hermes attestation scope, including symlink-escape defenses, and policy parsing is normalized/defensive so bad input fails cleanly. + +What this protects against: +1) Out-of-scope writes +- Output path must remain under `HERMES_HOME/security/attestations`. + +2) Symlink escapes +- Path resolution checks nearest existing ancestors and symlink behavior to prevent “write outside root” tricks. + +3) Safer policy parsing +- Missing/invalid structure gets normalized defaults where appropriate. +- Malformed JSON fails closed. +- List fields are trimmed, deduplicated, and sorted. + +Where it is wired: +- Guardrails: + - `skills/hermes-attestation-guardian/lib/attestation.mjs` + - `resolveHermesScopedOutputPath` +- Call sites: + - `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs` + - `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs` +- Policy parsing: + - `skills/hermes-attestation-guardian/lib/attestation.mjs` + - `parseAttestationPolicy` + +How to verify: +- `node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs` + - proves out-of-scope and symlink-escape output rejection. +- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs` + - proves cron helper also rejects out-of-scope output target. + +Quick scenario: +- Operator accidentally sets `--output /tmp/current.json`. +- Tool exits with critical path-scope error instead of writing outside Hermes scope. + +--- + +### Claim 6: Cron managed-block parser fails closed on malformed markers. + +In people-speak: + +If cron markers are malformed (dangling start/end or nested blocks), updater refuses to rewrite crontab to avoid accidental deletion or corruption. + +What this means operationally: +1) Marker structure is treated as integrity-sensitive input. +2) Malformed structure throws and aborts apply path. +3) No crontab write occurs after malformed marker detection. + +Where it is wired: +- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs` + - `removeManagedBlock` + - marker parsing and malformed-marker throw paths + +How to verify: +- `node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs` + - proves fail-closed behavior for: + - dangling start marker, + - unmatched end marker, + - nested markers, + - and verifies no write on malformed input. + +Quick scenario: +- Existing crontab has managed start marker with no end marker. +- Running `--apply` aborts with malformed-marker error and leaves crontab unchanged. + +## Key Files +- `skills/hermes-attestation-guardian/skill.json`: metadata, platform scope, operator review notes, SBOM. +- `skills/hermes-attestation-guardian/SKILL.md`: operator playbook, CLI usage, fail-closed policy. +- `skills/hermes-attestation-guardian/README.md`: quickstart and practical behavior notes. +- `skills/hermes-attestation-guardian/lib/attestation.mjs`: canonicalization, digest binding, schema checks, scoped output resolution, policy parsing. +- `skills/hermes-attestation-guardian/lib/diff.mjs`: baseline drift comparison and severity classification. +- `skills/hermes-attestation-guardian/scripts/generate_attestation.mjs`: deterministic attestation generation CLI. +- `skills/hermes-attestation-guardian/scripts/verify_attestation.mjs`: fail-closed verifier and baseline trust enforcement. +- `skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs`: cron managed-block helper. + +## Public Interfaces +- `generate_attestation.mjs` CLI + - Consumer: operators/automation + - Behavior: creates canonicalized attestation JSON and optional checksum artifact. +- `verify_attestation.mjs` CLI + - Consumer: operators/automation/cron + - Behavior: enforces schema/digest/signature checks and optional trusted-baseline drift checks. +- `setup_attestation_cron.mjs` CLI + - Consumer: operators + - Behavior: prints or applies managed cron block for scheduled generate+verify runs. +- Diff output contract + - Consumer: operators/CI + - Behavior: emits severity-ranked drift findings for security triage. + +## Validation Commands +```bash +python utils/validate_skill.py skills/hermes-attestation-guardian +node skills/hermes-attestation-guardian/test/attestation_schema.test.mjs +node skills/hermes-attestation-guardian/test/attestation_diff.test.mjs +node skills/hermes-attestation-guardian/test/attestation_cli.test.mjs +node skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs +``` + +## Update Notes +- 2026-04-15: Replaced table-style PR claim mapping with full narrative claim breakdowns (people-speak, wiring, verification, and concrete scenarios per claim). + +## Source References +- skills/hermes-attestation-guardian/skill.json +- skills/hermes-attestation-guardian/SKILL.md +- skills/hermes-attestation-guardian/README.md +- skills/hermes-attestation-guardian/CHANGELOG.md +- skills/hermes-attestation-guardian/lib/attestation.mjs +- skills/hermes-attestation-guardian/lib/diff.mjs +- skills/hermes-attestation-guardian/scripts/generate_attestation.mjs +- skills/hermes-attestation-guardian/scripts/verify_attestation.mjs +- skills/hermes-attestation-guardian/scripts/setup_attestation_cron.mjs +- skills/hermes-attestation-guardian/test/attestation_schema.test.mjs +- skills/hermes-attestation-guardian/test/attestation_diff.test.mjs +- skills/hermes-attestation-guardian/test/attestation_cli.test.mjs +- skills/hermes-attestation-guardian/test/setup_attestation_cron.test.mjs