377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const fixturesDir = "protocol/examples/evidence-chain/fixtures";
|
|
|
|
const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
|
|
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
const sha256Pattern = /^[a-f0-9]{64}$/;
|
|
|
|
const rootRequired = [
|
|
"chainId",
|
|
"schemaVersion",
|
|
"scenario",
|
|
"projectId",
|
|
"operationId",
|
|
"traceId",
|
|
"environment",
|
|
"hardwareOperation",
|
|
"auditEvents",
|
|
"traceEvents",
|
|
"evidenceRecords"
|
|
];
|
|
|
|
const hardwareOperationRequired = [
|
|
"operationId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"resourceId",
|
|
"capabilityId",
|
|
"requestedBy",
|
|
"status",
|
|
"environment",
|
|
"requestedAt",
|
|
"updatedAt"
|
|
];
|
|
|
|
const auditEventRequired = [
|
|
"auditId",
|
|
"traceId",
|
|
"actorType",
|
|
"actorId",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"serviceId",
|
|
"environment",
|
|
"occurredAt",
|
|
"projectId",
|
|
"operationId",
|
|
"outcome"
|
|
];
|
|
|
|
const traceEventRequired = [
|
|
"traceEventId",
|
|
"traceId",
|
|
"projectId",
|
|
"serviceId",
|
|
"level",
|
|
"message",
|
|
"environment",
|
|
"occurredAt",
|
|
"operationId"
|
|
];
|
|
|
|
const evidenceRecordRequired = [
|
|
"evidenceId",
|
|
"projectId",
|
|
"operationId",
|
|
"kind",
|
|
"uri",
|
|
"sha256",
|
|
"serviceId",
|
|
"environment",
|
|
"createdAt"
|
|
];
|
|
|
|
const operationStatuses = new Set([
|
|
"requested",
|
|
"accepted",
|
|
"queued",
|
|
"running",
|
|
"succeeded",
|
|
"failed",
|
|
"rejected",
|
|
"canceled",
|
|
"timed_out"
|
|
]);
|
|
|
|
const actorTypes = new Set(["user", "service", "agent", "worker", "system"]);
|
|
const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]);
|
|
const traceLevels = new Set(["debug", "info", "warn", "error"]);
|
|
const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]);
|
|
const scenarios = new Set(["hardware-trusted-closed-loop", "agent-automation-closed-loop"]);
|
|
|
|
function assertObject(value, label) {
|
|
assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`);
|
|
}
|
|
|
|
function assertRequired(value, fields, label) {
|
|
for (const field of fields) {
|
|
assert.ok(Object.hasOwn(value, field), `${label} missing ${field}`);
|
|
}
|
|
}
|
|
|
|
function assertId(value, label) {
|
|
assert.equal(typeof value, "string", `${label} must be a string`);
|
|
assert.match(value, idPattern, `${label} must match HWLAB id format`);
|
|
}
|
|
|
|
function assertTimestamp(value, label) {
|
|
assert.equal(typeof value, "string", `${label} must be a string`);
|
|
assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be a parseable timestamp`);
|
|
}
|
|
|
|
function assertServiceId(value, label) {
|
|
assert.ok(SERVICE_IDS.includes(value), `${label} must be a frozen service id`);
|
|
}
|
|
|
|
function assertUnique(values, label) {
|
|
assert.equal(new Set(values).size, values.length, `${label} must be unique`);
|
|
}
|
|
|
|
function assertSame(actual, expected, label) {
|
|
assert.equal(actual, expected, `${label} must match ${expected}`);
|
|
}
|
|
|
|
async function parseFixtures() {
|
|
const entries = await readdir(path.join(repoRoot, fixturesDir), { withFileTypes: true });
|
|
const files = entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => path.join(fixturesDir, entry.name))
|
|
.sort();
|
|
|
|
const fixtures = [];
|
|
for (const relativePath of files) {
|
|
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
|
|
fixtures.push({ relativePath, doc: JSON.parse(raw) });
|
|
}
|
|
return fixtures;
|
|
}
|
|
|
|
async function assertArtifact(record, label) {
|
|
assert.match(record.sha256, sha256Pattern, `${label}.sha256 must be lowercase sha256`);
|
|
assert.equal(typeof record.uri, "string", `${label}.uri must be a string`);
|
|
|
|
const artifactPath = path.resolve(repoRoot, record.uri);
|
|
assert.ok(
|
|
artifactPath.startsWith(`${repoRoot}${path.sep}`),
|
|
`${label}.uri must point inside the repository`
|
|
);
|
|
|
|
const content = await readFile(artifactPath);
|
|
const digest = createHash("sha256").update(content).digest("hex");
|
|
assert.equal(digest, record.sha256, `${label}.sha256 must match ${record.uri}`);
|
|
|
|
if (Object.hasOwn(record, "sizeBytes")) {
|
|
assert.equal(record.sizeBytes, content.byteLength, `${label}.sizeBytes must match ${record.uri}`);
|
|
}
|
|
}
|
|
|
|
function validateHardwareOperation(chain, operation) {
|
|
assertObject(operation, `${chain.chainId}.hardwareOperation`);
|
|
assertRequired(operation, hardwareOperationRequired, `${chain.chainId}.hardwareOperation`);
|
|
|
|
for (const field of [
|
|
"operationId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"resourceId",
|
|
"capabilityId",
|
|
"requestedBy"
|
|
]) {
|
|
assertId(operation[field], `${chain.chainId}.hardwareOperation.${field}`);
|
|
}
|
|
|
|
assertSame(operation.operationId, chain.operationId, `${chain.chainId}.hardwareOperation.operationId`);
|
|
assertSame(operation.projectId, chain.projectId, `${chain.chainId}.hardwareOperation.projectId`);
|
|
assertSame(operation.environment, chain.environment, `${chain.chainId}.hardwareOperation.environment`);
|
|
assert.ok(operationStatuses.has(operation.status), `${chain.chainId}.hardwareOperation.status is invalid`);
|
|
assertTimestamp(operation.requestedAt, `${chain.chainId}.hardwareOperation.requestedAt`);
|
|
assertTimestamp(operation.updatedAt, `${chain.chainId}.hardwareOperation.updatedAt`);
|
|
|
|
for (const field of ["startedAt", "completedAt"]) {
|
|
if (Object.hasOwn(operation, field)) {
|
|
assertTimestamp(operation[field], `${chain.chainId}.hardwareOperation.${field}`);
|
|
}
|
|
}
|
|
|
|
if (chain.agentSessionId) {
|
|
assertSame(operation.agentSessionId, chain.agentSessionId, `${chain.chainId}.hardwareOperation.agentSessionId`);
|
|
}
|
|
if (chain.workerSessionId) {
|
|
assertSame(operation.workerSessionId, chain.workerSessionId, `${chain.chainId}.hardwareOperation.workerSessionId`);
|
|
}
|
|
}
|
|
|
|
function validateAuditEvents(chain, auditEvents) {
|
|
assert.ok(Array.isArray(auditEvents), `${chain.chainId}.auditEvents must be an array`);
|
|
assert.ok(auditEvents.length >= 2, `${chain.chainId}.auditEvents must include operation and evidence audit events`);
|
|
assertUnique(
|
|
auditEvents.map((event) => event.auditId),
|
|
`${chain.chainId}.auditId values`
|
|
);
|
|
|
|
for (const [index, event] of auditEvents.entries()) {
|
|
const label = `${chain.chainId}.auditEvents[${index}]`;
|
|
assertObject(event, label);
|
|
assertRequired(event, auditEventRequired, label);
|
|
|
|
for (const field of ["auditId", "traceId", "actorId", "targetId", "projectId", "operationId"]) {
|
|
assertId(event[field], `${label}.${field}`);
|
|
}
|
|
|
|
assertSame(event.traceId, chain.traceId, `${label}.traceId`);
|
|
assertSame(event.projectId, chain.projectId, `${label}.projectId`);
|
|
assertSame(event.operationId, chain.operationId, `${label}.operationId`);
|
|
assertSame(event.environment, chain.environment, `${label}.environment`);
|
|
assert.ok(actorTypes.has(event.actorType), `${label}.actorType is invalid`);
|
|
assert.match(event.action, actionPattern, `${label}.action must be dotted`);
|
|
assert.equal(typeof event.targetType, "string", `${label}.targetType must be a string`);
|
|
assertServiceId(event.serviceId, `${label}.serviceId`);
|
|
assert.ok(auditOutcomes.has(event.outcome), `${label}.outcome is invalid`);
|
|
assertTimestamp(event.occurredAt, `${label}.occurredAt`);
|
|
|
|
if (Object.hasOwn(event, "gatewaySessionId")) {
|
|
assertSame(event.gatewaySessionId, chain.gatewaySessionId, `${label}.gatewaySessionId`);
|
|
}
|
|
if (chain.workerSessionId && Object.hasOwn(event, "workerSessionId")) {
|
|
assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
|
|
}
|
|
}
|
|
|
|
assert.ok(
|
|
auditEvents.some((event) => event.targetType === "hardware_operation" && event.targetId === chain.operationId),
|
|
`${chain.chainId} must audit the hardware operation`
|
|
);
|
|
}
|
|
|
|
function validateTraceEvents(chain, traceEvents, auditIds) {
|
|
assert.ok(Array.isArray(traceEvents), `${chain.chainId}.traceEvents must be an array`);
|
|
assert.ok(traceEvents.length >= 1, `${chain.chainId}.traceEvents must not be empty`);
|
|
assertUnique(
|
|
traceEvents.map((event) => event.traceEventId),
|
|
`${chain.chainId}.traceEventId values`
|
|
);
|
|
|
|
for (const [index, event] of traceEvents.entries()) {
|
|
const label = `${chain.chainId}.traceEvents[${index}]`;
|
|
assertObject(event, label);
|
|
assertRequired(event, traceEventRequired, label);
|
|
|
|
for (const field of ["traceEventId", "traceId", "projectId", "operationId"]) {
|
|
assertId(event[field], `${label}.${field}`);
|
|
}
|
|
|
|
assertSame(event.traceId, chain.traceId, `${label}.traceId`);
|
|
assertSame(event.projectId, chain.projectId, `${label}.projectId`);
|
|
assertSame(event.operationId, chain.operationId, `${label}.operationId`);
|
|
assertSame(event.environment, chain.environment, `${label}.environment`);
|
|
assertServiceId(event.serviceId, `${label}.serviceId`);
|
|
assert.ok(traceLevels.has(event.level), `${label}.level is invalid`);
|
|
assert.equal(typeof event.message, "string", `${label}.message must be a string`);
|
|
assert.ok(event.message.length > 0, `${label}.message must not be empty`);
|
|
assertTimestamp(event.occurredAt, `${label}.occurredAt`);
|
|
|
|
if (chain.agentSessionId) {
|
|
assertSame(event.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`);
|
|
}
|
|
if (chain.workerSessionId) {
|
|
assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
|
|
}
|
|
if (event.metadata?.auditId) {
|
|
assert.ok(auditIds.has(event.metadata.auditId), `${label}.metadata.auditId must reference same-chain audit`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function validateEvidenceRecords(chain, evidenceRecords, auditEvents) {
|
|
assert.ok(Array.isArray(evidenceRecords), `${chain.chainId}.evidenceRecords must be an array`);
|
|
assert.ok(evidenceRecords.length >= 1, `${chain.chainId}.evidenceRecords must not be empty`);
|
|
assertUnique(
|
|
evidenceRecords.map((record) => record.evidenceId),
|
|
`${chain.chainId}.evidenceId values`
|
|
);
|
|
|
|
const auditsById = new Map(auditEvents.map((event) => [event.auditId, event]));
|
|
|
|
for (const [index, record] of evidenceRecords.entries()) {
|
|
const label = `${chain.chainId}.evidenceRecords[${index}]`;
|
|
assertObject(record, label);
|
|
assertRequired(record, evidenceRecordRequired, label);
|
|
|
|
for (const field of ["evidenceId", "projectId", "operationId"]) {
|
|
assertId(record[field], `${label}.${field}`);
|
|
}
|
|
|
|
assertSame(record.projectId, chain.projectId, `${label}.projectId`);
|
|
assertSame(record.operationId, chain.operationId, `${label}.operationId`);
|
|
assertSame(record.environment, chain.environment, `${label}.environment`);
|
|
assert.ok(evidenceKinds.has(record.kind), `${label}.kind is invalid`);
|
|
assertServiceId(record.serviceId, `${label}.serviceId`);
|
|
assertTimestamp(record.createdAt, `${label}.createdAt`);
|
|
assertObject(record.metadata, `${label}.metadata`);
|
|
assertSame(record.metadata.traceId, chain.traceId, `${label}.metadata.traceId`);
|
|
assertId(record.metadata.producedByAuditId, `${label}.metadata.producedByAuditId`);
|
|
|
|
const producerAudit = auditsById.get(record.metadata.producedByAuditId);
|
|
assert.ok(producerAudit, `${label}.metadata.producedByAuditId must reference same-chain audit`);
|
|
assert.equal(producerAudit.targetType, "evidence_record", `${label} producer audit must target evidence_record`);
|
|
assert.equal(producerAudit.targetId, record.evidenceId, `${label} producer audit must target this evidenceId`);
|
|
|
|
if (chain.agentSessionId) {
|
|
assertSame(record.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`);
|
|
assertSame(record.metadata.agentSessionId, chain.agentSessionId, `${label}.metadata.agentSessionId`);
|
|
}
|
|
if (chain.workerSessionId) {
|
|
assertSame(record.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
|
|
assertSame(record.metadata.workerSessionId, chain.workerSessionId, `${label}.metadata.workerSessionId`);
|
|
}
|
|
|
|
await assertArtifact(record, label);
|
|
}
|
|
}
|
|
|
|
async function validateFixture({ relativePath, doc }) {
|
|
assertObject(doc, relativePath);
|
|
assertRequired(doc, rootRequired, relativePath);
|
|
|
|
for (const field of ["chainId", "projectId", "operationId", "traceId"]) {
|
|
assertId(doc[field], `${relativePath}.${field}`);
|
|
}
|
|
|
|
assert.equal(doc.schemaVersion, 1, `${relativePath}.schemaVersion must be 1`);
|
|
assert.ok(scenarios.has(doc.scenario), `${relativePath}.scenario is not recognized`);
|
|
assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath}.environment must be dev`);
|
|
|
|
for (const field of ["gatewaySessionId", "resourceId", "capabilityId", "agentSessionId", "workerSessionId"]) {
|
|
if (Object.hasOwn(doc, field)) {
|
|
assertId(doc[field], `${relativePath}.${field}`);
|
|
}
|
|
}
|
|
|
|
if (doc.scenario === "agent-automation-closed-loop") {
|
|
assertId(doc.agentSessionId, `${relativePath}.agentSessionId`);
|
|
assertId(doc.workerSessionId, `${relativePath}.workerSessionId`);
|
|
}
|
|
|
|
validateHardwareOperation(doc, doc.hardwareOperation);
|
|
validateAuditEvents(doc, doc.auditEvents);
|
|
|
|
const auditIds = new Set(doc.auditEvents.map((event) => event.auditId));
|
|
validateTraceEvents(doc, doc.traceEvents, auditIds);
|
|
await validateEvidenceRecords(doc, doc.evidenceRecords, doc.auditEvents);
|
|
}
|
|
|
|
const fixtures = await parseFixtures();
|
|
|
|
assert.ok(fixtures.length >= 2, "expected at least two MVP evidence chain fixtures");
|
|
|
|
const scenarioSet = new Set(fixtures.map(({ doc }) => doc.scenario));
|
|
for (const scenario of scenarios) {
|
|
assert.ok(scenarioSet.has(scenario), `missing ${scenario} fixture`);
|
|
}
|
|
|
|
for (const fixture of fixtures) {
|
|
await validateFixture(fixture);
|
|
}
|
|
|
|
const evidenceCount = fixtures.reduce((count, { doc }) => count + doc.evidenceRecords.length, 0);
|
|
console.log(`validated ${fixtures.length} evidence chain fixtures and ${evidenceCount} evidence records`);
|