#!/usr/bin/env node
/**
 * verify-trace: cross-check the runtime + shim provenance chain.
 *
 * Usage:
 *   node scripts/eval/verify-trace.mjs --trace <provenance.jsonl> --state <session-state.json> [options]
 *
 * Required flags:
 *   --trace <path>                  Provenance JSONL file to verify.
 *   --state <path>                  Session state file. Mandatory unless
 *                                   --allow-missing-state is explicitly passed.
 *
 * Hardening flags (see docs/security/witness-chain-attacks.md):
 *   --allow-missing-state           Explicit opt-out for --state. The OK line
 *                                   will include "(state-check-skipped)".
 *   --expected-run-id <id>          Reject if any entry's runId differs.
 *   --freshness-window-ms <N>       Reject if first entry's timestamp is
 *                                   outside now() +/- N ms.
 *   --expected-pair-count <N>       Reject if count of runtime/shim pairs != N.
 *   --min-entries <N>               Reject if total entry count < N.
 *   --expected-reviewer-family <id> Reject unless trace evidence carries a
 *                                   consistent reviewer.family matching <id>.
 *   --expected-binary-hashes <file> JSON file { binaryName: [sha256, ...] }.
 *                                   Reject shim_invocation_* entries whose
 *                                   binarySha256 is not in the allow-list.
 *   --capability-manifest <path>    Runner capability manifest for claim-profile
 *                                   verification.
 *   --require-claim-profile         Reject unless the capability manifest is
 *                                   claim-eligible and the trace carries safe
 *                                   runner capability evidence.
 *   --attestation <path>            Detached attestation.json for the bundle.
 *   --require-attestation           Reject unless --attestation verifies.
 *   --trusted-signers <path>        Trusted signer registry. Defaults to
 *                                   docs/security/trusted-signers.json.
 *   --revoked-signers <path>        Revoked signer registry. Defaults to
 *                                   docs/security/revoked-signers.json.
 *   --require-role <role>           Require attestation signerRole to match.
 *
 * Misc:
 *   --flow <path>                   Informational only (reserved).
 *   --json                          Emit machine-readable JSON result.
 *
 * Checks:
 *   a. Single runId across all entries; optionally == --expected-run-id.
 *   b. seq strictly monotonic starting from the first entry.
 *   c. prevEventHash chain unbroken; every eventHash is reproducible.
 *   d. Every agent_invocation_begin has a matching shim_invocation_begin
 *      with same pid, argv, stdinSha256; same for _end. If both sides of
 *      a pair happen to carry a binarySha256, they must agree.
 *   e. (Default) the last stateAfterHash in the chain equals hashState of
 *      the supplied state file contents.
 *   f. (Optional) first entry timestamp within --freshness-window-ms.
 *   g. (Optional) exactly --expected-pair-count runtime/shim pairs.
 *   h. (Optional) at least --min-entries total entries.
 *   i. (Optional) trace evidence carries reviewer.family == --expected-reviewer-family.
 *   j. (Optional) every shim binarySha256 is in --expected-binary-hashes.
 *   k. (Optional) detached attestation signature verifies, signer is trusted,
 *      non-revoked, and payload matches bundle artifacts.
 *   l. (Optional) claim profile rejects missing/unsafe runner capability
 *      manifest and trace evidence.
 */

import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve, basename } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { canonicalJSON, hashEvent, hashState, verifyChain } from './provenance-schema.mjs';
import { assessRunnerCapabilityManifest } from './runner-capability-manifest.mjs';
import {
  buildBundlePayload,
  DEFAULT_REVOKED_SIGNERS_PATH,
  DEFAULT_TRUSTED_SIGNERS_PATH,
  DEV_ROLE,
  verifyAttestationAgainstBundle,
} from '../experiments/meta/attestation-lib.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
const TRUST_ROOT_PATH = resolve(HERE, '..', '..', 'dist', 'eval', 'attestation-trust-root.js');

/**
 * F2 / prompt-qwvu.2: enforce the build-time hash pin on trusted-signers.json
 * and revoked-signers.json whenever the caller requests attestation
 * verification. If dist/eval/attestation-trust-root.js is missing (e.g. the
 * tree has not been built) or if the on-disk registries hash to values that
 * diverge from what `npm run build` saw, we refuse to trust the registry.
 *
 * This blocks the attack where the meta-flow (with workspace write) appends
 * its own signer entry AFTER the build ran: the verifier sees the divergent
 * sha256 and bails before any signature math executes.
 *
 * Callers may override via --trust-root-sha256 <hex> to support out-of-band
 * key-rotation flows without a fresh build, but the override must itself be
 * sourced from a trusted channel (G1 / NF5 footnote).
 */
async function loadTrustRoot() {
  if (!existsSync(TRUST_ROOT_PATH)) {
    return null;
  }
  try {
    const mod = await import(pathToFileURL(TRUST_ROOT_PATH).href);
    const root = mod?.ATTESTATION_TRUST_ROOT;
    if (
      !root ||
      typeof root.trustedSignersSha256 !== 'string' ||
      typeof root.revokedSignersSha256 !== 'string'
    ) {
      return null;
    }
    return root;
  } catch {
    return null;
  }
}

function sha256File(path) {
  return createHash('sha256').update(readFileSync(path)).digest('hex');
}

function enforceTrustRootPin({
  trustedSignersPath,
  revokedSignersPath,
  trustRoot,
  overrideTrustedSha256,
  overrideRevokedSha256,
}) {
  const errors = [];
  if (!existsSync(trustedSignersPath)) {
    errors.push(`trusted-signers file not found: ${trustedSignersPath}`);
  }
  if (!existsSync(revokedSignersPath)) {
    // revoked is optional on disk; downstream loader treats missing as empty,
    // so skip the pin if no file exists.
  }
  if (errors.length > 0) return { ok: false, errors };

  const trustedActual = sha256File(trustedSignersPath);
  const revokedActual = existsSync(revokedSignersPath) ? sha256File(revokedSignersPath) : null;

  const trustedPinned = overrideTrustedSha256 ?? trustRoot?.trustedSignersSha256 ?? null;
  const revokedPinned = overrideRevokedSha256 ?? trustRoot?.revokedSignersSha256 ?? null;

  if (!trustedPinned) {
    errors.push(
      'attestation trust-root pin is missing: run `npm run build` to generate dist/eval/attestation-trust-root.js, or pass --trusted-signers-sha256 <hex>',
    );
    return { ok: false, errors };
  }
  if (trustedPinned !== trustedActual) {
    errors.push(
      `attestation trust-root mismatch: trusted-signers.json sha256=${trustedActual} but pinned=${trustedPinned}`,
    );
  }
  if (revokedPinned && revokedActual && revokedPinned !== revokedActual) {
    errors.push(
      `attestation trust-root mismatch: revoked-signers.json sha256=${revokedActual} but pinned=${revokedPinned}`,
    );
  }
  if (errors.length > 0) return { ok: false, errors };
  return { ok: true, trustedSha256: trustedActual, revokedSha256: revokedActual };
}

function parseArgs(argv) {
  const out = {
    trace: null,
    state: null,
    allowMissingState: false,
    expectedRunId: null,
    freshnessWindowMs: null,
    expectedPairCount: null,
    minEntries: null,
    expectedReviewerFamily: null,
    expectedBinaryHashes: null,
    capabilityManifest: null,
    requireClaimProfile: false,
    attestation: null,
    requireAttestation: false,
    trustedSigners: DEFAULT_TRUSTED_SIGNERS_PATH,
    revokedSigners: DEFAULT_REVOKED_SIGNERS_PATH,
    trustedSignersSha256: null,
    revokedSignersSha256: null,
    requireRole: null,
    allowDevRole: false,
    flow: null,
    json: false,
  };
  for (let i = 0; i < argv.length; i += 1) {
    const a = argv[i];
    if (a === '--trace') out.trace = argv[++i];
    else if (a === '--state') out.state = argv[++i];
    else if (a === '--allow-missing-state') out.allowMissingState = true;
    else if (a === '--expected-run-id') out.expectedRunId = argv[++i];
    else if (a === '--freshness-window-ms') out.freshnessWindowMs = Number(argv[++i]);
    else if (a === '--expected-pair-count') out.expectedPairCount = Number(argv[++i]);
    else if (a === '--min-entries') out.minEntries = Number(argv[++i]);
    else if (a === '--expected-reviewer-family') out.expectedReviewerFamily = argv[++i];
    else if (a === '--expected-binary-hashes') out.expectedBinaryHashes = argv[++i];
    else if (a === '--capability-manifest') out.capabilityManifest = argv[++i];
    else if (a === '--require-claim-profile') out.requireClaimProfile = true;
    else if (a === '--attestation') out.attestation = argv[++i];
    else if (a === '--require-attestation') out.requireAttestation = true;
    else if (a === '--trusted-signers') out.trustedSigners = argv[++i];
    else if (a === '--revoked-signers') out.revokedSigners = argv[++i];
    else if (a === '--trusted-signers-sha256') out.trustedSignersSha256 = argv[++i];
    else if (a === '--revoked-signers-sha256') out.revokedSignersSha256 = argv[++i];
    else if (a === '--require-role') out.requireRole = argv[++i];
    else if (a === '--allow-dev-role') out.allowDevRole = true;
    else if (a === '--flow') out.flow = argv[++i];
    else if (a === '--json') out.json = true;
    else if (a === '--help' || a === '-h') {
      process.stdout.write(
        'Usage: verify-trace --trace <provenance.jsonl> --state <session-state.json> [options]\n' +
          '       (pass --allow-missing-state to opt out of --state)\n' +
          'Options:\n' +
          '  --expected-run-id <id>\n' +
          '  --freshness-window-ms <N>\n' +
          '  --expected-pair-count <N>\n' +
          '  --min-entries <N>\n' +
          '  --expected-reviewer-family <family>\n' +
          '  --expected-binary-hashes <file>\n' +
          '  --capability-manifest <file>\n' +
          '  --require-claim-profile\n' +
          '  --attestation <file>\n' +
          '  --require-attestation\n' +
          `  --trusted-signers <file> (default ${DEFAULT_TRUSTED_SIGNERS_PATH})\n` +
          `  --revoked-signers <file> (default ${DEFAULT_REVOKED_SIGNERS_PATH})\n` +
          '  --trusted-signers-sha256 <hex>  Override build-time trust-root pin\n' +
          '  --revoked-signers-sha256 <hex>  Override build-time revocation pin\n' +
          '  --require-role <operator|ci>\n' +
          '  --allow-dev-role                Widen accepted signer roles to include "dev" (not claim-eligible)\n' +
          '  --json\n',
      );
      process.exit(0);
    }
  }
  return out;
}

function loadTrace(path) {
  if (!existsSync(path)) throw new Error(`trace file not found: ${path}`);
  const raw = readFileSync(path, 'utf8');
  const entries = [];
  const lines = raw.split(/\r?\n/);
  for (let i = 0; i < lines.length; i += 1) {
    const line = lines[i];
    if (!line) continue;
    try {
      entries.push(JSON.parse(line));
    } catch (err) {
      throw new Error(`trace line ${i + 1} is not valid JSON: ${err.message}`);
    }
  }
  return entries;
}

function pairKey(entry) {
  // Pair runtime agent_invocation_* with shim_invocation_* by
  // pid + argv + stdinSha256. binarySha256 is enforced separately
  // (AP-3) via --expected-binary-hashes plus a per-pair agreement check
  // (checkBinaryAgreement). Including binarySha256 in pairKey unconditionally
  // is not yet safe because the runtime-side adapter does not populate it;
  // see the TODO noted in docs/security/witness-chain-attacks.md patch #4.
  return JSON.stringify({
    pid: entry.pid,
    argv: entry.argv || null,
    stdinSha256: entry.stdinSha256 || null,
  });
}

function checkWitnessPairing(entries) {
  const orphans = [];
  const runtimeBegins = new Map();
  const shimBegins = new Map();
  const runtimeEnds = new Map();
  const shimEnds = new Map();

  const push = (map, key, entry) => {
    const arr = map.get(key) || [];
    arr.push(entry);
    map.set(key, arr);
  };

  for (const e of entries) {
    const key = pairKey(e);
    if (e.event === 'agent_invocation_begin') push(runtimeBegins, key, e);
    else if (e.event === 'shim_invocation_begin') push(shimBegins, key, e);
    else if (e.event === 'agent_invocation_end') push(runtimeEnds, key, e);
    else if (e.event === 'shim_invocation_end') push(shimEnds, key, e);
  }

  const compare = (left, right, leftLabel, rightLabel) => {
    for (const [key, arr] of left.entries()) {
      const counter = right.get(key) || [];
      if (counter.length !== arr.length) {
        orphans.push({
          key,
          leftLabel,
          leftCount: arr.length,
          rightLabel,
          rightCount: counter.length,
          sampleSeq: arr[0]?.seq,
        });
      }
    }
    for (const [key, arr] of right.entries()) {
      if (!left.has(key)) {
        orphans.push({
          key,
          leftLabel,
          leftCount: 0,
          rightLabel,
          rightCount: arr.length,
          sampleSeq: arr[0]?.seq,
        });
      }
    }
  };

  compare(runtimeBegins, shimBegins, 'agent_invocation_begin', 'shim_invocation_begin');
  compare(runtimeEnds, shimEnds, 'agent_invocation_end', 'shim_invocation_end');
  return orphans;
}

function checkBinaryAgreement(entries) {
  // For each pair-key, if BOTH a runtime-side entry and a shim-side entry
  // carry a binarySha256, they must agree. This closes the AP-3 gap when
  // the runtime adapter eventually emits binarySha256, while keeping
  // compatibility with the current runtime that does not.
  const byKey = new Map();
  for (const e of entries) {
    if (
      e.event !== 'agent_invocation_begin' &&
      e.event !== 'agent_invocation_end' &&
      e.event !== 'shim_invocation_begin' &&
      e.event !== 'shim_invocation_end'
    ) {
      continue;
    }
    const key = pairKey(e);
    const bucket = byKey.get(key) || { runtime: new Set(), shim: new Set() };
    const side = e.event.startsWith('shim_') ? 'shim' : 'runtime';
    if (typeof e.binarySha256 === 'string' && e.binarySha256.length > 0) {
      bucket[side].add(e.binarySha256);
    }
    byKey.set(key, bucket);
  }
  const mismatches = [];
  for (const [key, bucket] of byKey.entries()) {
    if (bucket.runtime.size === 0 || bucket.shim.size === 0) continue;
    const runtime = [...bucket.runtime];
    const shim = [...bucket.shim];
    if (runtime.length !== 1 || shim.length !== 1 || runtime[0] !== shim[0]) {
      mismatches.push({ key, runtime, shim });
    }
  }
  return mismatches;
}

function findLastStateAfterHash(entries) {
  for (let i = entries.length - 1; i >= 0; i -= 1) {
    const h = entries[i].stateAfterHash;
    if (typeof h === 'string' && h.length > 0) return h;
  }
  return null;
}

function getReviewerFamilyEvidence(entry) {
  const candidates = [
    entry?.evidence?.reviewer?.family,
    entry?.reviewer?.family,
    entry?.review?.reviewerFamily,
  ];
  for (const candidate of candidates) {
    if (typeof candidate === 'string' && candidate.trim().length > 0) {
      return candidate.trim();
    }
  }
  return null;
}

function checkExpectedReviewerFamily(entries, expectedFamily) {
  const observed = new Set();
  for (const entry of entries) {
    const family = getReviewerFamilyEvidence(entry);
    if (family) observed.add(family);
  }
  if (observed.size === 0) {
    return {
      ok: false,
      error: 'expected-reviewer-family-missing: no reviewer.family evidence found in trace entries',
    };
  }
  if (observed.size > 1) {
    return {
      ok: false,
      error: `expected-reviewer-family-conflict: multiple reviewer.family values present (${[...observed].join(', ')})`,
    };
  }
  const [actual] = observed;
  if (actual !== expectedFamily) {
    return {
      ok: false,
      error: `expected-reviewer-family-mismatch: trace carries reviewer.family=${actual}, expected ${expectedFamily}`,
    };
  }
  return { ok: true, reviewerFamily: actual };
}

const SAFE_CLAIM_RUNNER_CAPABILITIES = Object.freeze({
  externalProcess: true,
  terminate: true,
  cwdOverride: true,
  modelPassThrough: true,
  stateDirPolling: true,
  inProcessExecution: false,
});

function getRunnerCapabilityEvidence(entry) {
  const candidates = [
    entry?.runnerCapabilities,
    entry?.runner?.capabilities,
    entry?.evidence?.runner?.capabilities,
  ];
  for (const candidate of candidates) {
    if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
      return candidate;
    }
  }
  return null;
}

function checkClaimEligibleRunnerCapabilities(entries) {
  const failures = [];
  let observed = 0;
  for (const entry of entries) {
    const capabilities = getRunnerCapabilityEvidence(entry);
    if (!capabilities) continue;
    observed += 1;
    for (const [name, expected] of Object.entries(SAFE_CLAIM_RUNNER_CAPABILITIES)) {
      if (capabilities[name] !== expected) {
        failures.push({
          seq: entry.seq,
          capability: name,
          expected,
          actual: capabilities[name],
        });
      }
    }
  }
  if (observed === 0) {
    return {
      ok: false,
      error:
        'claim-runner-capabilities-missing: no runner capability evidence found in trace entries',
      failures,
    };
  }
  if (failures.length > 0) {
    return {
      ok: false,
      error: `claim-runner-capabilities-unsafe: ${failures.length} unsafe runner capability value(s) found`,
      failures,
    };
  }
  return { ok: true, observed };
}

function loadCapabilityManifest(filePath) {
  if (!existsSync(filePath)) {
    throw new Error(`capability manifest file not found: ${filePath}`);
  }
  try {
    return JSON.parse(readFileSync(filePath, 'utf8'));
  } catch (err) {
    throw new Error(`capability manifest is not valid JSON: ${err.message}`);
  }
}

function loadBinaryAllowList(filePath) {
  if (!existsSync(filePath)) {
    throw new Error(`binary allow-list file not found: ${filePath}`);
  }
  const raw = readFileSync(filePath, 'utf8');
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (err) {
    throw new Error(`binary allow-list is not valid JSON: ${err.message}`);
  }
  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error('binary allow-list must be a JSON object mapping binary names to hash arrays');
  }
  const out = new Map();
  for (const [name, hashes] of Object.entries(parsed)) {
    if (name.startsWith('_')) continue; // allow-list may carry _comment etc.
    if (!Array.isArray(hashes)) {
      throw new Error(`binary allow-list entry for "${name}" must be an array of sha256 strings`);
    }
    out.set(name, new Set(hashes));
  }
  return out;
}

function shimBinaryName(entry) {
  if (typeof entry.binaryPath === 'string' && entry.binaryPath.length > 0) {
    const base = basename(entry.binaryPath);
    return base.replace(/\.(exe|cmd|bat)$/i, '');
  }
  if (typeof entry.shimName === 'string') return entry.shimName;
  return null;
}

function checkBinaryHashes(entries, allowList) {
  const failures = [];
  for (const e of entries) {
    if (e.event !== 'shim_invocation_begin' && e.event !== 'shim_invocation_end') continue;
    const name = shimBinaryName(e);
    const hash = e.binarySha256;
    if (!hash) {
      failures.push({ seq: e.seq, name, reason: 'missing-binarySha256' });
      continue;
    }
    if (!name) {
      failures.push({ seq: e.seq, name: null, hash, reason: 'missing-binary-name' });
      continue;
    }
    const allowed = allowList.get(name);
    if (!allowed || !allowed.has(hash)) {
      failures.push({ seq: e.seq, name, hash, reason: 'binary-hash-not-allowed' });
    }
  }
  return failures;
}

function report(result, asJson) {
  if (asJson) {
    process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
    return;
  }
  if (result.ok) {
    const suffix = result.stateCheckSkipped ? ' (state-check-skipped)' : '';
    const devSuffix = result.devRoleAllowed ? ' (dev-role-allowed)' : '';
    const attestationSuffix = result.attestation
      ? `, attested-by=${result.attestation.signer} role=${result.attestation.signerRole}`
      : '';
    // G1 / prompt-kv57.6: surface the trust-root identity so an auditor can
    // confirm which registry file the verifier consulted.
    const trustRootSuffix = result.trustRootSha256
      ? `, trust-root=sha256:${result.trustRootSha256.slice(0, 8)}${
          result.revokedRootSha256
            ? ` revoked-root=sha256:${result.revokedRootSha256.slice(0, 8)}`
            : ''
        }`
      : '';
    process.stdout.write(
      `verify-trace OK: ${result.entryCount} entries, ${result.runtimePairs} runtime/shim pairs, runId=${result.runId}${attestationSuffix}${trustRootSuffix}${suffix}${devSuffix}\n`,
    );
    return;
  }
  process.stderr.write(`verify-trace FAILED\n`);
  for (const err of result.errors) {
    process.stderr.write(`  - ${err}\n`);
  }
  for (const o of result.orphans || []) {
    process.stderr.write(
      `  orphan: ${o.leftLabel}=${o.leftCount} vs ${o.rightLabel}=${o.rightCount} (seq~${o.sampleSeq}) key=${o.key}\n`,
    );
  }
  for (const b of result.binaryHashFailures || []) {
    process.stderr.write(
      `  binary: ${b.reason} seq=${b.seq} name=${b.name ?? '?'} hash=${b.hash ?? '?'}\n`,
    );
  }
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  if (!args.trace) {
    process.stderr.write('verify-trace: --trace is required\n');
    process.exit(2);
  }
  if (args.requireRole && args.requireRole !== 'operator' && args.requireRole !== 'ci') {
    process.stderr.write('verify-trace: --require-role must be "operator" or "ci"\n');
    process.exit(2);
  }
  if (args.requireAttestation && !args.attestation) {
    report(
      {
        ok: false,
        errors: ['attestation required but absent'],
        orphans: [],
        binaryHashFailures: [],
      },
      args.json,
    );
    process.exit(1);
  }

  // AP-1/AP-2: --state mandatory unless explicitly opted out.
  if (!args.state && !args.allowMissingState) {
    process.stderr.write(
      'verify-trace: --state is required; pass --allow-missing-state to opt out explicitly\n',
    );
    process.exit(2);
  }

  const tracePath = resolve(args.trace);
  const result = {
    ok: true,
    entryCount: 0,
    runtimePairs: 0,
    runId: null,
    stateCheckSkipped: false,
    errors: [],
    orphans: [],
    binaryHashFailures: [],
    attestation: null,
    trustRootSha256: null,
    revokedRootSha256: null,
    devRoleAllowed: args.allowDevRole,
    runnerCapabilityEvidenceCount: 0,
    runnerCapabilityFailures: [],
    capabilityManifest: null,
    claimProfileRequired: args.requireClaimProfile,
  };

  let entries;
  try {
    entries = loadTrace(tracePath);
  } catch (err) {
    report({ ok: false, errors: [err.message], orphans: [], binaryHashFailures: [] }, args.json);
    process.exit(1);
  }
  result.entryCount = entries.length;
  if (entries.length === 0) {
    result.ok = false;
    result.errors.push('trace is empty');
  }

  // (a) single runId
  const runIds = new Set(entries.map((e) => e.runId));
  if (runIds.size > 1) {
    result.ok = false;
    result.errors.push(`multiple runIds present: ${[...runIds].join(', ')}`);
  } else if (runIds.size === 1) {
    result.runId = [...runIds][0];
  }

  // AP-5: expected runId nonce
  if (args.expectedRunId !== null) {
    for (const e of entries) {
      if (e.runId !== args.expectedRunId) {
        result.ok = false;
        result.errors.push(
          `expected-runId-mismatch: entry seq=${e.seq} has runId=${e.runId}, expected ${args.expectedRunId}`,
        );
        break;
      }
    }
  }

  // AP-5: freshness window
  if (
    args.freshnessWindowMs !== null &&
    !Number.isNaN(args.freshnessWindowMs) &&
    entries.length > 0
  ) {
    const first = entries[0];
    const ts = Date.parse(first.timestamp);
    if (Number.isNaN(ts)) {
      result.ok = false;
      result.errors.push(
        `freshness-window: first entry timestamp "${first.timestamp}" is not parseable`,
      );
    } else {
      const now = Date.now();
      const delta = Math.abs(now - ts);
      if (delta > args.freshnessWindowMs) {
        result.ok = false;
        result.errors.push(
          `freshness-window-exceeded: first entry timestamp is ${delta}ms from now (window=${args.freshnessWindowMs}ms)`,
        );
      }
    }
  }

  // (b, c) chain verification
  const chain = verifyChain(entries);
  if (!chain.ok) {
    result.ok = false;
    result.errors.push(`chain: ${chain.error}`);
  }

  // (d) witness pairing
  const orphans = checkWitnessPairing(entries);
  if (orphans.length > 0) {
    result.ok = false;
    result.errors.push(`witness pairing has ${orphans.length} orphan group(s)`);
    result.orphans = orphans;
  }
  const binaryDisagreements = checkBinaryAgreement(entries);
  if (binaryDisagreements.length > 0) {
    result.ok = false;
    result.errors.push(
      `binary-hash-disagreement: ${binaryDisagreements.length} pair(s) with mismatched runtime vs shim binarySha256`,
    );
    result.binaryDisagreements = binaryDisagreements;
  }
  result.runtimePairs = entries.filter((e) => e.event === 'agent_invocation_begin').length;

  // AP-8: expected pair count
  if (args.expectedPairCount !== null && !Number.isNaN(args.expectedPairCount)) {
    if (result.runtimePairs !== args.expectedPairCount) {
      result.ok = false;
      result.errors.push(
        `expected-pair-count-mismatch: trace has ${result.runtimePairs} runtime/shim pairs, expected ${args.expectedPairCount}`,
      );
    }
  }

  // AP-8: min entries
  if (args.minEntries !== null && !Number.isNaN(args.minEntries)) {
    if (result.entryCount < args.minEntries) {
      result.ok = false;
      result.errors.push(
        `min-entries-not-met: trace has ${result.entryCount} entries, need at least ${args.minEntries}`,
      );
    }
  }

  if (args.expectedReviewerFamily !== null) {
    const reviewerFamilyCheck = checkExpectedReviewerFamily(entries, args.expectedReviewerFamily);
    if (!reviewerFamilyCheck.ok) {
      result.ok = false;
      result.errors.push(reviewerFamilyCheck.error);
    } else {
      result.reviewerFamily = reviewerFamilyCheck.reviewerFamily;
    }
  }

  const claimEligibleVerification =
    args.requireClaimProfile ||
    (args.requireAttestation && args.requireRole !== null && !args.allowDevRole);
  if (claimEligibleVerification) {
    const runnerCapabilityCheck = checkClaimEligibleRunnerCapabilities(entries);
    if (!runnerCapabilityCheck.ok) {
      result.ok = false;
      result.errors.push(runnerCapabilityCheck.error);
      result.runnerCapabilityFailures = runnerCapabilityCheck.failures;
    } else {
      result.runnerCapabilityEvidenceCount = runnerCapabilityCheck.observed;
    }
  }

  if (args.capabilityManifest) {
    try {
      const capabilityManifestPath = resolve(args.capabilityManifest);
      const capabilityManifest = loadCapabilityManifest(capabilityManifestPath);
      const safety = assessRunnerCapabilityManifest(capabilityManifest);
      result.capabilityManifest = {
        path: capabilityManifestPath,
        sha256: sha256File(capabilityManifestPath),
        safety,
      };
      if (args.requireClaimProfile && safety.blockers.length > 0) {
        result.ok = false;
        result.errors.push(`claim-capability-manifest-unsafe: ${safety.blockers.join(', ')}`);
      }
    } catch (err) {
      result.ok = false;
      result.errors.push(`claim-capability-manifest: ${err.message}`);
    }
  } else if (args.requireClaimProfile) {
    result.ok = false;
    result.errors.push('claim-capability-manifest-missing: --capability-manifest is required');
  }

  // AP-3: binary hash allow-list
  if (args.expectedBinaryHashes) {
    try {
      const allowList = loadBinaryAllowList(resolve(args.expectedBinaryHashes));
      const failures = checkBinaryHashes(entries, allowList);
      if (failures.length > 0) {
        result.ok = false;
        result.errors.push(
          `binary-hash-not-allowed: ${failures.length} shim entr${failures.length === 1 ? 'y' : 'ies'} failed allow-list`,
        );
        result.binaryHashFailures = failures;
      }
    } catch (err) {
      result.ok = false;
      result.errors.push(`binary-allow-list: ${err.message}`);
    }
  }

  // (e) state cross-check (AP-1/AP-2)
  if (args.state) {
    const statePath = resolve(args.state);
    if (!existsSync(statePath)) {
      result.ok = false;
      result.errors.push(`state file not found: ${statePath}`);
    } else {
      try {
        const raw = readFileSync(statePath, 'utf8');
        const state = JSON.parse(raw);
        const computed = hashState(state);
        const claimed = findLastStateAfterHash(entries);
        if (!claimed) {
          result.ok = false;
          result.errors.push('no stateAfterHash present in trace to compare against --state');
        } else if (claimed !== computed) {
          result.ok = false;
          result.errors.push(
            `state hash mismatch: trace claims ${claimed}, state file hashes to ${computed}`,
          );
        }
      } catch (err) {
        result.ok = false;
        result.errors.push(`cannot read/parse state file: ${err.message}`);
      }
    }
  } else if (args.allowMissingState) {
    result.stateCheckSkipped = true;
  }

  if (args.attestation) {
    try {
      const attestationPath = resolve(args.attestation);
      if (!existsSync(attestationPath)) {
        throw new Error(`attestation file not found: ${attestationPath}`);
      }
      const trustedSignersAbs = resolve(args.trustedSigners);
      const revokedSignersAbs = resolve(args.revokedSigners);

      // F2 / NF5: enforce the build-time sha256 pin before loading.
      // --require-attestation makes this a hard failure; otherwise a missing
      // or overridden pin is a warning surface (unused today).
      const trustRoot = await loadTrustRoot();
      const pinCheck = enforceTrustRootPin({
        trustedSignersPath: trustedSignersAbs,
        revokedSignersPath: revokedSignersAbs,
        trustRoot,
        overrideTrustedSha256: args.trustedSignersSha256,
        overrideRevokedSha256: args.revokedSignersSha256,
      });
      if (!pinCheck.ok) {
        throw new Error(pinCheck.errors.join('; '));
      }
      result.trustRootSha256 = pinCheck.trustedSha256;
      result.revokedRootSha256 = pinCheck.revokedSha256 ?? null;

      const bundleDir = dirname(attestationPath);
      const attestation = JSON.parse(readFileSync(attestationPath, 'utf8'));
      const { payload: bundlePayload } = buildBundlePayload({
        bundleDir,
        tracePath,
        statePath: args.state ? resolve(args.state) : resolve(bundleDir, 'session-state.json'),
      });
      result.attestation = verifyAttestationAgainstBundle({
        attestation,
        bundlePayload,
        trustedSignersPath: trustedSignersAbs,
        revokedSignersPath: revokedSignersAbs,
        requireRole: args.requireRole,
        expectedRunId: args.expectedRunId,
        extraAllowedRoles: args.allowDevRole ? [DEV_ROLE] : [],
        expectedPairCount:
          args.expectedPairCount !== null && !Number.isNaN(args.expectedPairCount)
            ? args.expectedPairCount
            : null,
        freshnessWindowMs:
          args.freshnessWindowMs !== null && !Number.isNaN(args.freshnessWindowMs)
            ? args.freshnessWindowMs
            : null,
      });
    } catch (err) {
      result.ok = false;
      result.errors.push(err.message);
    }
  }

  report(result, args.json);
  process.exit(result.ok ? 0 : 1);
}

main().catch((err) => {
  process.stderr.write(`verify-trace: uncaught ${err.stack || err.message}\n`);
  process.exit(1);
});

// Suppress unused import warning; canonicalJSON and hashEvent are kept exported
// for future inline recomputation/debugging scenarios.
void canonicalJSON;
void hashEvent;
