文件预览

attestation_cli.test.mjs

查看 hermes-attestation-guardian 技能包中的文件内容。

文件内容

test/attestation_cli.test.mjs

#!/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 feedConfigFailureOutputPath = path.join(attestationsDir, "feed-config-fallback.json");
  const generateWithBrokenFeedConfig = runNode(
    generatorScript,
    ["--output", feedConfigFailureOutputPath, "--generated-at", generatedAt],
    {
      HERMES_HOME: hermesHome,
      HERMES_ADVISORY_CACHED_FEED: path.join(tempDir, "outside-cached-feed.json"),
      HERMES_ADVISORY_FEED_STATE_PATH: path.join(tempDir, "outside-state.json"),
    },
  );
  assert.equal(
    generateWithBrokenFeedConfig.status,
    0,
    `generator must tolerate invalid feed config paths: ${generateWithBrokenFeedConfig.stderr}`,
  );
  const fallbackAttestation = JSON.parse(await fs.readFile(feedConfigFailureOutputPath, "utf8"));
  assert.equal(fallbackAttestation.posture.feed_verification.status, "unknown");
  assert.equal(fallbackAttestation.posture.feed_verification.configured, false);
  assert.equal(
    fallbackAttestation.posture.feed_verification.state_path,
    path.join(hermesHome, "security", "advisories", "feed-verification-state.json"),
  );
  assert.ok(
    String(fallbackAttestation.posture.feed_verification.config_warning || "").includes("outside HERMES_HOME"),
    `expected explicit config warning, got: ${fallbackAttestation.posture.feed_verification.config_warning}`,
  );

  const outOfScope = runNode(generatorScript, ["--output", path.join(tempDir, "outside.json")], { HERMES_HOME: hermesHome });
  assert.notEqual(outOfScope.status, 0, "generator must reject out-of-scope --output");
  assert.ok(outOfScope.stderr.includes("output path must stay under"), outOfScope.stderr);

  await fs.writeFile(baselinePath, attestationRaw, "utf8");
  const baselineDigest = crypto.createHash("sha256").update(attestationRaw).digest("hex");

  const verifyUntrustedBaseline = runNode(verifierScript, ["--input", outputPath, "--baseline", baselinePath]);
  assert.notEqual(verifyUntrustedBaseline.status, 0, "baseline diff must fail when baseline is unauthenticated");
  assert.ok(verifyUntrustedBaseline.stdout.includes("BASELINE_UNTRUSTED"), verifyUntrustedBaseline.stdout);

  const verifyTrustedBaseline = runNode(verifierScript, [
    "--input",
    outputPath,
    "--baseline",
    baselinePath,
    "--baseline-expected-sha256",
    baselineDigest,
  ]);
  assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);

  const hardLinkPath = path.join(attestationsDir, "current-hardlink.json");
  const oldContent = "old-attestation-body\n";
  await fs.writeFile(outputPath, oldContent, "utf8");
  await fs.link(outputPath, hardLinkPath);

  const atomicRewrite = runNode(generatorScript, ["--output", outputPath, "--generated-at", generatedAt], {
    HERMES_HOME: hermesHome,
  });
  assert.equal(atomicRewrite.status, 0, `atomic rewrite failed: ${atomicRewrite.stderr}`);

  const rewrittenContent = await fs.readFile(outputPath, "utf8");
  const hardLinkedContent = await fs.readFile(hardLinkPath, "utf8");
  assert.notEqual(rewrittenContent, hardLinkedContent, "output rewrite should atomically replace file entry");
  assert.equal(hardLinkedContent, oldContent, "hard link should preserve previous file body after atomic replace");

  const invalidCurrent = JSON.parse(attestationRaw);
  delete invalidCurrent.platform;
  await fs.writeFile(outputPath, JSON.stringify(invalidCurrent, null, 2), "utf8");

  const verifyInvalidCurrent = runNode(verifierScript, ["--input", outputPath]);
  assert.notEqual(verifyInvalidCurrent.status, 0, "schema-invalid current attestation must be rejected");
  assert.ok(verifyInvalidCurrent.stdout.includes("SCHEMA_INVALID"), verifyInvalidCurrent.stdout);

  await fs.writeFile(outputPath, attestationRaw, "utf8");

  const baselineCanonicalMismatch = JSON.parse(attestationRaw);
  baselineCanonicalMismatch.posture.runtime.risky_toggles.allow_unsigned_mode = true;
  const baselineCanonicalMismatchRaw = JSON.stringify(baselineCanonicalMismatch, null, 2);
  await fs.writeFile(baselinePath, baselineCanonicalMismatchRaw, "utf8");
  const baselineCanonicalMismatchDigest = crypto.createHash("sha256").update(baselineCanonicalMismatchRaw).digest("hex");

  const verifyBaselineCanonicalMismatch = runNode(verifierScript, [
    "--input",
    outputPath,
    "--baseline",
    baselinePath,
    "--baseline-expected-sha256",
    baselineCanonicalMismatchDigest,
  ]);
  assert.notEqual(verifyBaselineCanonicalMismatch.status, 0, "baseline canonical digest mismatch must be rejected");
  assert.ok(
    verifyBaselineCanonicalMismatch.stdout.includes("BASELINE_CANONICAL_DIGEST_MISMATCH"),
    verifyBaselineCanonicalMismatch.stdout,
  );

  const baselineSchemaInvalid = JSON.parse(attestationRaw);
  delete baselineSchemaInvalid.platform;
  const baselineSchemaInvalidRaw = JSON.stringify(baselineSchemaInvalid, null, 2);
  await fs.writeFile(baselinePath, baselineSchemaInvalidRaw, "utf8");
  const baselineSchemaInvalidDigest = crypto.createHash("sha256").update(baselineSchemaInvalidRaw).digest("hex");

  const verifyBaselineSchemaInvalid = runNode(verifierScript, [
    "--input",
    outputPath,
    "--baseline",
    baselinePath,
    "--baseline-expected-sha256",
    baselineSchemaInvalidDigest,
  ]);
  assert.notEqual(verifyBaselineSchemaInvalid.status, 0, "schema-invalid baseline must be rejected");
  assert.ok(verifyBaselineSchemaInvalid.stdout.includes("BASELINE_SCHEMA_INVALID"), verifyBaselineSchemaInvalid.stdout);

  const baselineTampered = JSON.parse(attestationRaw);
  baselineTampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
  await fs.writeFile(baselinePath, JSON.stringify(baselineTampered, null, 2), "utf8");

  const verifyTamperedBaseline = runNode(verifierScript, [
    "--input",
    outputPath,
    "--baseline",
    baselinePath,
    "--baseline-expected-sha256",
    baselineDigest,
  ]);
  assert.notEqual(verifyTamperedBaseline.status, 0, "tampered baseline must be rejected");
  assert.ok(verifyTamperedBaseline.stdout.includes("BASELINE_DIGEST_MISMATCH"), verifyTamperedBaseline.stdout);

  const tampered = JSON.parse(attestationRaw);
  tampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
  await fs.writeFile(outputPath, JSON.stringify(tampered, null, 2), "utf8");

  const verifyTampered = runNode(verifierScript, ["--input", outputPath]);
  assert.notEqual(verifyTampered.status, 0, "verify must fail closed after tampering");
  assert.ok(
    verifyTampered.stderr.includes("CRITICAL") || verifyTampered.stdout.includes("CANONICAL_DIGEST_MISMATCH"),
    `expected critical verification signal, got stdout=${verifyTampered.stdout} stderr=${verifyTampered.stderr}`,
  );
});

await withTempDir(async (tempDir) => {
  const hermesHome = path.join(tempDir, ".hermes");
  const securityDir = path.join(hermesHome, "security");
  const attestationsDir = path.join(securityDir, "attestations");
  const escapedDir = path.join(tempDir, "escaped-attestations");
  const outputPath = path.join(attestationsDir, "current.json");

  await fs.mkdir(securityDir, { recursive: true });
  await fs.mkdir(escapedDir, { recursive: true });
  await fs.symlink(escapedDir, attestationsDir, "dir");

  const symlinkEscape = runNode(generatorScript, ["--output", outputPath], {
    HERMES_HOME: hermesHome,
  });
  assert.notEqual(symlinkEscape.status, 0, "generator must reject symlink-based output path escapes");
  assert.ok(symlinkEscape.stderr.includes("output path must stay under"), symlinkEscape.stderr);
});

await withTempDir(async (tempDir) => {
  const hermesHome = path.join(tempDir, ".hermes");
  const attestationsDir = path.join(hermesHome, "security", "attestations");
  const outputPath = path.join(attestationsDir, "broken-link.json");

  await fs.mkdir(attestationsDir, { recursive: true });
  await fs.symlink(path.join(tempDir, "outside-target.json"), outputPath);

  const brokenSymlinkOutput = runNode(generatorScript, ["--output", outputPath], {
    HERMES_HOME: hermesHome,
  });
  assert.notEqual(brokenSymlinkOutput.status, 0, "generator must reject broken symlink output paths");
  assert.ok(brokenSymlinkOutput.stderr.includes("output path must not be a symlink"), brokenSymlinkOutput.stderr);
});

console.log("attestation_cli.test.mjs: ok");