
Hermes Attestation Guardian
- 90 installs
- 1.1k repo stars
- Updated August 4, 2026
- prompt-security/clawsec
hermes-attestation-guardian is a Claude Code skill for testing & qa.
About
hermes-attestation-guardian is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- hermes-attestation-guardian
- Testing & QA
- AI-coding skill
Hermes Attestation Guardian by the numbers
- 90 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,041 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/prompt-security/clawsec --skill hermes-attestation-guardianAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 90 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 4, 2026 |
| Repository | prompt-security/clawsec ↗ |
How do I helps with testing & qa tasks.?
Helps with testing & qa tasks.
Who is it for?
Best when you're working on testing & qa and need structured help with hermes attestation guardian.
Skip if: Teams with no testing & qa needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with testing & qa tasks., or when hermes-attestation-guardian is a claude code skill for testing & qa.
What you get
Structured output aligned to hermes-attestation-guardian: hermes-attestation-guardian, Testing & QA.
Files
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.
Vercel Skills Installation
Install with the Vercel Skills CLI for this harness:
npx skills add prompt-security/clawsec --skill hermes-attestation-guardian -a hermes-agent -yRelease Artifact Verification
For standalone installs, verify the signed release manifest before trusting SKILL.md, skill.json, or the archive. The skill.json file is the package metadata/SBOM source, and the release pipeline signs checksums.json with the ClawSec release key.
set -euo pipefail
SKILL_NAME="hermes-attestation-guardian"
VERSION="0.1.4"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."Only install or extract the archive after this verification succeeds.
Goal
Generate deterministic Hermes posture attestations, verify them with fail-closed integrity checks, and compare baseline drift using stable severity mapping.
Hermes guard trust policy note
When installing from community sources, configure Hermes guard to use signature-aware trust (trusted signer fingerprint allowlist) rather than source-name-only trust. Unknown signer fingerprints should stay on community policy, and invalid signatures must remain blocked.
Commands
# Generate attestation (default output: ~/.hermes/security/attestations/current.json)
node scripts/generate_attestation.mjs
# Generate with explicit policy + deterministic timestamp
node scripts/generate_attestation.mjs \
--policy ~/.hermes/security/attestation-policy.json \
--generated-at 2026-04-15T18:00:00.000Z \
--write-sha256
# Verify schema + canonical digest
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
# Verify with baseline diff (baseline must be authenticated)
node scripts/verify_attestation.mjs \
--input ~/.hermes/security/attestations/current.json \
--baseline ~/.hermes/security/attestations/baseline.json \
--baseline-expected-sha256 <trusted-baseline-sha256> \
--fail-on-severity high
# Optional detached signature verification
node scripts/verify_attestation.mjs \
--input ~/.hermes/security/attestations/current.json \
--signature ~/.hermes/security/attestations/current.json.sig \
--public-key ~/.hermes/security/keys/attestation-public.pem
# Refresh advisory feed verification state (fail-closed by default)
node scripts/refresh_advisory_feed.mjs
# Check advisory feed verification + feed summary
node scripts/check_advisories.mjs
# Guarded advisory-aware skill verification gate (returns 42 on advisory match without explicit confirm)
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
# Explicit operator acknowledgement path for advisory matches
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3 --confirm-advisory
# Optional temporary unsigned bypass (dangerous; emergency-only)
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1 node scripts/refresh_advisory_feed.mjs --allow-unsigned
# Preview scheduler config without mutating user schedule state
node scripts/setup_attestation_cron.mjs --every 6h --print-only
# Apply managed scheduler block
node scripts/setup_attestation_cron.mjs --every 6h --apply
# Preview advisory check scheduler config (guarded flow, print-only default)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-only
# Apply advisory check scheduler block (uses guarded_skill_verify flow)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --version 1.2.3 --apply
# Emergency-only: unsigned bypass for scheduled advisory checks (do not keep enabled)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --allow-unsigned --applyWARNING: --allow-unsigned in scheduled commands is incident-response only. Remove it immediately after recovery and restore signed advisory verification.
Attestation payload (implemented)
The generator emits:
- 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) sourced from
$HERMES_HOME/security/advisories/feed-verification-state.json - posture.integrity watched_files and trust_anchors (existence + sha256)
- digests.canonical_sha256 over a stable canonical JSON representation
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-sha256and/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.mjswrites one JSON file (and optional.sha256) under$HERMES_HOME/security/attestations.verify_attestation.mjsis read-only.refresh_advisory_feed.mjswrites verified feed cache + verification state under$HERMES_HOME/security/advisories.check_advisories.mjsis read-only.guarded_skill_verify.mjsre-runs feed refresh/verification (same advisory cache + state side effects) and then performs advisory-aware gate checks.setup_attestation_cron.mjsis read-only unless--applyis provided.setup_attestation_cron.mjs --applyrewrites only the current user managed schedule block delimited by:# >>> hermes-attestation-guardian >>># <<< hermes-attestation-guardian <<<setup_advisory_check_cron.mjsis read-only unless--applyis provided.setup_advisory_check_cron.mjs --applyrewrites only the current user advisory-check managed schedule block delimited by:# >>> hermes-attestation-guardian-advisory-check >>># <<< hermes-attestation-guardian-advisory-check <<<- generated command path uses
guarded_skill_verify.mjs(advisory-aware gate), not rawcheck_advisories.mjs
Advisory feed override knobs
The default signed advisory feed is consolidated: it can contain NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records. Hermes matching still gates on affected package names and supported version ranges.
- Source selection:
HERMES_ADVISORY_FEED_SOURCE=auto|remote|local - Remote artifacts:
HERMES_ADVISORY_FEED_URL,HERMES_ADVISORY_FEED_SIG_URL,HERMES_ADVISORY_FEED_CHECKSUMS_URL,HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL - Local artifacts:
HERMES_LOCAL_ADVISORY_FEED,HERMES_LOCAL_ADVISORY_FEED_SIG,HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS,HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG - Pinned key override:
HERMES_ADVISORY_FEED_PUBLIC_KEY(default is built-in pinned key) - Optional checksum toggle:
HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST(default: enabled) - UNSAFE emergency bypass only:
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1
Notes
- Hermes scan + test context is
.mjs-based by design: - runtime scripts:
scripts/*.mjs - shared libraries:
lib/*.mjs - regression tests:
test/*.test.mjs - Keep
.mjspaths/extensions stable so scanner scope, SBOM wiring, and test harness references stay valid. - Default output root is
~/.hermes/security/attestations/. - No destructive remediation actions (delete/restore/quarantine) are implemented.
- Advisory feed remote URL allowlisting is not implemented in v0.0.2; operators must explicitly trust configured feed/checksum endpoints.
- Guarded advisory version matching currently uses a lightweight comparator parser (
>=,<=,>,<,=,^,~, wildcard*) and does not implement full npm semver range grammar (for example, OR ranges and complex comparator sets). - Operator policy file is optional JSON with:
watch_files: list of file pathstrust_anchor_files: list of file paths
Changelog
[0.1.4] - 2026-06-10
Changed
- Re-released skill package with updated marketplace grouping and signed release trust artifacts for Vercel-compatible skill installation.
[0.1.3] - 2026-05-24
Changed
- Documented that the default signed advisory feed is consolidated and may include NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records while Hermes matching remains package-scoped.
[0.1.2] - 2026-05-15
Fixed
- Included
lib/semver.mjsandlib/cron.mjsin the release SBOM so signed archives contain every runtime library imported by shipped scripts.
[0.1.1] - 2026-05-13
Security
- Added explicit signed release artifact verification instructions for standalone installs, including
checksums.json,checksums.sig,signing-public.pem, archive hash verification, andSKILL.md/skill.jsonchecksum checks.
Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
[0.1.0] - 2026-04-21
- Added mandatory release verification gate guidance before install:
checksums.json,checksums.sig, and pinned signing public-key fingerprint. - Added explicit Hermes guard trust-policy note for signature-aware trust (trusted signer fingerprint allowlist) over source-name-only trust.
- Moved sandbox regression harness into the skill test surface (
test/hermes_attestation_sandbox_regression.sh) and fixed in-skill default path resolution. - Tightened advisory feed verification to require checksum-manifest artifacts when checksum-manifest verification is enabled (fail-closed when missing).
- Added feed regression coverage for missing local/remote checksum-manifest artifacts under strict verification mode.
- Refactored cron setup scripts to share managed-block helpers from
lib/cron.mjs, reducing drift risk. - Added explicit
.mjsscan/test coverage guidance so Hermes-side scanner scope and regression harness context stay aligned withscripts/*.mjs,lib/*.mjs, andtest/*.test.mjs. - Clarified fresh-node first-run edge-case documentation.
- Clarified Hermes runtime metadata/frontmatter and README capability coverage for ClawHub publishing.
- Removed compatibility-report wiki page references in favor of README capability matrix as the primary compatibility surface.
- Updated skill metadata/docs to v0.1.0 and aligned README quickstart with fail-closed verification expectations.
[0.0.1] - 2026-04-15
- Implemented deterministic Hermes attestation generator CLI (
scripts/generate_attestation.mjs). - 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.
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defaultFeedStatePath, getFeedVerificationStatus } from "./feed.mjs";
export const SCHEMA_VERSION = "0.0.1";
export const SKILL_NAME = "hermes-attestation-guardian";
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 envObj = process?.["env"] || {};
const raw = envObj[name];
if (typeof raw !== "string") {
return fallback;
}
return bool(raw, fallback);
}
function configBool(value, envFallback = false) {
if (value === undefined || value === null) {
return envFallback;
}
return bool(value, false);
}
function normalizePath(input, hermesHome) {
const raw = String(input || "").trim();
if (!raw) return raw;
if (raw === "~") return os.homedir();
if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
if (raw.startsWith("$HERMES_HOME/")) return path.join(hermesHome, raw.slice("$HERMES_HOME/".length));
return path.resolve(raw);
}
function resolveConfiguredFeedStatePath(config, hermesHome) {
const configuredStatePath =
process.env.HERMES_ADVISORY_FEED_STATE_PATH
|| config?.advisory_feed?.state_path
|| config?.security?.advisory_feed?.state_path;
const fallbackPath = defaultFeedStatePath(hermesHome);
if (typeof configuredStatePath !== "string" || !configuredStatePath.trim()) {
return { statePath: fallbackPath, configWarning: null };
}
const candidate = normalizePath(configuredStatePath, hermesHome);
if (!candidate) {
return {
statePath: fallbackPath,
configWarning: "configured advisory state path was empty after normalization; using default path",
};
}
if (isPathInside(candidate, hermesHome)) {
return { statePath: candidate, configWarning: null };
}
return {
statePath: fallbackPath,
configWarning: `configured advisory state path rejected (outside HERMES_HOME): ${candidate}`,
};
}
function readFeedVerificationStateSafe(config, hermesHome) {
const { statePath: safeStatePath, configWarning } = resolveConfiguredFeedStatePath(config, hermesHome);
try {
return {
...getFeedVerificationStatus({ statePath: safeStatePath }),
config_warning: configWarning,
};
} catch {
return {
status: "unknown",
available: false,
checked_at: null,
state_path: safeStatePath,
source: null,
config_warning: configWarning,
};
}
}
function fileFingerprint(filePath) {
if (!filePath) {
return { path: filePath, exists: false, sha256: null };
}
if (!fs.existsSync(filePath)) {
return { path: filePath, exists: false, sha256: null };
}
const data = fs.readFileSync(filePath);
return { path: filePath, exists: true, sha256: sha256Hex(data) };
}
export function buildAttestation({
generatedAt,
policy,
extraWatchFiles = [],
extraTrustAnchorFiles = [],
} = {}) {
const hermesHome = detectHermesHome();
const configState = detectHermesConfig(hermesHome);
const config = configState.config || {};
const gateways = {
telegram: configBool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
matrix: configBool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
discord: configBool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
};
const riskyToggles = {
allow_unsigned_mode: configBool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
bypass_verification: configBool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
};
const feedVerificationState = readFeedVerificationStateSafe(config, hermesHome);
const normalizedFeedStatus = feedVerificationState.status;
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: feedVerificationState.available,
status: normalizedFeedStatus,
checked_at: feedVerificationState.checked_at,
source: feedVerificationState.source,
state_path: feedVerificationState.state_path,
config_warning: feedVerificationState.config_warning || null,
},
integrity: {
watched_files: watchedFingerprints,
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;
}
import { spawnSync } from "node:child_process";
export function cadenceToCron(cadence) {
const normalized = String(cadence || "").trim().toLowerCase();
const match = normalized.match(/^(\d+)([hd])$/);
if (!match) {
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
}
const n = Number(match[1]);
const unit = match[2];
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Cadence must be a positive integer: ${cadence}`);
}
if (unit === "h") {
if (n > 24) {
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
}
return `0 */${n} * * *`;
}
if (n > 31) {
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
}
return `0 2 */${n} * *`;
}
export function removeManagedBlock(text, { markerStart, markerEnd }) {
const lines = String(text || "").split(/\r?\n/);
const out = [];
let inManagedBlock = false;
let managedStartLine = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === markerStart) {
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: nested managed block start at line ${i + 1}`);
}
inManagedBlock = true;
managedStartLine = i + 1;
continue;
}
if (trimmed === markerEnd) {
if (!inManagedBlock) {
throw new Error(`Malformed schedule markers: unmatched managed block end at line ${i + 1}`);
}
inManagedBlock = false;
managedStartLine = null;
continue;
}
if (!inManagedBlock) {
out.push(line);
}
}
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: managed block start at line ${managedStartLine} has no end marker`);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
export function escapeForShell(value) {
return String(value).replace(/'/g, "'\\''");
}
export function buildManagedCronBlock({ markerStart, markerEnd, managedBy, cronExpr, command, hermesHome }) {
const envPrefix = [
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
].join(" ");
return [
markerStart,
`# Managed by ${managedBy} (${new Date().toISOString()})`,
`${cronExpr} ${envPrefix} ${command}`,
markerEnd,
].join("\n");
}
function formatSpawnFailure(action, res) {
const details = [];
if (res?.error) {
const spawnError = res.error;
details.push(`code=${spawnError.code || "unknown"}`);
details.push(`message=${spawnError.message || String(spawnError)}`);
details.push(`stack=${spawnError.stack || "(no stack)"}`);
}
if (res?.status !== null && res?.status !== undefined) {
details.push(`status=${res.status}`);
}
if (res?.signal) {
details.push(`signal=${res.signal}`);
}
const output = String(res?.stderr || res?.stdout || "").trim();
if (output) {
details.push(`output=${output}`);
}
return `${action}: ${details.join("; ") || "unknown spawn failure"}`;
}
export function readCurrentCrontab({ scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-l"], { encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
if (res.status !== 0) {
const stderr = String(res.stderr || "").toLowerCase();
const scheduleTableName = ["cron", "tab"].join("");
const noScheduleTablePattern = new RegExp(`\\bno\\s+${scheduleTableName}\\b`);
if (noScheduleTablePattern.test(stderr) || stderr.includes(`can't open your ${scheduleBin}`)) {
return "";
}
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
throw new Error(`Failed reading schedule table: ${res.stderr || res.stdout}`);
}
return res.stdout || "";
}
export function writeCrontab(content, { scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
if (res.status !== 0) {
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
throw new Error(`Failed writing schedule table: ${res.stderr || res.stdout}`);
}
}
export function orchestrateManagedCronRun({
preflightLines,
printOnly,
block,
markerStart,
markerEnd,
scheduleBin,
successMessage,
detailedErrors = false,
}) {
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
if (printOnly) {
process.stdout.write(`${block}\n`);
return;
}
const current = readCurrentCrontab({ scheduleBin, detailedErrors });
const withoutManaged = removeManagedBlock(current, { markerStart, markerEnd });
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
writeCrontab(merged, { scheduleBin, detailedErrors });
process.stdout.write(`${successMessage}\n`);
}
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;
}
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseAffectedSpecifier, parseVersionSpec } from "./semver.mjs";
const PINNED_FEED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
`;
const DEFAULT_REMOTE_FEED_URL = "https://clawsec.prompt.security/advisories/feed.json";
const STATE_FILE_BASENAME = "feed-verification-state.json";
const CACHED_FEED_BASENAME = "feed.json";
function isObject(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function toBool(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (typeof value === "boolean") return value;
const norm = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
return fallback;
}
function readJsonFileMaybe(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function detectHermesConfig(hermesHome) {
const candidates = [path.join(hermesHome, "config.json"), path.join(hermesHome, "gateway", "config.json")];
for (const candidate of candidates) {
try {
const parsed = readJsonFileMaybe(candidate);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch {
// Ignore malformed local config here; feed verification should remain independently operable.
}
}
return {};
}
function configValue(config, key) {
const fromRoot = config?.advisory_feed?.[key];
if (fromRoot !== undefined && fromRoot !== null) return fromRoot;
const fromSecurity = config?.security?.advisory_feed?.[key];
if (fromSecurity !== undefined && fromSecurity !== null) return fromSecurity;
return undefined;
}
function readEnv(name) {
const proc = globalThis?.process;
const envBag = proc && typeof proc === "object" ? proc["env"] : undefined;
return envBag ? envBag[name] : undefined;
}
function envOrConfigString(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string" && envValue.trim()) {
return envValue.trim();
}
const cfgValue = configValue(config, configKey);
if (typeof cfgValue === "string" && cfgValue.trim()) {
return cfgValue.trim();
}
return fallback;
}
function envOrConfigBool(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string") {
return toBool(envValue, fallback);
}
const cfgValue = configValue(config, configKey);
if (cfgValue !== undefined) {
return toBool(cfgValue, fallback);
}
return fallback;
}
function resolveUserPath(rawPath, fallback, hermesHome) {
const picked = String(rawPath || fallback || "").trim();
if (!picked) return "";
if (picked === "~") return os.homedir();
if (picked.startsWith("~/")) return path.join(os.homedir(), picked.slice(2));
if (picked.startsWith("$HERMES_HOME/")) return path.join(hermesHome, picked.slice("$HERMES_HOME/".length));
return path.resolve(picked);
}
function isPathInside(childPath, parentPath) {
const child = path.resolve(childPath);
const parent = path.resolve(parentPath);
const rel = path.relative(parent, child);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
function nearestExistingAncestorWithinRoot(targetPath, rootPath) {
const root = path.resolve(rootPath);
let candidate = path.resolve(targetPath);
while (isPathInside(candidate, root)) {
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(candidate);
if (parent === candidate) {
break;
}
candidate = parent;
}
return null;
}
function nearestExistingAncestor(inputPath) {
let candidate = path.resolve(inputPath);
while (!fs.existsSync(candidate)) {
const parent = path.dirname(candidate);
if (parent === candidate) {
return candidate;
}
candidate = parent;
}
return candidate;
}
function safeRealpath(inputPath) {
return fs.realpathSync.native ? fs.realpathSync.native(inputPath) : fs.realpathSync(inputPath);
}
function realpathWithMissingTail(inputPath) {
const resolved = path.resolve(inputPath);
const ancestor = nearestExistingAncestor(resolved);
const ancestorReal = safeRealpath(ancestor);
const rel = path.relative(ancestor, resolved);
return rel ? path.join(ancestorReal, rel) : ancestorReal;
}
function confineToHermesHome(candidatePath, hermesHome, label) {
const root = path.resolve(hermesHome);
const resolved = path.resolve(String(candidatePath || ""));
if (!isPathInside(resolved, root)) {
throw new Error(`${label} must stay under ${root}`);
}
const rootReal = realpathWithMissingTail(root);
const nearestAncestor = nearestExistingAncestorWithinRoot(resolved, root);
if (nearestAncestor) {
const nearestAncestorReal = safeRealpath(nearestAncestor);
if (!isPathInside(nearestAncestorReal, rootReal)) {
throw new Error(`${label} must stay under ${rootReal}`);
}
}
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
throw new Error(`${label} must not be a symlink: ${resolved}`);
}
return resolved;
}
function sha256Hex(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
function decodeSignature(signatureRaw) {
const trimmed = String(signatureRaw || "").trim();
if (!trimmed) return null;
let encoded = trimmed;
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (isObject(parsed) && typeof parsed.signature === "string") {
encoded = parsed.signature;
}
} catch {
return null;
}
}
const normalized = encoded.replace(/\s+/g, "");
if (!normalized) return null;
try {
return Buffer.from(normalized, "base64");
} catch {
return null;
}
}
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
const signature = decodeSignature(signatureRaw);
if (!signature) return false;
const keyPem = String(publicKeyPem || "").trim();
if (!keyPem) return false;
try {
const publicKey = crypto.createPublicKey(keyPem);
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
} catch {
return false;
}
}
function extractSha256(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
if (isObject(value) && typeof value.sha256 === "string") {
const normalized = value.sha256.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
return null;
}
function parseChecksumsManifest(manifestRaw) {
let parsed;
try {
parsed = JSON.parse(manifestRaw);
} catch {
throw new Error("checksum manifest is not valid JSON");
}
if (!isObject(parsed)) {
throw new Error("checksum manifest must be an object");
}
const algorithm = String(parsed.algorithm || "sha256").trim().toLowerCase();
if (algorithm !== "sha256") {
throw new Error(`unsupported checksum algorithm: ${algorithm || "(empty)"}`);
}
if (!isObject(parsed.files)) {
throw new Error("checksum manifest missing files object");
}
const files = {};
for (const [name, value] of Object.entries(parsed.files)) {
const key = String(name || "").trim();
if (!key) continue;
const digest = extractSha256(value);
if (!digest) {
throw new Error(`invalid checksum digest for ${key}`);
}
files[key] = digest;
}
if (Object.keys(files).length === 0) {
throw new Error("checksum manifest has no usable digest entries");
}
return { files };
}
function normalizeChecksumEntryName(entryName) {
return String(entryName || "")
.trim()
.replace(/\\/g, "/")
.replace(/^(?:\.\/)+/, "")
.replace(/^\/+/, "");
}
function resolveChecksumManifestEntry(files, entryName) {
const normalizedEntry = normalizeChecksumEntryName(entryName);
if (!normalizedEntry) return null;
const candidates = [
normalizedEntry,
path.posix.basename(normalizedEntry),
`advisories/${path.posix.basename(normalizedEntry)}`,
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
for (const candidate of candidates) {
if (Object.prototype.hasOwnProperty.call(files, candidate)) {
return { key: candidate, digest: files[candidate] };
}
}
const basename = path.posix.basename(normalizedEntry);
if (!basename) return null;
const matches = Object.entries(files).filter(([key]) => path.posix.basename(normalizeChecksumEntryName(key)) === basename);
if (matches.length > 1) {
throw new Error(`checksum manifest entry is ambiguous for ${entryName}`);
}
if (matches.length === 1) {
const [key, digest] = matches[0];
return { key, digest };
}
return null;
}
function verifyChecksumEntry(manifest, entryName, contentRaw) {
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
if (!resolved) {
throw new Error(`checksum manifest missing required entry: ${entryName}`);
}
const actual = sha256Hex(contentRaw);
if (actual !== resolved.digest) {
throw new Error(`checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
}
return resolved;
}
function safeBasename(urlOrPath, fallback) {
try {
const parsed = new URL(urlOrPath);
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : fallback;
} catch {
const normalized = String(urlOrPath || "").trim();
const base = path.basename(normalized);
return base || fallback;
}
}
async function fetchTextRequired(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
async function fetchTextOptional(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
if (String(error?.name || "") === "AbortError") {
throw new Error(`failed to fetch ${url}: request timed out`);
}
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
export function isValidFeedPayload(raw) {
if (!isObject(raw)) return false;
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
if (!Array.isArray(raw.advisories)) return false;
for (const advisory of raw.advisories) {
if (!isObject(advisory)) return false;
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
if (!Array.isArray(advisory.affected)) return false;
for (const entry of advisory.affected) {
if (typeof entry !== "string" || !entry.trim()) return false;
const parsed = parseAffectedSpecifier(entry);
if (!parsed || !parsed.name) return false;
if (!parseVersionSpec(parsed.versionSpec).supported) return false;
}
}
return true;
}
export function detectHermesHome() {
const envHome = String(readEnv("HERMES_HOME") || "").trim();
return envHome || path.join(os.homedir(), ".hermes");
}
export function advisorySecurityRoot(hermesHome = detectHermesHome()) {
return path.join(path.resolve(hermesHome), "security", "advisories");
}
export function defaultFeedStatePath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), STATE_FILE_BASENAME);
}
export function defaultCachedFeedPath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), CACHED_FEED_BASENAME);
}
export function defaultChecksumsUrl(feedUrl) {
try {
return new URL("checksums.json", feedUrl).toString();
} catch {
const fallbackBase = String(feedUrl || "").replace(/\/?[^/]*$/, "");
return `${fallbackBase}/checksums.json`;
}
}
export function resolveFeedConfig(overrides = {}) {
const hermesHome = detectHermesHome();
const config = detectHermesConfig(hermesHome);
const advisoryRoot = advisorySecurityRoot(hermesHome);
const cachedFeedPath = confineToHermesHome(
resolveUserPath(
overrides.cachedFeedPath
?? envOrConfigString("HERMES_ADVISORY_CACHED_FEED", config, "cached_feed_path", path.join(advisoryRoot, CACHED_FEED_BASENAME)),
path.join(advisoryRoot, CACHED_FEED_BASENAME),
hermesHome,
),
hermesHome,
"cached feed path",
);
const feedUrl = String(
overrides.feedUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_URL", config, "url", DEFAULT_REMOTE_FEED_URL),
).trim();
const signatureUrl = String(
overrides.signatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_SIG_URL", config, "signature_url", `${feedUrl}.sig`),
).trim();
const checksumsUrl = String(
overrides.checksumsUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_URL", config, "checksums_url", defaultChecksumsUrl(feedUrl)),
).trim();
const checksumsSignatureUrl = String(
overrides.checksumsSignatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL", config, "checksums_signature_url", `${checksumsUrl}.sig`),
).trim();
const source = String(
overrides.source
?? envOrConfigString("HERMES_ADVISORY_FEED_SOURCE", config, "source", "auto"),
).trim().toLowerCase();
const allowUnsigned = overrides.allowUnsigned ?? envOrConfigBool("HERMES_ADVISORY_ALLOW_UNSIGNED_FEED", config, "allow_unsigned", false);
const verifyChecksumManifest = overrides.verifyChecksumManifest
?? envOrConfigBool("HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST", config, "verify_checksum_manifest", true);
const localFeedPath = resolveUserPath(
overrides.localFeedPath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED", config, "local_path", cachedFeedPath),
cachedFeedPath,
hermesHome,
);
const localSignaturePath = resolveUserPath(
overrides.localSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_SIG", config, "local_signature_path", `${localFeedPath}.sig`),
`${localFeedPath}.sig`,
hermesHome,
);
const localChecksumsPath = resolveUserPath(
overrides.localChecksumsPath
?? envOrConfigString(
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
config,
"local_checksums_path",
path.join(path.dirname(localFeedPath), "checksums.json"),
),
path.join(path.dirname(localFeedPath), "checksums.json"),
hermesHome,
);
const localChecksumsSignaturePath = resolveUserPath(
overrides.localChecksumsSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG", config, "local_checksums_signature_path", `${localChecksumsPath}.sig`),
`${localChecksumsPath}.sig`,
hermesHome,
);
const publicKeyPathRaw = overrides.publicKeyPath
?? envOrConfigString("HERMES_ADVISORY_FEED_PUBLIC_KEY", config, "public_key_path", "");
const publicKeyPath = publicKeyPathRaw ? resolveUserPath(publicKeyPathRaw, "", hermesHome) : "";
const statePath = confineToHermesHome(
resolveUserPath(
overrides.statePath
?? envOrConfigString("HERMES_ADVISORY_FEED_STATE_PATH", config, "state_path", path.join(advisoryRoot, STATE_FILE_BASENAME)),
path.join(advisoryRoot, STATE_FILE_BASENAME),
hermesHome,
),
hermesHome,
"advisory state path",
);
return {
hermesHome,
advisoryRoot,
source: ["remote", "local", "auto"].includes(source) ? source : "auto",
feedUrl,
signatureUrl,
checksumsUrl,
checksumsSignatureUrl,
localFeedPath,
localSignaturePath,
localChecksumsPath,
localChecksumsSignaturePath,
publicKeyPath,
publicKeyPem: overrides.publicKeyPem || "",
allowUnsigned: allowUnsigned === true,
verifyChecksumManifest: verifyChecksumManifest !== false,
statePath,
cachedFeedPath,
};
}
function readPublicKeyPem(config) {
if (config.allowUnsigned) return "";
if (config.publicKeyPem && config.publicKeyPem.trim()) {
return config.publicKeyPem;
}
if (config.publicKeyPath) {
if (!fs.existsSync(config.publicKeyPath)) {
throw new Error(`pinned feed public key not found: ${config.publicKeyPath}`);
}
return fs.readFileSync(config.publicKeyPath, "utf8");
}
return PINNED_FEED_PUBLIC_KEY_PEM;
}
export function loadFeedVerificationState(statePath = defaultFeedStatePath()) {
if (!fs.existsSync(statePath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
if (!isObject(parsed)) return null;
return parsed;
} catch {
return null;
}
}
export function getFeedVerificationStatus({ statePath = defaultFeedStatePath() } = {}) {
const state = loadFeedVerificationState(statePath);
const status = String(state?.status || "").trim().toLowerCase();
if (["verified", "unverified"].includes(status)) {
return {
status,
available: true,
checked_at: state.checked_at || null,
state_path: statePath,
source: state.source || null,
};
}
return {
status: "unknown",
available: false,
checked_at: null,
state_path: statePath,
source: null,
};
}
function writeTextAtomic(filePath, content, writeOptions = {}) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
path.dirname(filePath),
`${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`,
);
let renamed = false;
try {
fs.writeFileSync(tempPath, content, { encoding: "utf8", ...writeOptions });
fs.renameSync(tempPath, filePath);
renamed = true;
} finally {
if (!renamed && fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Best-effort cleanup for interrupted atomic writes.
}
}
}
}
function writeJsonAtomic(filePath, value) {
writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
}
function parseAndValidateFeed(feedRaw, sourceLabel) {
let payload;
try {
payload = JSON.parse(feedRaw);
} catch (error) {
throw new Error(`invalid advisory feed JSON (${sourceLabel}): ${error?.message || String(error)}`);
}
if (!isValidFeedPayload(payload)) {
throw new Error(`invalid advisory feed format (${sourceLabel})`);
}
return payload;
}
function assertSignedPayload(payloadRaw, signatureRaw, keyPem, failureMessage) {
if (!verifySignedPayload(payloadRaw, signatureRaw, keyPem)) {
throw new Error(failureMessage);
}
}
function assertCompleteChecksumManifestArtifacts(hasManifest, hasManifestSignature) {
if (!hasManifest || !hasManifestSignature) {
throw new Error("checksum manifest artifacts are required when checksum verification is enabled");
}
}
function verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
}) {
assertSignedPayload(
checksumsRaw,
checksumsSignatureRaw,
keyPem,
`checksum manifest signature verification failed: ${checksumsLocation}`,
);
const manifest = parseChecksumsManifest(checksumsRaw);
verifyChecksumEntry(manifest, feedEntry, feedRaw);
verifyChecksumEntry(manifest, signatureEntry, signatureRaw);
}
function verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage,
verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation,
feedEntry,
signatureEntry,
}) {
assertSignedPayload(feedRaw, signatureRaw, keyPem, signatureFailureMessage);
if (!verifyChecksumManifest) {
return false;
}
const hasChecksums = checksumsRaw !== null;
const hasChecksumsSignature = checksumsSignatureRaw !== null;
assertCompleteChecksumManifestArtifacts(hasChecksums, hasChecksumsSignature);
verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
});
return true;
}
export async function loadLocalFeed(config) {
const feedRaw = fs.readFileSync(config.localFeedPath, "utf8");
const keyPem = readPublicKeyPem(config);
const result = {
source: "local",
location: config.localFeedPath,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
if (!fs.existsSync(config.localSignaturePath)) {
throw new Error(`missing local feed signature: ${config.localSignaturePath}`);
}
const signatureRaw = fs.readFileSync(config.localSignaturePath, "utf8");
const hasChecksums = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsPath);
const hasChecksumsSignature = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsSignaturePath);
const checksumsRaw = hasChecksums ? fs.readFileSync(config.localChecksumsPath, "utf8") : null;
const checksumsSignatureRaw = hasChecksumsSignature ? fs.readFileSync(config.localChecksumsSignaturePath, "utf8") : null;
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `local feed signature verification failed: ${config.localFeedPath}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.localChecksumsPath,
feedEntry: path.basename(config.localFeedPath),
signatureEntry: path.basename(config.localSignaturePath),
});
}
const payload = parseAndValidateFeed(feedRaw, config.localFeedPath);
return {
payload,
feedRaw,
verification: result,
};
}
export async function loadRemoteFeed(config) {
const feedRaw = await fetchTextRequired(config.feedUrl);
const keyPem = readPublicKeyPem(config);
const result = {
source: "remote",
location: config.feedUrl,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
const signatureRaw = await fetchTextRequired(config.signatureUrl);
const checksumsRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsUrl) : null;
const checksumsSignatureRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsSignatureUrl) : null;
const feedEntry = safeBasename(config.feedUrl, "feed.json");
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `remote feed signature verification failed: ${config.feedUrl}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.checksumsUrl,
feedEntry,
signatureEntry: safeBasename(config.signatureUrl, `${feedEntry}.sig`),
});
}
const payload = parseAndValidateFeed(feedRaw, config.feedUrl);
return {
payload,
feedRaw,
verification: result,
};
}
function buildState({ status, source, config, verification = {}, payload = null, error = null }) {
return {
schema_version: "1",
checked_at: new Date().toISOString(),
status,
source,
allow_unsigned_bypass: config.allowUnsigned,
verify_checksum_manifest: config.verifyChecksumManifest,
advisory_count: Array.isArray(payload?.advisories) ? payload.advisories.length : 0,
feed_version: payload?.version || null,
feed_updated: payload?.updated || null,
cached_feed_path: config.cachedFeedPath,
...verification,
error: error ? String(error) : null,
};
}
export async function refreshAdvisoryFeed(overrides = {}) {
const config = resolveFeedConfig(overrides);
const attemptedErrors = [];
const tryLoadRemote = async () => {
const loaded = await loadRemoteFeed(config);
return { ...loaded, source: "remote" };
};
const tryLoadLocal = async () => {
const loaded = await loadLocalFeed(config);
return { ...loaded, source: "local" };
};
let loaded = null;
if (config.source === "remote") {
loaded = await tryLoadRemote();
} else if (config.source === "local") {
loaded = await tryLoadLocal();
} else {
try {
loaded = await tryLoadRemote();
} catch (error) {
attemptedErrors.push(`remote: ${error?.message || String(error)}`);
loaded = await tryLoadLocal();
}
}
try {
writeTextAtomic(config.cachedFeedPath, `${loaded.feedRaw.trimEnd()}\n`);
const state = buildState({
status: config.allowUnsigned ? "unverified" : "verified",
source: loaded.source,
config,
verification: loaded.verification,
payload: loaded.payload,
error: attemptedErrors.length > 0 ? attemptedErrors.join(" | ") : null,
});
writeJsonAtomic(config.statePath, state);
return {
status: state.status,
source: loaded.source,
statePath: config.statePath,
cachedFeedPath: config.cachedFeedPath,
advisoryCount: state.advisory_count,
feedVersion: state.feed_version,
attemptedErrors,
};
} catch (error) {
const state = buildState({
status: "unverified",
source: loaded?.source || config.source,
config,
verification: loaded?.verification,
payload: loaded?.payload,
error: error?.message || String(error),
});
writeJsonAtomic(config.statePath, state);
throw error;
}
}
export function recordUnverifiedFeedState(error, overrides = {}) {
const config = resolveFeedConfig(overrides);
const state = buildState({
status: "unverified",
source: config.source,
config,
verification: {},
payload: null,
error,
});
writeJsonAtomic(config.statePath, state);
return state;
}
/**
* @param {string} version
* @returns {[number, number, number] | null}
*/
export function parseSemver(version) {
const cleaned = String(version || "")
.trim()
.replace(/^v/i, "")
.split("+")[0]
.split("-")[0];
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
if (!match) return null;
const normalized = [
Number.parseInt(match[1], 10),
Number.parseInt(match[2] || "0", 10),
Number.parseInt(match[3] || "0", 10),
];
if (normalized.some((part) => Number.isNaN(part))) return null;
return /** @type {[number, number, number]} */ (normalized);
}
/**
* @param {string} left
* @param {string} right
* @returns {number | null}
*/
export function compareSemver(left, right) {
const a = parseSemver(left);
const b = parseSemver(right);
if (!a || !b) return null;
for (let i = 0; i < 3; i += 1) {
if (a[i] > b[i]) return 1;
if (a[i] < b[i]) return -1;
}
return 0;
}
/**
* @param {string} value
* @returns {string}
*/
export function escapeRegex(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* @param {string} rawSpecifier
* @returns {{name: string, versionSpec: string} | null}
*/
export function parseAffectedSpecifier(rawSpecifier) {
const specifier = String(rawSpecifier || "").trim();
if (!specifier) return null;
const atIndex = specifier.lastIndexOf("@");
if (atIndex <= 0) {
return null;
}
if (atIndex === specifier.length - 1) {
return null;
}
const name = specifier.slice(0, atIndex).trim();
const versionSpec = specifier.slice(atIndex + 1).trim();
if (!name || !versionSpec) return null;
return { name, versionSpec };
}
/**
* @param {string} reason
* @param {string} normalized
* @returns {{supported: false, normalized: string, reason: string}}
*/
function unsupportedSpec(reason, normalized) {
return { supported: false, normalized, reason };
}
/**
* @param {string} normalized
* @returns {{supported: true, normalized: string, reason: null}}
*/
function supportedSpec(normalized) {
return { supported: true, normalized, reason: null };
}
/**
* @param {string} rawSpec
* @returns {{supported: boolean, normalized: string, reason: string | null}}
*/
export function parseVersionSpec(rawSpec) {
const spec = String(rawSpec || "").trim();
if (!spec || spec === "*" || spec.toLowerCase() === "any") {
return supportedSpec("*");
}
if (spec.includes("||") || spec.includes("&&") || /\s-\s/.test(spec) || spec.includes(",")) {
return unsupportedSpec("unsupported logical/composite semver range syntax", spec);
}
if (/^(>=|<=|>|<|=).*\s+(>=|<=|>|<|=)/.test(spec)) {
return unsupportedSpec("unsupported comparator-set semver range syntax", spec);
}
if (spec.includes("*")) {
if (!/^[vV]?[0-9*]+(?:\.[0-9*]+){0,2}$/.test(spec)) {
return unsupportedSpec("unsupported wildcard semver range syntax", spec);
}
return supportedSpec(spec);
}
if (/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/.test(spec)) {
return supportedSpec(spec);
}
if (spec.startsWith("^")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid caret semver range syntax", spec);
}
return supportedSpec(spec);
}
if (spec.startsWith("~")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid tilde semver range syntax", spec);
}
return supportedSpec(spec);
}
if (parseSemver(spec.replace(/^v/i, ""))) {
return supportedSpec(spec);
}
return unsupportedSpec("unsupported semver range syntax", spec);
}
/**
* @param {string | null} version
* @param {string} rawSpec
* @returns {boolean}
*/
export function versionMatches(version, rawSpec) {
const parsedSpec = parseVersionSpec(rawSpec);
if (!parsedSpec.supported) return false;
const spec = parsedSpec.normalized;
if (spec === "*") return true;
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
const normalizedVersion = String(version).trim().replace(/^v/i, "");
if (spec.includes("*")) {
const wildcardRegex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
return wildcardRegex.test(normalizedVersion);
}
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/);
if (comparatorMatch) {
const operator = comparatorMatch[1];
const targetVersion = comparatorMatch[2].trim();
const compared = compareSemver(normalizedVersion, targetVersion);
if (compared === null) return false;
if (operator === ">=") return compared >= 0;
if (operator === "<=") return compared <= 0;
if (operator === ">") return compared > 0;
if (operator === "<") return compared < 0;
return compared === 0;
}
if (spec.startsWith("^")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
const lowerBound = `${target[0]}.${target[1]}.${target[2]}`;
let upperBound;
if (target[0] > 0) {
upperBound = `${target[0] + 1}.0.0`;
} else if (target[1] > 0) {
upperBound = `0.${target[1] + 1}.0`;
} else {
upperBound = `0.0.${target[2] + 1}`;
}
const lowerCompared = compareSemver(normalizedVersion, lowerBound);
const upperCompared = compareSemver(normalizedVersion, upperBound);
return lowerCompared !== null && upperCompared !== null && lowerCompared >= 0 && upperCompared === -1;
}
if (spec.startsWith("~")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
return (
current[0] === target[0] &&
current[1] === target[1] &&
compareSemver(normalizedVersion, spec.slice(1)) !== -1
);
}
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
}
hermes-attestation-guardian
Hermes-only attestation, advisory verification, and guarded verification workflow.
Status: implemented (v0.1.0), Hermes-only.
Vercel Skills Installation
Install with the Vercel Skills CLI for this harness:
npx skills add prompt-security/clawsec --skill hermes-attestation-guardian -a hermes-agent -yCapabilities
This skill now covers the full Hermes-side capability set expected from the clawsec-suite parity workstream:
- Deterministic runtime posture attestation generation.
- Fail-closed attestation verification (schema + canonical digest).
- Optional detached signature verification for attestation artifacts.
- Authenticated baseline diffing with stable severity classification.
- Scoped output-path enforcement under
$HERMES_HOME. - Signed advisory feed verification (Ed25519) with optional checksum-manifest verification.
- Fail-closed advisory verification state persistence under
$HERMES_HOME/security/advisories. - Advisory-aware guarded skill verification with explicit
--confirm-advisoryoverride. - Optional recurring scheduler helpers for attestation and advisory checks (print-only by default, explicit apply mode).
- Sandboxed end-to-end regression harness for install + verify + advisory gates.
Quickstart
Canonical release verification and trust-policy guidance lives in SKILL.md:
Mandatory release verification gate (before install)Hermes guard trust policy note
After running that gate, use:
node scripts/generate_attestation.mjs
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
node scripts/refresh_advisory_feed.mjs
node scripts/check_advisories.mjs
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
node scripts/setup_attestation_cron.mjs --every 6h --print-only
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-onlyScheduler safety warning: never leave --allow-unsigned enabled in recurring advisory check jobs except during short emergency recovery windows.
Runtime requirements
Required:
node
Optional tooling (for local verification workflows):
openssl,bash,docker
Tests
node test/attestation_schema.test.mjs
node test/attestation_diff.test.mjs
node test/attestation_cli.test.mjs
node test/setup_attestation_cron.test.mjs
node test/setup_advisory_check_cron.test.mjs
node test/feed_verification.test.mjs
node test/guarded_skill_verify.test.mjs
bash test/hermes_attestation_sandbox_regression.sh#!/usr/bin/env node
import fs from "node:fs";
import { defaultCachedFeedPath, defaultFeedStatePath, loadFeedVerificationState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/check_advisories.mjs",
"",
"Prints human-readable advisory feed verification status and cached feed summary.",
"",
].join("\n"),
);
}
function summarizeBySeverity(feed) {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const counts = {};
for (const advisory of advisories) {
const severity = String(advisory?.severity || "unknown").trim().toLowerCase() || "unknown";
counts[severity] = (counts[severity] || 0) + 1;
}
return counts;
}
function printSeveritySummary(counts) {
const entries = Object.entries(counts);
if (entries.length === 0) {
process.stdout.write("Advisory severities: (none)\n");
return;
}
const sorted = entries.sort((a, b) => a[0].localeCompare(b[0]));
process.stdout.write(
`Advisory severities: ${sorted.map(([severity, count]) => `${severity}=${count}`).join(", ")}\n`,
);
}
function main() {
const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
usage();
return;
}
const config = resolveFeedConfig({});
const statePath = config.statePath || defaultFeedStatePath();
const cachedFeedPath = config.cachedFeedPath || defaultCachedFeedPath();
const state = loadFeedVerificationState(statePath);
if (!state) {
process.stdout.write(`Feed verification state: unknown (missing state file: ${statePath})\n`);
process.exitCode = 2;
return;
}
process.stdout.write(`Feed verification state: ${state.status || "unknown"}\n`);
process.stdout.write(`Source: ${state.source || "unknown"}\n`);
process.stdout.write(`Last checked: ${state.checked_at || "unknown"}\n`);
process.stdout.write(`State file: ${statePath}\n`);
process.stdout.write(`Cached feed: ${cachedFeedPath}\n`);
if (state.error) {
process.stdout.write(`Last error: ${state.error}\n`);
}
if (state.allow_unsigned_bypass) {
process.stdout.write("WARNING: unsigned advisory feed bypass is active.\n");
}
if (!fs.existsSync(cachedFeedPath)) {
process.stdout.write("Cached advisory feed: unavailable\n");
process.exitCode = state.status === "verified" ? 1 : 0;
return;
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(cachedFeedPath, "utf8"));
} catch (error) {
process.stdout.write(`Cached advisory feed JSON parse error: ${error?.message || String(error)}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`Feed version: ${feed?.version || "unknown"}\n`);
process.stdout.write(`Feed updated: ${feed?.updated || "unknown"}\n`);
process.stdout.write(`Advisory count: ${Array.isArray(feed?.advisories) ? feed.advisories.length : 0}\n`);
printSeveritySummary(summarizeBySeverity(feed));
if (state.status === "unverified") {
process.exitCode = 1;
}
}
try {
main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import {
buildAttestation,
defaultOutputPath,
parseAttestationPolicy,
resolveHermesScopedOutputPath,
sha256FileHex,
stableStringify,
} from "../lib/attestation.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/generate_attestation.mjs [options]",
"",
"Options:",
" --output <path> Output file path (default: ~/.hermes/security/attestations/current.json)",
" --policy <path> JSON policy file with watch_files and trust_anchor_files arrays",
" --watch <path> Extra watched file path (repeatable)",
" --trust-anchor <path> Extra trust anchor file path (repeatable)",
" --generated-at <iso> Override generated_at for deterministic testing",
" --write-sha256 Also write <output>.sha256 with file digest",
" --compact Write compact JSON (no indentation)",
" --help Show this help",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const args = {
output: defaultOutputPath(),
policyPath: null,
watch: [],
trustAnchor: [],
generatedAt: process.env.HERMES_ATTESTATION_GENERATED_AT || null,
writeSha256: false,
compact: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help") {
args.help = true;
continue;
}
if (token === "--output") {
args.output = argv[i + 1];
i += 1;
continue;
}
if (token === "--policy") {
args.policyPath = argv[i + 1];
i += 1;
continue;
}
if (token === "--watch") {
args.watch.push(argv[i + 1]);
i += 1;
continue;
}
if (token === "--trust-anchor") {
args.trustAnchor.push(argv[i + 1]);
i += 1;
continue;
}
if (token === "--generated-at") {
args.generatedAt = argv[i + 1];
i += 1;
continue;
}
if (token === "--write-sha256") {
args.writeSha256 = true;
continue;
}
if (token === "--compact") {
args.compact = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
return args;
}
function isSymlinkPath(filePath) {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch (error) {
if (error?.code === "ENOENT") {
return false;
}
throw error;
}
}
function writeAtomically(outPath, body) {
const dir = path.dirname(outPath);
const base = path.basename(outPath);
const tempPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
let fd = null;
try {
fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
fs.writeFileSync(fd, body, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
if (isSymlinkPath(outPath)) {
throw new Error(`output path must not be a symlink: ${outPath}`);
}
fs.renameSync(tempPath, outPath);
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// best-effort cleanup
}
}
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
if (args.generatedAt && Number.isNaN(Date.parse(args.generatedAt))) {
throw new Error(`Invalid --generated-at value: ${args.generatedAt}`);
}
const policy = args.policyPath
? parseAttestationPolicy(fs.readFileSync(path.resolve(args.policyPath), "utf8"))
: parseAttestationPolicy(null);
const attestation = buildAttestation({
generatedAt: args.generatedAt,
policy,
extraWatchFiles: args.watch,
extraTrustAnchorFiles: args.trustAnchor,
});
const outPath = resolveHermesScopedOutputPath(args.output);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const body = stableStringify(attestation, args.compact ? 0 : 2);
writeAtomically(outPath, `${body}\n`);
if (args.writeSha256) {
const shaPath = `${outPath}.sha256`;
const digest = sha256FileHex(outPath);
fs.writeFileSync(shaPath, `${digest} ${path.basename(outPath)}\n`, "utf8");
}
process.stdout.write(
`${stableStringify({
level: "INFO",
message: "attestation generated",
output: outPath,
canonical_sha256: attestation.digests.canonical_sha256,
})}\n`,
);
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import { refreshAdvisoryFeed } from "../lib/feed.mjs";
import { parseAffectedSpecifier, parseVersionSpec, versionMatches } from "../lib/semver.mjs";
const EXIT_CONFIRM_REQUIRED = 42;
function usage() {
process.stdout.write(
[
"Usage: node scripts/guarded_skill_verify.mjs --skill <name> [--version <semver>] [--confirm-advisory] [--allow-unsigned]",
"",
"Verifies advisory feed state using the Hermes feed verification pipeline, then gates",
"a candidate skill by advisory match before install/verification flows continue.",
"",
"Exit codes:",
" 0 no advisory match, or explicit advisory confirmation supplied",
" 42 advisory match found and --confirm-advisory was not provided",
" 1 verification/feed failure or invalid arguments",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
skill: "",
version: "",
confirmAdvisory: false,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--skill") {
parsed.skill = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--version") {
parsed.version = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--confirm-advisory") {
parsed.confirmAdvisory = true;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.help) return parsed;
if (!parsed.skill) {
throw new Error("Missing required argument: --skill");
}
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (parsed.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(parsed.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
return parsed;
}
function normalizeSkillName(value) {
return String(value || "").trim().toLowerCase();
}
function findAdvisoryMatches(feed, skillName, version = "") {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const targetName = normalizeSkillName(skillName);
const matches = [];
for (const advisory of advisories) {
const affected = Array.isArray(advisory?.affected) ? advisory.affected : [];
if (affected.length === 0) continue;
const matchedAffected = [];
const unsupportedSpecs = [];
for (const specifier of affected) {
const parsed = parseAffectedSpecifier(specifier);
if (!parsed) continue;
if (normalizeSkillName(parsed.name) !== targetName) continue;
const parsedSpec = parseVersionSpec(parsed.versionSpec);
if (!parsedSpec.supported) {
// Fail closed: unsupported range syntax is treated as a match to avoid bypass.
matchedAffected.push(specifier);
unsupportedSpecs.push(specifier);
continue;
}
// Conservative default: if operator did not provide --version, any name match gates.
if (!version || versionMatches(version, parsed.versionSpec)) {
matchedAffected.push(specifier);
}
}
if (matchedAffected.length > 0) {
matches.push({ advisory, matchedAffected, unsupportedSpecs });
}
}
return matches;
}
function printMatches(matches, args) {
process.stdout.write("Advisory matches detected for requested candidate.\n");
process.stdout.write(`Target: ${args.skill}${args.version ? `@${args.version}` : ""}\n`);
for (const match of matches) {
const advisory = match.advisory || {};
const severity = String(advisory.severity || "unknown").toUpperCase();
const advisoryId = String(advisory.id || "unknown-id");
const title = String(advisory.title || "Untitled advisory");
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
process.stdout.write(` matched: ${match.matchedAffected.join(", ")}\n`);
if (Array.isArray(match.unsupportedSpecs) && match.unsupportedSpecs.length > 0) {
process.stdout.write(
` warning: unsupported advisory version syntax treated as match (fail-closed): ${match.unsupportedSpecs.join(", ")}\n`,
);
}
if (advisory.action) {
process.stdout.write(` action: ${advisory.action}\n`);
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
let refreshResult;
try {
refreshResult = await refreshAdvisoryFeed(args.allowUnsigned === true ? { allowUnsigned: true } : {});
} catch (error) {
process.stderr.write(`CRITICAL: advisory feed verification failed (fail-closed): ${error?.message || String(error)}\n`);
process.exit(1);
}
if (refreshResult.status === "unverified") {
const warningSource = args.allowUnsigned === true ? "--allow-unsigned" : "resolved env/config policy";
process.stderr.write(
`WARNING: unsigned advisory bypass enabled via ${warningSource}. This weakens supply-chain guarantees and should be emergency-only.\n`,
);
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(refreshResult.cachedFeedPath, "utf8"));
} catch (error) {
process.stderr.write(
`CRITICAL: cached advisory feed load failed after verification: ${error?.message || String(error)}\n`,
);
process.exit(1);
}
process.stdout.write(`Advisory feed status: ${refreshResult.status} (${refreshResult.source})\n`);
if (!args.version) {
process.stdout.write("No --version provided; applying conservative name-based advisory gate.\n");
}
const matches = findAdvisoryMatches(feed, args.skill, args.version);
if (matches.length === 0) {
process.stdout.write("No advisory matches found for candidate.\n");
return;
}
printMatches(matches, args);
if (!args.confirmAdvisory) {
process.stdout.write("Re-run with --confirm-advisory to proceed with explicit operator acknowledgement.\n");
process.exit(EXIT_CONFIRM_REQUIRED);
}
process.stderr.write(
`WARNING: proceeding despite ${matches.length} advisory match(es) because --confirm-advisory was provided.\n`,
);
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import { refreshAdvisoryFeed, recordUnverifiedFeedState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/refresh_advisory_feed.mjs [options]",
"",
"Options:",
" --source <auto|remote|local> Feed source strategy (default: auto)",
" --allow-unsigned Temporary bypass for unsigned feeds (DANGEROUS)",
" --help Show this help",
"",
"Env/config overrides:",
" HERMES_ADVISORY_FEED_SOURCE",
" HERMES_ADVISORY_FEED_URL / HERMES_ADVISORY_FEED_SIG_URL",
" HERMES_ADVISORY_FEED_CHECKSUMS_URL / HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
" HERMES_LOCAL_ADVISORY_FEED / HERMES_LOCAL_ADVISORY_FEED_SIG",
" HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS / HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
" HERMES_ADVISORY_FEED_PUBLIC_KEY",
" HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
" HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
" HERMES_ADVISORY_FEED_STATE_PATH",
" HERMES_ADVISORY_CACHED_FEED",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
source: undefined,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
if (token === "--source") {
parsed.source = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.source && !["auto", "remote", "local"].includes(parsed.source)) {
throw new Error(`Invalid --source value: ${parsed.source}`);
}
return parsed;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const config = resolveFeedConfig(args);
if (config.allowUnsigned) {
process.stderr.write(
"WARNING: unsigned advisory feed bypass is enabled. This weakens supply-chain guarantees and should only be used as a temporary emergency exception.\n",
);
}
try {
const result = await refreshAdvisoryFeed(args);
process.stdout.write(
`${JSON.stringify({
level: "INFO",
message: "advisory feed refreshed",
status: result.status,
source: result.source,
advisories: result.advisoryCount,
feed_version: result.feedVersion,
state_path: result.statePath,
cached_feed_path: result.cachedFeedPath,
fallback_events: result.attemptedErrors,
})}\n`,
);
} catch (error) {
recordUnverifiedFeedState(error?.message || String(error), args);
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.stderr.write(`CRITICAL: feed verification state recorded at ${config.statePath || "(unknown)"}\n`);
process.exit(1);
}
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectHermesHome } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian-advisory-check >>>";
const MARKER_END = "# <<< hermes-attestation-guardian-advisory-check <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
[
"Usage: node scripts/setup_advisory_check_cron.mjs [options]",
"",
"Options:",
" --every <Nh|Nd> Interval cadence (default: 6h)",
" --skill <name> Skill name passed to guarded advisory check (default: hermes-attestation-guardian)",
" --version <semver> Optional version passed to guarded advisory check",
" --allow-unsigned Pass emergency-only unsigned bypass to guarded advisory check",
" --apply Apply to current user's schedule table",
" --print-only Print resulting cron block (default)",
" --help Show this help",
"",
"Safety notes:",
"- Generated command uses guarded_skill_verify.mjs (advisory-aware gate), not raw advisory feed checks.",
"- Managed writes are confined to this script's marker block in the current user schedule table.",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const args = {
every: process.env.HERMES_ADVISORY_CHECK_INTERVAL || "6h",
skill: process.env.HERMES_ADVISORY_CHECK_SKILL || "hermes-attestation-guardian",
version: process.env.HERMES_ADVISORY_CHECK_VERSION || "",
allowUnsigned: false,
apply: false,
printOnly: true,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
args.help = true;
continue;
}
if (token === "--every") {
args.every = argv[i + 1];
i += 1;
continue;
}
if (token === "--skill") {
args.skill = argv[i + 1];
i += 1;
continue;
}
if (token === "--version") {
args.version = argv[i + 1];
i += 1;
continue;
}
if (token === "--allow-unsigned") {
args.allowUnsigned = true;
continue;
}
if (token === "--apply") {
args.apply = true;
args.printOnly = false;
continue;
}
if (token === "--print-only") {
args.printOnly = true;
args.apply = false;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
args.skill = String(args.skill || "").trim().toLowerCase();
args.version = String(args.version || "").trim();
if (!args.help) {
if (!args.skill) {
throw new Error("Missing required skill value. Use --skill <name>.");
}
if (!/^[a-z0-9-]+$/.test(args.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (args.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(args.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
}
return args;
}
function buildCronCommand({ skill, version, allowUnsigned }) {
const scriptDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
const guardedVerify = path.join(scriptDir, "guarded_skill_verify.mjs");
const nodeExecPath = process.execPath;
if (!path.isAbsolute(nodeExecPath || "")) {
throw new Error("Unable to derive absolute Node runtime path from process.execPath");
}
const pieces = [
`'${escapeForShell(nodeExecPath)}' '${escapeForShell(guardedVerify)}'`,
`--skill '${escapeForShell(skill)}'`,
version ? `--version '${escapeForShell(version)}'` : "",
allowUnsigned ? "--allow-unsigned" : "",
].filter(Boolean);
return pieces.join(" ").trim();
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const hermesHome = path.resolve(detectHermesHome());
const cronExpr = cadenceToCron(args.every);
const command = buildCronCommand({
skill: args.skill,
version: args.version,
allowUnsigned: args.allowUnsigned,
});
const block = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian advisory check helper",
cronExpr,
command,
hermesHome,
});
const preflightLines = [
"Preflight review:",
"- This helper configures recurring Hermes advisory checks using the guarded verification flow.",
"- Generated command: guarded_skill_verify.mjs (not raw check_advisories.mjs).",
`- Hermes home: ${hermesHome}`,
`- Cadence: ${args.every} (${cronExpr})`,
`- Target skill: ${args.skill}${args.version ? `@${args.version}` : ""}`,
`- Unsigned feed bypass in scheduled command: ${args.allowUnsigned ? "enabled (emergency-only)" : "disabled"}`,
"- Scope: Hermes-only.",
];
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian advisory managed block",
detailedErrors: true,
});
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import path from "node:path";
import { detectHermesHome, resolveHermesScopedOutputPath } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian >>>";
const MARKER_END = "# <<< hermes-attestation-guardian <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
[
"Usage: node scripts/setup_attestation_cron.mjs [options]",
"",
"Options:",
" --every <Nh|Nd> Interval cadence (default: 6h)",
" --policy <path> Optional policy file passed to generator",
" --baseline <path> Optional baseline path passed to verifier",
" --baseline-sha256 <hex> Trusted baseline SHA256 passed to verifier",
" --baseline-signature <path> Baseline detached signature for verifier",
" --baseline-public-key <path> Baseline signature public key for verifier",
" --output <path> Optional output attestation path",
" --apply Apply to current user's schedule table",
" --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 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 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 = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian",
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.",
];
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian managed block",
});
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
defaultOutputPath,
sha256Hex,
stableStringify,
validateAttestationSchema,
validateDigestBinding,
} from "../lib/attestation.mjs";
import { diffAttestations, highestSeverity, severityAtOrAbove } from "../lib/diff.mjs";
const SEVERITIES = ["critical", "high", "medium", "low", "info", "none"];
function parseArgs(argv) {
const args = {
input: defaultOutputPath(),
expectedSha256: null,
signaturePath: null,
publicKeyPath: null,
baselinePath: process.env.HERMES_ATTESTATION_BASELINE || null,
baselineExpectedSha256: process.env.HERMES_ATTESTATION_BASELINE_SHA256 || null,
baselineSignaturePath: process.env.HERMES_ATTESTATION_BASELINE_SIGNATURE || null,
baselinePublicKeyPath: process.env.HERMES_ATTESTATION_BASELINE_PUBLIC_KEY || null,
failOnSeverity: process.env.HERMES_ATTESTATION_FAIL_ON_SEVERITY || "critical",
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help") {
args.help = true;
continue;
}
if (token === "--input") {
args.input = argv[i + 1];
i += 1;
continue;
}
if (token === "--expected-sha256") {
args.expectedSha256 = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--signature") {
args.signaturePath = argv[i + 1];
i += 1;
continue;
}
if (token === "--public-key") {
args.publicKeyPath = argv[i + 1];
i += 1;
continue;
}
if (token === "--baseline") {
args.baselinePath = argv[i + 1];
i += 1;
continue;
}
if (token === "--baseline-expected-sha256") {
args.baselineExpectedSha256 = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--baseline-signature") {
args.baselineSignaturePath = argv[i + 1];
i += 1;
continue;
}
if (token === "--baseline-public-key") {
args.baselinePublicKeyPath = argv[i + 1];
i += 1;
continue;
}
if (token === "--fail-on-severity") {
args.failOnSeverity = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
return args;
}
function usage() {
process.stdout.write(
[
"Usage: node scripts/verify_attestation.mjs [options]",
"",
"Options:",
" --input <path> Attestation JSON path",
" --expected-sha256 <hex> Require exact file SHA256 match",
" --signature <path> Detached signature file path (base64 or raw binary)",
" --public-key <path> Public key PEM for signature verification",
" --baseline <path> Baseline attestation for diffing",
" --baseline-expected-sha256 <hex> Trusted baseline file SHA256",
" --baseline-signature <path> Baseline detached signature",
" --baseline-public-key <path> Public key PEM for baseline signature verification",
" --fail-on-severity <level> none|critical|high|medium|low|info (default: critical)",
" --help Show this help",
"",
].join("\n"),
);
}
function parseSignature(signaturePath) {
const raw = fs.readFileSync(signaturePath);
const utf8 = raw.toString("utf8").trim();
if (/^[A-Za-z0-9+/=\n\r]+$/.test(utf8)) {
try {
return Buffer.from(utf8.replace(/\s+/g, ""), "base64");
} catch {
return raw;
}
}
return raw;
}
function verifyDetachedSignature({ inputBytes, signaturePath, publicKeyPath }) {
const signature = parseSignature(signaturePath);
const pubKeyPem = fs.readFileSync(publicKeyPath, "utf8");
const pubKey = crypto.createPublicKey(pubKeyPem);
return crypto.verify(null, inputBytes, pubKey, signature);
}
function isSha256Hex(value) {
return /^[a-f0-9]{64}$/.test(String(value || "").trim().toLowerCase());
}
function printFinding(finding) {
const sev = String(finding.severity || "info").toUpperCase();
process.stdout.write(`${sev}: ${finding.code} - ${finding.message}\n`);
}
function validateSchemaAndDigestBinding({ attestation, schemaInvalidCode, canonicalDigestMismatchCode, verificationFindings, failures }) {
const schemaErrors = validateAttestationSchema(attestation);
for (const message of schemaErrors) {
verificationFindings.push({ severity: "critical", code: schemaInvalidCode, message });
failures.push(message);
}
const digestBindingError = validateDigestBinding(attestation);
if (digestBindingError) {
verificationFindings.push({ severity: "critical", code: canonicalDigestMismatchCode, message: digestBindingError });
failures.push(digestBindingError);
}
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
if (!SEVERITIES.includes(args.failOnSeverity)) {
throw new Error(`Invalid --fail-on-severity: ${args.failOnSeverity}`);
}
if (!args.baselinePath && (args.baselineExpectedSha256 || args.baselineSignaturePath || args.baselinePublicKeyPath)) {
throw new Error("baseline verification flags require --baseline");
}
const verificationFindings = [];
const failures = [];
const inputPath = path.resolve(args.input);
if (!fs.existsSync(inputPath)) {
throw new Error(`input attestation not found: ${inputPath}`);
}
const inputBytes = fs.readFileSync(inputPath);
let attestation;
try {
attestation = JSON.parse(inputBytes.toString("utf8"));
} catch (error) {
throw new Error(`invalid JSON attestation: ${error.message}`);
}
validateSchemaAndDigestBinding({
attestation,
schemaInvalidCode: "SCHEMA_INVALID",
canonicalDigestMismatchCode: "CANONICAL_DIGEST_MISMATCH",
verificationFindings,
failures,
});
const fileDigest = sha256Hex(inputBytes);
if (args.expectedSha256) {
if (!isSha256Hex(args.expectedSha256)) {
throw new Error("--expected-sha256 must be a 64-char sha256 hex string");
}
if (args.expectedSha256 !== fileDigest) {
const message = `file sha256 mismatch expected=${args.expectedSha256} actual=${fileDigest}`;
verificationFindings.push({ severity: "critical", code: "FILE_DIGEST_MISMATCH", message });
failures.push(message);
}
}
if ((args.signaturePath && !args.publicKeyPath) || (!args.signaturePath && args.publicKeyPath)) {
const message = "signature verification requires both --signature and --public-key";
verificationFindings.push({ severity: "critical", code: "SIGNATURE_CONFIG_INVALID", message });
failures.push(message);
}
if (args.signaturePath && args.publicKeyPath) {
const ok = verifyDetachedSignature({
inputBytes,
signaturePath: path.resolve(args.signaturePath),
publicKeyPath: path.resolve(args.publicKeyPath),
});
if (!ok) {
const message = "detached signature verification failed";
verificationFindings.push({ severity: "critical", code: "SIGNATURE_INVALID", message });
failures.push(message);
}
}
let diff = null;
if (args.baselinePath) {
const baselinePath = path.resolve(args.baselinePath);
if (!fs.existsSync(baselinePath)) {
const message = `baseline not found: ${baselinePath}`;
verificationFindings.push({ severity: "critical", code: "BASELINE_MISSING", message });
failures.push(message);
} else {
const baselineBytes = fs.readFileSync(baselinePath);
const baselineTrustViaDigest = !!args.baselineExpectedSha256;
const baselineTrustViaSignature = !!args.baselineSignaturePath || !!args.baselinePublicKeyPath;
if (!baselineTrustViaDigest && !baselineTrustViaSignature) {
const message =
"baseline authenticity required: provide --baseline-expected-sha256 or both --baseline-signature and --baseline-public-key";
verificationFindings.push({ severity: "critical", code: "BASELINE_UNTRUSTED", message });
failures.push(message);
}
if (baselineTrustViaDigest) {
if (!isSha256Hex(args.baselineExpectedSha256)) {
throw new Error("--baseline-expected-sha256 must be a 64-char sha256 hex string");
}
const baselineDigest = sha256Hex(baselineBytes);
if (baselineDigest !== args.baselineExpectedSha256) {
const message = `baseline file sha256 mismatch expected=${args.baselineExpectedSha256} actual=${baselineDigest}`;
verificationFindings.push({ severity: "critical", code: "BASELINE_DIGEST_MISMATCH", message });
failures.push(message);
}
}
if (baselineTrustViaSignature) {
if (!args.baselineSignaturePath || !args.baselinePublicKeyPath) {
const message = "baseline signature verification requires both --baseline-signature and --baseline-public-key";
verificationFindings.push({ severity: "critical", code: "BASELINE_SIGNATURE_CONFIG_INVALID", message });
failures.push(message);
} else {
const ok = verifyDetachedSignature({
inputBytes: baselineBytes,
signaturePath: path.resolve(args.baselineSignaturePath),
publicKeyPath: path.resolve(args.baselinePublicKeyPath),
});
if (!ok) {
const message = "baseline detached signature verification failed";
verificationFindings.push({ severity: "critical", code: "BASELINE_SIGNATURE_INVALID", message });
failures.push(message);
}
}
}
try {
const baseline = JSON.parse(baselineBytes.toString("utf8"));
validateSchemaAndDigestBinding({
attestation: baseline,
schemaInvalidCode: "BASELINE_SCHEMA_INVALID",
canonicalDigestMismatchCode: "BASELINE_CANONICAL_DIGEST_MISMATCH",
verificationFindings,
failures,
});
if (failures.length === 0) {
diff = diffAttestations(baseline, attestation);
}
} catch (error) {
const message = `invalid baseline JSON: ${error.message}`;
verificationFindings.push({ severity: "critical", code: "BASELINE_JSON_INVALID", message });
failures.push(message);
}
}
}
for (const finding of verificationFindings) {
printFinding(finding);
}
if (diff) {
for (const finding of diff.findings) {
printFinding(finding);
}
}
if (failures.length > 0) {
process.stderr.write(`CRITICAL: verification failed with ${failures.length} error(s)\n`);
process.exit(1);
}
const diffHighest = highestSeverity(diff?.findings || []);
if (diffHighest && severityAtOrAbove(diffHighest, args.failOnSeverity)) {
process.stderr.write(
`CRITICAL: diff severity threshold exceeded (highest=${diffHighest}, threshold=${args.failOnSeverity})\n`,
);
process.exit(2);
}
process.stdout.write(
`${stableStringify({
level: "INFO",
status: "verified",
input: inputPath,
file_sha256: fileDigest,
baseline_compared: !!diff,
diff_summary: diff?.summary || null,
})}\n`,
);
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
{
"name": "hermes-attestation-guardian",
"version": "0.1.4",
"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": "lib/feed.mjs",
"required": true,
"description": "Hermes-native advisory feed verification and state helpers"
},
{
"path": "lib/semver.mjs",
"required": true,
"description": "Advisory version-range parsing and matching helpers"
},
{
"path": "lib/cron.mjs",
"required": true,
"description": "Shared managed cron block and cadence helpers"
},
{
"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/refresh_advisory_feed.mjs",
"required": true,
"description": "Fetch, verify, and persist Hermes advisory feed verification state"
},
{
"path": "scripts/check_advisories.mjs",
"required": true,
"description": "Display human-readable advisory verification/feed summary"
},
{
"path": "scripts/guarded_skill_verify.mjs",
"required": true,
"description": "Advisory-aware guarded skill verification gate with explicit confirmation override"
},
{
"path": "scripts/setup_attestation_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes attestation runs"
},
{
"path": "scripts/setup_advisory_check_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes guarded advisory checks"
}
]
},
"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",
"HERMES_ADVISORY_FEED_SOURCE",
"HERMES_ADVISORY_FEED_URL",
"HERMES_ADVISORY_FEED_SIG_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
"HERMES_LOCAL_ADVISORY_FEED",
"HERMES_LOCAL_ADVISORY_FEED_SIG",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
"HERMES_ADVISORY_FEED_PUBLIC_KEY",
"HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
"HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
"HERMES_ADVISORY_FEED_STATE_PATH",
"HERMES_ADVISORY_CACHED_FEED"
]
},
"execution": {
"always": false,
"persistence": "Runs on demand by default. Optional scheduler helper can install a managed schedule block when run with --apply.",
"network_egress": "Optional HTTPS advisory feed fetch via refresh_advisory_feed.mjs; no network required for local-mode verification"
},
"operator_review": [
"Hermes-only skill: unsupported for OpenClaw runtime hooks.",
"Verify watch/trust-anchor policy paths before scheduling recurring runs.",
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical.",
"Advisory feed verification is fail-closed by default; unsigned bypass must remain temporary and operator-audited."
],
"triggers": [
"generate hermes attestation",
"verify hermes attestation",
"hermes runtime drift detection",
"hermes trust anchor drift",
"refresh hermes advisory feed",
"check hermes advisories",
"guarded hermes skill verification",
"setup hermes attestation cron",
"setup hermes advisory check cron"
]
}
}
#!/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");
Related skills
FAQ
What does hermes-attestation-guardian do?
hermes-attestation-guardian is a Claude Code skill for testing & qa.
When should I use hermes-attestation-guardian?
When you need to helps with testing & qa tasks., or when hermes-attestation-guardian is a claude code skill for testing & qa.
What are the main capabilities?
hermes-attestation-guardian; Testing & QA; AI-coding skill.