mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-19 16:31:20 +03:00
feat(hermes-attestation-guardian): harden attestation verification and drift controls
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Hermes Attestation Guardian (Historical Draft Record)
|
||||
|
||||
Date: 2026-04-15
|
||||
Owner: draft prepared for delegated implementation
|
||||
Status: Implemented in repository (v0.0.1); this draft is historical context only
|
||||
|
||||
## 1) Proposed skill identity
|
||||
|
||||
Name: hermes-attestation-guardian
|
||||
Why this name:
|
||||
- Explicitly Hermes-scoped (no ambiguity with OpenClaw runtime skills)
|
||||
- Keeps ClawSec "guardian" naming family
|
||||
- Clear security function (attestation + guard)
|
||||
|
||||
Tagline:
|
||||
"Continuously attest Hermes runtime security posture and surface drift before it becomes incident."
|
||||
|
||||
## 2) Product positioning (Hermes-first)
|
||||
|
||||
Primary audience:
|
||||
- Operators running Hermes CLI/Gateway infra (Telegram/Matrix/Discord, local + remote hosts)
|
||||
|
||||
Non-goal:
|
||||
- OpenClaw hook runtime protection
|
||||
- Generic multi-agent platform abstraction beyond Hermes
|
||||
|
||||
Core value pillars:
|
||||
1. Trust posture visibility: one signed snapshot of security state
|
||||
2. Drift detection: compare baseline vs current and emit high-signal deltas
|
||||
3. Operator-safe controls: no destructive remediation by default, explicit approvals required
|
||||
|
||||
## 3) Security outcomes
|
||||
|
||||
The skill should attest and diff at least:
|
||||
- Enabled toolsets and risky toggles
|
||||
- Messaging/gateway active state
|
||||
- Signed feed verification state (where configured)
|
||||
- Integrity-sensitive file hashes (operator-selected policy)
|
||||
- Runtime version + dependency fingerprint summary
|
||||
|
||||
Priority alerts:
|
||||
- unsigned mode or bypass flags enabled in production profile
|
||||
- critical verification regression (signature/checksum failure)
|
||||
- protected config/runtime file drift
|
||||
- change in trust anchors (key fingerprint mismatch)
|
||||
|
||||
## 4) Hermes alignment rules (must-have)
|
||||
|
||||
Naming and metadata:
|
||||
- skill.json name must be hermes-attestation-guardian
|
||||
- platform field must be hermes
|
||||
- triggers must include explicit Hermes language (avoid generic "security check" only)
|
||||
|
||||
Paths and side effects:
|
||||
- default output under ~/.hermes/security/attestations/
|
||||
- no writes to ~/.openclaw paths
|
||||
- no destructive actions (quarantine/delete/restore) in MVP
|
||||
|
||||
Runtime/UX:
|
||||
- fail closed for attestation verification errors
|
||||
- operator-facing messages must separate: INFO / WARNING / CRITICAL
|
||||
- remediation must be recommendation-only unless user explicitly opts in
|
||||
|
||||
## 5) ClawSec pipeline/distribution requirements
|
||||
|
||||
Based on existing repo automation, this skill must satisfy:
|
||||
- skills/<name>/skill.json contains required fields: name, version, description, author, license, sbom.files
|
||||
- skills/<name>/SKILL.md includes YAML frontmatter with matching version
|
||||
- version parity: skill.json version == SKILL.md frontmatter version
|
||||
- CHANGELOG.md must contain heading exactly:
|
||||
## [<version>] - YYYY-MM-DD
|
||||
- SBOM required file entries must exist in repo
|
||||
- README.md recommended for site rendering
|
||||
|
||||
Release mechanics this skill should be compatible with:
|
||||
- scripts/release-skill.sh version bump + URL rewrites
|
||||
- .github/workflows/skill-release.yml parity/release-note checks
|
||||
- utils/validate_skill.py local validation
|
||||
|
||||
## 6) Skeleton contents created
|
||||
|
||||
skills/hermes-attestation-guardian/
|
||||
- skill.json
|
||||
- SKILL.md
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- scripts/generate_attestation.mjs
|
||||
- scripts/verify_attestation.mjs
|
||||
- scripts/setup_attestation_cron.mjs
|
||||
- lib/diff.mjs
|
||||
- test/attestation_schema.test.mjs
|
||||
- test/attestation_diff.test.mjs
|
||||
|
||||
## 7) Delegate implementation brief
|
||||
|
||||
Delegate goal:
|
||||
Prepare a production-ready v0.0.1 of hermes-attestation-guardian from this skeleton while keeping Hermes-only positioning and ClawSec release compatibility.
|
||||
|
||||
Required implementation scope:
|
||||
1. Implement deterministic attestation schema + generator CLI
|
||||
2. Implement verifier CLI (schema + signature/checksum hooks, fail-closed semantics)
|
||||
3. Implement baseline diff engine with stable severity mapping
|
||||
4. Implement cron setup helper for Hermes environments (no OpenClaw assumptions)
|
||||
5. Add/expand tests for schema, diff correctness, and failure behavior
|
||||
6. Keep docs and metadata aligned with actual behavior (no aspirational claims)
|
||||
|
||||
Acceptance checks before return:
|
||||
- python utils/validate_skill.py skills/hermes-attestation-guardian
|
||||
- Version parity and changelog heading satisfy skill-release policy
|
||||
- Node tests in skill directory pass
|
||||
- Tool/runtime side effects are explicitly documented in SKILL.md
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.1] - 2026-04-15
|
||||
|
||||
- Implemented deterministic Hermes attestation generator CLI (`scripts/generate_attestation.mjs`).
|
||||
- Implemented fail-closed verifier CLI with schema, canonical digest, expected checksum, and optional detached signature checks (`scripts/verify_attestation.mjs`).
|
||||
- Implemented meaningful baseline diff engine with stable severity mapping for risky toggle regressions, feed verification regressions, trust anchor drift, and watched file drift (`lib/diff.mjs`).
|
||||
- Implemented Hermes-only cron setup helper with print-only default and managed-block apply mode (`scripts/setup_attestation_cron.mjs`).
|
||||
- Added shared attestation library for canonicalization, schema validation, digest generation, and policy parsing (`lib/attestation.mjs`).
|
||||
- Expanded tests for schema determinism, diff behavior, generator/verifier fail-closed behavior, and cron helper Hermes-only output.
|
||||
- Updated metadata/docs to match actual implemented behavior and ClawSec release pipeline expectations.
|
||||
@@ -0,0 +1,45 @@
|
||||
# hermes-attestation-guardian
|
||||
|
||||
Hermes-only security attestation and drift detection skill.
|
||||
|
||||
Status: implemented (v0.0.1), Hermes-only.
|
||||
|
||||
## What it does
|
||||
|
||||
- Generates deterministic Hermes runtime posture attestations.
|
||||
- Verifies attestation schema + canonical digest with fail-closed semantics.
|
||||
- Optionally verifies detached signatures using a provided public key.
|
||||
- Fails closed on baseline diffing unless baseline authenticity is verified (trusted digest and/or detached signature).
|
||||
- Restricts attestation output writes to Hermes attestation scope (`$HERMES_HOME/security/attestations`).
|
||||
- Compares baseline vs current attestations with stable severity classification.
|
||||
- Provides an optional Hermes-oriented cron setup helper (print-only by default).
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
In scope:
|
||||
- Hermes environment posture snapshots
|
||||
- deterministic baseline diffing
|
||||
- fail-closed verification semantics
|
||||
- Hermes user-crontab scheduling helper
|
||||
|
||||
Out of scope / unsupported (v0.0.1):
|
||||
- OpenClaw runtime hooks (unsupported)
|
||||
- destructive auto-remediation
|
||||
- automatic rollback of runtime configuration
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
node scripts/generate_attestation.mjs
|
||||
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
|
||||
node scripts/setup_attestation_cron.mjs --every 6h --print-only
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
node test/attestation_schema.test.mjs
|
||||
node test/attestation_diff.test.mjs
|
||||
node test/attestation_cli.test.mjs
|
||||
node test/setup_attestation_cron.test.mjs
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: hermes-attestation-guardian
|
||||
version: 0.0.1
|
||||
description: Hermes-only runtime security attestation and drift detection skill for operator-managed Hermes infrastructure.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🛡️"
|
||||
requires:
|
||||
bins: [node]
|
||||
---
|
||||
|
||||
# Hermes Attestation Guardian
|
||||
|
||||
IMPORTANT SCOPE:
|
||||
- This skill targets Hermes infrastructure only (CLI/Gateway/profile-managed deployments).
|
||||
- This skill is not an OpenClaw runtime hook package.
|
||||
|
||||
## Goal
|
||||
|
||||
Generate deterministic Hermes posture attestations, verify them with fail-closed integrity checks, and compare baseline drift using stable severity mapping.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Generate attestation (default output: ~/.hermes/security/attestations/current.json)
|
||||
node scripts/generate_attestation.mjs
|
||||
|
||||
# Generate with explicit policy + deterministic timestamp
|
||||
node scripts/generate_attestation.mjs \
|
||||
--policy ~/.hermes/security/attestation-policy.json \
|
||||
--generated-at 2026-04-15T18:00:00.000Z \
|
||||
--write-sha256
|
||||
|
||||
# Verify schema + canonical digest
|
||||
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
|
||||
|
||||
# Verify with baseline diff (baseline must be authenticated)
|
||||
node scripts/verify_attestation.mjs \
|
||||
--input ~/.hermes/security/attestations/current.json \
|
||||
--baseline ~/.hermes/security/attestations/baseline.json \
|
||||
--baseline-expected-sha256 <trusted-baseline-sha256> \
|
||||
--fail-on-severity high
|
||||
|
||||
# Optional detached signature verification
|
||||
node scripts/verify_attestation.mjs \
|
||||
--input ~/.hermes/security/attestations/current.json \
|
||||
--signature ~/.hermes/security/attestations/current.json.sig \
|
||||
--public-key ~/.hermes/security/keys/attestation-public.pem
|
||||
|
||||
# Preview cron config without mutating crontab
|
||||
node scripts/setup_attestation_cron.mjs --every 6h --print-only
|
||||
|
||||
# Apply managed cron block
|
||||
node scripts/setup_attestation_cron.mjs --every 6h --apply
|
||||
```
|
||||
|
||||
## Attestation payload (implemented)
|
||||
|
||||
The generator emits:
|
||||
- schema_version, platform, generated_at
|
||||
- generator metadata (skill + node version)
|
||||
- host metadata (hostname/platform/arch)
|
||||
- posture.runtime (gateway enabled flags + risky toggles)
|
||||
- posture.feed_verification status (verified|unverified|unknown)
|
||||
- posture.integrity watched_files and trust_anchors (existence + sha256)
|
||||
- digests.canonical_sha256 over a stable canonical JSON representation
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
Verifier exits non-zero when:
|
||||
- schema validation fails
|
||||
- canonical digest algorithm is unsupported or digest binding mismatches
|
||||
- expected file sha256 mismatches (if configured)
|
||||
- detached signature verification fails (if configured)
|
||||
- baseline is provided without authenticated trust binding (`--baseline-expected-sha256` and/or baseline signature + public key)
|
||||
- baseline authenticity or baseline schema/digest validation fails
|
||||
- baseline diff highest severity is at/above `--fail-on-severity` (default: critical)
|
||||
|
||||
Severity messages are emitted as INFO / WARNING / CRITICAL style lines.
|
||||
|
||||
## Side effects
|
||||
|
||||
- `generate_attestation.mjs` writes one JSON file (and optional `.sha256`) under `$HERMES_HOME/security/attestations`.
|
||||
- `verify_attestation.mjs` is read-only.
|
||||
- `setup_attestation_cron.mjs` is read-only unless `--apply` is provided.
|
||||
- `setup_attestation_cron.mjs --apply` rewrites only the current user crontab managed block delimited by:
|
||||
- `# >>> hermes-attestation-guardian >>>`
|
||||
- `# <<< hermes-attestation-guardian <<<`
|
||||
|
||||
## Notes
|
||||
|
||||
- Default output root is `~/.hermes/security/attestations/`.
|
||||
- No destructive remediation actions (delete/restore/quarantine) are implemented.
|
||||
- Operator policy file is optional JSON with:
|
||||
- `watch_files`: list of file paths
|
||||
- `trust_anchor_files`: list of file paths
|
||||
@@ -0,0 +1,389 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const SCHEMA_VERSION = "0.0.1";
|
||||
export const SKILL_NAME = "hermes-attestation-guardian";
|
||||
export const SKILL_VERSION = "0.0.1";
|
||||
export const DIGEST_ALGORITHM = "sha256";
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function stableSortObject(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableSortObject);
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
out[key] = stableSortObject(value[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stableStringify(value, spacing = 2) {
|
||||
return JSON.stringify(stableSortObject(value), null, spacing);
|
||||
}
|
||||
|
||||
export function sha256Hex(input) {
|
||||
return crypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
export function sha256FileHex(filePath) {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
export function detectHermesHome() {
|
||||
const candidate = (process.env.HERMES_HOME || "").trim();
|
||||
return candidate || path.join(os.homedir(), ".hermes");
|
||||
}
|
||||
|
||||
export function defaultOutputPath() {
|
||||
return path.join(detectHermesHome(), "security", "attestations", "current.json");
|
||||
}
|
||||
|
||||
export function attestationOutputRoot(hermesHome = detectHermesHome()) {
|
||||
return path.join(path.resolve(hermesHome), "security", "attestations");
|
||||
}
|
||||
|
||||
function nearestExistingAncestor(inputPath) {
|
||||
let candidate = path.resolve(inputPath);
|
||||
while (!fs.existsSync(candidate)) {
|
||||
const parent = path.dirname(candidate);
|
||||
if (parent === candidate) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = parent;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function safeRealpath(inputPath) {
|
||||
return fs.realpathSync.native ? fs.realpathSync.native(inputPath) : fs.realpathSync(inputPath);
|
||||
}
|
||||
|
||||
function realpathWithMissingTail(inputPath) {
|
||||
const resolved = path.resolve(inputPath);
|
||||
const ancestor = nearestExistingAncestor(resolved);
|
||||
const ancestorReal = safeRealpath(ancestor);
|
||||
const rel = path.relative(ancestor, resolved);
|
||||
return rel ? path.join(ancestorReal, rel) : ancestorReal;
|
||||
}
|
||||
|
||||
function nearestExistingAncestorWithinRoot(targetPath, rootPath) {
|
||||
const stopAt = path.resolve(path.dirname(rootPath));
|
||||
let candidate = path.resolve(targetPath);
|
||||
|
||||
while (true) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate === stopAt) {
|
||||
return null;
|
||||
}
|
||||
const parent = path.dirname(candidate);
|
||||
if (parent === candidate) {
|
||||
return null;
|
||||
}
|
||||
candidate = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveHermesScopedOutputPath(outputPath, hermesHome = detectHermesHome()) {
|
||||
const root = attestationOutputRoot(hermesHome);
|
||||
const resolvedOutput = path.resolve(String(outputPath || defaultOutputPath()));
|
||||
if (!isPathInside(resolvedOutput, root)) {
|
||||
throw new Error(`output path must stay under ${root}`);
|
||||
}
|
||||
|
||||
const hermesHomeReal = realpathWithMissingTail(hermesHome);
|
||||
const rootReal = path.join(hermesHomeReal, "security", "attestations");
|
||||
const nearestOutputAncestor = nearestExistingAncestorWithinRoot(resolvedOutput, root);
|
||||
if (nearestOutputAncestor) {
|
||||
const nearestOutputAncestorReal = safeRealpath(nearestOutputAncestor);
|
||||
if (!isPathInside(nearestOutputAncestorReal, rootReal)) {
|
||||
throw new Error(`output path must stay under ${rootReal}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(resolvedOutput) && fs.lstatSync(resolvedOutput).isSymbolicLink()) {
|
||||
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
|
||||
}
|
||||
|
||||
return resolvedOutput;
|
||||
}
|
||||
|
||||
export function isPathInside(childPath, parentPath) {
|
||||
const child = path.resolve(childPath);
|
||||
const parent = path.resolve(parentPath);
|
||||
const rel = path.relative(parent, child);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
export function parseAttestationPolicy(policyContent) {
|
||||
if (!policyContent) {
|
||||
return { watch_files: [], trust_anchor_files: [] };
|
||||
}
|
||||
const parsed = JSON.parse(policyContent);
|
||||
const watchFiles = Array.isArray(parsed.watch_files) ? parsed.watch_files : [];
|
||||
const trustAnchors = Array.isArray(parsed.trust_anchor_files) ? parsed.trust_anchor_files : [];
|
||||
return {
|
||||
watch_files: [...new Set(watchFiles.map((v) => String(v).trim()).filter(Boolean))].sort(),
|
||||
trust_anchor_files: [...new Set(trustAnchors.map((v) => String(v).trim()).filter(Boolean))].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonFileMaybe(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export function detectHermesConfig(hermesHome) {
|
||||
const configCandidates = [
|
||||
path.join(hermesHome, "config.json"),
|
||||
path.join(hermesHome, "gateway", "config.json"),
|
||||
];
|
||||
|
||||
for (const candidate of configCandidates) {
|
||||
try {
|
||||
const parsed = readJsonFileMaybe(candidate);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return { path: candidate, config: parsed };
|
||||
}
|
||||
} catch {
|
||||
// Continue trying fallbacks; verifier reports malformed artifacts, not local config issues.
|
||||
}
|
||||
}
|
||||
|
||||
return { path: null, config: {} };
|
||||
}
|
||||
|
||||
function bool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
function readEnvBool(name, fallback = false) {
|
||||
const raw = process.env[name];
|
||||
if (typeof raw !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const norm = raw.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
|
||||
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizePath(input, hermesHome) {
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return raw;
|
||||
if (raw === "~") return os.homedir();
|
||||
if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
|
||||
if (raw.startsWith("$HERMES_HOME/")) return path.join(hermesHome, raw.slice("$HERMES_HOME/".length));
|
||||
return path.resolve(raw);
|
||||
}
|
||||
|
||||
function fileFingerprint(filePath) {
|
||||
if (!filePath) {
|
||||
return { path: filePath, exists: false, sha256: null };
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { path: filePath, exists: false, sha256: null };
|
||||
}
|
||||
const data = fs.readFileSync(filePath);
|
||||
return { path: filePath, exists: true, sha256: sha256Hex(data) };
|
||||
}
|
||||
|
||||
export function buildAttestation({
|
||||
generatedAt,
|
||||
policy,
|
||||
extraWatchFiles = [],
|
||||
extraTrustAnchorFiles = [],
|
||||
} = {}) {
|
||||
const hermesHome = detectHermesHome();
|
||||
const configState = detectHermesConfig(hermesHome);
|
||||
const config = configState.config || {};
|
||||
|
||||
const gateways = {
|
||||
telegram: bool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
|
||||
matrix: bool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
|
||||
discord: bool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
|
||||
};
|
||||
|
||||
const riskyToggles = {
|
||||
allow_unsigned_mode: bool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
|
||||
bypass_verification: bool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
|
||||
};
|
||||
|
||||
const feedStatus = String(
|
||||
process.env.HERMES_FEED_VERIFICATION_STATUS || config?.feed_verification?.status || "unknown",
|
||||
).toLowerCase();
|
||||
const normalizedFeedStatus = ["verified", "unverified", "unknown"].includes(feedStatus) ? feedStatus : "unknown";
|
||||
|
||||
const selectedPolicy = policy || { watch_files: [], trust_anchor_files: [] };
|
||||
|
||||
const watchFiles = [...new Set([...(selectedPolicy.watch_files || []), ...extraWatchFiles])]
|
||||
.map((p) => normalizePath(p, hermesHome))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
const trustAnchorFiles = [...new Set([...(selectedPolicy.trust_anchor_files || []), ...extraTrustAnchorFiles])]
|
||||
.map((p) => normalizePath(p, hermesHome))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
const watchedFingerprints = watchFiles.map(fileFingerprint);
|
||||
const trustAnchorFingerprints = trustAnchorFiles.map(fileFingerprint);
|
||||
|
||||
const payload = {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
platform: "hermes",
|
||||
generated_at: generatedAt || new Date().toISOString(),
|
||||
generator: {
|
||||
skill: SKILL_NAME,
|
||||
version: SKILL_VERSION,
|
||||
node: process.version,
|
||||
},
|
||||
host: {
|
||||
hostname: os.hostname(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
posture: {
|
||||
hermes_home: hermesHome,
|
||||
config_source: configState.path,
|
||||
runtime: {
|
||||
gateways,
|
||||
risky_toggles: riskyToggles,
|
||||
},
|
||||
feed_verification: {
|
||||
configured: normalizedFeedStatus !== "unknown",
|
||||
status: normalizedFeedStatus,
|
||||
},
|
||||
integrity: {
|
||||
watched_files: watchedFingerprints,
|
||||
trust_anchors: trustAnchorFingerprints,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const canonicalWithoutDigest = stableStringify(payload, 0);
|
||||
const canonicalSha256 = sha256Hex(canonicalWithoutDigest);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
digests: {
|
||||
canonical_sha256: canonicalSha256,
|
||||
algorithm: DIGEST_ALGORITHM,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDigestAlgorithm(algorithm) {
|
||||
return String(algorithm || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isSupportedDigestAlgorithm(algorithm) {
|
||||
return normalizeDigestAlgorithm(algorithm) === DIGEST_ALGORITHM;
|
||||
}
|
||||
|
||||
export function computeCanonicalDigest(attestation) {
|
||||
const clone = JSON.parse(JSON.stringify(attestation || {}));
|
||||
delete clone.digests;
|
||||
return sha256Hex(stableStringify(clone, 0));
|
||||
}
|
||||
|
||||
export function validateDigestBinding(attestation) {
|
||||
if (!attestation || typeof attestation !== "object") {
|
||||
return "attestation must be a JSON object";
|
||||
}
|
||||
if (!isSupportedDigestAlgorithm(attestation?.digests?.algorithm)) {
|
||||
return `unsupported digest algorithm: ${attestation?.digests?.algorithm ?? "(missing)"}`;
|
||||
}
|
||||
const expectedCanonical = String(attestation?.digests?.canonical_sha256 || "").toLowerCase();
|
||||
const actualCanonical = computeCanonicalDigest(attestation);
|
||||
if (expectedCanonical !== actualCanonical) {
|
||||
return `canonical digest mismatch expected=${expectedCanonical} actual=${actualCanonical}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAttestationSchema(attestation) {
|
||||
const errors = [];
|
||||
|
||||
if (!isPlainObject(attestation)) {
|
||||
return ["attestation must be a JSON object"];
|
||||
}
|
||||
|
||||
if (attestation.schema_version !== SCHEMA_VERSION) {
|
||||
errors.push(`schema_version must be ${SCHEMA_VERSION}`);
|
||||
}
|
||||
if (attestation.platform !== "hermes") {
|
||||
errors.push("platform must be hermes");
|
||||
}
|
||||
|
||||
const generatedAt = String(attestation.generated_at || "").trim();
|
||||
if (!generatedAt || Number.isNaN(Date.parse(generatedAt))) {
|
||||
errors.push("generated_at must be an ISO timestamp");
|
||||
}
|
||||
|
||||
if (!isPlainObject(attestation.generator)) {
|
||||
errors.push("generator object is required");
|
||||
}
|
||||
if (!isPlainObject(attestation.host)) {
|
||||
errors.push("host object is required");
|
||||
}
|
||||
|
||||
if (!isPlainObject(attestation.posture)) {
|
||||
errors.push("posture object is required");
|
||||
} else {
|
||||
if (!isPlainObject(attestation.posture.runtime)) {
|
||||
errors.push("posture.runtime object is required");
|
||||
}
|
||||
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 {
|
||||
if (!Array.isArray(integrity.watched_files)) {
|
||||
errors.push("posture.integrity.watched_files must be an array");
|
||||
}
|
||||
if (!Array.isArray(integrity.trust_anchors)) {
|
||||
errors.push("posture.integrity.trust_anchors must be an array");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlainObject(attestation.digests)) {
|
||||
errors.push("digests object is required");
|
||||
} else {
|
||||
if (!/^[a-f0-9]{64}$/i.test(String(attestation.digests.canonical_sha256 || ""))) {
|
||||
errors.push("digests.canonical_sha256 must be a 64-char sha256 hex string");
|
||||
}
|
||||
if (!isSupportedDigestAlgorithm(attestation.digests.algorithm)) {
|
||||
errors.push(`digests.algorithm must be ${DIGEST_ALGORITHM}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
||||
|
||||
function bumpSummary(summary, severity) {
|
||||
if (summary[severity] === undefined) {
|
||||
summary[severity] = 0;
|
||||
}
|
||||
summary[severity] += 1;
|
||||
}
|
||||
|
||||
function compareBooleanFindings({ findings, summary, codeOnEnable, codeOnDisable, path, before, after, enableSeverity = "high" }) {
|
||||
if (!!before === !!after) return;
|
||||
|
||||
if (!before && after) {
|
||||
findings.push({
|
||||
severity: enableSeverity,
|
||||
code: codeOnEnable,
|
||||
path,
|
||||
message: `${path} changed false -> true`,
|
||||
});
|
||||
bumpSummary(summary, enableSeverity);
|
||||
return;
|
||||
}
|
||||
|
||||
findings.push({
|
||||
severity: "info",
|
||||
code: codeOnDisable,
|
||||
path,
|
||||
message: `${path} changed true -> false`,
|
||||
});
|
||||
bumpSummary(summary, "info");
|
||||
}
|
||||
|
||||
function mapByPath(entries) {
|
||||
const out = new Map();
|
||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||
if (!entry || typeof entry.path !== "string") continue;
|
||||
out.set(entry.path, entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareHashedEntries({ findings, summary, beforeEntries, afterEntries, changedCode, missingCode }) {
|
||||
const beforeMap = mapByPath(beforeEntries);
|
||||
const afterMap = mapByPath(afterEntries);
|
||||
|
||||
for (const [itemPath, before] of beforeMap.entries()) {
|
||||
const after = afterMap.get(itemPath);
|
||||
if (!after) {
|
||||
findings.push({
|
||||
severity: "high",
|
||||
code: missingCode,
|
||||
path: itemPath,
|
||||
message: `${itemPath} missing in current attestation`,
|
||||
});
|
||||
bumpSummary(summary, "high");
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeHash = before.sha256 || null;
|
||||
const afterHash = after.sha256 || null;
|
||||
if (beforeHash !== afterHash) {
|
||||
findings.push({
|
||||
severity: "critical",
|
||||
code: changedCode,
|
||||
path: itemPath,
|
||||
message: `${itemPath} fingerprint changed`,
|
||||
});
|
||||
bumpSummary(summary, "critical");
|
||||
}
|
||||
}
|
||||
|
||||
for (const [itemPath, after] of afterMap.entries()) {
|
||||
if (beforeMap.has(itemPath)) continue;
|
||||
findings.push({
|
||||
severity: "low",
|
||||
code: "NEW_INTEGRITY_SCOPE",
|
||||
path: itemPath,
|
||||
message: `${itemPath} added to integrity tracking scope`,
|
||||
details: { exists: !!after.exists },
|
||||
});
|
||||
bumpSummary(summary, "low");
|
||||
}
|
||||
}
|
||||
|
||||
function compareFeedVerification({ findings, summary, baselineFeed, currentFeed }) {
|
||||
const beforeStatus = baselineFeed?.status || "unknown";
|
||||
const afterStatus = currentFeed?.status || "unknown";
|
||||
|
||||
if (beforeStatus === afterStatus) return;
|
||||
|
||||
if (beforeStatus === "verified" && afterStatus !== "verified") {
|
||||
findings.push({
|
||||
severity: "critical",
|
||||
code: "FEED_VERIFICATION_REGRESSION",
|
||||
path: "posture.feed_verification.status",
|
||||
message: `Feed verification regressed verified -> ${afterStatus}`,
|
||||
});
|
||||
bumpSummary(summary, "critical");
|
||||
return;
|
||||
}
|
||||
|
||||
findings.push({
|
||||
severity: "medium",
|
||||
code: "FEED_VERIFICATION_CHANGED",
|
||||
path: "posture.feed_verification.status",
|
||||
message: `Feed verification status changed ${beforeStatus} -> ${afterStatus}`,
|
||||
});
|
||||
bumpSummary(summary, "medium");
|
||||
}
|
||||
|
||||
function comparePlatform({ findings, summary, baseline, current }) {
|
||||
if (baseline.platform === current.platform) return;
|
||||
findings.push({
|
||||
severity: "critical",
|
||||
code: "PLATFORM_MISMATCH",
|
||||
path: "platform",
|
||||
message: `platform changed ${baseline.platform} -> ${current.platform}`,
|
||||
});
|
||||
bumpSummary(summary, "critical");
|
||||
}
|
||||
|
||||
function compareSchema({ findings, summary, baseline, current }) {
|
||||
if (baseline.schema_version === current.schema_version) return;
|
||||
findings.push({
|
||||
severity: "high",
|
||||
code: "SCHEMA_VERSION_CHANGED",
|
||||
path: "schema_version",
|
||||
message: `schema_version changed ${baseline.schema_version} -> ${current.schema_version}`,
|
||||
});
|
||||
bumpSummary(summary, "high");
|
||||
}
|
||||
|
||||
function compareGenerator({ findings, summary, baseline, current }) {
|
||||
const before = baseline?.generator?.version || "unknown";
|
||||
const after = current?.generator?.version || "unknown";
|
||||
if (before === after) return;
|
||||
findings.push({
|
||||
severity: "info",
|
||||
code: "GENERATOR_VERSION_CHANGED",
|
||||
path: "generator.version",
|
||||
message: `generator.version changed ${before} -> ${after}`,
|
||||
});
|
||||
bumpSummary(summary, "info");
|
||||
}
|
||||
|
||||
export function diffAttestations(baseline, current) {
|
||||
const findings = [];
|
||||
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
|
||||
const baselineSafe = baseline && typeof baseline === "object" ? baseline : {};
|
||||
const currentSafe = current && typeof current === "object" ? current : {};
|
||||
|
||||
comparePlatform({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||
compareSchema({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||
compareGenerator({ findings, summary, baseline: baselineSafe, current: currentSafe });
|
||||
|
||||
const baselineRuntime = baselineSafe?.posture?.runtime || {};
|
||||
const currentRuntime = currentSafe?.posture?.runtime || {};
|
||||
|
||||
compareBooleanFindings({
|
||||
findings,
|
||||
summary,
|
||||
codeOnEnable: "UNSIGNED_MODE_ENABLED",
|
||||
codeOnDisable: "UNSIGNED_MODE_DISABLED",
|
||||
path: "posture.runtime.risky_toggles.allow_unsigned_mode",
|
||||
before: baselineRuntime?.risky_toggles?.allow_unsigned_mode,
|
||||
after: currentRuntime?.risky_toggles?.allow_unsigned_mode,
|
||||
enableSeverity: "critical",
|
||||
});
|
||||
|
||||
compareBooleanFindings({
|
||||
findings,
|
||||
summary,
|
||||
codeOnEnable: "BYPASS_VERIFICATION_ENABLED",
|
||||
codeOnDisable: "BYPASS_VERIFICATION_DISABLED",
|
||||
path: "posture.runtime.risky_toggles.bypass_verification",
|
||||
before: baselineRuntime?.risky_toggles?.bypass_verification,
|
||||
after: currentRuntime?.risky_toggles?.bypass_verification,
|
||||
enableSeverity: "critical",
|
||||
});
|
||||
|
||||
for (const gateway of ["telegram", "matrix", "discord"]) {
|
||||
compareBooleanFindings({
|
||||
findings,
|
||||
summary,
|
||||
codeOnEnable: "GATEWAY_ENABLED",
|
||||
codeOnDisable: "GATEWAY_DISABLED",
|
||||
path: `posture.runtime.gateways.${gateway}`,
|
||||
before: baselineRuntime?.gateways?.[gateway],
|
||||
after: currentRuntime?.gateways?.[gateway],
|
||||
enableSeverity: "low",
|
||||
});
|
||||
}
|
||||
|
||||
compareFeedVerification({
|
||||
findings,
|
||||
summary,
|
||||
baselineFeed: baselineSafe?.posture?.feed_verification,
|
||||
currentFeed: currentSafe?.posture?.feed_verification,
|
||||
});
|
||||
|
||||
compareHashedEntries({
|
||||
findings,
|
||||
summary,
|
||||
beforeEntries: baselineSafe?.posture?.integrity?.trust_anchors,
|
||||
afterEntries: currentSafe?.posture?.integrity?.trust_anchors,
|
||||
changedCode: "TRUST_ANCHOR_MISMATCH",
|
||||
missingCode: "TRUST_ANCHOR_REMOVED",
|
||||
});
|
||||
|
||||
compareHashedEntries({
|
||||
findings,
|
||||
summary,
|
||||
beforeEntries: baselineSafe?.posture?.integrity?.watched_files,
|
||||
afterEntries: currentSafe?.posture?.integrity?.watched_files,
|
||||
changedCode: "WATCHED_FILE_DRIFT",
|
||||
missingCode: "WATCHED_FILE_REMOVED",
|
||||
});
|
||||
|
||||
findings.sort((a, b) => {
|
||||
const sev = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
||||
if (sev !== 0) return sev;
|
||||
const codeCmp = String(a.code || "").localeCompare(String(b.code || ""));
|
||||
if (codeCmp !== 0) return codeCmp;
|
||||
return String(a.path || "").localeCompare(String(b.path || ""));
|
||||
});
|
||||
|
||||
return {
|
||||
summary,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
export function highestSeverity(findings = []) {
|
||||
for (const severity of SEVERITY_ORDER) {
|
||||
if (findings.some((finding) => finding?.severity === severity)) {
|
||||
return severity;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function severityAtOrAbove(severity, threshold) {
|
||||
if (!threshold || threshold === "none") return false;
|
||||
const idx = SEVERITY_ORDER.indexOf(severity);
|
||||
const thresholdIdx = SEVERITY_ORDER.indexOf(threshold);
|
||||
if (idx < 0 || thresholdIdx < 0) return false;
|
||||
return idx <= thresholdIdx;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildAttestation,
|
||||
defaultOutputPath,
|
||||
parseAttestationPolicy,
|
||||
resolveHermesScopedOutputPath,
|
||||
sha256FileHex,
|
||||
stableStringify,
|
||||
} from "../lib/attestation.mjs";
|
||||
|
||||
function usage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node scripts/generate_attestation.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --output <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 run() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.generatedAt && Number.isNaN(Date.parse(args.generatedAt))) {
|
||||
throw new Error(`Invalid --generated-at value: ${args.generatedAt}`);
|
||||
}
|
||||
|
||||
const policy = args.policyPath
|
||||
? parseAttestationPolicy(fs.readFileSync(path.resolve(args.policyPath), "utf8"))
|
||||
: parseAttestationPolicy(null);
|
||||
|
||||
const attestation = buildAttestation({
|
||||
generatedAt: args.generatedAt,
|
||||
policy,
|
||||
extraWatchFiles: args.watch,
|
||||
extraTrustAnchorFiles: args.trustAnchor,
|
||||
});
|
||||
|
||||
const outPath = resolveHermesScopedOutputPath(args.output);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
const body = stableStringify(attestation, args.compact ? 0 : 2);
|
||||
fs.writeFileSync(outPath, `${body}\n`, "utf8");
|
||||
|
||||
if (args.writeSha256) {
|
||||
const shaPath = `${outPath}.sha256`;
|
||||
const digest = sha256FileHex(outPath);
|
||||
fs.writeFileSync(shaPath, `${digest} ${path.basename(outPath)}\n`, "utf8");
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${stableStringify({
|
||||
level: "INFO",
|
||||
message: "attestation generated",
|
||||
output: outPath,
|
||||
canonical_sha256: attestation.digests.canonical_sha256,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { detectHermesHome, resolveHermesScopedOutputPath } from "../lib/attestation.mjs";
|
||||
|
||||
const MARKER_START = "# >>> hermes-attestation-guardian >>>";
|
||||
const MARKER_END = "# <<< hermes-attestation-guardian <<<";
|
||||
|
||||
function usage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node scripts/setup_attestation_cron.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --every <Nh|Nd> Interval cadence (default: 6h)",
|
||||
" --policy <path> Optional policy file passed to generator",
|
||||
" --baseline <path> Optional baseline path passed to verifier",
|
||||
" --baseline-sha256 <hex> Trusted baseline SHA256 passed to verifier",
|
||||
" --baseline-signature <path> Baseline detached signature for verifier",
|
||||
" --baseline-public-key <path> Baseline signature public key for verifier",
|
||||
" --output <path> Optional output attestation path",
|
||||
" --apply Apply to current user's crontab",
|
||||
" --print-only Print resulting cron block (default)",
|
||||
" --help Show this help",
|
||||
"",
|
||||
"Hermes assumptions:",
|
||||
"- Writes only under ~/.hermes paths by default",
|
||||
"- Uses Node + this skill's scripts only",
|
||||
"- No OpenClaw runtime dependencies",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
every: process.env.HERMES_ATTESTATION_INTERVAL || "6h",
|
||||
policy: process.env.HERMES_ATTESTATION_POLICY || null,
|
||||
baseline: process.env.HERMES_ATTESTATION_BASELINE || null,
|
||||
baselineSha256: process.env.HERMES_ATTESTATION_BASELINE_SHA256 || null,
|
||||
baselineSignature: process.env.HERMES_ATTESTATION_BASELINE_SIGNATURE || null,
|
||||
baselinePublicKey: process.env.HERMES_ATTESTATION_BASELINE_PUBLIC_KEY || null,
|
||||
output: process.env.HERMES_ATTESTATION_OUTPUT_DIR
|
||||
? path.join(process.env.HERMES_ATTESTATION_OUTPUT_DIR, "current.json")
|
||||
: null,
|
||||
apply: false,
|
||||
printOnly: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--help") {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--every") {
|
||||
args.every = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--policy") {
|
||||
args.policy = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--baseline") {
|
||||
args.baseline = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--baseline-sha256") {
|
||||
args.baselineSha256 = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--baseline-signature") {
|
||||
args.baselineSignature = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--baseline-public-key") {
|
||||
args.baselinePublicKey = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--output") {
|
||||
args.output = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--apply") {
|
||||
args.apply = true;
|
||||
args.printOnly = false;
|
||||
continue;
|
||||
}
|
||||
if (token === "--print-only") {
|
||||
args.printOnly = true;
|
||||
args.apply = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function cadenceToCron(cadence) {
|
||||
const normalized = String(cadence || "").trim().toLowerCase();
|
||||
const match = normalized.match(/^(\d+)([hd])$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
|
||||
}
|
||||
|
||||
const n = Number(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`Cadence must be a positive integer: ${cadence}`);
|
||||
}
|
||||
|
||||
if (unit === "h") {
|
||||
if (n > 24) {
|
||||
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
|
||||
}
|
||||
return `0 */${n} * * *`;
|
||||
}
|
||||
|
||||
if (n > 31) {
|
||||
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
|
||||
}
|
||||
return `0 2 */${n} * *`;
|
||||
}
|
||||
|
||||
function escapeForShell(value) {
|
||||
return String(value).replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSignature, baselinePublicKey }) {
|
||||
const scriptDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
||||
const generator = path.join(scriptDir, "generate_attestation.mjs");
|
||||
const verifier = path.join(scriptDir, "verify_attestation.mjs");
|
||||
|
||||
const outputArg = output ? `--output '${escapeForShell(path.resolve(output))}'` : "";
|
||||
const policyArg = policy ? `--policy '${escapeForShell(path.resolve(policy))}'` : "";
|
||||
const baselineArg = baseline ? `--baseline '${escapeForShell(path.resolve(baseline))}'` : "";
|
||||
const baselineShaArg = baselineSha256 ? `--baseline-expected-sha256 '${escapeForShell(String(baselineSha256).trim())}'` : "";
|
||||
const baselineSigArg = baselineSignature
|
||||
? `--baseline-signature '${escapeForShell(path.resolve(baselineSignature))}'`
|
||||
: "";
|
||||
const baselinePubArg = baselinePublicKey
|
||||
? `--baseline-public-key '${escapeForShell(path.resolve(baselinePublicKey))}'`
|
||||
: "";
|
||||
|
||||
return [
|
||||
`node '${escapeForShell(generator)}' ${outputArg} ${policyArg}`.replace(/\s+/g, " ").trim(),
|
||||
`node '${escapeForShell(verifier)}' --input '${escapeForShell(path.resolve(output || path.join(detectHermesHome(), "security", "attestations", "current.json")))}' ${baselineArg} ${baselineShaArg} ${baselineSigArg} ${baselinePubArg}`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
function buildCronBlock({ cronExpr, command, hermesHome }) {
|
||||
const envPrefix = [
|
||||
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
|
||||
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
|
||||
].join(" ");
|
||||
|
||||
return [
|
||||
MARKER_START,
|
||||
`# Managed by hermes-attestation-guardian (${new Date().toISOString()})`,
|
||||
`${cronExpr} ${envPrefix} ${command}`,
|
||||
MARKER_END,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function removeManagedBlock(text) {
|
||||
const lines = String(text || "").split(/\r?\n/);
|
||||
const out = [];
|
||||
|
||||
let inManagedBlock = false;
|
||||
let managedStartLine = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === MARKER_START) {
|
||||
if (inManagedBlock) {
|
||||
throw new Error(`Malformed crontab markers: nested managed block start at line ${i + 1}`);
|
||||
}
|
||||
inManagedBlock = true;
|
||||
managedStartLine = i + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === MARKER_END) {
|
||||
if (!inManagedBlock) {
|
||||
throw new Error(`Malformed crontab markers: unmatched managed block end at line ${i + 1}`);
|
||||
}
|
||||
inManagedBlock = false;
|
||||
managedStartLine = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inManagedBlock) {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (inManagedBlock) {
|
||||
throw new Error(`Malformed crontab markers: managed block start at line ${managedStartLine} has no end marker`);
|
||||
}
|
||||
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function readCurrentCrontab() {
|
||||
const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
||||
if (res.status !== 0) {
|
||||
const stderr = String(res.stderr || "").toLowerCase();
|
||||
if (stderr.includes("no crontab") || stderr.includes("can't open your crontab")) {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Failed reading crontab: ${res.stderr || res.stdout}`);
|
||||
}
|
||||
return res.stdout || "";
|
||||
}
|
||||
|
||||
function writeCrontab(content) {
|
||||
const res = spawnSync("crontab", ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
|
||||
if (res.status !== 0) {
|
||||
throw new Error(`Failed writing crontab: ${res.stderr || res.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const hermesHome = path.resolve(detectHermesHome());
|
||||
const output = resolveHermesScopedOutputPath(args.output, hermesHome);
|
||||
|
||||
if (args.baseline && !args.baselineSha256 && !(args.baselineSignature && args.baselinePublicKey)) {
|
||||
throw new Error(
|
||||
"baseline scheduling requires --baseline-sha256 or both --baseline-signature and --baseline-public-key",
|
||||
);
|
||||
}
|
||||
|
||||
const cronExpr = cadenceToCron(args.every);
|
||||
const command = buildCronCommand({
|
||||
output,
|
||||
policy: args.policy,
|
||||
baseline: args.baseline,
|
||||
baselineSha256: args.baselineSha256,
|
||||
baselineSignature: args.baselineSignature,
|
||||
baselinePublicKey: args.baselinePublicKey,
|
||||
});
|
||||
const block = buildCronBlock({ cronExpr, command, hermesHome });
|
||||
|
||||
const preflightLines = [
|
||||
"Preflight review:",
|
||||
"- This helper configures recurring Hermes attestation generation + verification.",
|
||||
`- Hermes home: ${hermesHome}`,
|
||||
`- Attestation output: ${output}`,
|
||||
`- Cadence: ${args.every} (${cronExpr})`,
|
||||
`- Baseline: ${args.baseline ? path.resolve(args.baseline) : "not configured"}`,
|
||||
`- Baseline trusted sha256: ${args.baselineSha256 ? String(args.baselineSha256).trim() : "not configured"}`,
|
||||
`- Baseline signature: ${args.baselineSignature ? path.resolve(args.baselineSignature) : "not configured"}`,
|
||||
`- Baseline public key: ${args.baselinePublicKey ? path.resolve(args.baselinePublicKey) : "not configured"}`,
|
||||
`- Policy: ${args.policy ? path.resolve(args.policy) : "not configured"}`,
|
||||
"- Scope: Hermes-only.",
|
||||
];
|
||||
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
|
||||
|
||||
if (args.printOnly) {
|
||||
process.stdout.write(`${block}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = readCurrentCrontab();
|
||||
const withoutManaged = removeManagedBlock(current);
|
||||
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
|
||||
writeCrontab(merged);
|
||||
|
||||
process.stdout.write("INFO: Updated user crontab with hermes-attestation-guardian managed block\n");
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/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 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}`);
|
||||
}
|
||||
|
||||
const schemaErrors = validateAttestationSchema(attestation);
|
||||
for (const message of schemaErrors) {
|
||||
verificationFindings.push({ severity: "critical", code: "SCHEMA_INVALID", message });
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
const digestBindingError = validateDigestBinding(attestation);
|
||||
if (digestBindingError) {
|
||||
verificationFindings.push({ severity: "critical", code: "CANONICAL_DIGEST_MISMATCH", message: digestBindingError });
|
||||
failures.push(digestBindingError);
|
||||
}
|
||||
|
||||
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"));
|
||||
const baselineSchemaErrors = validateAttestationSchema(baseline);
|
||||
for (const message of baselineSchemaErrors) {
|
||||
verificationFindings.push({ severity: "critical", code: "BASELINE_SCHEMA_INVALID", message });
|
||||
failures.push(message);
|
||||
}
|
||||
const baselineDigestBindingError = validateDigestBinding(baseline);
|
||||
if (baselineDigestBindingError) {
|
||||
verificationFindings.push({
|
||||
severity: "critical",
|
||||
code: "BASELINE_CANONICAL_DIGEST_MISMATCH",
|
||||
message: baselineDigestBindingError,
|
||||
});
|
||||
failures.push(baselineDigestBindingError);
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
diff = diffAttestations(baseline, attestation);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `invalid baseline JSON: ${error.message}`;
|
||||
verificationFindings.push({ severity: "critical", code: "BASELINE_JSON_INVALID", message });
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const finding of verificationFindings) {
|
||||
printFinding(finding);
|
||||
}
|
||||
if (diff) {
|
||||
for (const finding of diff.findings) {
|
||||
printFinding(finding);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`CRITICAL: verification failed with ${failures.length} error(s)\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const diffHighest = highestSeverity(diff?.findings || []);
|
||||
if (diffHighest && severityAtOrAbove(diffHighest, args.failOnSeverity)) {
|
||||
process.stderr.write(
|
||||
`CRITICAL: diff severity threshold exceeded (highest=${diffHighest}, threshold=${args.failOnSeverity})\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${stableStringify({
|
||||
level: "INFO",
|
||||
status: "verified",
|
||||
input: inputPath,
|
||||
file_sha256: fileDigest,
|
||||
baseline_compared: !!diff,
|
||||
diff_summary: diff?.summary || null,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "hermes-attestation-guardian",
|
||||
"version": "0.0.1",
|
||||
"description": "Hermes-only runtime security attestation and drift detection skill. Generates deterministic posture artifacts, verifies integrity fail-closed, and classifies baseline drift severity.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"platform": "hermes",
|
||||
"keywords": [
|
||||
"security",
|
||||
"hermes",
|
||||
"attestation",
|
||||
"integrity",
|
||||
"drift-detection",
|
||||
"posture"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Skill documentation and operator playbook"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"required": true,
|
||||
"description": "Human-oriented overview and quickstart"
|
||||
},
|
||||
{
|
||||
"path": "lib/attestation.mjs",
|
||||
"required": true,
|
||||
"description": "Attestation schema, canonicalization, digest and validation helpers"
|
||||
},
|
||||
{
|
||||
"path": "lib/diff.mjs",
|
||||
"required": true,
|
||||
"description": "Baseline comparison and severity classification"
|
||||
},
|
||||
{
|
||||
"path": "scripts/generate_attestation.mjs",
|
||||
"required": true,
|
||||
"description": "Generate deterministic Hermes posture attestation artifact"
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify_attestation.mjs",
|
||||
"required": true,
|
||||
"description": "Verify attestation schema, digest and optional detached signature"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_attestation_cron.mjs",
|
||||
"required": true,
|
||||
"description": "Optional recurring schedule setup for Hermes attestation runs"
|
||||
},
|
||||
{
|
||||
"path": "test/attestation_schema.test.mjs",
|
||||
"required": false,
|
||||
"description": "Schema and determinism tests"
|
||||
},
|
||||
{
|
||||
"path": "test/attestation_diff.test.mjs",
|
||||
"required": false,
|
||||
"description": "Diff and severity mapping tests"
|
||||
},
|
||||
{
|
||||
"path": "test/attestation_cli.test.mjs",
|
||||
"required": false,
|
||||
"description": "Generator/verifier CLI behavior tests"
|
||||
},
|
||||
{
|
||||
"path": "test/setup_attestation_cron.test.mjs",
|
||||
"required": false,
|
||||
"description": "Hermes-only cron setup tests"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hermes": {
|
||||
"emoji": "🛡️",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [],
|
||||
"optional_env": [
|
||||
"HERMES_HOME",
|
||||
"HERMES_ATTESTATION_OUTPUT_DIR",
|
||||
"HERMES_ATTESTATION_BASELINE",
|
||||
"HERMES_ATTESTATION_INTERVAL",
|
||||
"HERMES_ATTESTATION_FAIL_ON_SEVERITY",
|
||||
"HERMES_ATTESTATION_POLICY"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No persistence by default. scripts/setup_attestation_cron.mjs can install a user crontab block when run with --apply.",
|
||||
"network_egress": "None"
|
||||
},
|
||||
"operator_review": [
|
||||
"Hermes-only skill: unsupported for OpenClaw runtime hooks.",
|
||||
"Verify watch/trust-anchor policy paths before scheduling recurring runs.",
|
||||
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical."
|
||||
],
|
||||
"triggers": [
|
||||
"generate hermes attestation",
|
||||
"verify hermes attestation",
|
||||
"hermes runtime drift detection",
|
||||
"hermes trust anchor drift",
|
||||
"setup hermes attestation cron"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, "..");
|
||||
const generatorScript = path.join(skillRoot, "scripts", "generate_attestation.mjs");
|
||||
const verifierScript = path.join(skillRoot, "scripts", "verify_attestation.mjs");
|
||||
|
||||
function runNode(scriptPath, args = [], extraEnv = {}) {
|
||||
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
cwd: skillRoot,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
|
||||
async function withTempDir(run) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-cli-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const attestationsDir = path.join(hermesHome, "security", "attestations");
|
||||
const outputPath = path.join(attestationsDir, "current.json");
|
||||
const baselinePath = path.join(attestationsDir, "baseline.json");
|
||||
const watchedPath = path.join(tempDir, "config.json");
|
||||
|
||||
await fs.mkdir(attestationsDir, { recursive: true });
|
||||
await fs.writeFile(watchedPath, JSON.stringify({ secure: true }), "utf8");
|
||||
|
||||
const generatedAt = "2026-04-15T18:01:00.000Z";
|
||||
const generate = runNode(
|
||||
generatorScript,
|
||||
["--output", outputPath, "--watch", watchedPath, "--generated-at", generatedAt, "--write-sha256"],
|
||||
{ HERMES_HOME: hermesHome },
|
||||
);
|
||||
|
||||
assert.equal(generate.status, 0, `generate failed: ${generate.stderr}`);
|
||||
const attestationRaw = await fs.readFile(outputPath, "utf8");
|
||||
const attestation = JSON.parse(attestationRaw);
|
||||
assert.equal(attestation.platform, "hermes");
|
||||
assert.equal(attestation.generated_at, generatedAt);
|
||||
|
||||
const verify = runNode(verifierScript, ["--input", outputPath]);
|
||||
assert.equal(verify.status, 0, `verify should pass: ${verify.stderr}`);
|
||||
|
||||
const outOfScope = runNode(generatorScript, ["--output", path.join(tempDir, "outside.json")], { HERMES_HOME: hermesHome });
|
||||
assert.notEqual(outOfScope.status, 0, "generator must reject out-of-scope --output");
|
||||
assert.ok(outOfScope.stderr.includes("output path must stay under"), outOfScope.stderr);
|
||||
|
||||
await fs.writeFile(baselinePath, attestationRaw, "utf8");
|
||||
const baselineDigest = crypto.createHash("sha256").update(attestationRaw).digest("hex");
|
||||
|
||||
const verifyUntrustedBaseline = runNode(verifierScript, ["--input", outputPath, "--baseline", baselinePath]);
|
||||
assert.notEqual(verifyUntrustedBaseline.status, 0, "baseline diff must fail when baseline is unauthenticated");
|
||||
assert.ok(verifyUntrustedBaseline.stdout.includes("BASELINE_UNTRUSTED"), verifyUntrustedBaseline.stdout);
|
||||
|
||||
const verifyTrustedBaseline = runNode(verifierScript, [
|
||||
"--input",
|
||||
outputPath,
|
||||
"--baseline",
|
||||
baselinePath,
|
||||
"--baseline-expected-sha256",
|
||||
baselineDigest,
|
||||
]);
|
||||
assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);
|
||||
|
||||
const baselineTampered = JSON.parse(attestationRaw);
|
||||
baselineTampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||
await fs.writeFile(baselinePath, JSON.stringify(baselineTampered, null, 2), "utf8");
|
||||
|
||||
const verifyTamperedBaseline = runNode(verifierScript, [
|
||||
"--input",
|
||||
outputPath,
|
||||
"--baseline",
|
||||
baselinePath,
|
||||
"--baseline-expected-sha256",
|
||||
baselineDigest,
|
||||
]);
|
||||
assert.notEqual(verifyTamperedBaseline.status, 0, "tampered baseline must be rejected");
|
||||
assert.ok(verifyTamperedBaseline.stdout.includes("BASELINE_DIGEST_MISMATCH"), verifyTamperedBaseline.stdout);
|
||||
|
||||
const tampered = JSON.parse(attestationRaw);
|
||||
tampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||
await fs.writeFile(outputPath, JSON.stringify(tampered, null, 2), "utf8");
|
||||
|
||||
const verifyTampered = runNode(verifierScript, ["--input", outputPath]);
|
||||
assert.notEqual(verifyTampered.status, 0, "verify must fail closed after tampering");
|
||||
assert.ok(
|
||||
verifyTampered.stderr.includes("CRITICAL") || verifyTampered.stdout.includes("CANONICAL_DIGEST_MISMATCH"),
|
||||
`expected critical verification signal, got stdout=${verifyTampered.stdout} stderr=${verifyTampered.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const securityDir = path.join(hermesHome, "security");
|
||||
const attestationsDir = path.join(securityDir, "attestations");
|
||||
const escapedDir = path.join(tempDir, "escaped-attestations");
|
||||
const outputPath = path.join(attestationsDir, "current.json");
|
||||
|
||||
await fs.mkdir(securityDir, { recursive: true });
|
||||
await fs.mkdir(escapedDir, { recursive: true });
|
||||
await fs.symlink(escapedDir, attestationsDir, "dir");
|
||||
|
||||
const symlinkEscape = runNode(generatorScript, ["--output", outputPath], {
|
||||
HERMES_HOME: hermesHome,
|
||||
});
|
||||
assert.notEqual(symlinkEscape.status, 0, "generator must reject symlink-based output path escapes");
|
||||
assert.ok(symlinkEscape.stderr.includes("output path must stay under"), symlinkEscape.stderr);
|
||||
});
|
||||
|
||||
console.log("attestation_cli.test.mjs: ok");
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { diffAttestations, highestSeverity, severityAtOrAbove } from "../lib/diff.mjs";
|
||||
|
||||
const baseline = {
|
||||
schema_version: "0.0.1",
|
||||
platform: "hermes",
|
||||
generator: { version: "0.0.1" },
|
||||
posture: {
|
||||
runtime: {
|
||||
gateways: { telegram: true, matrix: false, discord: false },
|
||||
risky_toggles: {
|
||||
allow_unsigned_mode: false,
|
||||
bypass_verification: false,
|
||||
},
|
||||
},
|
||||
feed_verification: { status: "verified" },
|
||||
integrity: {
|
||||
trust_anchors: [{ path: "/etc/hermes/trust.pem", sha256: "aaa" }],
|
||||
watched_files: [{ path: "/etc/hermes/config.json", sha256: "bbb" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const drifted = {
|
||||
schema_version: "0.0.1",
|
||||
platform: "hermes",
|
||||
generator: { version: "0.0.2" },
|
||||
posture: {
|
||||
runtime: {
|
||||
gateways: { telegram: true, matrix: true, discord: false },
|
||||
risky_toggles: {
|
||||
allow_unsigned_mode: true,
|
||||
bypass_verification: false,
|
||||
},
|
||||
},
|
||||
feed_verification: { status: "unverified" },
|
||||
integrity: {
|
||||
trust_anchors: [{ path: "/etc/hermes/trust.pem", sha256: "ccc" }],
|
||||
watched_files: [{ path: "/etc/hermes/config.json", sha256: "ddd" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const clean = JSON.parse(JSON.stringify(baseline));
|
||||
|
||||
const driftOut = diffAttestations(baseline, drifted);
|
||||
assert.ok(Array.isArray(driftOut.findings));
|
||||
assert.ok(driftOut.findings.length >= 4, "expected multiple meaningful drift findings");
|
||||
assert.ok(driftOut.findings.some((f) => f.code === "UNSIGNED_MODE_ENABLED"));
|
||||
assert.ok(driftOut.findings.some((f) => f.code === "FEED_VERIFICATION_REGRESSION"));
|
||||
assert.ok(driftOut.findings.some((f) => f.code === "TRUST_ANCHOR_MISMATCH"));
|
||||
assert.ok(driftOut.findings.some((f) => f.code === "WATCHED_FILE_DRIFT"));
|
||||
assert.equal(highestSeverity(driftOut.findings), "critical");
|
||||
assert.equal(severityAtOrAbove("critical", "high"), true);
|
||||
assert.equal(severityAtOrAbove("low", "critical"), false);
|
||||
|
||||
const cleanOut = diffAttestations(baseline, clean);
|
||||
assert.equal(cleanOut.findings.length, 0, "identical attestations should produce no findings");
|
||||
assert.deepEqual(cleanOut.summary, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
|
||||
|
||||
console.log("attestation_diff.test.mjs: ok");
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildAttestation,
|
||||
computeCanonicalDigest,
|
||||
parseAttestationPolicy,
|
||||
stableStringify,
|
||||
validateAttestationSchema,
|
||||
validateDigestBinding,
|
||||
} from "../lib/attestation.mjs";
|
||||
|
||||
async function withTempDir(run) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-schema-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function testBuildAttestationIsSchemaValidAndDeterministic() {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const watchedFile = path.join(tempDir, "watch.txt");
|
||||
const trustAnchor = path.join(tempDir, "anchor.pem");
|
||||
await fs.writeFile(watchedFile, "watch-contents\n", "utf8");
|
||||
await fs.writeFile(trustAnchor, "trust-anchor\n", "utf8");
|
||||
|
||||
const policy = parseAttestationPolicy(
|
||||
JSON.stringify({ watch_files: [watchedFile], trust_anchor_files: [trustAnchor] }),
|
||||
);
|
||||
|
||||
const generatedAt = "2026-04-15T18:00:00.000Z";
|
||||
const first = buildAttestation({ generatedAt, policy });
|
||||
const second = buildAttestation({ generatedAt, policy });
|
||||
|
||||
assert.deepEqual(first, second, "attestation must be deterministic for fixed inputs");
|
||||
assert.equal(first.platform, "hermes");
|
||||
assert.equal(first.schema_version, "0.0.1");
|
||||
assert.equal(first.generated_at, generatedAt);
|
||||
|
||||
const schemaErrors = validateAttestationSchema(first);
|
||||
assert.equal(schemaErrors.length, 0, `schema errors: ${schemaErrors.join(", ")}`);
|
||||
|
||||
const computedDigest = computeCanonicalDigest(first);
|
||||
assert.equal(first.digests.canonical_sha256, computedDigest, "digest must match canonical payload");
|
||||
|
||||
const stableOne = stableStringify(first);
|
||||
const stableTwo = stableStringify(second);
|
||||
assert.equal(stableOne, stableTwo, "stable stringify should produce same output ordering");
|
||||
});
|
||||
}
|
||||
|
||||
function testSchemaValidationFailsClosed() {
|
||||
const invalid = {
|
||||
schema_version: "0.0.0",
|
||||
platform: "openclaw",
|
||||
generated_at: "not-a-date",
|
||||
digests: { canonical_sha256: "1234" },
|
||||
};
|
||||
const errors = validateAttestationSchema(invalid);
|
||||
assert.ok(errors.length >= 4, "invalid schema should emit multiple errors");
|
||||
assert.ok(errors.some((msg) => msg.includes("platform must be hermes")));
|
||||
}
|
||||
|
||||
function testDigestBindingRejectsUnsupportedAlgorithm() {
|
||||
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
attestation.digests.algorithm = "sha1";
|
||||
|
||||
const schemaErrors = validateAttestationSchema(attestation);
|
||||
assert.ok(schemaErrors.some((msg) => msg.includes("digests.algorithm must be sha256")));
|
||||
|
||||
const digestBindingError = validateDigestBinding(attestation);
|
||||
assert.ok(digestBindingError?.includes("unsupported digest algorithm"));
|
||||
}
|
||||
|
||||
await testBuildAttestationIsSchemaValidAndDeterministic();
|
||||
testSchemaValidationFailsClosed();
|
||||
testDigestBindingRejectsUnsupportedAlgorithm();
|
||||
console.log("attestation_schema.test.mjs: ok");
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, "..");
|
||||
const setupScript = path.join(skillRoot, "scripts", "setup_attestation_cron.mjs");
|
||||
|
||||
function runSetup(args = [], env = {}) {
|
||||
return spawnSync(process.execPath, [setupScript, ...args], {
|
||||
cwd: skillRoot,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
async function withTempDir(run) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-cron-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const result = runSetup(["--every", "6h", "--print-only"], {
|
||||
HERMES_HOME: hermesHome,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, `setup script failed: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes("Preflight review:"));
|
||||
assert.ok(result.stdout.includes("Scope: Hermes-only"));
|
||||
assert.ok(result.stdout.includes("hermes-attestation-guardian"));
|
||||
assert.ok(result.stdout.includes("generate_attestation.mjs"));
|
||||
assert.ok(result.stdout.includes("verify_attestation.mjs"));
|
||||
assert.equal(result.stdout.toLowerCase().includes("openclaw"), false, "must not mention OpenClaw runtime");
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const result = runSetup(["--print-only", "--output", path.join(tempDir, "outside.json")], {
|
||||
HERMES_HOME: hermesHome,
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0, "out-of-scope output path must be rejected");
|
||||
assert.ok(result.stderr.includes("output path must stay under"), result.stderr);
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const weirdPolicy = path.join(tempDir, "policy'withquote.json");
|
||||
const result = runSetup(["--every", "6h", "--policy", weirdPolicy, "--print-only"], {
|
||||
HERMES_HOME: hermesHome,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes("policy'\\''withquote.json"), "single quotes must be shell-escaped in cron command");
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const fakeBinDir = path.join(tempDir, "bin");
|
||||
const logPath = path.join(tempDir, "crontab.log");
|
||||
const writePath = path.join(tempDir, "crontab.write");
|
||||
await fs.mkdir(fakeBinDir, { recursive: true });
|
||||
|
||||
const fakeCrontab = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const logPath = ${JSON.stringify(logPath)};
|
||||
const writePath = ${JSON.stringify(writePath)};
|
||||
if (args[0] === '-l') {
|
||||
fs.appendFileSync(logPath, 'list\\n', 'utf8');
|
||||
process.stdout.write('# >>> hermes-attestation-guardian >>>\\n# dangling-start-no-end\\n0 0 * * * /usr/bin/true\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === '-') {
|
||||
fs.appendFileSync(logPath, 'write\\n', 'utf8');
|
||||
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
|
||||
process.exit(2);
|
||||
`;
|
||||
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
|
||||
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
|
||||
|
||||
const result = runSetup(["--apply"], {
|
||||
HERMES_HOME: hermesHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH}`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0, "unmatched start marker must fail closed");
|
||||
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
|
||||
const log = await fs.readFile(logPath, "utf8");
|
||||
assert.ok(log.includes("list"), "script should read crontab before writing");
|
||||
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
|
||||
assert.equal(wrote, false, "script must not write crontab on malformed marker block");
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const fakeBinDir = path.join(tempDir, "bin");
|
||||
const logPath = path.join(tempDir, "crontab.log");
|
||||
const writePath = path.join(tempDir, "crontab.write");
|
||||
await fs.mkdir(fakeBinDir, { recursive: true });
|
||||
|
||||
const fakeCrontab = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const logPath = ${JSON.stringify(logPath)};
|
||||
const writePath = ${JSON.stringify(writePath)};
|
||||
if (args[0] === '-l') {
|
||||
fs.appendFileSync(logPath, 'list\\n', 'utf8');
|
||||
process.stdout.write('# <<< hermes-attestation-guardian <<<\\n0 0 * * * /usr/bin/true\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === '-') {
|
||||
fs.appendFileSync(logPath, 'write\\n', 'utf8');
|
||||
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
|
||||
process.exit(2);
|
||||
`;
|
||||
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
|
||||
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
|
||||
|
||||
const result = runSetup(["--apply"], {
|
||||
HERMES_HOME: hermesHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH}`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0, "unmatched end marker must fail closed");
|
||||
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
|
||||
const log = await fs.readFile(logPath, "utf8");
|
||||
assert.ok(log.includes("list"), "script should read crontab before writing");
|
||||
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
|
||||
assert.equal(wrote, false, "script must not write crontab when end marker is unmatched");
|
||||
});
|
||||
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
const fakeBinDir = path.join(tempDir, "bin");
|
||||
const logPath = path.join(tempDir, "crontab.log");
|
||||
const writePath = path.join(tempDir, "crontab.write");
|
||||
await fs.mkdir(fakeBinDir, { recursive: true });
|
||||
|
||||
const fakeCrontab = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const logPath = ${JSON.stringify(logPath)};
|
||||
const writePath = ${JSON.stringify(writePath)};
|
||||
if (args[0] === '-l') {
|
||||
fs.appendFileSync(logPath, 'list\\n', 'utf8');
|
||||
process.stdout.write('# >>> hermes-attestation-guardian >>>\\n# >>> hermes-attestation-guardian >>>\\n# nested-start\\n# <<< hermes-attestation-guardian <<<\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === '-') {
|
||||
fs.appendFileSync(logPath, 'write\\n', 'utf8');
|
||||
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
|
||||
process.exit(2);
|
||||
`;
|
||||
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
|
||||
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
|
||||
|
||||
const result = runSetup(["--apply"], {
|
||||
HERMES_HOME: hermesHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH}`,
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0, "nested start marker must fail closed");
|
||||
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
|
||||
const log = await fs.readFile(logPath, "utf8");
|
||||
assert.ok(log.includes("list"), "script should read crontab before writing");
|
||||
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
|
||||
assert.equal(wrote, false, "script must not write crontab when marker blocks are nested");
|
||||
});
|
||||
|
||||
console.log("setup_attestation_cron.test.mjs: ok");
|
||||
Reference in New Issue
Block a user