From e76dae5cc6c19bbe4634787cd5ccb3963d78c950 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 30 Jul 2026 08:20:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20Milestone=201=20&=202=20?= =?UTF-8?q?=E2=80=94=20workflow=5Flayer=20validation,=20compliance=20contr?= =?UTF-8?q?ols,=20ISO=2027001=20&=20EU=20AI=20Act=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #11 #12 #13 #14 #15 - #11: workflow_layer schema validation tests (10 new test cases) covering required fields, step structure, depends_on, allowed_tools, and integration with tool_layer references - #12: action_pathway (workflow_layer) field descriptors added to SCHEMA_FIELDS_V0_1; all subfields documented (workflow_id, workflow_name, steps, step_id, action, allowed_tools) - #13: ControlResult type added to checkCompliance output — per-control pass/fail with control_id, description, errors, warnings, passed_checks - #14: compliance.test.ts expanded with ISO 27001:2022, EU AI Act Annex IV profiles end-to-end; ControlResult structure tests - #15: agentbom-autogen generateAgentBOM maps AutoGen workflow definitions to workflow_layer; 3 new test cases including multi-workflow AutoGen usage --- .changeset/milestone-1-2-impl.md | 11 + packages/agentbom-autogen/src/index.test.ts | 117 +++ packages/agentbom-autogen/src/index.ts | 60 ++ packages/agentbom-cli/src/agentbom-diff.ts | 17 +- packages/agentbom-cli/src/agentbom-inspect.ts | 8 +- .../agentbom-cli/src/agentbom-pipeline.ts | 39 +- .../agentbom-cli/src/audit-report.test.ts | 447 +++++++----- packages/agentbom-cli/src/audit-report.ts | 407 ++++++----- packages/agentbom-cli/src/bom-generate.ts | 143 ++-- packages/agentbom-cli/src/chain.test.ts | 103 +-- packages/agentbom-cli/src/chain.ts | 234 +++--- .../agentbom-cli/src/compliance-check.test.ts | 566 +++++++++------ packages/agentbom-cli/src/compliance-check.ts | 327 +++++---- .../agentbom-cli/src/compose-team.test.ts | 277 +++---- packages/agentbom-cli/src/compose-team.ts | 40 +- .../agentbom-cli/src/export-dashboard.test.ts | 402 +++++----- packages/agentbom-cli/src/export-dashboard.ts | 563 ++++++++------ .../agentbom-cli/src/export-marketplace.ts | 85 ++- packages/agentbom-cli/src/index.ts | 414 ++++++----- packages/agentbom-cli/src/mcp-posture-diff.ts | 17 +- .../agentbom-cli/src/mcp-posture-inspect.ts | 11 +- .../agentbom-cli/src/mcp-posture-validate.ts | 12 +- packages/agentbom-cli/src/passport-inspect.ts | 18 +- packages/agentbom-cli/src/passport-sign.ts | 116 +-- .../agentbom-cli/src/passport-validate.ts | 19 +- .../src/passport-verify-signed.ts | 118 +-- .../src/regulatory-report.test.ts | 428 ++++++----- .../agentbom-cli/src/regulatory-report.ts | 683 ++++++++++------- packages/agentbom-cli/src/sigstore-verify.ts | 185 +++-- packages/agentbom-cli/src/trust-diff.test.ts | 685 +++++++++--------- packages/agentbom-cli/src/trust-diff.ts | 159 ++-- .../agentbom-cli/src/trust-publish.test.ts | 567 ++++++++------- packages/agentbom-cli/src/trust-publish.ts | 154 ++-- packages/agentbom-cli/src/trust-pull.test.ts | 629 ++++++++-------- packages/agentbom-cli/src/trust-pull.ts | 163 +++-- .../agentbom-cli/src/trust-subscribe.test.ts | 445 ++++++------ packages/agentbom-cli/src/trust-subscribe.ts | 155 ++-- .../src/trust-verify-chain.test.ts | 460 ++++++------ .../agentbom-cli/src/trust-verify-chain.ts | 227 +++--- packages/agentbom-core/src/compliance.test.ts | 399 ++++++++++ packages/agentbom-core/src/compliance.ts | 40 + packages/agentbom-core/src/index.test.ts | 244 +++++++ packages/agentbom-core/src/index.ts | 46 +- 43 files changed, 6154 insertions(+), 4086 deletions(-) create mode 100644 .changeset/milestone-1-2-impl.md diff --git a/.changeset/milestone-1-2-impl.md b/.changeset/milestone-1-2-impl.md new file mode 100644 index 0000000..2c37f63 --- /dev/null +++ b/.changeset/milestone-1-2-impl.md @@ -0,0 +1,11 @@ +--- +"@wasmagent/agentbom-core": minor +"@wasmagent/agentbom-autogen": patch +--- + +feat: complete Milestone 1 & 2 — workflow_layer validation, compliance controls, ISO 27001 & EU AI Act tests + +- validateAgentBOM: full workflow_layer (action_pathway) field validation with schema descriptor entries +- checkCompliance: adds ControlResult type for per-control pass/fail structured result +- compliance.test.ts: SOC2, ISO 27001, EU AI Act Annex IV end-to-end profile coverage +- agentbom-autogen: AutoGen 0.4+ adapter now maps workflows to workflow_layer diff --git a/packages/agentbom-autogen/src/index.test.ts b/packages/agentbom-autogen/src/index.test.ts index 5e607be..2b2c62e 100644 --- a/packages/agentbom-autogen/src/index.test.ts +++ b/packages/agentbom-autogen/src/index.test.ts @@ -162,4 +162,121 @@ describe("autogen-agentbom generateAgentBOM", () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); + + it("maps workflow definitions to workflow_layer", () => { + const bom = generateAgentBOM({ + agent_id: "ag-agent-006", + agent_name: "Workflow Agent", + workflows: [ + { + workflow_id: "wf-001", + workflow_name: "Search and Summarize", + description: "Search the web and summarize results", + version: "1.0.0", + steps: [ + { + step_id: "step-search", + action: "web_search", + description: "Execute web search", + allowed_tools: ["web-search"], + }, + { + step_id: "step-summarize", + action: "summarize", + description: "Summarize results", + depends_on: ["step-search"], + }, + ], + }, + ], + }); + + expect(bom.workflow_layer).toBeDefined(); + expect(bom.workflow_layer).toHaveLength(1); + expect(bom.workflow_layer?.[0].workflow_id).toBe("wf-001"); + expect(bom.workflow_layer?.[0].workflow_name).toBe("Search and Summarize"); + expect(bom.workflow_layer?.[0].description).toBe( + "Search the web and summarize results", + ); + expect(bom.workflow_layer?.[0].version).toBe("1.0.0"); + expect(bom.workflow_layer?.[0].steps).toHaveLength(2); + expect(bom.workflow_layer?.[0].steps[0].step_id).toBe("step-search"); + expect(bom.workflow_layer?.[0].steps[1].depends_on).toEqual([ + "step-search", + ]); + + const result = validateAgentBOM(bom); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("omits workflow_layer when no workflows provided", () => { + const bom = generateAgentBOM({ + agent_id: "ag-agent-007", + agent_name: "No Workflow Agent", + }); + expect(bom.workflow_layer).toBeUndefined(); + + const result = validateAgentBOM(bom); + expect(result.valid).toBe(true); + }); + + it("maps multiple workflows for multi-agent AutoGen usage", () => { + const bom = generateAgentBOM({ + agent_id: "ag-multiagent-001", + agent_name: "AutoGen Group Chat Orchestrator", + agent_version: "0.4.0", + deployment_context: "production", + tools: [ + { + name: "code_executor", + permissions: ["process:exec", "fs:write"], + risk_signals: ["code_execution"], + }, + { + name: "web_browser", + permissions: ["network:outbound"], + }, + ], + workflows: [ + { + workflow_id: "wf-code-review", + workflow_name: "Code Review Pipeline", + steps: [ + { step_id: "write", action: "code_executor" }, + { step_id: "test", action: "code_executor", depends_on: ["write"] }, + { + step_id: "review", + action: "decision", + depends_on: ["test"], + allowed_tools: [], + }, + ], + }, + { + workflow_id: "wf-research", + workflow_name: "Research Pipeline", + steps: [ + { + step_id: "search", + action: "web_browser", + allowed_tools: ["web-browser"], + }, + { + step_id: "summarize", + action: "decision", + depends_on: ["search"], + }, + ], + }, + ], + }); + + expect(bom.workflow_layer).toHaveLength(2); + expect(bom.tool_layer).toHaveLength(2); + + const result = validateAgentBOM(bom); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); }); diff --git a/packages/agentbom-autogen/src/index.ts b/packages/agentbom-autogen/src/index.ts index b23346d..68098f4 100644 --- a/packages/agentbom-autogen/src/index.ts +++ b/packages/agentbom-autogen/src/index.ts @@ -55,6 +55,34 @@ export interface AutoGenAgentConfig { granted_scopes?: string[]; data_access?: string[]; credential_references?: string[]; + /** Workflow / action pathway definitions. */ + workflows?: AutoGenWorkflowConfig[]; +} + +export interface AutoGenWorkflowStep { + /** Unique step identifier within the workflow. */ + step_id: string; + /** Action to perform (tool_id, prompt name, sub_workflow, or decision). */ + action: string; + /** Human-readable step description. */ + description?: string; + /** Step IDs this step depends on. */ + depends_on?: string[]; + /** Tool IDs allowed at this step. */ + allowed_tools?: string[]; +} + +export interface AutoGenWorkflowConfig { + /** Unique workflow identifier. */ + workflow_id: string; + /** Human-readable workflow name. */ + workflow_name: string; + /** Workflow description. */ + description?: string; + /** Workflow version (semver). */ + version?: string; + /** Ordered steps. */ + steps: AutoGenWorkflowStep[]; } /** The shape of a generated AgentBOM document. */ @@ -92,6 +120,19 @@ export interface AgentBOMRecord { data_access?: string[]; credential_references?: string[]; }; + workflow_layer?: Array<{ + workflow_id: string; + workflow_name: string; + description?: string; + version?: string; + steps: Array<{ + step_id: string; + action: string; + description?: string; + depends_on?: string[]; + allowed_tools?: string[]; + }>; + }>; attestation: { generator: string; generator_version: string; @@ -185,6 +226,25 @@ export function generateAgentBOM(config: AutoGenAgentConfig): AgentBOMRecord { }, } : {}), + ...(config.workflows?.length + ? { + workflow_layer: config.workflows.map((wf) => ({ + workflow_id: wf.workflow_id, + workflow_name: wf.workflow_name, + ...(wf.description ? { description: wf.description } : {}), + ...(wf.version ? { version: wf.version } : {}), + steps: wf.steps.map((s) => ({ + step_id: s.step_id, + action: s.action, + ...(s.description ? { description: s.description } : {}), + ...(s.depends_on?.length ? { depends_on: s.depends_on } : {}), + ...(s.allowed_tools?.length + ? { allowed_tools: s.allowed_tools } + : {}), + })), + })), + } + : {}), attestation: { generator: GENERATOR, generator_version: GENERATOR_VERSION, diff --git a/packages/agentbom-cli/src/agentbom-diff.ts b/packages/agentbom-cli/src/agentbom-diff.ts index d9787e4..edcf58d 100644 --- a/packages/agentbom-cli/src/agentbom-diff.ts +++ b/packages/agentbom-cli/src/agentbom-diff.ts @@ -1,18 +1,21 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { diffAgentBOM, formatAgentBOMDiff, validateAgentBOM, -} from '@wasmagent/agentbom-core'; +} from "@wasmagent/agentbom-core"; -export function diffAgentBOMCommand(oldFilePath: string, newFilePath: string): number { +export function diffAgentBOMCommand( + oldFilePath: string, + newFilePath: string, +): number { const oldPath = resolve(oldFilePath); const newPath = resolve(newFilePath); let oldRaw: string; try { - oldRaw = readFileSync(oldPath, 'utf-8'); + oldRaw = readFileSync(oldPath, "utf-8"); } catch { console.error(`Error: cannot read file "${oldPath}"`); return 1; @@ -20,7 +23,7 @@ export function diffAgentBOMCommand(oldFilePath: string, newFilePath: string): n let newRaw: string; try { - newRaw = readFileSync(newPath, 'utf-8'); + newRaw = readFileSync(newPath, "utf-8"); } catch { console.error(`Error: cannot read file "${newPath}"`); return 1; @@ -64,7 +67,7 @@ export function diffAgentBOMCommand(oldFilePath: string, newFilePath: string): n const newBom = newData as Record; const diff = diffAgentBOM(oldBom, newBom); - console.log('Comparing AgentBOMs:'); + console.log("Comparing AgentBOMs:"); console.log(` old: ${oldPath}`); console.log(` new: ${newPath}`); console.log(); diff --git a/packages/agentbom-cli/src/agentbom-inspect.ts b/packages/agentbom-cli/src/agentbom-inspect.ts index ead4749..9764d60 100644 --- a/packages/agentbom-cli/src/agentbom-inspect.ts +++ b/packages/agentbom-cli/src/agentbom-inspect.ts @@ -1,13 +1,13 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { inspectAgentBOM, validateAgentBOM } from '@wasmagent/agentbom-core'; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { inspectAgentBOM, validateAgentBOM } from "@wasmagent/agentbom-core"; export function inspectAgentBOMCommand(filePath: string): number { const resolvedPath = resolve(filePath); let raw: string; try { - raw = readFileSync(resolvedPath, 'utf-8'); + raw = readFileSync(resolvedPath, "utf-8"); } catch { console.error(`Error: cannot read file "${resolvedPath}"`); return 1; diff --git a/packages/agentbom-cli/src/agentbom-pipeline.ts b/packages/agentbom-cli/src/agentbom-pipeline.ts index bd6bea5..d0318fe 100644 --- a/packages/agentbom-cli/src/agentbom-pipeline.ts +++ b/packages/agentbom-cli/src/agentbom-pipeline.ts @@ -1,5 +1,8 @@ -import { resolve } from 'node:path'; -import { type PipelineConfig, runPipeline } from '@wasmagent/agentbom-core/pipeline'; +import { resolve } from "node:path"; +import { + type PipelineConfig, + runPipeline, +} from "@wasmagent/agentbom-core/pipeline"; /** * `agentbom pipeline [--partitions N] [--no-incremental]` @@ -10,8 +13,10 @@ import { type PipelineConfig, runPipeline } from '@wasmagent/agentbom-core/pipel */ export async function agentbomPipelineCommand(args: string[]): Promise { if (args.length < 1) { - console.error('Error: agentbom pipeline requires a argument'); - console.error('Usage: agentbom pipeline [--partitions N] [--no-incremental]'); + console.error("Error: agentbom pipeline requires a argument"); + console.error( + "Usage: agentbom pipeline [--partitions N] [--no-incremental]", + ); return 1; } @@ -22,15 +27,17 @@ export async function agentbomPipelineCommand(args: string[]): Promise { let i = 1; while (i < args.length) { const flag = args[i]; - if (flag === '--partitions' && args[i + 1] !== undefined) { + if (flag === "--partitions" && args[i + 1] !== undefined) { const n = Number.parseInt(args[i + 1], 10); if (Number.isNaN(n) || n < 1) { - console.error(`Error: --partitions must be a positive integer, got "${args[i + 1]}"`); + console.error( + `Error: --partitions must be a positive integer, got "${args[i + 1]}"`, + ); return 1; } config.partitionCount = n; i += 2; - } else if (flag === '--no-incremental') { + } else if (flag === "--no-incremental") { config.emitIncremental = false; i += 1; } else { @@ -40,8 +47,8 @@ export async function agentbomPipelineCommand(args: string[]): Promise { } const partLabel = config.partitionCount - ? ` (${config.partitionCount} partition${config.partitionCount > 1 ? 's' : ''})` - : ''; + ? ` (${config.partitionCount} partition${config.partitionCount > 1 ? "s" : ""})` + : ""; console.log(`Processing BOM artifacts from: ${filePath}${partLabel}`); const wallStart = Date.now(); @@ -50,7 +57,7 @@ export async function agentbomPipelineCommand(args: string[]): Promise { // Per-artifact results for (const result of results) { - const status = result.valid ? '✓' : '✗'; + const status = result.valid ? "✓" : "✗"; console.log( ` ${status} ${result.artifactId} — ${result.durationMs.toFixed(1)}ms — ${result.sizeBytes} bytes`, ); @@ -59,22 +66,26 @@ export async function agentbomPipelineCommand(args: string[]): Promise { console.log(` ${err}`); } if (result.validation.errors.length > 5) { - console.log(` ... and ${result.validation.errors.length - 5} more errors`); + console.log( + ` ... and ${result.validation.errors.length - 5} more errors`, + ); } } } // Summary console.log(); - console.log('Pipeline summary:'); + console.log("Pipeline summary:"); console.log(` Artifacts: ${metrics.totalProcessed}`); console.log(` Errors: ${metrics.totalErrors}`); console.log(` Bytes: ${metrics.totalBytesProcessed}`); - console.log(` Duration: ${metrics.durationMs.toFixed(1)}ms (wall: ${wallMs}ms)`); + console.log( + ` Duration: ${metrics.durationMs.toFixed(1)}ms (wall: ${wallMs}ms)`, + ); console.log(` Peak heap: ${metrics.peakMemoryBytes} bytes`); if (config.partitionCount && config.partitionCount > 1) { - console.log(' Partition counts:'); + console.log(" Partition counts:"); for (const [p, count] of metrics.partitionCounts) { console.log(` Partition ${p}: ${count}`); } diff --git a/packages/agentbom-cli/src/audit-report.test.ts b/packages/agentbom-cli/src/audit-report.test.ts index 3311707..1304209 100644 --- a/packages/agentbom-cli/src/audit-report.test.ts +++ b/packages/agentbom-cli/src/audit-report.test.ts @@ -1,222 +1,253 @@ -import { describe, expect, it } from 'bun:test'; -import { generateAuditReport, generateMultiAgentAuditReport } from './audit-report.js'; +import { describe, expect, it } from "bun:test"; +import { + generateAuditReport, + generateMultiAgentAuditReport, +} from "./audit-report.js"; // --- Minimal valid AgentBOM fixture --- const baseBOM = { - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Agent Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Agent Alpha", + generated_at: "2026-01-15T10:00:00Z", }, }; const baseBOMv2 = { - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Agent Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Agent Beta", + generated_at: "2026-01-15T10:05:00Z", }, }; -describe('generateAuditReport', () => { - it('returns a report header for a minimal BOM', () => { +describe("generateAuditReport", () => { + it("returns a report header for a minimal BOM", () => { const report = generateAuditReport(baseBOM as never); - expect(report).toContain('AGENT TRUST AUDIT REPORT'); - expect(report).toContain('Agent Alpha'); - expect(report).toContain('agent-001'); + expect(report).toContain("AGENT TRUST AUDIT REPORT"); + expect(report).toContain("Agent Alpha"); + expect(report).toContain("agent-001"); }); - it('includes audit summary statistics', () => { + it("includes audit summary statistics", () => { const bom = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:00:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success" as const, }, { - timestamp: '2026-01-15T10:01:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'failure' as const, + timestamp: "2026-01-15T10:01:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "failure" as const, }, ], }; const report = generateAuditReport(bom as never); - expect(report).toContain('Total Audit Events: 2'); - expect(report).toContain('Successful Events: 1'); - expect(report).toContain('Failed Events: 1'); + expect(report).toContain("Total Audit Events: 2"); + expect(report).toContain("Successful Events: 1"); + expect(report).toContain("Failed Events: 1"); }); - it('includes risk summary by severity', () => { + it("includes risk summary by severity", () => { const bom = { ...baseBOM, risk_layer: [ - { risk_id: 'r1', severity: 'high', category: 'perm', description: 'test', status: 'open' }, { - risk_id: 'r2', - severity: 'critical', - category: 'perm', - description: 'test', - status: 'open', + risk_id: "r1", + severity: "high", + category: "perm", + description: "test", + status: "open", }, { - risk_id: 'r3', - severity: 'low', - category: 'info', - description: 'test', - status: 'mitigated', + risk_id: "r2", + severity: "critical", + category: "perm", + description: "test", + status: "open", + }, + { + risk_id: "r3", + severity: "low", + category: "info", + description: "test", + status: "mitigated", }, ], }; const report = generateAuditReport(bom as never); - expect(report).toContain('Critical: 1'); - expect(report).toContain('High: 1'); - expect(report).toContain('Low: 1'); - expect(report).toContain('Open Risk Findings: 2'); + expect(report).toContain("Critical: 1"); + expect(report).toContain("High: 1"); + expect(report).toContain("Low: 1"); + expect(report).toContain("Open Risk Findings: 2"); }); - it('includes evidence citations', () => { + it("includes evidence citations", () => { const bom = { ...baseBOM, evidence_layer: { - aep_references: ['aep://event/123'], - evidence_hashes: [{ type: 'sha256', hash: 'abc123', timestamp: '2026-01-15T10:00:00Z' }], + aep_references: ["aep://event/123"], + evidence_hashes: [ + { type: "sha256", hash: "abc123", timestamp: "2026-01-15T10:00:00Z" }, + ], }, }; const report = generateAuditReport(bom as never); - expect(report).toContain('EVIDENCE CITATIONS'); - expect(report).toContain('aep://event/123'); - expect(report).toContain('sha256: abc123'); + expect(report).toContain("EVIDENCE CITATIONS"); + expect(report).toContain("aep://event/123"); + expect(report).toContain("sha256: abc123"); }); - it('shows no audit trail when empty', () => { + it("shows no audit trail when empty", () => { const report = generateAuditReport(baseBOM as never); - expect(report).toContain('No audit log entries found.'); + expect(report).toContain("No audit log entries found."); }); }); -describe('generateMultiAgentAuditReport', () => { - it('returns multi-agent header with agent count', () => { - const report = generateMultiAgentAuditReport([baseBOM as never, baseBOMv2 as never]); - expect(report).toContain('MULTI-AGENT TRUST AUDIT REPORT'); - expect(report).toContain('Agents in Scope: 2'); +describe("generateMultiAgentAuditReport", () => { + it("returns multi-agent header with agent count", () => { + const report = generateMultiAgentAuditReport([ + baseBOM as never, + baseBOMv2 as never, + ]); + expect(report).toContain("MULTI-AGENT TRUST AUDIT REPORT"); + expect(report).toContain("Agents in Scope: 2"); }); - it('includes agent roster with peer counts', () => { + it("includes agent roster with peer counts", () => { const bom1 = { ...baseBOM, agent_collaboration: { - peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + peer_agents: [{ agent_id: "agent-002", role: "delegate" }], shared_resources: [ - { resource_id: 'db-1', resource_type: 'datastore', access_pattern: 'read_write' }, + { + resource_id: "db-1", + resource_type: "datastore", + access_pattern: "read_write", + }, ], }, }; - const report = generateMultiAgentAuditReport([bom1 as never, baseBOMv2 as never]); - expect(report).toContain('Agent Alpha'); - expect(report).toContain('Agent Beta'); - expect(report).toContain('AGENT ROSTER'); - expect(report).toContain('Peers: 1'); - expect(report).toContain('Shared Resources: 1'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + baseBOMv2 as never, + ]); + expect(report).toContain("Agent Alpha"); + expect(report).toContain("Agent Beta"); + expect(report).toContain("AGENT ROSTER"); + expect(report).toContain("Peers: 1"); + expect(report).toContain("Shared Resources: 1"); }); - it('includes collaboration topology summary', () => { + it("includes collaboration topology summary", () => { const bom1 = { ...baseBOM, agent_collaboration: { peer_agents: [ - { agent_id: 'agent-002', role: 'delegate' }, - { agent_id: 'agent-003', role: 'peer' }, + { agent_id: "agent-002", role: "delegate" }, + { agent_id: "agent-003", role: "peer" }, ], delegation_boundaries: [ { - boundary_id: 'bnd-1', - direction: 'outbound', - constraint_type: 'tool_delegation', - target_agents: ['agent-002'], + boundary_id: "bnd-1", + direction: "outbound", + constraint_type: "tool_delegation", + target_agents: ["agent-002"], max_delegation_depth: 2, }, ], shared_resources: [ { - resource_id: 'db-1', - resource_type: 'datastore', - access_pattern: 'read_write', - accessing_agents: ['agent-001', 'agent-002'], + resource_id: "db-1", + resource_type: "datastore", + access_pattern: "read_write", + accessing_agents: ["agent-001", "agent-002"], }, ], }, }; - const report = generateMultiAgentAuditReport([bom1 as never, baseBOMv2 as never]); - expect(report).toContain('COLLABORATION TOPOLOGY'); - expect(report).toContain('Unique Peer Agents: 2'); - expect(report).toContain('Shared Resources: 1'); - expect(report).toContain('Delegation Boundaries: 1'); - expect(report).toContain('bnd-1'); - expect(report).toContain('outbound'); - expect(report).toContain('tool_delegation'); - expect(report).toContain('Max delegation depth: 2'); - expect(report).toContain('db-1'); - expect(report).toContain('datastore'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + baseBOMv2 as never, + ]); + expect(report).toContain("COLLABORATION TOPOLOGY"); + expect(report).toContain("Unique Peer Agents: 2"); + expect(report).toContain("Shared Resources: 1"); + expect(report).toContain("Delegation Boundaries: 1"); + expect(report).toContain("bnd-1"); + expect(report).toContain("outbound"); + expect(report).toContain("tool_delegation"); + expect(report).toContain("Max delegation depth: 2"); + expect(report).toContain("db-1"); + expect(report).toContain("datastore"); }); - it('reconstructs causal chains from delegation events', () => { + it("reconstructs causal chains from delegation events", () => { const bom1 = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'delegation', - actor: 'agent-001', - outcome: 'success', - details: { delegated_to: 'agent-002' }, + timestamp: "2026-01-15T10:00:00Z", + event_type: "delegation", + actor: "agent-001", + outcome: "success", + details: { delegated_to: "agent-002" }, }, ], agent_collaboration: { - peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + peer_agents: [{ agent_id: "agent-002", role: "delegate" }], }, }; const bom2 = { ...baseBOMv2, audit_log: [ { - timestamp: '2026-01-15T10:00:05Z', - event_type: 'tool_call', - actor: 'agent-002', - resource: 'tool-search', - outcome: 'success', + timestamp: "2026-01-15T10:00:05Z", + event_type: "tool_call", + actor: "agent-002", + resource: "tool-search", + outcome: "success", }, ], }; - const report = generateMultiAgentAuditReport([bom1 as never, bom2 as never]); - expect(report).toContain('CAUSAL CHAIN ANALYSIS'); - expect(report).toContain('Reconstructed Causal Chains: 1'); - expect(report).toContain('chain-1'); - expect(report).toContain('Agent Alpha'); - expect(report).toContain('Agent Beta'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + bom2 as never, + ]); + expect(report).toContain("CAUSAL CHAIN ANALYSIS"); + expect(report).toContain("Reconstructed Causal Chains: 1"); + expect(report).toContain("chain-1"); + expect(report).toContain("Agent Alpha"); + expect(report).toContain("Agent Beta"); }); - it('detects causal links via shared resource access', () => { + it("detects causal links via shared resource access", () => { const bom1 = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'data_read', - actor: 'agent-001', - resource: 'shared-db', - outcome: 'success', + timestamp: "2026-01-15T10:00:00Z", + event_type: "data_read", + actor: "agent-001", + resource: "shared-db", + outcome: "success", }, ], agent_collaboration: { shared_resources: [ - { resource_id: 'shared-db', resource_type: 'datastore', access_pattern: 'read_write' }, + { + resource_id: "shared-db", + resource_type: "datastore", + access_pattern: "read_write", + }, ], }, }; @@ -224,59 +255,65 @@ describe('generateMultiAgentAuditReport', () => { ...baseBOMv2, audit_log: [ { - timestamp: '2026-01-15T10:00:03Z', - event_type: 'data_write', - actor: 'agent-002', - resource: 'shared-db', - outcome: 'success', + timestamp: "2026-01-15T10:00:03Z", + event_type: "data_write", + actor: "agent-002", + resource: "shared-db", + outcome: "success", }, ], }; - const report = generateMultiAgentAuditReport([bom1 as never, bom2 as never]); - expect(report).toContain('CAUSAL CHAIN ANALYSIS'); - expect(report).toContain('Reconstructed Causal Chains: 1'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + bom2 as never, + ]); + expect(report).toContain("CAUSAL CHAIN ANALYSIS"); + expect(report).toContain("Reconstructed Causal Chains: 1"); }); - it('detects causal links via actor matching previous agent', () => { + it("detects causal links via actor matching previous agent", () => { const bom1 = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'delegation', - actor: 'agent-001', - outcome: 'success', + timestamp: "2026-01-15T10:00:00Z", + event_type: "delegation", + actor: "agent-001", + outcome: "success", }, ], agent_collaboration: { - peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + peer_agents: [{ agent_id: "agent-002", role: "delegate" }], }, }; const bom2 = { ...baseBOMv2, audit_log: [ { - timestamp: '2026-01-15T10:00:02Z', - event_type: 'tool_call', - actor: 'agent-001', - resource: 'tool-calc', - outcome: 'success', + timestamp: "2026-01-15T10:00:02Z", + event_type: "tool_call", + actor: "agent-001", + resource: "tool-calc", + outcome: "success", }, ], }; - const report = generateMultiAgentAuditReport([bom1 as never, bom2 as never]); - expect(report).toContain('Reconstructed Causal Chains: 1'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + bom2 as never, + ]); + expect(report).toContain("Reconstructed Causal Chains: 1"); }); - it('shows no causal chains when agents are independent', () => { + it("shows no causal chains when agents are independent", () => { const bom1 = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success', + timestamp: "2026-01-15T10:00:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success", }, ], }; @@ -284,114 +321,126 @@ describe('generateMultiAgentAuditReport', () => { ...baseBOMv2, audit_log: [ { - timestamp: '2026-01-15T11:00:00Z', - event_type: 'tool_call', - actor: 'agent-002', - outcome: 'success', + timestamp: "2026-01-15T11:00:00Z", + event_type: "tool_call", + actor: "agent-002", + outcome: "success", }, ], }; - const report = generateMultiAgentAuditReport([bom1 as never, bom2 as never]); - expect(report).toContain('No cross-agent causal chains detected.'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + bom2 as never, + ]); + expect(report).toContain("No cross-agent causal chains detected."); }); - it('combines audit statistics across agents', () => { + it("combines audit statistics across agents", () => { const bom1 = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:00:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success" as const, }, { - timestamp: '2026-01-15T10:01:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'failure' as const, + timestamp: "2026-01-15T10:01:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "failure" as const, }, ], risk_layer: [ - { risk_id: 'r1', severity: 'high', category: 'perm', description: 'test', status: 'open' }, + { + risk_id: "r1", + severity: "high", + category: "perm", + description: "test", + status: "open", + }, ], }; const bom2 = { ...baseBOMv2, audit_log: [ { - timestamp: '2026-01-15T10:05:00Z', - event_type: 'data_access', - actor: 'agent-002', - outcome: 'success' as const, + timestamp: "2026-01-15T10:05:00Z", + event_type: "data_access", + actor: "agent-002", + outcome: "success" as const, }, ], risk_layer: [ { - risk_id: 'r2', - severity: 'critical', - category: 'data', - description: 'test', - status: 'open', + risk_id: "r2", + severity: "critical", + category: "data", + description: "test", + status: "open", }, ], }; - const report = generateMultiAgentAuditReport([bom1 as never, bom2 as never]); - expect(report).toContain('COMBINED AUDIT SUMMARY'); - expect(report).toContain('Total Audit Events: 3'); - expect(report).toContain('Successful Events: 2'); - expect(report).toContain('Failed Events: 1'); - expect(report).toContain('Unique Event Types: 2'); - expect(report).toContain('Events by Type:'); - expect(report).toContain('tool_call: 2'); - expect(report).toContain('data_access: 1'); - expect(report).toContain('Open Risk Findings: 2'); - expect(report).toContain('Risk per Agent:'); - expect(report).toContain('Agent Alpha: 1 total, 1 open, 0 critical'); - expect(report).toContain('Agent Beta: 1 total, 1 open, 1 critical'); + const report = generateMultiAgentAuditReport([ + bom1 as never, + bom2 as never, + ]); + expect(report).toContain("COMBINED AUDIT SUMMARY"); + expect(report).toContain("Total Audit Events: 3"); + expect(report).toContain("Successful Events: 2"); + expect(report).toContain("Failed Events: 1"); + expect(report).toContain("Unique Event Types: 2"); + expect(report).toContain("Events by Type:"); + expect(report).toContain("tool_call: 2"); + expect(report).toContain("data_access: 1"); + expect(report).toContain("Open Risk Findings: 2"); + expect(report).toContain("Risk per Agent:"); + expect(report).toContain("Agent Alpha: 1 total, 1 open, 0 critical"); + expect(report).toContain("Agent Beta: 1 total, 1 open, 1 critical"); }); - it('works with a single agent (graceful degradation)', () => { + it("works with a single agent (graceful degradation)", () => { const report = generateMultiAgentAuditReport([baseBOM as never]); - expect(report).toContain('MULTI-AGENT TRUST AUDIT REPORT'); - expect(report).toContain('Agents in Scope: 1'); - expect(report).toContain('No cross-agent causal chains detected.'); + expect(report).toContain("MULTI-AGENT TRUST AUDIT REPORT"); + expect(report).toContain("Agents in Scope: 1"); + expect(report).toContain("No cross-agent causal chains detected."); }); - it('includes per-event-type breakdown sorted by count', () => { + it("includes per-event-type breakdown sorted by count", () => { const bom = { ...baseBOM, audit_log: [ { - timestamp: '2026-01-15T10:00:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:00:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success" as const, }, { - timestamp: '2026-01-15T10:01:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:01:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success" as const, }, { - timestamp: '2026-01-15T10:02:00Z', - event_type: 'tool_call', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:02:00Z", + event_type: "tool_call", + actor: "agent-001", + outcome: "success" as const, }, { - timestamp: '2026-01-15T10:03:00Z', - event_type: 'data_access', - actor: 'agent-001', - outcome: 'success' as const, + timestamp: "2026-01-15T10:03:00Z", + event_type: "data_access", + actor: "agent-001", + outcome: "success" as const, }, ], }; const report = generateMultiAgentAuditReport([bom as never]); // tool_call (3) should appear before data_access (1) - const toolIdx = report.indexOf('tool_call: 3'); - const dataIdx = report.indexOf('data_access: 1'); + const toolIdx = report.indexOf("tool_call: 3"); + const dataIdx = report.indexOf("data_access: 1"); expect(toolIdx).toBeGreaterThan(-1); expect(dataIdx).toBeGreaterThan(-1); expect(toolIdx).toBeLessThan(dataIdx); diff --git a/packages/agentbom-cli/src/audit-report.ts b/packages/agentbom-cli/src/audit-report.ts index c25ca24..6be5a2c 100644 --- a/packages/agentbom-cli/src/audit-report.ts +++ b/packages/agentbom-cli/src/audit-report.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { validateAgentBOM } from '@wasmagent/agentbom-core'; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateAgentBOM } from "@wasmagent/agentbom-core"; /** Audit log entry structure */ interface AuditLogEntry { @@ -9,7 +9,7 @@ interface AuditLogEntry { event_type: string; actor: string; resource?: string; - outcome?: 'success' | 'failure' | 'partial'; + outcome?: "success" | "failure" | "partial"; details?: Record; } @@ -87,18 +87,22 @@ export function generateAuditReport(bom: AgentBOM): string { const lines: string[] = []; // Header - lines.push('════════════════════════════════════════════════════════════════════════════════'); - lines.push(' AGENT TRUST AUDIT REPORT'); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); + lines.push(" AGENT TRUST AUDIT REPORT"); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); // Agent Identity const identity = bom.identity; if (identity) { - lines.push(''); - lines.push('Agent Identity:'); - lines.push(` Agent ID: ${identity.agent_id ?? 'unknown'}`); - lines.push(` Agent Name: ${identity.agent_name ?? 'unknown'}`); - lines.push(` Generated At: ${identity.generated_at ?? 'unknown'}`); + lines.push(""); + lines.push("Agent Identity:"); + lines.push(` Agent ID: ${identity.agent_id ?? "unknown"}`); + lines.push(` Agent Name: ${identity.agent_name ?? "unknown"}`); + lines.push(` Generated At: ${identity.generated_at ?? "unknown"}`); } // Audit Summary Statistics @@ -106,13 +110,13 @@ export function generateAuditReport(bom: AgentBOM): string { const evidenceLayer = bom.evidence_layer; const riskLayer = bom.risk_layer ?? []; - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' AUDIT SUMMARY'); + lines.push(" AUDIT SUMMARY"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); // Event statistics by outcome @@ -125,9 +129,9 @@ export function generateAuditReport(bom: AgentBOM): string { for (const log of auditLogs) { const outcome = log.outcome; - if (outcome === 'success') outcomeStats.success++; - else if (outcome === 'failure') outcomeStats.failure++; - else if (outcome === 'partial') outcomeStats.partial++; + if (outcome === "success") outcomeStats.success++; + else if (outcome === "failure") outcomeStats.failure++; + else if (outcome === "partial") outcomeStats.partial++; else outcomeStats.unknown++; } @@ -135,14 +139,20 @@ export function generateAuditReport(bom: AgentBOM): string { lines.push(` Successful Events: ${outcomeStats.success}`); lines.push(` Failed Events: ${outcomeStats.failure}`); lines.push(` Partial Success Events: ${outcomeStats.partial}`); - lines.push(` Evidence Hashes: ${evidenceLayer?.evidence_hashes?.length ?? 0}`); - lines.push(` AEP References: ${evidenceLayer?.aep_references?.length ?? 0}`); - lines.push(` Open Risk Findings: ${riskLayer.filter((r) => r.status === 'open').length}`); + lines.push( + ` Evidence Hashes: ${evidenceLayer?.evidence_hashes?.length ?? 0}`, + ); + lines.push( + ` AEP References: ${evidenceLayer?.aep_references?.length ?? 0}`, + ); + lines.push( + ` Open Risk Findings: ${riskLayer.filter((r) => r.status === "open").length}`, + ); // Risk Summary if (riskLayer.length > 0) { - lines.push(''); - lines.push('Risk Summary:'); + lines.push(""); + lines.push("Risk Summary:"); const riskBySeverity = { critical: 0, high: 0, @@ -152,7 +162,8 @@ export function generateAuditReport(bom: AgentBOM): string { }; for (const risk of riskLayer) { - const severity = risk.severity.toLowerCase() as keyof typeof riskBySeverity; + const severity = + risk.severity.toLowerCase() as keyof typeof riskBySeverity; if (severity in riskBySeverity) { riskBySeverity[severity]++; } @@ -167,56 +178,64 @@ export function generateAuditReport(bom: AgentBOM): string { // Evidence Citations if (evidenceLayer) { - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' EVIDENCE CITATIONS'); + lines.push(" EVIDENCE CITATIONS"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - if (evidenceLayer.evidence_hashes && evidenceLayer.evidence_hashes.length > 0) { - lines.push(''); - lines.push('Evidence Hashes:'); + if ( + evidenceLayer.evidence_hashes && + evidenceLayer.evidence_hashes.length > 0 + ) { + lines.push(""); + lines.push("Evidence Hashes:"); for (const evidence of evidenceLayer.evidence_hashes) { - const timestamp = evidence.timestamp ?? 'unknown'; + const timestamp = evidence.timestamp ?? "unknown"; lines.push(` [${timestamp}] ${evidence.type}: ${evidence.hash}`); } } - if (evidenceLayer.aep_references && evidenceLayer.aep_references.length > 0) { - lines.push(''); - lines.push('AEP Event References:'); + if ( + evidenceLayer.aep_references && + evidenceLayer.aep_references.length > 0 + ) { + lines.push(""); + lines.push("AEP Event References:"); for (const ref of evidenceLayer.aep_references) { lines.push(` → ${ref}`); } } if ( - (!evidenceLayer.evidence_hashes || evidenceLayer.evidence_hashes.length === 0) && - (!evidenceLayer.aep_references || evidenceLayer.aep_references.length === 0) + (!evidenceLayer.evidence_hashes || + evidenceLayer.evidence_hashes.length === 0) && + (!evidenceLayer.aep_references || + evidenceLayer.aep_references.length === 0) ) { - lines.push(''); - lines.push(' No evidence citations found.'); + lines.push(""); + lines.push(" No evidence citations found."); } } // Audit Log Entries if (auditLogs.length > 0) { - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' AUDIT TRAIL'); + lines.push(" AUDIT TRAIL"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); // Group by event type for better readability const logsByEventType = new Map(); for (const log of auditLogs) { - const eventType = log.event_type ?? 'unknown'; + const eventType = log.event_type ?? "unknown"; if (!logsByEventType.has(eventType)) { logsByEventType.set(eventType, []); } @@ -229,20 +248,22 @@ export function generateAuditReport(bom: AgentBOM): string { for (const eventType of sortedEventTypes) { const events = logsByEventType.get(eventType) ?? []; - lines.push(''); + lines.push(""); lines.push(`${eventType} (${events.length} events):`); // Sort events by timestamp const sortedEvents = events.sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), ); for (const event of sortedEvents) { const timestamp = event.timestamp; const actor = event.actor; - const resource = event.resource ?? 'N/A'; - const outcome = event.outcome ?? 'unknown'; - const outcomeSymbol = outcome === 'success' ? '✓' : outcome === 'failure' ? '✗' : '○'; + const resource = event.resource ?? "N/A"; + const outcome = event.outcome ?? "unknown"; + const outcomeSymbol = + outcome === "success" ? "✓" : outcome === "failure" ? "✗" : "○"; lines.push(` ${outcomeSymbol} [${timestamp}]`); lines.push(` Actor: ${actor}`); @@ -255,25 +276,29 @@ export function generateAuditReport(bom: AgentBOM): string { } } } else { - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' AUDIT TRAIL'); + lines.push(" AUDIT TRAIL"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(''); - lines.push(' No audit log entries found.'); + lines.push(""); + lines.push(" No audit log entries found."); } // Footer - lines.push(''); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(""); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); lines.push(`Report Generated: ${new Date().toISOString()}`); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); - return lines.join('\n'); + return lines.join("\n"); } // --------------------------------------------------------------------------- @@ -360,16 +385,22 @@ function reconstructCausalChains(boms: AgentBOM[]): CausalChain[] { } const allEntries: TaggedEntry[] = []; for (const bom of boms) { - const aid = bom.identity?.agent_id ?? 'unknown'; + const aid = bom.identity?.agent_id ?? "unknown"; const aname = bom.identity?.agent_name ?? aid; for (const entry of bom.audit_log ?? []) { - allEntries.push({ entry, source_agent_id: aid, source_agent_name: aname }); + allEntries.push({ + entry, + source_agent_id: aid, + source_agent_name: aname, + }); } } // Sort all entries chronologically allEntries.sort( - (a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime(), + (a, b) => + new Date(a.entry.timestamp).getTime() - + new Date(b.entry.timestamp).getTime(), ); // Build causal chains by following delegation / shared-resource links @@ -469,7 +500,8 @@ function isCausallyLinked( for (const peer of peers) { if (peer.agent_id === candidate.source_agent_id) { // Peer relationship + temporal proximity (within 60s) - const timeDiff = new Date(ce.timestamp).getTime() - new Date(pe.timestamp).getTime(); + const timeDiff = + new Date(ce.timestamp).getTime() - new Date(pe.timestamp).getTime(); if (timeDiff >= 0 && timeDiff <= 60_000) { return true; } @@ -484,8 +516,11 @@ function isCausallyLinked( * Build a human-readable summary string for a causal chain. * Example: "Agent A delegated to Agent B which accessed tool C" */ -function buildChainSummary(steps: CausalStep[], nameLookup: Map): string { - if (steps.length === 0) return ''; +function buildChainSummary( + steps: CausalStep[], + nameLookup: Map, +): string { + if (steps.length === 0) return ""; const parts: string[] = []; for (let i = 0; i < steps.length; i++) { @@ -496,7 +531,7 @@ function buildChainSummary(steps: CausalStep[], nameLookup: Map) parts.push(name); } else { const prevStep = steps[i - 1]; - if (step.event_type.includes('delegat')) { + if (step.event_type.includes("delegat")) { parts.push(`delegated to ${name}`); } else if (step.resource) { parts.push(`${name} accessed ${step.resource}`); @@ -506,7 +541,7 @@ function buildChainSummary(steps: CausalStep[], nameLookup: Map) } } - return parts.join(' which '); + return parts.join(" which "); } /** @@ -516,25 +551,29 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { const lines: string[] = []; // Header - lines.push('════════════════════════════════════════════════════════════════════════════════'); - lines.push(' MULTI-AGENT TRUST AUDIT REPORT'); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); + lines.push(" MULTI-AGENT TRUST AUDIT REPORT"); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); lines.push(`Report Generated: ${new Date().toISOString()}`); lines.push(`Agents in Scope: ${boms.length}`); // Agent roster - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' AGENT ROSTER'); + lines.push(" AGENT ROSTER"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); for (const bom of boms) { - const id = bom.identity?.agent_id ?? 'unknown'; - const name = bom.identity?.agent_name ?? 'unnamed'; + const id = bom.identity?.agent_id ?? "unknown"; + const name = bom.identity?.agent_name ?? "unnamed"; const peers = bom.agent_collaboration?.peer_agents?.length ?? 0; const sharedRes = bom.agent_collaboration?.shared_resources?.length ?? 0; lines.push(` ${name} (${id})`); @@ -559,13 +598,13 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { } } - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' COLLABORATION TOPOLOGY'); + lines.push(" COLLABORATION TOPOLOGY"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); lines.push(` Unique Peer Agents: ${totalPeers.size}`); lines.push(` Shared Resources: ${totalShared.size}`); @@ -573,13 +612,15 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { // Delegation boundary details if (totalBoundaries.length > 0) { - lines.push(''); - lines.push(' Delegation Boundaries:'); + lines.push(""); + lines.push(" Delegation Boundaries:"); for (const b of totalBoundaries) { const targets = b.target_agents?.length - ? ` → [${b.target_agents.join(', ')}]` - : ' → [all peers]'; - lines.push(` ${b.boundary_id} (${b.direction}, ${b.constraint_type})${targets}`); + ? ` → [${b.target_agents.join(", ")}]` + : " → [all peers]"; + lines.push( + ` ${b.boundary_id} (${b.direction}, ${b.constraint_type})${targets}`, + ); if (b.description) lines.push(` ${b.description}`); if (b.max_delegation_depth !== undefined) { lines.push(` Max delegation depth: ${b.max_delegation_depth}`); @@ -589,17 +630,20 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { // Shared resource details if (totalShared.size > 0) { - lines.push(''); - lines.push(' Shared Resources:'); + lines.push(""); + lines.push(" Shared Resources:"); const seen = new Set(); for (const bom of boms) { for (const sr of bom.agent_collaboration?.shared_resources ?? []) { if (seen.has(sr.resource_id)) continue; seen.add(sr.resource_id); - const agents = sr.accessing_agents?.join(', ') ?? 'unspecified'; - lines.push(` ${sr.resource_id} (${sr.resource_type}, ${sr.access_pattern})`); + const agents = sr.accessing_agents?.join(", ") ?? "unspecified"; + lines.push( + ` ${sr.resource_id} (${sr.resource_type}, ${sr.access_pattern})`, + ); lines.push(` Accessed by: ${agents}`); - if (sr.isolation_level) lines.push(` Isolation: ${sr.isolation_level}`); + if (sr.isolation_level) + lines.push(` Isolation: ${sr.isolation_level}`); } } } @@ -607,43 +651,46 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { // Cross-agent causal chains const chains = reconstructCausalChains(boms); - lines.push(''); + lines.push(""); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' CAUSAL CHAIN ANALYSIS'); + lines.push(" CAUSAL CHAIN ANALYSIS"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); if (chains.length === 0) { - lines.push(''); - lines.push(' No cross-agent causal chains detected.'); + lines.push(""); + lines.push(" No cross-agent causal chains detected."); } else { lines.push(` Reconstructed Causal Chains: ${chains.length}`); - lines.push(''); + lines.push(""); for (const chain of chains) { lines.push(` ┌─ ${chain.chain_id}: ${chain.summary}`); for (const step of chain.steps) { const name = step.agent_name; - const outcome = step.outcome ?? 'unknown'; - const symbol = outcome === 'success' ? '✓' : outcome === 'failure' ? '✗' : '○'; - const resource = step.resource ? ` → ${step.resource}` : ''; - lines.push(` │ ${symbol} [${step.timestamp}] ${name}: ${step.event_type}${resource}`); + const outcome = step.outcome ?? "unknown"; + const symbol = + outcome === "success" ? "✓" : outcome === "failure" ? "✗" : "○"; + const resource = step.resource ? ` → ${step.resource}` : ""; + lines.push( + ` │ ${symbol} [${step.timestamp}] ${name}: ${step.event_type}${resource}`, + ); } - lines.push(` └${'─'.repeat(Math.max(2, chain.summary.length + 12))}`); - lines.push(''); + lines.push(` └${"─".repeat(Math.max(2, chain.summary.length + 12))}`); + lines.push(""); } } // Combined audit summary statistics lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); - lines.push(' COMBINED AUDIT SUMMARY'); + lines.push(" COMBINED AUDIT SUMMARY"); lines.push( - '────────────────────────────────────────────────────────────────────────────────────', + "────────────────────────────────────────────────────────────────────────────────────", ); const combinedStats = { success: 0, failure: 0, partial: 0, unknown: 0 }; @@ -654,12 +701,15 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { for (const bom of boms) { for (const log of bom.audit_log ?? []) { const outcome = log.outcome; - if (outcome === 'success') combinedStats.success++; - else if (outcome === 'failure') combinedStats.failure++; - else if (outcome === 'partial') combinedStats.partial++; + if (outcome === "success") combinedStats.success++; + else if (outcome === "failure") combinedStats.failure++; + else if (outcome === "partial") combinedStats.partial++; else combinedStats.unknown++; - eventTypeCounts.set(log.event_type, (eventTypeCounts.get(log.event_type) ?? 0) + 1); + eventTypeCounts.set( + log.event_type, + (eventTypeCounts.get(log.event_type) ?? 0) + 1, + ); } for (const risk of bom.risk_layer ?? []) { totalRisks++; @@ -669,7 +719,10 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { } const totalEvents = - combinedStats.success + combinedStats.failure + combinedStats.partial + combinedStats.unknown; + combinedStats.success + + combinedStats.failure + + combinedStats.partial + + combinedStats.unknown; lines.push(` Total Audit Events: ${totalEvents}`); lines.push(` Successful Events: ${combinedStats.success}`); lines.push(` Failed Events: ${combinedStats.failure}`); @@ -680,9 +733,11 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { // Per-event-type breakdown if (eventTypeCounts.size > 0) { - lines.push(''); - lines.push(' Events by Type:'); - const sortedTypes = Array.from(eventTypeCounts.entries()).sort((a, b) => b[1] - a[1]); + lines.push(""); + lines.push(" Events by Type:"); + const sortedTypes = Array.from(eventTypeCounts.entries()).sort( + (a, b) => b[1] - a[1], + ); for (const [type, count] of sortedTypes) { lines.push(` ${type}: ${count}`); } @@ -690,8 +745,8 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { // Risk severity summary if (totalRisks > 0) { - lines.push(''); - lines.push(' Risk Severity Distribution:'); + lines.push(""); + lines.push(" Risk Severity Distribution:"); lines.push(` Critical: ${riskBySeverity.critical}`); lines.push(` High: ${riskBySeverity.high}`); lines.push(` Medium: ${riskBySeverity.medium}`); @@ -700,34 +755,45 @@ export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { } // Per-agent risk summary - lines.push(''); - lines.push(' Risk per Agent:'); + lines.push(""); + lines.push(" Risk per Agent:"); for (const bom of boms) { - const id = bom.identity?.agent_id ?? 'unknown'; - const name = bom.identity?.agent_name ?? 'unnamed'; + const id = bom.identity?.agent_id ?? "unknown"; + const name = bom.identity?.agent_name ?? "unnamed"; const risks = bom.risk_layer ?? []; - const open = risks.filter((r) => r.status === 'open').length; - const critical = risks.filter((r) => r.severity.toLowerCase() === 'critical').length; - lines.push(` ${name}: ${risks.length} total, ${open} open, ${critical} critical`); + const open = risks.filter((r) => r.status === "open").length; + const critical = risks.filter( + (r) => r.severity.toLowerCase() === "critical", + ).length; + lines.push( + ` ${name}: ${risks.length} total, ${open} open, ${critical} critical`, + ); } // Footer - lines.push(''); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(""); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); lines.push(`Report Generated: ${new Date().toISOString()}`); - lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push( + "════════════════════════════════════════════════════════════════════════════════", + ); - return lines.join('\n'); + return lines.join("\n"); } /** * Load and validate a single AgentBOM from a file path. */ -function loadAgentBOM(filePath: string): { bom: AgentBOM | null; error: string | null } { +function loadAgentBOM(filePath: string): { + bom: AgentBOM | null; + error: string | null; +} { const resolved = resolve(filePath); let content: string; try { - content = readFileSync(resolved, 'utf-8'); + content = readFileSync(resolved, "utf-8"); } catch { return { bom: null, error: `cannot read file "${resolved}"` }; } @@ -742,7 +808,10 @@ function loadAgentBOM(filePath: string): { bom: AgentBOM | null; error: string | const bom = data as AgentBOM; const validation = validateAgentBOM(bom); if (!validation.valid) { - return { bom: null, error: `Invalid AgentBOM: ${validation.errors.join('; ')}` }; + return { + bom: null, + error: `Invalid AgentBOM: ${validation.errors.join("; ")}`, + }; } return { bom, error: null }; @@ -754,22 +823,22 @@ function loadAgentBOM(filePath: string): { bom: AgentBOM | null; error: string | * Usage: agent-trust audit-report multi [bom2.json ...] */ export function multiAgentAuditReportCommand(args: string[]): number { - if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log( [ - 'Usage: agent-trust audit-report multi [bom2.json ...]', - '', - 'Generates a unified multi-agent audit report with causal chain reconstruction', - 'across a team of agents described by their AgentBOM files.', - '', - 'The report includes:', - ' - Agent roster with collaboration topology', - ' - Delegation boundary analysis', - ' - Shared resource access patterns', + "Usage: agent-trust audit-report multi [bom2.json ...]", + "", + "Generates a unified multi-agent audit report with causal chain reconstruction", + "across a team of agents described by their AgentBOM files.", + "", + "The report includes:", + " - Agent roster with collaboration topology", + " - Delegation boundary analysis", + " - Shared resource access patterns", ' - Causal chain reconstruction (e.g., "Agent A delegated to Agent B which accessed tool C")', - ' - Combined audit summary statistics', - ' - Per-agent risk breakdown', - ].join('\n'), + " - Combined audit summary statistics", + " - Per-agent risk breakdown", + ].join("\n"), ); return 0; } @@ -779,9 +848,9 @@ export function multiAgentAuditReportCommand(args: string[]): number { let dirPath: string | null = null; for (let i = 0; i < args.length; i++) { - if (args[i] === '--dir' && i + 1 < args.length) { + if (args[i] === "--dir" && i + 1 < args.length) { dirPath = args[++i]; - } else if (!args[i].startsWith('--')) { + } else if (!args[i].startsWith("--")) { filePaths.push(args[i]); } } @@ -796,7 +865,7 @@ export function multiAgentAuditReportCommand(args: string[]): number { return 1; } for (const entry of entries) { - if (entry.endsWith('.json')) { + if (entry.endsWith(".json")) { const fullPath = resolve(resolvedDir, entry); if (statSync(fullPath).isFile()) { filePaths.push(fullPath); @@ -807,7 +876,7 @@ export function multiAgentAuditReportCommand(args: string[]): number { if (filePaths.length === 0) { console.error( - 'Error: no AgentBOM files provided. Use: audit-report multi [bom2.json ...] or --dir ', + "Error: no AgentBOM files provided. Use: audit-report multi [bom2.json ...] or --dir ", ); return 1; } @@ -823,7 +892,7 @@ export function multiAgentAuditReportCommand(args: string[]): number { } if (boms.length === 0) { - console.error('Error: no valid AgentBOM files found'); + console.error("Error: no valid AgentBOM files found"); return 1; } @@ -834,43 +903,43 @@ export function multiAgentAuditReportCommand(args: string[]): number { export function auditReportCommand(args: string[]): number { // Dispatch to multi-agent subcommand - if (args.length > 0 && args[0] === 'multi') { + if (args.length > 0 && args[0] === "multi") { return multiAgentAuditReportCommand(args.slice(1)); } - if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log( [ - 'Usage: agent-trust audit-report ', - ' agent-trust audit-report multi [bom2.json ...]', - ' agent-trust audit-report multi --dir ', - '', - 'Generates a human-readable audit summary with evidence citations from an AgentBOM file.', + "Usage: agent-trust audit-report ", + " agent-trust audit-report multi [bom2.json ...]", + " agent-trust audit-report multi --dir ", + "", + "Generates a human-readable audit summary with evidence citations from an AgentBOM file.", 'Use "multi" subcommand for unified multi-agent audit reports with causal chain reconstruction.', - '', - 'Arguments:', - ' Path to the AgentBOM JSON file', - '', - 'Output includes:', - ' - Agent identity information', - ' - Audit summary statistics', - ' - Risk summary by severity', - ' - Evidence citations (hashes and AEP references)', - ' - Detailed audit trail grouped by event type', - ].join('\n'), + "", + "Arguments:", + " Path to the AgentBOM JSON file", + "", + "Output includes:", + " - Agent identity information", + " - Audit summary statistics", + " - Risk summary by severity", + " - Evidence citations (hashes and AEP references)", + " - Detailed audit trail grouped by event type", + ].join("\n"), ); return 0; } const bomPath = args[0]; try { - const content = readFileSync(resolve(bomPath), 'utf-8'); + const content = readFileSync(resolve(bomPath), "utf-8"); const data = JSON.parse(content) as AgentBOM; // Validate the AgentBOM const validation = validateAgentBOM(data); if (!validation.valid) { - console.error('Error: Invalid AgentBOM file:'); + console.error("Error: Invalid AgentBOM file:"); for (const error of validation.errors) { console.error(` ${error}`); } diff --git a/packages/agentbom-cli/src/bom-generate.ts b/packages/agentbom-cli/src/bom-generate.ts index 917ca22..7319ccc 100644 --- a/packages/agentbom-cli/src/bom-generate.ts +++ b/packages/agentbom-cli/src/bom-generate.ts @@ -1,6 +1,12 @@ -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { basename, join, resolve } from 'node:path'; -import { validateAgentBOM } from '@wasmagent/agentbom-core'; +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { validateAgentBOM } from "@wasmagent/agentbom-core"; interface GenerateOptions { agentPath: string; @@ -10,7 +16,7 @@ interface GenerateOptions { interface ToolDefinition { tool_id: string; tool_name: string; - source: 'mcp' | 'builtin' | 'plugin'; + source: "mcp" | "builtin" | "plugin"; mcp_server_id?: string; permissions: string[]; risk_signals: string[]; @@ -20,7 +26,7 @@ interface AgentInfo { agent_id: string; agent_name: string; agent_version?: string; - deployment_context?: 'development' | 'staging' | 'production'; + deployment_context?: "development" | "staging" | "production"; } interface ParsedGenerateArgs { @@ -28,7 +34,7 @@ interface ParsedGenerateArgs { outputPath?: string; } -const USAGE = 'Usage: agent-trust generate bom --agent [--out ]'; +const USAGE = "Usage: agent-trust generate bom --agent [--out ]"; function parseGenerateArgs(args: string[]): ParsedGenerateArgs | null { let agentPath: string | undefined; @@ -36,10 +42,10 @@ function parseGenerateArgs(args: string[]): ParsedGenerateArgs | null { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; - if (arg === '--agent') { + if (arg === "--agent") { agentPath = args[i + 1]; i += 1; - } else if (arg === '--out') { + } else if (arg === "--out") { outputPath = args[i + 1]; i += 1; } else { @@ -55,18 +61,19 @@ function parseGenerateArgs(args: string[]): ParsedGenerateArgs | null { * Extract basic agent information from package.json or directory name */ function extractAgentInfo(agentPath: string): AgentInfo { - const pkgPath = resolve(agentPath, 'package.json'); - let agentId = basename(agentPath).replace(/[^a-zA-Z0-9-]/g, '-'); + const pkgPath = resolve(agentPath, "package.json"); + let agentId = basename(agentPath).replace(/[^a-zA-Z0-9-]/g, "-"); let agentName = agentId; let agentVersion: string | undefined; - const deploymentContext: 'development' | 'staging' | 'production' = 'development'; + const deploymentContext: "development" | "staging" | "production" = + "development"; try { - const pkgContent = readFileSync(pkgPath, 'utf-8'); + const pkgContent = readFileSync(pkgPath, "utf-8"); const pkg = JSON.parse(pkgContent); if (pkg.name) { - agentName = pkg.name.replace(/^@[^/]+\//, ''); + agentName = pkg.name.replace(/^@[^/]+\//, ""); agentId = agentName; } if (pkg.version) { @@ -104,15 +111,15 @@ function scanAgentDirectory(agentPath: string): ToolDefinition[] { if (stat.isDirectory()) { // Check for MCP server patterns - const mcpConfigPath = join(fullPath, 'mcp.config.json'); + const mcpConfigPath = join(fullPath, "mcp.config.json"); try { - const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf-8')); + const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, "utf-8")); if (mcpConfig.tools && Array.isArray(mcpConfig.tools)) { for (const tool of mcpConfig.tools) { tools.push({ tool_id: `mcp-${entry}-${tool.name}`, tool_name: tool.name, - source: 'mcp', + source: "mcp", mcp_server_id: entry, permissions: tool.permissions || [], risk_signals: [], @@ -137,45 +144,45 @@ function scanAgentDirectory(agentPath: string): ToolDefinition[] { function generateStandardToolInventory(): ToolDefinition[] { return [ { - tool_id: 'file-read', - tool_name: 'Read', - source: 'builtin', - permissions: ['fs:read'], + tool_id: "file-read", + tool_name: "Read", + source: "builtin", + permissions: ["fs:read"], risk_signals: [], }, { - tool_id: 'file-write', - tool_name: 'Write', - source: 'builtin', - permissions: ['fs:write'], + tool_id: "file-write", + tool_name: "Write", + source: "builtin", + permissions: ["fs:write"], risk_signals: [], }, { - tool_id: 'file-edit', - tool_name: 'Edit', - source: 'builtin', - permissions: ['fs:read', 'fs:write'], + tool_id: "file-edit", + tool_name: "Edit", + source: "builtin", + permissions: ["fs:read", "fs:write"], risk_signals: [], }, { - tool_id: 'bash-exec', - tool_name: 'Bash', - source: 'builtin', - permissions: ['process:exec', 'fs:read', 'fs:write'], - risk_signals: ['command_execution'], + tool_id: "bash-exec", + tool_name: "Bash", + source: "builtin", + permissions: ["process:exec", "fs:read", "fs:write"], + risk_signals: ["command_execution"], }, { - tool_id: 'content-grep', - tool_name: 'Grep', - source: 'builtin', - permissions: ['fs:read'], + tool_id: "content-grep", + tool_name: "Grep", + source: "builtin", + permissions: ["fs:read"], risk_signals: [], }, { - tool_id: 'file-glob', - tool_name: 'Glob', - source: 'builtin', - permissions: ['fs:read'], + tool_id: "file-glob", + tool_name: "Glob", + source: "builtin", + permissions: ["fs:read"], risk_signals: [], }, ]; @@ -201,50 +208,53 @@ function extractPermissionScope(toolLayer: ToolDefinition[]): string[] { */ function generateRiskAssessment(toolLayer: ToolDefinition[]): Array<{ risk_id: string; - severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + severity: "critical" | "high" | "medium" | "low" | "info"; category: string; description: string; - status: 'open' | 'mitigated' | 'accepted'; + status: "open" | "mitigated" | "accepted"; }> { const risks: Array<{ risk_id: string; - severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + severity: "critical" | "high" | "medium" | "low" | "info"; category: string; description: string; - status: 'open' | 'mitigated' | 'accepted'; + status: "open" | "mitigated" | "accepted"; }> = []; for (const tool of toolLayer) { // Check for high-risk tools - if (tool.tool_id === 'bash-exec' || tool.tool_id.includes('exec')) { + if (tool.tool_id === "bash-exec" || tool.tool_id.includes("exec")) { risks.push({ risk_id: `risk-${tool.tool_id}-001`, - severity: 'medium', - category: 'command_execution', + severity: "medium", + category: "command_execution", description: `${tool.tool_name} tool allows arbitrary process execution`, - status: 'accepted', + status: "accepted", }); } // Check for network-related tools - if (tool.source === 'mcp' && tool.permissions.some((p) => p.includes('network'))) { + if ( + tool.source === "mcp" && + tool.permissions.some((p) => p.includes("network")) + ) { risks.push({ risk_id: `risk-${tool.tool_id}-001`, - severity: 'high', - category: 'ssrf', + severity: "high", + category: "ssrf", description: `${tool.tool_name} makes outbound network requests`, - status: 'open', + status: "open", }); } // Check for file write operations - if (tool.permissions.includes('fs:write')) { + if (tool.permissions.includes("fs:write")) { risks.push({ risk_id: `risk-${tool.tool_id}-002`, - severity: 'low', - category: 'data_modification', + severity: "low", + category: "data_modification", description: `${tool.tool_name} can modify files in the workspace`, - status: 'accepted', + status: "accepted", }); } } @@ -255,7 +265,9 @@ function generateRiskAssessment(toolLayer: ToolDefinition[]): Array<{ /** * Generate AgentBOM JSON from agent path */ -export function generateAgentBOM(options: GenerateOptions): Record { +export function generateAgentBOM( + options: GenerateOptions, +): Record { const { agentPath } = options; // Extract agent information @@ -280,7 +292,7 @@ export function generateAgentBOM(options: GenerateOptions): Record = { - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { agent_id: agentInfo.agent_id, agent_name: agentInfo.agent_name, @@ -289,19 +301,20 @@ export function generateAgentBOM(options: GenerateOptions): Record).agent_version = agentInfo.agent_version; + (agentbom.identity as Record).agent_version = + agentInfo.agent_version; } if (agentInfo.deployment_context) { (agentbom.identity as Record).deployment_context = @@ -333,7 +346,7 @@ export function generateAgentBOMCommand(args: string[]): number { // Validate the generated AgentBOM const validation = validateAgentBOM(agentbom); if (!validation.valid) { - console.error('Error: Generated AgentBOM is invalid:'); + console.error("Error: Generated AgentBOM is invalid:"); for (const err of validation.errors) { console.error(` - ${err}`); } @@ -343,7 +356,7 @@ export function generateAgentBOMCommand(args: string[]): number { // Output the AgentBOM JSON const output = `${JSON.stringify(agentbom, null, 2)}\n`; if (parsed.outputPath) { - writeFileSync(resolve(parsed.outputPath), output, 'utf-8'); + writeFileSync(resolve(parsed.outputPath), output, "utf-8"); } else { console.log(output.trimEnd()); } diff --git a/packages/agentbom-cli/src/chain.test.ts b/packages/agentbom-cli/src/chain.test.ts index 098a01c..4705a97 100644 --- a/packages/agentbom-cli/src/chain.test.ts +++ b/packages/agentbom-cli/src/chain.test.ts @@ -5,40 +5,46 @@ * fixtures, fully offline, and asserts every step is valid. If this test * fails, the public demo is broken. */ -import { describe, expect, it } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { CHAIN_STEPS, chainCommand, runChain } from './chain.js'; +import { describe, expect, it } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CHAIN_STEPS, chainCommand, runChain } from "./chain.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEMO_DIR = resolve(__dirname, "../examples/bscode-agent"); function readJSON(path: string): unknown { - return JSON.parse(readFileSync(path, 'utf-8')); + return JSON.parse(readFileSync(path, "utf-8")); } -describe('agent-trust chain (end-to-end, offline)', () => { - it('runs against examples/bscode-agent with overall status valid', () => { +describe("agent-trust chain (end-to-end, offline)", () => { + it("runs against examples/bscode-agent with overall status valid", () => { const report = runChain(DEMO_DIR); - expect(report.overall.status).toBe('valid'); + expect(report.overall.status).toBe("valid"); expect(report.overall.total_steps).toBe(5); expect(report.overall.valid_steps).toBe(5); }); - it('produces exactly 5 steps, each valid, in the documented order', () => { + it("produces exactly 5 steps, each valid, in the documented order", () => { const report = runChain(DEMO_DIR); expect(report.steps.map((s) => s.step)).toEqual([...CHAIN_STEPS]); expect(report.steps.map((s) => s.step)).toEqual([ - 'manifest', - 'agentbom', - 'mcp-posture', - 'audit-report', - 'trust-passport', + "manifest", + "agentbom", + "mcp-posture", + "audit-report", + "trust-passport", ]); for (const step of report.steps) { - expect(step.verdict).toBe('valid'); + expect(step.verdict).toBe("valid"); expect(step.errors).toEqual([]); expect(step.duration_ms).toBeGreaterThanOrEqual(0); expect(step.output_hash).toMatch(/^sha256:[0-9a-f]{64}$/); @@ -46,25 +52,27 @@ describe('agent-trust chain (end-to-end, offline)', () => { } }); - it('records timestamp and repo_sha', () => { + it("records timestamp and repo_sha", () => { const report = runChain(DEMO_DIR); expect(report.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/); - expect(typeof report.repo_sha).toBe('string'); + expect(typeof report.repo_sha).toBe("string"); expect(report.repo_sha.length).toBeGreaterThan(0); expect(report.example).toBe(resolve(DEMO_DIR)); }); - it('per-step output hashes are deterministic across runs', () => { + it("per-step output hashes are deterministic across runs", () => { const a = runChain(DEMO_DIR); const b = runChain(DEMO_DIR); - expect(a.steps.map((s) => s.output_hash)).toEqual(b.steps.map((s) => s.output_hash)); + expect(a.steps.map((s) => s.output_hash)).toEqual( + b.steps.map((s) => s.output_hash), + ); }); - it('chainCommand exits 0 and writes chain-report.json with a valid overall status', () => { - const outDir = mkdtempSync(join(tmpdir(), 'chain-cmd-')); - const outPath = join(outDir, 'chain-report.json'); + it("chainCommand exits 0 and writes chain-report.json with a valid overall status", () => { + const outDir = mkdtempSync(join(tmpdir(), "chain-cmd-")); + const outPath = join(outDir, "chain-report.json"); try { - const code = chainCommand(['--example', DEMO_DIR, '--out', outPath]); + const code = chainCommand(["--example", DEMO_DIR, "--out", outPath]); expect(code).toBe(0); expect(existsSync(outPath)).toBe(true); @@ -72,24 +80,24 @@ describe('agent-trust chain (end-to-end, offline)', () => { overall: { status: string }; steps: { verdict: string }[]; }; - expect(report.overall.status).toBe('valid'); + expect(report.overall.status).toBe("valid"); expect(report.steps).toHaveLength(5); - expect(report.steps.every((s) => s.verdict === 'valid')).toBe(true); + expect(report.steps.every((s) => s.verdict === "valid")).toBe(true); } finally { rmSync(outDir, { recursive: true, force: true }); } }); - it('chainCommand --help exits 0 and documents an example', () => { - const code = chainCommand(['--help']); + it("chainCommand --help exits 0 and documents an example", () => { + const code = chainCommand(["--help"]); expect(code).toBe(0); }); - it('fails closed when the example is missing required fixtures', () => { - const emptyDir = mkdtempSync(join(tmpdir(), 'chain-empty-')); + it("fails closed when the example is missing required fixtures", () => { + const emptyDir = mkdtempSync(join(tmpdir(), "chain-empty-")); try { const report = runChain(emptyDir); - expect(report.overall.status).toBe('invalid'); + expect(report.overall.status).toBe("invalid"); expect(report.overall.valid_steps).toBe(0); // Every step should record at least one error. expect(report.steps.every((s) => s.errors.length > 0)).toBe(true); @@ -98,26 +106,31 @@ describe('agent-trust chain (end-to-end, offline)', () => { } }); - it('flags an expired passport without touching the network', () => { - const sandbox = mkdtempSync(join(tmpdir(), 'chain-bad-passport-')); + it("flags an expired passport without touching the network", () => { + const sandbox = mkdtempSync(join(tmpdir(), "chain-bad-passport-")); try { // Reuse the valid agentbom/posture but make the passport expired. - const bom = readJSON(join(DEMO_DIR, 'agentbom.json')); - const posture = readJSON(join(DEMO_DIR, 'posture.json')); - const passport = readJSON(join(DEMO_DIR, 'trust-passport.json')) as { + const bom = readJSON(join(DEMO_DIR, "agentbom.json")); + const posture = readJSON(join(DEMO_DIR, "posture.json")); + const passport = readJSON(join(DEMO_DIR, "trust-passport.json")) as { validity: { expires_at: string }; }; - passport.validity.expires_at = '2020-01-01T00:00:00Z'; + passport.validity.expires_at = "2020-01-01T00:00:00Z"; - writeFileSync(join(sandbox, 'agentbom.json'), JSON.stringify(bom)); - writeFileSync(join(sandbox, 'posture.json'), JSON.stringify(posture)); - writeFileSync(join(sandbox, 'trust-passport.json'), JSON.stringify(passport)); + writeFileSync(join(sandbox, "agentbom.json"), JSON.stringify(bom)); + writeFileSync(join(sandbox, "posture.json"), JSON.stringify(posture)); + writeFileSync( + join(sandbox, "trust-passport.json"), + JSON.stringify(passport), + ); const report = runChain(sandbox); - expect(report.overall.status).toBe('invalid'); - const passportStep = report.steps.find((s) => s.step === 'trust-passport'); - expect(passportStep?.verdict).toBe('invalid'); - expect(passportStep?.errors.join(' ')).toContain('expired'); + expect(report.overall.status).toBe("invalid"); + const passportStep = report.steps.find( + (s) => s.step === "trust-passport", + ); + expect(passportStep?.verdict).toBe("invalid"); + expect(passportStep?.errors.join(" ")).toContain("expired"); } finally { rmSync(sandbox, { recursive: true, force: true }); } diff --git a/packages/agentbom-cli/src/chain.ts b/packages/agentbom-cli/src/chain.ts index 3c5296e..ca50d90 100644 --- a/packages/agentbom-cli/src/chain.ts +++ b/packages/agentbom-cli/src/chain.ts @@ -20,17 +20,18 @@ * not touch the network. Each step records a `verdict`, a `duration_ms`, and a * deterministic `output_hash` so the result is reproducible. */ -import { execSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { inspectTrustPassport, isExpired, validateTrustPassport } from '@openagentaudit/passport'; -import { inspectAgentBOM, validateAgentBOM } from '@wasmagent/agentbom-core'; +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { - inspectMCPPosture, - validateMCPPosture, -} from '@wasmagent/mcp-posture'; + inspectTrustPassport, + isExpired, + validateTrustPassport, +} from "@openagentaudit/passport"; +import { inspectAgentBOM, validateAgentBOM } from "@wasmagent/agentbom-core"; +import { inspectMCPPosture, validateMCPPosture } from "@wasmagent/mcp-posture"; /** Per-step outcome written to `chain-report.json` and streamed to stdout. */ export interface ChainStepResult { @@ -39,7 +40,7 @@ export interface ChainStepResult { /** Human-readable label. */ label: string; /** "valid" when the step passed, "invalid" otherwise. */ - verdict: 'valid' | 'invalid'; + verdict: "valid" | "invalid"; /** Wall-clock duration of the step in milliseconds. */ duration_ms: number; /** Deterministic SHA-256 of the step's canonical output (`sha256:`). */ @@ -60,7 +61,7 @@ export interface ChainReport { example: string; /** Roll-up of the per-step verdicts. */ overall: { - status: 'valid' | 'invalid'; + status: "valid" | "invalid"; valid_steps: number; total_steps: number; }; @@ -70,50 +71,50 @@ export interface ChainReport { /** Ordered step identifiers — the 6 chain nodes joined by 5 verifiable links. */ export const CHAIN_STEPS = [ - 'manifest', - 'agentbom', - 'mcp-posture', - 'audit-report', - 'trust-passport', + "manifest", + "agentbom", + "mcp-posture", + "audit-report", + "trust-passport", ] as const; const DEFAULT_EXAMPLE_DIR = resolve( dirname(fileURLToPath(import.meta.url)), - '../../examples/bscode-agent', + "../../examples/bscode-agent", ); const CHAIN_USAGE = [ - 'Usage: agent-trust chain [--example ] [--out ]', - '', - 'Runs the full Agent Trust Infrastructure chain in-process and fully offline:', - ' bscode → CapabilityManifest + AEP → AgentBOM → MCP Posture → audit report → Trust Passport', - '', - 'Emits one JSON object per step to stdout and writes chain-report.json.', - '', - 'Options:', - ' --example Example directory containing agentbom.json, posture.json,', - ' and trust-passport.json (default: examples/bscode-agent).', - ' --out Output path for chain-report.json (default: ./chain-report.json).', - '', - 'Example:', - ' agent-trust chain --example examples/bscode-agent --out chain-report.json', -].join('\n'); + "Usage: agent-trust chain [--example ] [--out ]", + "", + "Runs the full Agent Trust Infrastructure chain in-process and fully offline:", + " bscode → CapabilityManifest + AEP → AgentBOM → MCP Posture → audit report → Trust Passport", + "", + "Emits one JSON object per step to stdout and writes chain-report.json.", + "", + "Options:", + " --example Example directory containing agentbom.json, posture.json,", + " and trust-passport.json (default: examples/bscode-agent).", + " --out Output path for chain-report.json (default: ./chain-report.json).", + "", + "Example:", + " agent-trust chain --example examples/bscode-agent --out chain-report.json", +].join("\n"); /** Deterministic JSON canonicalization: object keys sorted recursively. */ function canonicalize(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; - if (value !== null && typeof value === 'object') { + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + if (value !== null && typeof value === "object") { const obj = value as Record; return `{${Object.keys(obj) .sort() .map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`) - .join(',')}}`; + .join(",")}}`; } return JSON.stringify(value); } function sha256Hex(input: string): string { - return createHash('sha256').update(input, 'utf-8').digest('hex'); + return createHash("sha256").update(input, "utf-8").digest("hex"); } function hashObject(value: unknown): string { @@ -121,22 +122,22 @@ function hashObject(value: unknown): string { } function hashFile(path: string): string { - return `sha256:${sha256Hex(readFileSync(path, 'utf-8'))}`; + return `sha256:${sha256Hex(readFileSync(path, "utf-8"))}`; } function loadJSON(path: string): T { - return JSON.parse(readFileSync(path, 'utf-8')) as T; + return JSON.parse(readFileSync(path, "utf-8")) as T; } function repoSha(): string { try { - return execSync('git rev-parse HEAD', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], + return execSync("git rev-parse HEAD", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], }).trim(); } catch { // Offline / non-git context: no sha available. - return 'unknown'; + return "unknown"; } } @@ -149,7 +150,7 @@ const startTimer: Timer = () => Date.now(); const elapsedMs = (start: number): number => Date.now() - start; function asRecord(value: unknown): Record { - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { return value as Record; } return {}; @@ -170,7 +171,7 @@ function stringArray(value: unknown): unknown[] { function runManifestStep(exampleDir: string): ChainStepResult { const start = startTimer(); const errors: string[] = []; - const bomPath = resolve(exampleDir, 'agentbom.json'); + const bomPath = resolve(exampleDir, "agentbom.json"); let manifest: Record = {}; try { @@ -181,7 +182,7 @@ function runManifestStep(exampleDir: string): ChainStepResult { const modelLayer = asRecord(bom.model_layer); manifest = { - manifest_version: '0.1', + manifest_version: "0.1", agent_id: identity.agent_id ?? null, capabilities: toolLayer.map((t) => asRecord(t).tool_id ?? null), permissions: stringArray(permissionLayer.granted_scopes), @@ -189,21 +190,26 @@ function runManifestStep(exampleDir: string): ChainStepResult { aep_evidence: true, }; - if (!manifest.agent_id) errors.push('manifest: missing agent_id'); - if (!Array.isArray(manifest.capabilities) || manifest.capabilities.length === 0) { - errors.push('manifest: capability list is empty'); + if (!manifest.agent_id) errors.push("manifest: missing agent_id"); + if ( + !Array.isArray(manifest.capabilities) || + manifest.capabilities.length === 0 + ) { + errors.push("manifest: capability list is empty"); } } catch (err) { - errors.push(`manifest: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `manifest: ${err instanceof Error ? err.message : String(err)}`, + ); } return { - step: 'manifest', - label: 'CapabilityManifest + AEP', - verdict: errors.length === 0 ? 'valid' : 'invalid', + step: "manifest", + label: "CapabilityManifest + AEP", + verdict: errors.length === 0 ? "valid" : "invalid", duration_ms: elapsedMs(start), output_hash: hashObject(manifest), - detail: { source: 'agentbom.json', reconstructed: true }, + detail: { source: "agentbom.json", reconstructed: true }, errors, }; } @@ -212,9 +218,9 @@ function runManifestStep(exampleDir: string): ChainStepResult { function runAgentBomStep(exampleDir: string): ChainStepResult { const start = startTimer(); const errors: string[] = []; - const bomPath = resolve(exampleDir, 'agentbom.json'); - let outputHash = ''; - let summary = ''; + const bomPath = resolve(exampleDir, "agentbom.json"); + let outputHash = ""; + let summary = ""; try { const data = loadJSON(bomPath); @@ -223,16 +229,18 @@ function runAgentBomStep(exampleDir: string): ChainStepResult { outputHash = hashFile(bomPath); summary = inspectAgentBOM(asRecord(data)); } catch (err) { - errors.push(`agentbom: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `agentbom: ${err instanceof Error ? err.message : String(err)}`, + ); } return { - step: 'agentbom', - label: 'AgentBOM', - verdict: errors.length === 0 ? 'valid' : 'invalid', + step: "agentbom", + label: "AgentBOM", + verdict: errors.length === 0 ? "valid" : "invalid", duration_ms: elapsedMs(start), output_hash: outputHash, - detail: { schema: 'specs/agentbom/schema.json', inspect: summary }, + detail: { schema: "specs/agentbom/schema.json", inspect: summary }, errors, }; } @@ -241,9 +249,9 @@ function runAgentBomStep(exampleDir: string): ChainStepResult { function runPostureStep(exampleDir: string): ChainStepResult { const start = startTimer(); const errors: string[] = []; - const posturePath = resolve(exampleDir, 'posture.json'); - let outputHash = ''; - let summary = ''; + const posturePath = resolve(exampleDir, "posture.json"); + let outputHash = ""; + let summary = ""; try { const data = loadJSON(posturePath); @@ -252,16 +260,18 @@ function runPostureStep(exampleDir: string): ChainStepResult { outputHash = hashFile(posturePath); summary = inspectMCPPosture(asRecord(data)); } catch (err) { - errors.push(`mcp-posture: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `mcp-posture: ${err instanceof Error ? err.message : String(err)}`, + ); } return { - step: 'mcp-posture', - label: 'MCP Posture', - verdict: errors.length === 0 ? 'valid' : 'invalid', + step: "mcp-posture", + label: "MCP Posture", + verdict: errors.length === 0 ? "valid" : "invalid", duration_ms: elapsedMs(start), output_hash: outputHash, - detail: { schema: 'specs/mcp-posture/schema.json', inspect: summary }, + detail: { schema: "specs/mcp-posture/schema.json", inspect: summary }, errors, }; } @@ -277,44 +287,50 @@ function runAuditStep(exampleDir: string): ChainStepResult { let report: Record = {}; try { - const bom = asRecord(loadJSON(resolve(exampleDir, 'agentbom.json'))); - const posture = asRecord(loadJSON(resolve(exampleDir, 'posture.json'))); + const bom = asRecord(loadJSON(resolve(exampleDir, "agentbom.json"))); + const posture = asRecord(loadJSON(resolve(exampleDir, "posture.json"))); const bomIdentity = asRecord(bom.identity); const postureIdentity = asRecord(posture.identity); const permissionGraph = asRecord(posture.permission_graph); const risks = stringArray(bom.risk_layer); - const openRisks = risks.filter((r) => asRecord(r).status === 'open'); + const openRisks = risks.filter((r) => asRecord(r).status === "open"); report = { - report_id: 'audit-bscode-demo-001', + report_id: "audit-bscode-demo-001", agent_id: bomIdentity.agent_id ?? null, snapshot_id: postureIdentity.snapshot_id ?? null, generated_at: (bomIdentity.generated_at as string | undefined) ?? null, open_findings: openRisks.length, high_risk_tools: permissionGraph.high_risk_tools ?? 0, - framework_mappings: [{ framework: 'OWASP-MCP-Top10', coverage: 'partial' }], - note: 'Demo placeholder. Not a real audit.', + framework_mappings: [ + { framework: "OWASP-MCP-Top10", coverage: "partial" }, + ], + note: "Demo placeholder. Not a real audit.", }; - if (!report.agent_id) errors.push('audit-report: missing agent_id'); + if (!report.agent_id) errors.push("audit-report: missing agent_id"); if ( bomIdentity.agent_id && postureIdentity.agent_id && bomIdentity.agent_id !== postureIdentity.agent_id ) { - errors.push('audit-report: agent_id mismatch between AgentBOM and posture'); + errors.push( + "audit-report: agent_id mismatch between AgentBOM and posture", + ); } } catch (err) { - errors.push(`audit-report: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `audit-report: ${err instanceof Error ? err.message : String(err)}`, + ); } return { - step: 'audit-report', - label: 'Audit report', - verdict: errors.length === 0 ? 'valid' : 'invalid', + step: "audit-report", + label: "Audit report", + verdict: errors.length === 0 ? "valid" : "invalid", duration_ms: elapsedMs(start), output_hash: hashObject(report), - detail: { synthesized: true, reference: 'open-agent-audit (placeholder)' }, + detail: { synthesized: true, reference: "open-agent-audit (placeholder)" }, errors, }; } @@ -323,13 +339,13 @@ function runAuditStep(exampleDir: string): ChainStepResult { function runPassportStep(exampleDir: string): ChainStepResult { const start = startTimer(); const errors: string[] = []; - const passportPath = resolve(exampleDir, 'trust-passport.json'); - let outputHash = ''; - let summary = ''; + const passportPath = resolve(exampleDir, "trust-passport.json"); + let outputHash = ""; + let summary = ""; try { - const bom = asRecord(loadJSON(resolve(exampleDir, 'agentbom.json'))); - const posture = asRecord(loadJSON(resolve(exampleDir, 'posture.json'))); + const bom = asRecord(loadJSON(resolve(exampleDir, "agentbom.json"))); + const posture = asRecord(loadJSON(resolve(exampleDir, "posture.json"))); const passport = loadJSON(passportPath); const result = validateTrustPassport(passport); @@ -347,7 +363,7 @@ function runPassportStep(exampleDir: string): ChainStepResult { agentbomRef.agentbom_id !== bomIdentity.agent_id ) { errors.push( - 'trust-passport: agentbom_ref.agentbom_id does not match AgentBOM identity.agent_id', + "trust-passport: agentbom_ref.agentbom_id does not match AgentBOM identity.agent_id", ); } if ( @@ -356,26 +372,28 @@ function runPassportStep(exampleDir: string): ChainStepResult { postureRef.snapshot_id !== postureIdentity.snapshot_id ) { errors.push( - 'trust-passport: posture_ref.snapshot_id does not match posture identity.snapshot_id', + "trust-passport: posture_ref.snapshot_id does not match posture identity.snapshot_id", ); } if (isExpired(passportObj)) { - errors.push('trust-passport: passport has expired'); + errors.push("trust-passport: passport has expired"); } outputHash = hashFile(passportPath); summary = inspectTrustPassport(passportObj); } catch (err) { - errors.push(`trust-passport: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `trust-passport: ${err instanceof Error ? err.message : String(err)}`, + ); } return { - step: 'trust-passport', - label: 'Trust Passport', - verdict: errors.length === 0 ? 'valid' : 'invalid', + step: "trust-passport", + label: "Trust Passport", + verdict: errors.length === 0 ? "valid" : "invalid", duration_ms: elapsedMs(start), output_hash: outputHash, - detail: { schema: 'specs/trust-passport/schema.json', inspect: summary }, + detail: { schema: "specs/trust-passport/schema.json", inspect: summary }, errors, }; } @@ -384,7 +402,9 @@ function runPassportStep(exampleDir: string): ChainStepResult { * Run the full chain in-process against an example directory. Pure: does not * print or write files. Use {@link chainCommand} for the CLI wrapper. */ -export function runChain(exampleDir: string = DEFAULT_EXAMPLE_DIR): ChainReport { +export function runChain( + exampleDir: string = DEFAULT_EXAMPLE_DIR, +): ChainReport { const resolved = resolve(exampleDir); const steps: ChainStepResult[] = [ runManifestStep(resolved), @@ -394,13 +414,13 @@ export function runChain(exampleDir: string = DEFAULT_EXAMPLE_DIR): ChainReport runPassportStep(resolved), ]; - const validSteps = steps.filter((s) => s.verdict === 'valid').length; + const validSteps = steps.filter((s) => s.verdict === "valid").length; return { timestamp: now(), repo_sha: repoSha(), example: resolved, overall: { - status: validSteps === steps.length ? 'valid' : 'invalid', + status: validSteps === steps.length ? "valid" : "invalid", valid_steps: validSteps, total_steps: steps.length, }, @@ -410,20 +430,20 @@ export function runChain(exampleDir: string = DEFAULT_EXAMPLE_DIR): ChainReport /** CLI entry point for `agent-trust chain`. */ export function chainCommand(args: string[]): number { - if (args.includes('--help') || args.includes('-h')) { + if (args.includes("--help") || args.includes("-h")) { console.log(CHAIN_USAGE); return 0; } let exampleDir = DEFAULT_EXAMPLE_DIR; - let outPath = 'chain-report.json'; + let outPath = "chain-report.json"; for (let i = 0; i < args.length; i++) { const arg = args[i]; const next = args[i + 1]; - if (arg === '--example' && next) { + if (arg === "--example" && next) { exampleDir = next; i++; - } else if (arg === '--out' && next) { + } else if (arg === "--out" && next) { outPath = next; i++; } else { @@ -441,13 +461,13 @@ export function chainCommand(args: string[]): number { } const resolvedOut = resolve(outPath); - writeFileSync(resolvedOut, `${JSON.stringify(report, null, 2)}\n`, 'utf-8'); + writeFileSync(resolvedOut, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); - if (report.overall.status !== 'valid') { + if (report.overall.status !== "valid") { const failed = report.steps - .filter((s) => s.verdict !== 'valid') + .filter((s) => s.verdict !== "valid") .map((s) => s.step) - .join(', '); + .join(", "); console.error(`Chain failed: ${failed}`); return 1; } diff --git a/packages/agentbom-cli/src/compliance-check.test.ts b/packages/agentbom-cli/src/compliance-check.test.ts index b62e7fc..6b719b7 100644 --- a/packages/agentbom-cli/src/compliance-check.test.ts +++ b/packages/agentbom-cli/src/compliance-check.test.ts @@ -5,20 +5,26 @@ * Known-good fixtures should pass compliance checks, while known-bad fixtures * should fail with specific error messages. */ -import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + checkProfileSchemaCompatibility, + getSchemaFieldDescriptors, + upgradeProfileMappings, + validateAgentBOM, +} from "@wasmagent/agentbom-core"; import { complianceCheckCommand, upgradeProfileCommand, verifyProfileCommand, -} from './compliance-check.js'; +} from "./compliance-check.js"; -import { checkProfileSchemaCompatibility, getSchemaFieldDescriptors, upgradeProfileMappings, validateAgentBOM } from '@wasmagent/agentbom-core'; -import { readFileSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); -describe('compliance-check: profile validation with fixtures', () => { +describe("compliance-check: profile validation with fixtures", () => { let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let consoleOutput: string[] = []; @@ -31,10 +37,10 @@ describe('compliance-check: profile validation with fixtures', () => { errorOutput = []; console.log = (...args: unknown[]) => { - consoleOutput.push(args.map(String).join(' ')); + consoleOutput.push(args.map(String).join(" ")); }; console.error = (...args: unknown[]) => { - errorOutput.push(args.map(String).join(' ')); + errorOutput.push(args.map(String).join(" ")); }; }); @@ -44,31 +50,43 @@ describe('compliance-check: profile validation with fixtures', () => { }); function getFixturePath(fixtureName: string): string { - return resolve(__dirname, 'compliance-fixtures', fixtureName); + return resolve(__dirname, "compliance-fixtures", fixtureName); } - describe('SOC2 2024 profile', () => { - const profileId = 'soc2-2024'; + describe("SOC2 2024 profile", () => { + const profileId = "soc2-2024"; - it('accepts known-good SOC2 fixture', () => { - const fixturePath = getFixturePath('soc2-2024-known-good.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("accepts known-good SOC2 fixture", () => { + const fixturePath = getFixturePath("soc2-2024-known-good.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(0); expect(errorOutput).toHaveLength(0); - expect(consoleOutput.some((line) => line.includes('✓ COMPLIANT'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('SOC2'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('2024'))).toBe(true); + expect(consoleOutput.some((line) => line.includes("✓ COMPLIANT"))).toBe( + true, + ); + expect(consoleOutput.some((line) => line.includes("SOC2"))).toBe(true); + expect(consoleOutput.some((line) => line.includes("2024"))).toBe(true); }); - it('rejects known-bad SOC2 fixture', () => { - const fixturePath = getFixturePath('soc2-2024-known-bad.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("rejects known-bad SOC2 fixture", () => { + const fixturePath = getFixturePath("soc2-2024-known-bad.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(1); - expect(consoleOutput.some((line) => line.includes('✗ NON-COMPLIANT'))).toBe(true); + expect( + consoleOutput.some((line) => line.includes("✗ NON-COMPLIANT")), + ).toBe(true); - const errorText = consoleOutput.join('\n'); + const errorText = consoleOutput.join("\n"); // Known-bad fixture has multiple violations: // - development context (not in allowed contexts) // - tools with high/critical severity @@ -83,28 +101,42 @@ describe('compliance-check: profile validation with fixtures', () => { }); }); - describe('ISO27001 2022 profile', () => { - const profileId = 'iso27001-2022'; + describe("ISO27001 2022 profile", () => { + const profileId = "iso27001-2022"; - it('accepts known-good ISO27001 fixture', () => { - const fixturePath = getFixturePath('iso27001-2022-known-good.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("accepts known-good ISO27001 fixture", () => { + const fixturePath = getFixturePath("iso27001-2022-known-good.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(0); expect(errorOutput).toHaveLength(0); - expect(consoleOutput.some((line) => line.includes('✓ COMPLIANT'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('ISO27001'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('2022'))).toBe(true); + expect(consoleOutput.some((line) => line.includes("✓ COMPLIANT"))).toBe( + true, + ); + expect(consoleOutput.some((line) => line.includes("ISO27001"))).toBe( + true, + ); + expect(consoleOutput.some((line) => line.includes("2022"))).toBe(true); }); - it('rejects known-bad ISO27001 fixture', () => { - const fixturePath = getFixturePath('iso27001-2022-known-bad.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("rejects known-bad ISO27001 fixture", () => { + const fixturePath = getFixturePath("iso27001-2022-known-bad.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(1); - expect(consoleOutput.some((line) => line.includes('✗ NON-COMPLIANT'))).toBe(true); + expect( + consoleOutput.some((line) => line.includes("✗ NON-COMPLIANT")), + ).toBe(true); - const errorText = consoleOutput.join('\n'); + const errorText = consoleOutput.join("\n"); // Known-bad fixture has multiple violations: // - staging context (only production allowed) // - tools with critical severity @@ -119,28 +151,42 @@ describe('compliance-check: profile validation with fixtures', () => { }); }); - describe('EIDAS controlled profile', () => { - const profileId = 'eidas-controlled'; + describe("EIDAS controlled profile", () => { + const profileId = "eidas-controlled"; - it('accepts known-good EIDAS fixture', () => { - const fixturePath = getFixturePath('eidas-controlled-known-good.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("accepts known-good EIDAS fixture", () => { + const fixturePath = getFixturePath("eidas-controlled-known-good.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(0); expect(errorOutput).toHaveLength(0); - expect(consoleOutput.some((line) => line.includes('✓ COMPLIANT'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('EIDAS'))).toBe(true); - expect(consoleOutput.some((line) => line.includes('controlled'))).toBe(true); + expect(consoleOutput.some((line) => line.includes("✓ COMPLIANT"))).toBe( + true, + ); + expect(consoleOutput.some((line) => line.includes("EIDAS"))).toBe(true); + expect(consoleOutput.some((line) => line.includes("controlled"))).toBe( + true, + ); }); - it('rejects known-bad EIDAS fixture', () => { - const fixturePath = getFixturePath('eidas-controlled-known-bad.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + it("rejects known-bad EIDAS fixture", () => { + const fixturePath = getFixturePath("eidas-controlled-known-bad.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(1); - expect(consoleOutput.some((line) => line.includes('✗ NON-COMPLIANT'))).toBe(true); + expect( + consoleOutput.some((line) => line.includes("✗ NON-COMPLIANT")), + ).toBe(true); - const errorText = consoleOutput.join('\n'); + const errorText = consoleOutput.join("\n"); // Known-bad fixture has multiple violations: // - development context (only production allowed) // - tools with medium severity (max allowed is low) @@ -156,18 +202,17 @@ describe('compliance-check: profile validation with fixtures', () => { }); }); - describe('fixture schema validation', () => { - it('all known-good fixtures have valid AgentBOM schema', () => { - + describe("fixture schema validation", () => { + it("all known-good fixtures have valid AgentBOM schema", () => { const knownGoodFixtures = [ - 'soc2-2024-known-good.json', - 'iso27001-2022-known-good.json', - 'eidas-controlled-known-good.json', + "soc2-2024-known-good.json", + "iso27001-2022-known-good.json", + "eidas-controlled-known-good.json", ]; for (const fixtureName of knownGoodFixtures) { const fixturePath = getFixturePath(fixtureName); - const fixtureContent = readFileSync(fixturePath, 'utf-8'); + const fixtureContent = readFileSync(fixturePath, "utf-8"); const fixtureData = JSON.parse(fixtureContent); const validation = validateAgentBOM(fixtureData); @@ -176,17 +221,16 @@ describe('compliance-check: profile validation with fixtures', () => { } }); - it('all known-bad fixtures have valid AgentBOM schema but fail compliance', () => { - + it("all known-bad fixtures have valid AgentBOM schema but fail compliance", () => { const knownBadFixtures = [ - 'soc2-2024-known-bad.json', - 'iso27001-2022-known-bad.json', - 'eidas-controlled-known-bad.json', + "soc2-2024-known-bad.json", + "iso27001-2022-known-bad.json", + "eidas-controlled-known-bad.json", ]; for (const fixtureName of knownBadFixtures) { const fixturePath = getFixturePath(fixtureName); - const fixtureContent = readFileSync(fixturePath, 'utf-8'); + const fixtureContent = readFileSync(fixturePath, "utf-8"); const fixtureData = JSON.parse(fixtureContent); // Schema should be valid @@ -197,45 +241,61 @@ describe('compliance-check: profile validation with fixtures', () => { }); }); - describe('compliance check behavior', () => { - it('returns error for non-existent profile', () => { - const fixturePath = getFixturePath('soc2-2024-known-good.json'); - const exitCode = complianceCheckCommand([fixturePath, '--profile', 'nonexistent-profile']); + describe("compliance check behavior", () => { + it("returns error for non-existent profile", () => { + const fixturePath = getFixturePath("soc2-2024-known-good.json"); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + "nonexistent-profile", + ]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('cannot load compliance profile'))).toBe( - true, - ); + expect( + errorOutput.some((line) => + line.includes("cannot load compliance profile"), + ), + ).toBe(true); }); - it('returns error for non-existent fixture file', () => { - const exitCode = complianceCheckCommand(['/nonexistent/file.json', '--profile', 'soc2-2024']); + it("returns error for non-existent fixture file", () => { + const exitCode = complianceCheckCommand([ + "/nonexistent/file.json", + "--profile", + "soc2-2024", + ]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('cannot read AgentBOM file'))).toBe(true); + expect( + errorOutput.some((line) => line.includes("cannot read AgentBOM file")), + ).toBe(true); }); - it('returns error for invalid JSON', () => { - const exitCode = complianceCheckCommand(['--profile', 'soc2-2024']); + it("returns error for invalid JSON", () => { + const exitCode = complianceCheckCommand(["--profile", "soc2-2024"]); expect(exitCode).toBe(1); expect(errorOutput.length).toBeGreaterThan(0); }); }); - describe('profile coverage', () => { - it('all three compliance profiles are tested', () => { - const profiles = ['soc2-2024', 'iso27001-2022', 'eidas-controlled']; + describe("profile coverage", () => { + it("all three compliance profiles are tested", () => { + const profiles = ["soc2-2024", "iso27001-2022", "eidas-controlled"]; const fixtures = [ - 'soc2-2024-known-good.json', - 'iso27001-2022-known-good.json', - 'eidas-controlled-known-good.json', + "soc2-2024-known-good.json", + "iso27001-2022-known-good.json", + "eidas-controlled-known-good.json", ]; for (let i = 0; i < profiles.length; i++) { const profileId = profiles[i]; const fixturePath = getFixturePath(fixtures[i]); - const exitCode = complianceCheckCommand([fixturePath, '--profile', profileId]); + const exitCode = complianceCheckCommand([ + fixturePath, + "--profile", + profileId, + ]); expect(exitCode).toBe(0); } @@ -243,7 +303,7 @@ describe('compliance-check: profile validation with fixtures', () => { }); }); -describe('compliance-verify-profile: backward compatibility checking', () => { +describe("compliance-verify-profile: backward compatibility checking", () => { let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let consoleOutput: string[] = []; @@ -256,10 +316,10 @@ describe('compliance-verify-profile: backward compatibility checking', () => { errorOutput = []; console.log = (...args: unknown[]) => { - consoleOutput.push(args.map(String).join(' ')); + consoleOutput.push(args.map(String).join(" ")); }; console.error = (...args: unknown[]) => { - errorOutput.push(args.map(String).join(' ')); + errorOutput.push(args.map(String).join(" ")); }; }); @@ -268,29 +328,33 @@ describe('compliance-verify-profile: backward compatibility checking', () => { console.error = originalConsoleError; }); - describe('existing profiles against schema v0.1', () => { - const profiles = ['soc2-2024', 'iso27001-2022', 'eidas-controlled']; + describe("existing profiles against schema v0.1", () => { + const profiles = ["soc2-2024", "iso27001-2022", "eidas-controlled"]; for (const profileId of profiles) { it(`${profileId}: no breaking issues with schema v0.1`, () => { - const exitCode = verifyProfileCommand([profileId, '--schema-version', '0.1']); + const exitCode = verifyProfileCommand([ + profileId, + "--schema-version", + "0.1", + ]); expect(exitCode).toBe(0); - expect(consoleOutput.some((line) => line.includes('✓ yes'))).toBe(true); - const outputText = consoleOutput.join('\n'); + expect(consoleOutput.some((line) => line.includes("✓ yes"))).toBe(true); + const outputText = consoleOutput.join("\n"); expect(outputText).not.toMatch(/Breaking issues/); }); it(`${profileId}: uses latest schema version by default`, () => { verifyProfileCommand([profileId]); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); expect(outputText).toMatch(/AgentBOM schema:\s+v0\.1/); }); it(`${profileId}: reports coverage gaps for uncovered schema sections`, () => { - verifyProfileCommand([profileId, '--schema-version', '0.1']); + verifyProfileCommand([profileId, "--schema-version", "0.1"]); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); // All profiles cover identity, tool_layer, risk_layer, attestation // but NOT model_layer, prompt_layer, permission_layer, etc. expect(outputText).toMatch(/Coverage gaps/); @@ -302,102 +366,110 @@ describe('compliance-verify-profile: backward compatibility checking', () => { } }); - describe('breaking change detection', () => { - it('detects profile referencing a removed identity field', () => { + describe("breaking change detection", () => { + it("detects profile referencing a removed identity field", () => { // Directly test the library function with a synthetic profile const result = checkProfileSchemaCompatibility( { - profile_version: '0.1', + profile_version: "0.1", rules: { identity: { - required_fields: ['agent_version', 'nonexistent_field'], + required_fields: ["agent_version", "nonexistent_field"], }, }, }, - '0.1', + "0.1", ); expect(result.compatible).toBe(false); expect(result.breaking.length).toBeGreaterThan(0); expect( - result.breaking.some((b: { field: string }) => b.field === 'identity.nonexistent_field'), + result.breaking.some( + (b: { field: string }) => b.field === "identity.nonexistent_field", + ), ).toBe(true); }); - it('detects profile requiring attestation.signature against schema without it', () => { - + it("detects profile requiring attestation.signature against schema without it", () => { // Simulate a future schema version where signature was removed // by checking against an unknown version (no fields registered) const result = checkProfileSchemaCompatibility( { - profile_version: '0.1', + profile_version: "0.1", rules: { attestation: { requires_signature: true, }, }, }, - '99.0', // unknown version → no fields → everything breaks + "99.0", // unknown version → no fields → everything breaks ); expect(result.compatible).toBe(false); expect( - result.breaking.some((b: { field: string }) => b.field === 'attestation.signature'), + result.breaking.some( + (b: { field: string }) => b.field === "attestation.signature", + ), ).toBe(true); }); - it('detects tool_layer rules against schema without tool_layer', () => { - + it("detects tool_layer rules against schema without tool_layer", () => { const result = checkProfileSchemaCompatibility( { - profile_version: '0.1', + profile_version: "0.1", rules: { tool_layer: { requires_tool_inventory: true, - blocked_permissions: ['fs:write'], + blocked_permissions: ["fs:write"], }, }, }, - '99.0', + "99.0", ); expect(result.compatible).toBe(false); - expect(result.breaking.some((b: { field: string }) => b.field === 'tool_layer')).toBe(true); + expect( + result.breaking.some( + (b: { field: string }) => b.field === "tool_layer", + ), + ).toBe(true); }); - it('detects risk_layer rules against schema without risk_layer', () => { - + it("detects risk_layer rules against schema without risk_layer", () => { const result = checkProfileSchemaCompatibility( { - profile_version: '0.1', + profile_version: "0.1", rules: { risk_layer: { requires_risk_assessment: true, - requires_mitigation_for: ['critical'], + requires_mitigation_for: ["critical"], }, }, }, - '99.0', + "99.0", ); expect(result.compatible).toBe(false); - expect(result.breaking.some((b: { field: string }) => b.field === 'risk_layer')).toBe(true); + expect( + result.breaking.some( + (b: { field: string }) => b.field === "risk_layer", + ), + ).toBe(true); }); - it('generates mapping updates for breaking changes', () => { - + it("generates mapping updates for breaking changes", () => { // Use a version with no fields to simulate breaking changes const result = checkProfileSchemaCompatibility( { - profile_version: '0.1', + profile_version: "0.1", rules: { - identity: { required_fields: ['agent_version'] }, + identity: { required_fields: ["agent_version"] }, attestation: { requires_signature: true }, - tool_layer: { blocked_permissions: ['fs:write'] }, + tool_layer: { blocked_permissions: ["fs:write"] }, }, }, - '99.0', + "99.0", ); expect(result.mapping_updates.length).toBeGreaterThan(0); @@ -405,55 +477,66 @@ describe('compliance-verify-profile: backward compatibility checking', () => { }); }); - describe('schema field descriptors', () => { - it('returns field descriptors for known version', () => { - - const fields = getSchemaFieldDescriptors('0.1'); + describe("schema field descriptors", () => { + it("returns field descriptors for known version", () => { + const fields = getSchemaFieldDescriptors("0.1"); expect(fields.length).toBeGreaterThan(0); // Check for key fields - expect(fields.some((f: { path: string }) => f.path === 'identity')).toBe(true); - expect(fields.some((f: { path: string }) => f.path === 'identity.agent_id')).toBe(true); - expect(fields.some((f: { path: string }) => f.path === 'tool_layer')).toBe(true); - expect(fields.some((f: { path: string }) => f.path === 'attestation.signature')).toBe(true); - expect(fields.some((f: { path: string }) => f.path === 'distribution')).toBe(true); + expect(fields.some((f: { path: string }) => f.path === "identity")).toBe( + true, + ); + expect( + fields.some((f: { path: string }) => f.path === "identity.agent_id"), + ).toBe(true); + expect( + fields.some((f: { path: string }) => f.path === "tool_layer"), + ).toBe(true); + expect( + fields.some( + (f: { path: string }) => f.path === "attestation.signature", + ), + ).toBe(true); + expect( + fields.some((f: { path: string }) => f.path === "distribution"), + ).toBe(true); }); - it('returns empty array for unknown version', () => { - - const fields = getSchemaFieldDescriptors('99.0'); + it("returns empty array for unknown version", () => { + const fields = getSchemaFieldDescriptors("99.0"); expect(fields).toHaveLength(0); }); }); - describe('CLI error handling', () => { - it('returns error when no profile ID given', () => { + describe("CLI error handling", () => { + it("returns error when no profile ID given", () => { const exitCode = verifyProfileCommand([]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('Usage'))).toBe(true); + expect(errorOutput.some((line) => line.includes("Usage"))).toBe(true); }); - it('returns error for non-existent profile', () => { - const exitCode = verifyProfileCommand(['nonexistent-profile']); + it("returns error for non-existent profile", () => { + const exitCode = verifyProfileCommand(["nonexistent-profile"]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('cannot load compliance profile'))).toBe( - true, - ); + expect( + errorOutput.some((line) => + line.includes("cannot load compliance profile"), + ), + ).toBe(true); }); }); }); -describe('upgradeProfileMappings: automated mapping updates', () => { - it('returns unchanged profile when already compatible', () => { - +describe("upgradeProfileMappings: automated mapping updates", () => { + it("returns unchanged profile when already compatible", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { identity: { - required_fields: ['agent_version', 'deployment_context'], - allowed_contexts: ['production'], + required_fields: ["agent_version", "deployment_context"], + allowed_contexts: ["production"], requires_version: true, }, attestation: { @@ -463,7 +546,7 @@ describe('upgradeProfileMappings: automated mapping updates', () => { }, }; - const result = upgradeProfileMappings(profile, '0.1'); + const result = upgradeProfileMappings(profile, "0.1"); expect(result.changes_applied).toBe(false); expect(result.applied_updates).toHaveLength(0); @@ -471,32 +554,38 @@ describe('upgradeProfileMappings: automated mapping updates', () => { expect(result.upgraded_profile).toEqual(profile); }); - it('removes identity.required_fields referencing non-existent fields', () => { - + it("removes identity.required_fields referencing non-existent fields", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { identity: { - required_fields: ['agent_version', 'nonexistent_field', 'another_fake'], + required_fields: [ + "agent_version", + "nonexistent_field", + "another_fake", + ], }, }, }; - const result = upgradeProfileMappings(profile, '0.1'); + const result = upgradeProfileMappings(profile, "0.1"); expect(result.changes_applied).toBe(true); expect(result.applied_updates.length).toBeGreaterThan(0); - expect(result.applied_updates.some((u: string) => u.includes('identity.required_fields'))).toBe( - true, - ); - expect(result.upgraded_profile.rules.identity?.required_fields).toEqual(['agent_version']); + expect( + result.applied_updates.some((u: string) => + u.includes("identity.required_fields"), + ), + ).toBe(true); + expect(result.upgraded_profile.rules.identity?.required_fields).toEqual([ + "agent_version", + ]); expect(result.unresolved).toHaveLength(0); }); - it('disables attestation.requires_signature when signature field removed', () => { - + it("disables attestation.requires_signature when signature field removed", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { attestation: { requires_signature: true, @@ -506,110 +595,124 @@ describe('upgradeProfileMappings: automated mapping updates', () => { }; // Unknown version has no fields → attestation.signature is "removed" - const result = upgradeProfileMappings(profile, '99.0'); + const result = upgradeProfileMappings(profile, "99.0"); expect(result.changes_applied).toBe(true); - expect(result.upgraded_profile.rules.attestation?.requires_signature).toBe(false); - expect(result.upgraded_profile.rules.attestation?.requires_timestamp).toBe(false); - expect(result.applied_updates.some((u: string) => u.includes('requires_signature'))).toBe(true); - expect(result.applied_updates.some((u: string) => u.includes('requires_timestamp'))).toBe(true); + expect(result.upgraded_profile.rules.attestation?.requires_signature).toBe( + false, + ); + expect(result.upgraded_profile.rules.attestation?.requires_timestamp).toBe( + false, + ); + expect( + result.applied_updates.some((u: string) => + u.includes("requires_signature"), + ), + ).toBe(true); + expect( + result.applied_updates.some((u: string) => + u.includes("requires_timestamp"), + ), + ).toBe(true); }); - it('clears tool_layer rules when section removed from schema', () => { - + it("clears tool_layer rules when section removed from schema", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { tool_layer: { - max_severity: 'medium', + max_severity: "medium", requires_tool_inventory: true, - blocked_permissions: ['fs:write'], - blocked_sources: ['unverified'], + blocked_permissions: ["fs:write"], + blocked_sources: ["unverified"], }, }, }; - const result = upgradeProfileMappings(profile, '99.0'); + const result = upgradeProfileMappings(profile, "99.0"); expect(result.changes_applied).toBe(true); expect(result.upgraded_profile.rules.tool_layer).toBeUndefined(); expect( - result.applied_updates.some((u: string) => u.includes('tool_layer') && u.includes('cleared')), + result.applied_updates.some( + (u: string) => u.includes("tool_layer") && u.includes("cleared"), + ), ).toBe(true); }); - it('clears risk_layer rules when section removed from schema', () => { - + it("clears risk_layer rules when section removed from schema", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { risk_layer: { requires_risk_assessment: true, - requires_mitigation_for: ['critical', 'high'], + requires_mitigation_for: ["critical", "high"], max_unmitigated_critical: 0, }, }, }; - const result = upgradeProfileMappings(profile, '99.0'); + const result = upgradeProfileMappings(profile, "99.0"); expect(result.changes_applied).toBe(true); expect(result.upgraded_profile.rules.risk_layer).toBeUndefined(); expect( - result.applied_updates.some((u: string) => u.includes('risk_layer') && u.includes('cleared')), + result.applied_updates.some( + (u: string) => u.includes("risk_layer") && u.includes("cleared"), + ), ).toBe(true); }); - it('handles multiple breaking changes across sections', () => { - + it("handles multiple breaking changes across sections", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { identity: { - required_fields: ['agent_version', 'nonexistent_field'], + required_fields: ["agent_version", "nonexistent_field"], }, attestation: { requires_signature: true, }, tool_layer: { - blocked_permissions: ['fs:write'], + blocked_permissions: ["fs:write"], }, risk_layer: { - requires_mitigation_for: ['critical'], + requires_mitigation_for: ["critical"], }, }, }; - const result = upgradeProfileMappings(profile, '99.0'); + const result = upgradeProfileMappings(profile, "99.0"); expect(result.changes_applied).toBe(true); // v99.0 has no fields at all, so agent_version is also stripped as non-existent expect(result.upgraded_profile.rules.identity?.required_fields).toEqual([]); - expect(result.upgraded_profile.rules.attestation?.requires_signature).toBe(false); + expect(result.upgraded_profile.rules.attestation?.requires_signature).toBe( + false, + ); expect(result.upgraded_profile.rules.tool_layer).toBeUndefined(); expect(result.upgraded_profile.rules.risk_layer).toBeUndefined(); expect(result.applied_updates.length).toBeGreaterThanOrEqual(4); }); - it('reports compatibility check from before upgrade', () => { - + it("reports compatibility check from before upgrade", () => { const profile = { - profile_version: '0.1', + profile_version: "0.1", rules: { identity: { - required_fields: ['nonexistent_field'], + required_fields: ["nonexistent_field"], }, }, }; - const result = upgradeProfileMappings(profile, '99.0'); + const result = upgradeProfileMappings(profile, "99.0"); expect(result.compatibility.compatible).toBe(false); expect(result.compatibility.breaking.length).toBeGreaterThan(0); }); }); -describe('compliance-upgrade-profile: CLI command', () => { +describe("compliance-upgrade-profile: CLI command", () => { let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let consoleOutput: string[] = []; @@ -622,10 +725,10 @@ describe('compliance-upgrade-profile: CLI command', () => { errorOutput = []; console.log = (...args: unknown[]) => { - consoleOutput.push(args.map(String).join(' ')); + consoleOutput.push(args.map(String).join(" ")); }; console.error = (...args: unknown[]) => { - errorOutput.push(args.map(String).join(' ')); + errorOutput.push(args.map(String).join(" ")); }; }); @@ -634,69 +737,94 @@ describe('compliance-upgrade-profile: CLI command', () => { console.error = originalConsoleError; }); - it('returns error when no profile ID given', () => { + it("returns error when no profile ID given", () => { const exitCode = upgradeProfileCommand([]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('Usage'))).toBe(true); + expect(errorOutput.some((line) => line.includes("Usage"))).toBe(true); }); - it('returns error for non-existent profile', () => { - const exitCode = upgradeProfileCommand(['nonexistent-profile']); + it("returns error for non-existent profile", () => { + const exitCode = upgradeProfileCommand(["nonexistent-profile"]); expect(exitCode).toBe(1); - expect(errorOutput.some((line) => line.includes('cannot load compliance profile'))).toBe(true); + expect( + errorOutput.some((line) => + line.includes("cannot load compliance profile"), + ), + ).toBe(true); }); - it('reports already compatible profile without changes', () => { - const exitCode = upgradeProfileCommand(['soc2-2024', '--schema-version', '0.1']); + it("reports already compatible profile without changes", () => { + const exitCode = upgradeProfileCommand([ + "soc2-2024", + "--schema-version", + "0.1", + ]); expect(exitCode).toBe(0); - expect(consoleOutput.some((line) => line.includes('already compatible'))).toBe(true); + expect( + consoleOutput.some((line) => line.includes("already compatible")), + ).toBe(true); }); - it('reports coverage gaps for existing profiles', () => { - upgradeProfileCommand(['soc2-2024', '--schema-version', '0.1']); + it("reports coverage gaps for existing profiles", () => { + upgradeProfileCommand(["soc2-2024", "--schema-version", "0.1"]); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); expect(outputText).toMatch(/coverage gap/); }); - it('detects and reports breaking issues against unknown schema version', () => { - const exitCode = upgradeProfileCommand(['soc2-2024', '--schema-version', '99.0']); + it("detects and reports breaking issues against unknown schema version", () => { + const exitCode = upgradeProfileCommand([ + "soc2-2024", + "--schema-version", + "99.0", + ]); expect(exitCode).toBe(0); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); expect(outputText).toMatch(/Breaking issues/); expect(outputText).toMatch(/Auto-applied/); }); - it('supports --dry-run flag', () => { - const exitCode = upgradeProfileCommand(['soc2-2024', '--schema-version', '99.0', '--dry-run']); + it("supports --dry-run flag", () => { + const exitCode = upgradeProfileCommand([ + "soc2-2024", + "--schema-version", + "99.0", + "--dry-run", + ]); expect(exitCode).toBe(0); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); expect(outputText).toMatch(/dry-run/); }); - it('outputs upgraded profile JSON by default', () => { - upgradeProfileCommand(['soc2-2024', '--schema-version', '99.0']); + it("outputs upgraded profile JSON by default", () => { + upgradeProfileCommand(["soc2-2024", "--schema-version", "99.0"]); - const outputText = consoleOutput.join('\n'); + const outputText = consoleOutput.join("\n"); // The JSON output should contain the upgraded profile structure expect(outputText).toMatch(/"profile_version"/); expect(outputText).toMatch(/"rules"/); }); - describe('all existing profiles upgrade against schema v0.1', () => { - const profiles = ['soc2-2024', 'iso27001-2022', 'eidas-controlled']; + describe("all existing profiles upgrade against schema v0.1", () => { + const profiles = ["soc2-2024", "iso27001-2022", "eidas-controlled"]; for (const profileId of profiles) { it(`${profileId}: reports already compatible with v0.1`, () => { - const exitCode = upgradeProfileCommand([profileId, '--schema-version', '0.1']); + const exitCode = upgradeProfileCommand([ + profileId, + "--schema-version", + "0.1", + ]); expect(exitCode).toBe(0); - expect(consoleOutput.some((line) => line.includes('already compatible'))).toBe(true); + expect( + consoleOutput.some((line) => line.includes("already compatible")), + ).toBe(true); }); } }); diff --git a/packages/agentbom-cli/src/compliance-check.ts b/packages/agentbom-cli/src/compliance-check.ts index 11486b7..d49364b 100644 --- a/packages/agentbom-cli/src/compliance-check.ts +++ b/packages/agentbom-cli/src/compliance-check.ts @@ -1,13 +1,13 @@ -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { type CompatibilityProfileInput, checkProfileSchemaCompatibility, getLatestVersion, upgradeProfileMappings, validateAgentBOM, -} from '@wasmagent/agentbom-core'; +} from "@wasmagent/agentbom-core"; interface ComplianceResult { compliant: boolean; @@ -38,7 +38,7 @@ interface ComplianceProfile { }; tool_layer?: { weight?: number; - max_severity?: 'low' | 'medium' | 'high' | 'critical'; + max_severity?: "low" | "medium" | "high" | "critical"; requires_tool_inventory?: boolean; blocked_permissions?: string[]; blocked_sources?: string[]; @@ -49,7 +49,7 @@ interface ComplianceProfile { max_unmitigated_critical?: number; max_unmitigated_high?: number; max_unmitigated_medium?: number; - requires_mitigation_for?: ('critical' | 'high' | 'medium' | 'low')[]; + requires_mitigation_for?: ("critical" | "high" | "medium" | "low")[]; }; attestation?: { weight?: number; @@ -74,12 +74,15 @@ function severityLevel(severity: string): number { } function loadProfile(profileId: string): ComplianceProfile | null { - const profilesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../profiles'); + const profilesDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../profiles", + ); const profilePath = resolve(profilesDir, `${profileId}.json`); try { - const raw = readFileSync(profilePath, 'utf-8'); + const raw = readFileSync(profilePath, "utf-8"); return JSON.parse(raw) as ComplianceProfile; } catch { return null; @@ -97,20 +100,24 @@ function checkIdentity( const identity = data.identity as Record | undefined; if (!identity) { - errors.push('identity section is missing'); + errors.push("identity section is missing"); return { errors, warnings, passed }; } const rules = profile.rules.identity; if (!rules) { - passed.push('identity: no rules defined'); + passed.push("identity: no rules defined"); return { errors, warnings, passed }; } // Check required fields if (rules.required_fields) { for (const field of rules.required_fields) { - if (!(field in identity) || identity[field] === undefined || identity[field] === null) { + if ( + !(field in identity) || + identity[field] === undefined || + identity[field] === null + ) { errors.push(`identity: missing required field "${field}"`); } else { passed.push(`identity: field "${field}" present`); @@ -120,10 +127,10 @@ function checkIdentity( // Check allowed contexts if (rules.allowed_contexts && rules.allowed_contexts.length > 0) { - const context = String(identity.deployment_context ?? ''); + const context = String(identity.deployment_context ?? ""); if (!rules.allowed_contexts.includes(context)) { errors.push( - `identity: deployment_context "${context}" not in allowed contexts [${rules.allowed_contexts.join(', ')}]`, + `identity: deployment_context "${context}" not in allowed contexts [${rules.allowed_contexts.join(", ")}]`, ); } else { passed.push(`identity: deployment_context "${context}" is allowed`); @@ -132,10 +139,15 @@ function checkIdentity( // Check version requirement if (rules.requires_version) { - if (!identity.agent_version || String(identity.agent_version).trim() === '') { - errors.push('identity: agent_version is required but missing or empty'); + if ( + !identity.agent_version || + String(identity.agent_version).trim() === "" + ) { + errors.push("identity: agent_version is required but missing or empty"); } else { - passed.push(`identity: agent_version "${identity.agent_version}" present`); + passed.push( + `identity: agent_version "${identity.agent_version}" present`, + ); } } @@ -154,16 +166,20 @@ function checkToolLayer( const rules = profile.rules.tool_layer; if (!rules) { - passed.push('tool_layer: no rules defined'); + passed.push("tool_layer: no rules defined"); return { errors, warnings, passed }; } // Check tool inventory requirement if (rules.requires_tool_inventory) { if (!toolLayer || toolLayer.length === 0) { - errors.push('tool_layer: tool inventory is required but missing or empty'); + errors.push( + "tool_layer: tool inventory is required but missing or empty", + ); } else { - passed.push(`tool_layer: tool inventory present (${toolLayer.length} tools)`); + passed.push( + `tool_layer: tool inventory present (${toolLayer.length} tools)`, + ); } } @@ -175,12 +191,12 @@ function checkToolLayer( if (rules.max_severity) { const maxLevel = severityLevel(rules.max_severity); for (const tool of toolLayer) { - if (typeof tool === 'object' && tool !== null) { + if (typeof tool === "object" && tool !== null) { const t = tool as Record; const riskSignals = t.risk_signals as string[] | undefined; if (riskSignals) { for (const signal of riskSignals) { - const severity = signal.split(':')[0]?.toLowerCase(); + const severity = signal.split(":")[0]?.toLowerCase(); if (severity && severityLevel(severity) > maxLevel) { errors.push( `tool_layer: tool "${t.tool_name}" has risk signal "${signal}" exceeding max severity "${rules.max_severity}"`, @@ -190,8 +206,10 @@ function checkToolLayer( } } } - if (errors.filter((e) => e.startsWith('tool_layer: tool')).length === 0) { - passed.push(`tool_layer: all tools within max severity "${rules.max_severity}"`); + if (errors.filter((e) => e.startsWith("tool_layer: tool")).length === 0) { + passed.push( + `tool_layer: all tools within max severity "${rules.max_severity}"`, + ); } } @@ -199,7 +217,7 @@ function checkToolLayer( if (rules.blocked_permissions && rules.blocked_permissions.length > 0) { const blockedPermissions = rules.blocked_permissions; for (const tool of toolLayer) { - if (typeof tool === 'object' && tool !== null) { + if (typeof tool === "object" && tool !== null) { const t = tool as Record; const permissions = t.permissions as string[] | undefined; if (permissions) { @@ -221,20 +239,22 @@ function checkToolLayer( if (rules.blocked_sources && rules.blocked_sources.length > 0) { const blockedSources = rules.blocked_sources; for (const tool of toolLayer) { - if (typeof tool === 'object' && tool !== null) { + if (typeof tool === "object" && tool !== null) { const t = tool as Record; - const source = String(t.source ?? ''); + const source = String(t.source ?? ""); for (const blocked of blockedSources) { if (source.toLowerCase().includes(blocked.toLowerCase())) { - errors.push(`tool_layer: tool "${t.tool_name}" has blocked source "${source}"`); + errors.push( + `tool_layer: tool "${t.tool_name}" has blocked source "${source}"`, + ); } } } } } - if (errors.filter((e) => e.startsWith('tool_layer:')).length === 0) { - passed.push('tool_layer: no blocked permissions or sources found'); + if (errors.filter((e) => e.startsWith("tool_layer:")).length === 0) { + passed.push("tool_layer: no blocked permissions or sources found"); } return { errors, warnings, passed }; @@ -252,16 +272,20 @@ function checkRiskLayer( const rules = profile.rules.risk_layer; if (!rules) { - passed.push('risk_layer: no rules defined'); + passed.push("risk_layer: no rules defined"); return { errors, warnings, passed }; } // Check risk assessment requirement if (rules.requires_risk_assessment) { if (!riskLayer || riskLayer.length === 0) { - errors.push('risk_layer: risk assessment is required but missing or empty'); + errors.push( + "risk_layer: risk assessment is required but missing or empty", + ); } else { - passed.push(`risk_layer: risk assessment present (${riskLayer.length} risks)`); + passed.push( + `risk_layer: risk assessment present (${riskLayer.length} risks)`, + ); } } @@ -270,15 +294,20 @@ function checkRiskLayer( } // Count unmitigated risks by severity - const unmitigatedCounts: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + const unmitigatedCounts: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + }; for (const risk of riskLayer) { - if (typeof risk === 'object' && risk !== null) { + if (typeof risk === "object" && risk !== null) { const r = risk as Record; - const severity = String(r.severity ?? '').toLowerCase(); - const status = String(r.status ?? '').toLowerCase(); + const severity = String(r.severity ?? "").toLowerCase(); + const status = String(r.status ?? "").toLowerCase(); - if (status !== 'mitigated' && status !== 'accepted') { + if (status !== "mitigated" && status !== "accepted") { if (severity in unmitigatedCounts) { unmitigatedCounts[severity]++; } @@ -329,19 +358,22 @@ function checkRiskLayer( } // Check mitigation requirements - if (rules.requires_mitigation_for && rules.requires_mitigation_for.length > 0) { + if ( + rules.requires_mitigation_for && + rules.requires_mitigation_for.length > 0 + ) { for (const risk of riskLayer) { - if (typeof risk === 'object' && risk !== null) { + if (typeof risk === "object" && risk !== null) { const r = risk as Record; - const severity = String(r.severity ?? '').toLowerCase(); - const status = String(r.status ?? '').toLowerCase(); + const severity = String(r.severity ?? "").toLowerCase(); + const status = String(r.status ?? "").toLowerCase(); if ( rules.requires_mitigation_for?.includes( - severity as 'critical' | 'high' | 'medium' | 'low', + severity as "critical" | "high" | "medium" | "low", ) ) { - if (status !== 'mitigated' && status !== 'accepted') { + if (status !== "mitigated" && status !== "accepted") { warnings.push( `risk_layer: risk "${r.risk_id}" has severity "${severity}" without mitigation status`, ); @@ -365,33 +397,33 @@ function checkAttestation( const attestation = data.attestation as Record | undefined; if (!attestation) { - errors.push('attestation section is missing'); + errors.push("attestation section is missing"); return { errors, warnings, passed }; } const rules = profile.rules.attestation; if (!rules) { - passed.push('attestation: no rules defined'); + passed.push("attestation: no rules defined"); return { errors, warnings, passed }; } // Check signature requirement if (rules.requires_signature) { const signature = attestation.signature; - if (!signature || String(signature).trim() === '') { - errors.push('attestation: signature is required but missing or empty'); + if (!signature || String(signature).trim() === "") { + errors.push("attestation: signature is required but missing or empty"); } else { - passed.push('attestation: signature present'); + passed.push("attestation: signature present"); } } // Check timestamp requirement if (rules.requires_timestamp) { const timestamp = attestation.timestamp; - if (!timestamp || String(timestamp).trim() === '') { - errors.push('attestation: timestamp is required but missing or empty'); + if (!timestamp || String(timestamp).trim() === "") { + errors.push("attestation: timestamp is required but missing or empty"); } else { - passed.push('attestation: timestamp present'); + passed.push("attestation: timestamp present"); } } @@ -416,10 +448,10 @@ function computeWeightedScore( profile: ComplianceProfile, ): number { const ruleKeys: (keyof typeof profile.rules)[] = [ - 'identity', - 'tool_layer', - 'risk_layer', - 'attestation', + "identity", + "tool_layer", + "risk_layer", + "attestation", ]; let totalWeight = 0; let passedWeight = 0; @@ -428,7 +460,7 @@ function computeWeightedScore( const key = ruleKeys[i]; const section = profile.rules[key]; const weight = - section && typeof section === 'object' && 'weight' in section + section && typeof section === "object" && "weight" in section ? ((section as { weight?: number }).weight ?? DEFAULT_RULE_WEIGHT) : DEFAULT_RULE_WEIGHT; @@ -446,20 +478,22 @@ function computeWeightedScore( export function complianceCheckCommand(args: string[]): number { if (args.length < 3) { console.error( - 'Usage: agent-trust compliance-check --profile [--min-score ]', + "Usage: agent-trust compliance-check --profile [--min-score ]", + ); + console.error(""); + console.error("Available profiles:"); + console.error(" soc2-2024 SOC 2 Type II compliance (2024)"); + console.error(" iso27001-2022 ISO/IEC 27001:2022 compliance"); + console.error( + " eidas-controlled eIDAS controlled digital identity services", ); - console.error(''); - console.error('Available profiles:'); - console.error(' soc2-2024 SOC 2 Type II compliance (2024)'); - console.error(' iso27001-2022 ISO/IEC 27001:2022 compliance'); - console.error(' eidas-controlled eIDAS controlled digital identity services'); return 1; } const bomPath = args[0]; const profileArg = args[1]; - if (profileArg !== '--profile') { + if (profileArg !== "--profile") { console.error(`Error: expected "--profile" argument, got "${profileArg}"`); return 1; } @@ -469,12 +503,14 @@ export function complianceCheckCommand(args: string[]): number { // Parse --min-score if present let minScore = 1.0; for (let i = 3; i < args.length; i++) { - if (args[i] === '--min-score' && i + 1 < args.length) { + if (args[i] === "--min-score" && i + 1 < args.length) { const parsed = Number.parseFloat(args[i + 1]); if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) { minScore = parsed; } else { - console.error(`Error: --min-score must be a number between 0 and 1, got "${args[i + 1]}"`); + console.error( + `Error: --min-score must be a number between 0 and 1, got "${args[i + 1]}"`, + ); return 1; } break; @@ -485,7 +521,7 @@ export function complianceCheckCommand(args: string[]): number { const resolvedBomPath = resolve(bomPath); let bomRaw: string; try { - bomRaw = readFileSync(resolvedBomPath, 'utf-8'); + bomRaw = readFileSync(resolvedBomPath, "utf-8"); } catch { console.error(`Error: cannot read AgentBOM file "${resolvedBomPath}"`); return 1; @@ -502,7 +538,9 @@ export function complianceCheckCommand(args: string[]): number { // Validate AgentBOM schema const bomValidation = validateAgentBOM(bomData); if (!bomValidation.valid) { - console.error(`Error: AgentBOM validation failed for "${resolvedBomPath}":`); + console.error( + `Error: AgentBOM validation failed for "${resolvedBomPath}":`, + ); for (const err of bomValidation.errors) { console.error(` - ${err}`); } @@ -547,34 +585,38 @@ export function complianceCheckCommand(args: string[]): number { } // Output results - console.log(`Compliance Check: ${profile.framework.name} ${profile.framework.version}`); + console.log( + `Compliance Check: ${profile.framework.name} ${profile.framework.version}`, + ); console.log(`Profile: ${profile.profile_id}`); console.log(`AgentBOM: ${resolvedBomPath}`); - console.log(`Score: ${(score * 100).toFixed(1)}% (threshold: ${(minScore * 100).toFixed(0)}%)`); - console.log(''); + console.log( + `Score: ${(score * 100).toFixed(1)}% (threshold: ${(minScore * 100).toFixed(0)}%)`, + ); + console.log(""); if (result.passed_checks.length > 0) { - console.log('✓ Passed checks:'); + console.log("✓ Passed checks:"); for (const check of result.passed_checks) { console.log(` ${check}`); } - console.log(''); + console.log(""); } if (result.warnings.length > 0) { - console.log('⚠ Warnings:'); + console.log("⚠ Warnings:"); for (const warning of result.warnings) { console.log(` ${warning}`); } - console.log(''); + console.log(""); } if (result.errors.length > 0) { - console.log('✗ Failed checks:'); + console.log("✗ Failed checks:"); for (const error of result.errors) { console.log(` ${error}`); } - console.log(''); + console.log(""); } if (result.compliant) { @@ -591,12 +633,16 @@ export function complianceCheckCommand(args: string[]): number { export function verifyProfileCommand(args: string[]): number { if (args.length < 1) { - console.error('Usage: compliance-verify-profile [--schema-version ]'); - console.error(''); - console.error('Available profiles:'); - console.error(' soc2-2024 SOC 2 Type II compliance (2024)'); - console.error(' iso27001-2022 ISO/IEC 27001:2022 compliance'); - console.error(' eidas-controlled eIDAS controlled digital identity services'); + console.error( + "Usage: compliance-verify-profile [--schema-version ]", + ); + console.error(""); + console.error("Available profiles:"); + console.error(" soc2-2024 SOC 2 Type II compliance (2024)"); + console.error(" iso27001-2022 ISO/IEC 27001:2022 compliance"); + console.error( + " eidas-controlled eIDAS controlled digital identity services", + ); return 1; } @@ -605,7 +651,7 @@ export function verifyProfileCommand(args: string[]): number { // Parse --schema-version if present let schemaVersion = getLatestVersion(); for (let i = 1; i < args.length; i++) { - if (args[i] === '--schema-version' && i + 1 < args.length) { + if (args[i] === "--schema-version" && i + 1 < args.length) { schemaVersion = args[i + 1]; break; } @@ -632,18 +678,23 @@ export function verifyProfileCommand(args: string[]): number { tool_layer: profile.rules.tool_layer ? { max_severity: profile.rules.tool_layer.max_severity, - requires_tool_inventory: profile.rules.tool_layer.requires_tool_inventory, + requires_tool_inventory: + profile.rules.tool_layer.requires_tool_inventory, blocked_permissions: profile.rules.tool_layer.blocked_permissions, blocked_sources: profile.rules.tool_layer.blocked_sources, } : undefined, risk_layer: profile.rules.risk_layer ? { - requires_risk_assessment: profile.rules.risk_layer.requires_risk_assessment, - max_unmitigated_critical: profile.rules.risk_layer.max_unmitigated_critical, + requires_risk_assessment: + profile.rules.risk_layer.requires_risk_assessment, + max_unmitigated_critical: + profile.rules.risk_layer.max_unmitigated_critical, max_unmitigated_high: profile.rules.risk_layer.max_unmitigated_high, - max_unmitigated_medium: profile.rules.risk_layer.max_unmitigated_medium, - requires_mitigation_for: profile.rules.risk_layer.requires_mitigation_for, + max_unmitigated_medium: + profile.rules.risk_layer.max_unmitigated_medium, + requires_mitigation_for: + profile.rules.risk_layer.requires_mitigation_for, } : undefined, attestation: profile.rules.attestation @@ -660,15 +711,15 @@ export function verifyProfileCommand(args: string[]): number { console.log(`Profile Compatibility Check: ${profileId}`); console.log(` Profile version: ${result.profile_version}`); console.log(` AgentBOM schema: v${result.agentbom_version}`); - console.log(` Compatible: ${result.compatible ? '✓ yes' : '✗ no'}`); - console.log(''); + console.log(` Compatible: ${result.compatible ? "✓ yes" : "✗ no"}`); + console.log(""); if (result.breaking.length > 0) { console.log(`✗ Breaking issues (${result.breaking.length}):`); for (const issue of result.breaking) { console.log(` [${issue.section}] ${issue.field}: ${issue.message}`); } - console.log(''); + console.log(""); } if (result.gaps.length > 0) { @@ -677,19 +728,23 @@ export function verifyProfileCommand(args: string[]): number { for (const gap of result.gaps) { console.log(` ${gap.path} — ${gap.description}`); } - console.log(''); + console.log(""); } if (result.mapping_updates.length > 0) { - const optional = result.mapping_updates.filter((u) => u.type === 'optional'); - const breaking = result.mapping_updates.filter((u) => u.type === 'breaking'); + const optional = result.mapping_updates.filter( + (u) => u.type === "optional", + ); + const breaking = result.mapping_updates.filter( + (u) => u.type === "breaking", + ); if (breaking.length > 0) { console.log(`Breaking mapping updates (${breaking.length}):`); for (const update of breaking) { console.log(` [${update.profile_section}] ${update.description}`); console.log(` Action: ${update.action}`); } - console.log(''); + console.log(""); } if (optional.length > 0) { console.log(`Optional mapping updates (${optional.length}):`); @@ -697,12 +752,14 @@ export function verifyProfileCommand(args: string[]): number { console.log(` [${update.profile_section}] ${update.description}`); console.log(` Action: ${update.action}`); } - console.log(''); + console.log(""); } } if (result.breaking.length === 0 && result.gaps.length === 0) { - console.log(`✓ Profile is fully compatible with AgentBOM schema v${schemaVersion}`); + console.log( + `✓ Profile is fully compatible with AgentBOM schema v${schemaVersion}`, + ); } return result.compatible ? 0 : 1; @@ -711,19 +768,27 @@ export function verifyProfileCommand(args: string[]): number { export function upgradeProfileCommand(args: string[]): number { if (args.length < 1) { console.error( - 'Usage: compliance-upgrade-profile [--schema-version ] [--dry-run]', + "Usage: compliance-upgrade-profile [--schema-version ] [--dry-run]", + ); + console.error(""); + console.error( + "Automatically resolve breaking mapping changes in a compliance profile.", + ); + console.error(""); + console.error("Options:"); + console.error( + " --schema-version Target AgentBOM schema version (default: latest)", + ); + console.error( + " --dry-run Show what would change without modifying the profile", + ); + console.error(""); + console.error("Available profiles:"); + console.error(" soc2-2024 SOC 2 Type II compliance (2024)"); + console.error(" iso27001-2022 ISO/IEC 27001:2022 compliance"); + console.error( + " eidas-controlled eIDAS controlled digital identity services", ); - console.error(''); - console.error('Automatically resolve breaking mapping changes in a compliance profile.'); - console.error(''); - console.error('Options:'); - console.error(' --schema-version Target AgentBOM schema version (default: latest)'); - console.error(' --dry-run Show what would change without modifying the profile'); - console.error(''); - console.error('Available profiles:'); - console.error(' soc2-2024 SOC 2 Type II compliance (2024)'); - console.error(' iso27001-2022 ISO/IEC 27001:2022 compliance'); - console.error(' eidas-controlled eIDAS controlled digital identity services'); return 1; } @@ -732,10 +797,10 @@ export function upgradeProfileCommand(args: string[]): number { let schemaVersion = getLatestVersion(); let dryRun = false; for (let i = 1; i < args.length; i++) { - if (args[i] === '--schema-version' && i + 1 < args.length) { + if (args[i] === "--schema-version" && i + 1 < args.length) { schemaVersion = args[i + 1]; i++; - } else if (args[i] === '--dry-run') { + } else if (args[i] === "--dry-run") { dryRun = true; } } @@ -760,18 +825,23 @@ export function upgradeProfileCommand(args: string[]): number { tool_layer: profile.rules.tool_layer ? { max_severity: profile.rules.tool_layer.max_severity, - requires_tool_inventory: profile.rules.tool_layer.requires_tool_inventory, + requires_tool_inventory: + profile.rules.tool_layer.requires_tool_inventory, blocked_permissions: profile.rules.tool_layer.blocked_permissions, blocked_sources: profile.rules.tool_layer.blocked_sources, } : undefined, risk_layer: profile.rules.risk_layer ? { - requires_risk_assessment: profile.rules.risk_layer.requires_risk_assessment, - max_unmitigated_critical: profile.rules.risk_layer.max_unmitigated_critical, + requires_risk_assessment: + profile.rules.risk_layer.requires_risk_assessment, + max_unmitigated_critical: + profile.rules.risk_layer.max_unmitigated_critical, max_unmitigated_high: profile.rules.risk_layer.max_unmitigated_high, - max_unmitigated_medium: profile.rules.risk_layer.max_unmitigated_medium, - requires_mitigation_for: profile.rules.risk_layer.requires_mitigation_for, + max_unmitigated_medium: + profile.rules.risk_layer.max_unmitigated_medium, + requires_mitigation_for: + profile.rules.risk_layer.requires_mitigation_for, } : undefined, attestation: profile.rules.attestation @@ -788,18 +858,23 @@ export function upgradeProfileCommand(args: string[]): number { console.log(`Profile Upgrade: ${profileId}`); console.log(` Profile version: ${result.compatibility.profile_version}`); console.log(` AgentBOM schema: v${result.compatibility.agentbom_version}`); - console.log(''); + console.log(""); - if (!result.compatibility.compatible && result.compatibility.breaking.length > 0) { - console.log(`Breaking issues detected (${result.compatibility.breaking.length}):`); + if ( + !result.compatibility.compatible && + result.compatibility.breaking.length > 0 + ) { + console.log( + `Breaking issues detected (${result.compatibility.breaking.length}):`, + ); for (const issue of result.compatibility.breaking) { console.log(` [${issue.section}] ${issue.field}: ${issue.message}`); } - console.log(''); + console.log(""); } if (!result.changes_applied) { - console.log('✓ No breaking changes — profile is already compatible.'); + console.log("✓ No breaking changes — profile is already compatible."); if (result.compatibility.gaps.length > 0) { console.log( `ℹ ${result.compatibility.gaps.length} coverage gap(s) remain (optional updates).`, @@ -812,19 +887,21 @@ export function upgradeProfileCommand(args: string[]): number { for (const update of result.applied_updates) { console.log(` ✓ ${update}`); } - console.log(''); + console.log(""); if (result.unresolved.length > 0) { - console.log(`Unresolved issues (${result.unresolved.length}) — manual review needed:`); + console.log( + `Unresolved issues (${result.unresolved.length}) — manual review needed:`, + ); for (const update of result.unresolved) { console.log(` [${update.profile_section}] ${update.description}`); console.log(` Action: ${update.action}`); } - console.log(''); + console.log(""); } if (dryRun) { - console.log('(dry-run — no output written)'); + console.log("(dry-run — no output written)"); return 0; } diff --git a/packages/agentbom-cli/src/compose-team.test.ts b/packages/agentbom-cli/src/compose-team.test.ts index 060eac1..69910d4 100644 --- a/packages/agentbom-cli/src/compose-team.test.ts +++ b/packages/agentbom-cli/src/compose-team.test.ts @@ -1,10 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { composeTeamCommand } from './compose-team.js'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { composeTeamCommand } from "./compose-team.js"; -describe('composeTeamCommand', () => { +describe("composeTeamCommand", () => { let tmpDir: string; let logOutput: string[]; let errorOutput: string[]; @@ -19,12 +19,14 @@ describe('composeTeamCommand', () => { mkdirSync(tmpDir, { recursive: true }); logOutput = []; errorOutput = []; - logSpy = spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logOutput.push(args.map(String).join(' ')); - }); - errorSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errorOutput.push(args.map(String).join(' ')); + logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logOutput.push(args.map(String).join(" ")); }); + errorSpy = spyOn(console, "error").mockImplementation( + (...args: unknown[]) => { + errorOutput.push(args.map(String).join(" ")); + }, + ); }); afterEach(() => { @@ -35,83 +37,90 @@ describe('composeTeamCommand', () => { // --- Argument validation --- - it('returns 1 and prints error when no args provided', () => { + it("returns 1 and prints error when no args provided", () => { const result = composeTeamCommand([]); expect(result).toBe(1); - expect(errorOutput).toEqual(['Error: compose-team requires at least 2 agent BOM files']); + expect(errorOutput).toEqual([ + "Error: compose-team requires at least 2 agent BOM files", + ]); expect(logOutput).toEqual([]); }); - it('returns 1 when only 1 BOM path provided', () => { - const result = composeTeamCommand(['single.bom']); + it("returns 1 when only 1 BOM path provided", () => { + const result = composeTeamCommand(["single.bom"]); expect(result).toBe(1); - expect(errorOutput).toEqual(['Error: compose-team requires at least 2 agent BOM files']); + expect(errorOutput).toEqual([ + "Error: compose-team requires at least 2 agent BOM files", + ]); }); // --- File I/O errors --- - it('returns 1 and prints error when BOM file cannot be read', () => { - const result = composeTeamCommand(['/nonexistent/a.bom', '/nonexistent/b.bom']); + it("returns 1 and prints error when BOM file cannot be read", () => { + const result = composeTeamCommand([ + "/nonexistent/a.bom", + "/nonexistent/b.bom", + ]); expect(result).toBe(1); - expect(errorOutput[0]).toContain('Error: Cannot read BOM file:'); - expect(errorOutput[0]).toContain('/nonexistent/a.bom'); + expect(errorOutput[0]).toContain("Error: Cannot read BOM file:"); + expect(errorOutput[0]).toContain("/nonexistent/a.bom"); }); - it('returns 1 and prints error for invalid JSON content', () => { - const badPath = join(tmpDir, 'bad.json'); - writeFileSync(badPath, 'not valid json{{{'); + it("returns 1 and prints error for invalid JSON content", () => { + const badPath = join(tmpDir, "bad.json"); + writeFileSync(badPath, "not valid json{{{"); - const result = composeTeamCommand([badPath, '/nonexistent/other.bom']); + const result = composeTeamCommand([badPath, "/nonexistent/other.bom"]); expect(result).toBe(1); - expect(errorOutput[0]).toContain('Error: Invalid BOM format in file:'); - expect(errorOutput[0]).toContain('not valid JSON'); + expect(errorOutput[0]).toContain("Error: Invalid BOM format in file:"); + expect(errorOutput[0]).toContain("not valid JSON"); }); - it('returns 1 and prints first validation error for invalid BOM schema', () => { - const badPath = join(tmpDir, 'invalid-bom.json'); - writeFileSync(badPath, JSON.stringify({ foo: 'bar' })); + it("returns 1 and prints first validation error for invalid BOM schema", () => { + const badPath = join(tmpDir, "invalid-bom.json"); + writeFileSync(badPath, JSON.stringify({ foo: "bar" })); - const result = composeTeamCommand([badPath, '/nonexistent/other.bom']); + const result = composeTeamCommand([badPath, "/nonexistent/other.bom"]); expect(result).toBe(1); - expect(errorOutput[0]).toContain('Error: Invalid BOM format in file:'); + expect(errorOutput[0]).toContain("Error: Invalid BOM format in file:"); // First error should reference the missing required fields expect(errorOutput[0]).toMatch(/required|must/i); }); // --- Happy path: 2 valid BOMs --- - it('returns 0 and prints composite manifest JSON for 2 valid BOMs', () => { + it("returns 0 and prints composite manifest JSON for 2 valid BOMs", () => { const bom1 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Alpha", + generated_at: "2026-01-15T10:00:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); const bom2 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Beta", + generated_at: "2026-01-15T10:05:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); - const path1 = join(tmpDir, 'agent1.json'); - const path2 = join(tmpDir, 'agent2.json'); + const path1 = join(tmpDir, "agent1.json"); + const path2 = join(tmpDir, "agent2.json"); writeFileSync(path1, bom1); writeFileSync(path2, bom2); const result = composeTeamCommand([path1, path2]); expect(result).toBe(0); - const output = logOutput.join('\n'); + const output = logOutput.join("\n"); const manifest = JSON.parse(output); - expect(manifest.schema).toBe('composite-trust-manifest/v1'); + expect(manifest.schema).toBe("composite-trust-manifest/v1"); expect(manifest.agent_count).toBe(2); expect(manifest.agents).toHaveLength(2); expect(manifest.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); @@ -119,197 +128,201 @@ describe('composeTeamCommand', () => { expect(manifest.trust_relationships).toEqual([]); }); - it('includes correct agent entries with bom_path', () => { + it("includes correct agent entries with bom_path", () => { const bom1 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Alpha", + generated_at: "2026-01-15T10:00:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); const bom2 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Beta", + generated_at: "2026-01-15T10:05:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); - const path1 = join(tmpDir, 'alpha.json'); - const path2 = join(tmpDir, 'beta.json'); + const path1 = join(tmpDir, "alpha.json"); + const path2 = join(tmpDir, "beta.json"); writeFileSync(path1, bom1); writeFileSync(path2, bom2); const result = composeTeamCommand([path1, path2]); expect(result).toBe(0); - const manifest = JSON.parse(logOutput.join('\n')); + const manifest = JSON.parse(logOutput.join("\n")); expect(manifest.agents[0]).toEqual({ - agent_id: 'agent-001', - agent_name: 'Alpha', + agent_id: "agent-001", + agent_name: "Alpha", bom_path: path1, }); expect(manifest.agents[1]).toEqual({ - agent_id: 'agent-002', - agent_name: 'Beta', + agent_id: "agent-002", + agent_name: "Beta", bom_path: path2, }); }); // --- Capabilities aggregation --- - it('aggregates capabilities as sorted union from model_layer.capabilities', () => { + it("aggregates capabilities as sorted union from model_layer.capabilities", () => { const bom1 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Alpha", + generated_at: "2026-01-15T10:00:00Z", }, model_layer: { - provider: 'openai', - model_id: 'gpt-4', - capabilities: ['code-generation', 'analysis'], + provider: "openai", + model_id: "gpt-4", + capabilities: ["code-generation", "analysis"], }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); const bom2 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Beta", + generated_at: "2026-01-15T10:05:00Z", }, model_layer: { - provider: 'anthropic', - model_id: 'claude-3', - capabilities: ['analysis', 'planning'], + provider: "anthropic", + model_id: "claude-3", + capabilities: ["analysis", "planning"], }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); - const path1 = join(tmpDir, 'a.json'); - const path2 = join(tmpDir, 'b.json'); + const path1 = join(tmpDir, "a.json"); + const path2 = join(tmpDir, "b.json"); writeFileSync(path1, bom1); writeFileSync(path2, bom2); const result = composeTeamCommand([path1, path2]); expect(result).toBe(0); - const manifest = JSON.parse(logOutput.join('\n')); + const manifest = JSON.parse(logOutput.join("\n")); // Union of capabilities, sorted - expect(manifest.aggregated_capabilities).toEqual(['analysis', 'code-generation', 'planning']); + expect(manifest.aggregated_capabilities).toEqual([ + "analysis", + "code-generation", + "planning", + ]); }); // --- Trust relationships from peer_agents --- - it('collects trust relationships from agent_collaboration.peer_agents', () => { + it("collects trust relationships from agent_collaboration.peer_agents", () => { const bom1 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Alpha", + generated_at: "2026-01-15T10:00:00Z", }, agent_collaboration: { - peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + peer_agents: [{ agent_id: "agent-002", role: "delegate" }], }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); const bom2 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Beta", + generated_at: "2026-01-15T10:05:00Z", }, agent_collaboration: { - peer_agents: [{ agent_id: 'agent-001', role: 'supervisor' }], + peer_agents: [{ agent_id: "agent-001", role: "supervisor" }], }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); - const path1 = join(tmpDir, 'a.json'); - const path2 = join(tmpDir, 'b.json'); + const path1 = join(tmpDir, "a.json"); + const path2 = join(tmpDir, "b.json"); writeFileSync(path1, bom1); writeFileSync(path2, bom2); const result = composeTeamCommand([path1, path2]); expect(result).toBe(0); - const manifest = JSON.parse(logOutput.join('\n')); + const manifest = JSON.parse(logOutput.join("\n")); expect(manifest.trust_relationships).toHaveLength(2); expect(manifest.trust_relationships).toContainEqual({ - from: 'agent-001', - to: 'agent-002', - type: 'delegate', + from: "agent-001", + to: "agent-002", + type: "delegate", }); expect(manifest.trust_relationships).toContainEqual({ - from: 'agent-002', - to: 'agent-001', - type: 'supervisor', + from: "agent-002", + to: "agent-001", + type: "supervisor", }); }); // --- JSON output quality --- - it('prints valid JSON via JSON.stringify(manifest, null, 2) that can be re-parsed', () => { + it("prints valid JSON via JSON.stringify(manifest, null, 2) that can be re-parsed", () => { const bom1 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-001', - agent_name: 'Alpha', - generated_at: '2026-01-15T10:00:00Z', + agent_id: "agent-001", + agent_name: "Alpha", + generated_at: "2026-01-15T10:00:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); const bom2 = JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'agent-002', - agent_name: 'Beta', - generated_at: '2026-01-15T10:05:00Z', + agent_id: "agent-002", + agent_name: "Beta", + generated_at: "2026-01-15T10:05:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }); - const path1 = join(tmpDir, 'a.json'); - const path2 = join(tmpDir, 'b.json'); + const path1 = join(tmpDir, "a.json"); + const path2 = join(tmpDir, "b.json"); writeFileSync(path1, bom1); writeFileSync(path2, bom2); const result = composeTeamCommand([path1, path2]); expect(result).toBe(0); - const raw = logOutput.join('\n'); + const raw = logOutput.join("\n"); expect(() => JSON.parse(raw)).not.toThrow(); const manifest = JSON.parse(raw); - expect(manifest).toHaveProperty('schema', 'composite-trust-manifest/v1'); + expect(manifest).toHaveProperty("schema", "composite-trust-manifest/v1"); }); // --- 3+ BOMs --- - it('handles 3 or more BOMs', () => { + it("handles 3 or more BOMs", () => { const paths: string[] = []; for (let i = 1; i <= 3; i++) { const path = join(tmpDir, `agent${i}.json`); writeFileSync( path, JSON.stringify({ - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: `agent-${String(i).padStart(3, '0')}`, + agent_id: `agent-${String(i).padStart(3, "0")}`, agent_name: `Agent ${i}`, - generated_at: '2026-01-15T10:00:00Z', + generated_at: "2026-01-15T10:00:00Z", }, - attestation: { generator: 'test' }, + attestation: { generator: "test" }, }), ); paths.push(path); @@ -318,13 +331,11 @@ describe('composeTeamCommand', () => { const result = composeTeamCommand(paths); expect(result).toBe(0); - const manifest = JSON.parse(logOutput.join('\n')); + const manifest = JSON.parse(logOutput.join("\n")); expect(manifest.agent_count).toBe(3); expect(manifest.agents).toHaveLength(3); - expect(manifest.agents.map((a: { agent_id: string }) => a.agent_id)).toEqual([ - 'agent-001', - 'agent-002', - 'agent-003', - ]); + expect( + manifest.agents.map((a: { agent_id: string }) => a.agent_id), + ).toEqual(["agent-001", "agent-002", "agent-003"]); }); }); diff --git a/packages/agentbom-cli/src/compose-team.ts b/packages/agentbom-cli/src/compose-team.ts index 3b4d4f3..78d969c 100644 --- a/packages/agentbom-cli/src/compose-team.ts +++ b/packages/agentbom-cli/src/compose-team.ts @@ -1,10 +1,10 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { validateAgentBOM } from '@wasmagent/agentbom-core'; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateAgentBOM } from "@wasmagent/agentbom-core"; /** Composite trust manifest produced by compose-team */ interface CompositeManifest { - schema: 'composite-trust-manifest/v1'; + schema: "composite-trust-manifest/v1"; generated_at: string; agent_count: number; agents: Array<{ agent_id: string; agent_name: string; bom_path: string }>; @@ -18,7 +18,7 @@ interface CompositeManifest { */ export function composeTeamCommand(args: string[]): number { if (args.length < 2) { - console.error('Error: compose-team requires at least 2 agent BOM files'); + console.error("Error: compose-team requires at least 2 agent BOM files"); return 1; } @@ -30,7 +30,7 @@ export function composeTeamCommand(args: string[]): number { // Read file let raw: string; try { - raw = readFileSync(resolvedPath, 'utf-8'); + raw = readFileSync(resolvedPath, "utf-8"); } catch { console.error(`Error: Cannot read BOM file: ${resolvedPath}`); return 1; @@ -41,14 +41,18 @@ export function composeTeamCommand(args: string[]): number { try { data = JSON.parse(raw); } catch { - console.error(`Error: Invalid BOM format in file: ${resolvedPath}: not valid JSON`); + console.error( + `Error: Invalid BOM format in file: ${resolvedPath}: not valid JSON`, + ); return 1; } // Validate against AgentBOM schema const result = validateAgentBOM(data); if (!result.valid) { - console.error(`Error: Invalid BOM format in file: ${resolvedPath}: ${result.errors[0]}`); + console.error( + `Error: Invalid BOM format in file: ${resolvedPath}: ${result.errors[0]}`, + ); return 1; } @@ -56,15 +60,15 @@ export function composeTeamCommand(args: string[]): number { } // Build composite manifest - const agents: CompositeManifest['agents'] = []; + const agents: CompositeManifest["agents"] = []; const allCapabilities = new Set(); - const trustRelationships: CompositeManifest['trust_relationships'] = []; + const trustRelationships: CompositeManifest["trust_relationships"] = []; for (const { data, path } of boms) { // Extract identity const identity = data.identity as Record | undefined; - const agentId = String(identity?.agent_id ?? 'unknown'); - const agentName = String(identity?.agent_name ?? 'unnamed'); + const agentId = String(identity?.agent_id ?? "unknown"); + const agentName = String(identity?.agent_name ?? "unnamed"); agents.push({ agent_id: agentId, agent_name: agentName, bom_path: path }); @@ -73,29 +77,31 @@ export function composeTeamCommand(args: string[]): number { const capabilities = modelLayer?.capabilities; if (Array.isArray(capabilities)) { for (const cap of capabilities) { - if (typeof cap === 'string') { + if (typeof cap === "string") { allCapabilities.add(cap); } } } // Collect trust relationships from agent_collaboration.peer_agents - const collab = data.agent_collaboration as Record | undefined; + const collab = data.agent_collaboration as + | Record + | undefined; const peerAgents = collab?.peer_agents; if (Array.isArray(peerAgents)) { for (const peer of peerAgents) { const p = peer as Record; trustRelationships.push({ from: agentId, - to: String(p.agent_id ?? 'unknown'), - type: String(p.role ?? 'peer'), + to: String(p.agent_id ?? "unknown"), + type: String(p.role ?? "peer"), }); } } } const manifest: CompositeManifest = { - schema: 'composite-trust-manifest/v1', + schema: "composite-trust-manifest/v1", generated_at: new Date().toISOString(), agent_count: boms.length, agents, diff --git a/packages/agentbom-cli/src/export-dashboard.test.ts b/packages/agentbom-cli/src/export-dashboard.test.ts index 87680cf..64d08d4 100644 --- a/packages/agentbom-cli/src/export-dashboard.test.ts +++ b/packages/agentbom-cli/src/export-dashboard.test.ts @@ -4,11 +4,17 @@ * fleets, BOM dependency graphs, compliance heatmaps, and audit log search with * temporal filtering". */ -import { describe, expect, it, spyOn } from 'bun:test'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { validateAgentBOM } from '@wasmagent/agentbom-core'; +import { describe, expect, it, spyOn } from "bun:test"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateAgentBOM } from "@wasmagent/agentbom-core"; import { type AgentBOM, assessControls, @@ -19,191 +25,200 @@ import { mergeAuditEntries, postureScore, renderDependencyGraphSVG, -} from './export-dashboard.js'; -import { runCommand } from './index.js'; +} from "./export-dashboard.js"; +import { runCommand } from "./index.js"; const BOM_A: AgentBOM = { - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'fleet-a', - agent_name: 'Fleet Agent A', - agent_version: '1.0.0', - deployment_context: 'production', - generated_at: '2026-07-20T00:00:00Z', + agent_id: "fleet-a", + agent_name: "Fleet Agent A", + agent_version: "1.0.0", + deployment_context: "production", + generated_at: "2026-07-20T00:00:00Z", }, model_layer: { - provider: 'anthropic', - model_id: 'claude-fable-5', - model_version: '2026-07', - capabilities: ['tool_use'], + provider: "anthropic", + model_id: "claude-fable-5", + model_version: "2026-07", + capabilities: ["tool_use"], }, tool_layer: [ { - tool_id: 'fs-read', - tool_name: 'read_file', - source: 'builtin', - permissions: ['fs:read'], + tool_id: "fs-read", + tool_name: "read_file", + source: "builtin", + permissions: ["fs:read"], risk_signals: [], }, { - tool_id: 'gh-mcp', - tool_name: 'create_pr', - source: 'mcp', - mcp_server_id: 'github', - permissions: ['network:outbound'], - risk_signals: ['privilege_escalation'], + tool_id: "gh-mcp", + tool_name: "create_pr", + source: "mcp", + mcp_server_id: "github", + permissions: ["network:outbound"], + risk_signals: ["privilege_escalation"], }, ], permission_layer: { - granted_scopes: ['fs:read', 'network:outbound'], + granted_scopes: ["fs:read", "network:outbound"], data_access: [], credential_references: [], }, evidence_layer: { - aep_references: ['aep-1'], + aep_references: ["aep-1"], evidence_hashes: [ - { type: 'system_prompt', hash: 'sha256:abc', timestamp: '2026-07-20T00:00:00Z' }, + { + type: "system_prompt", + hash: "sha256:abc", + timestamp: "2026-07-20T00:00:00Z", + }, ], }, risk_layer: [ { - risk_id: 'r1', - severity: 'high', - category: 'privilege_escalation', - description: 'PR creation risk', - status: 'open', + risk_id: "r1", + severity: "high", + category: "privilege_escalation", + description: "PR creation risk", + status: "open", }, ], audit_log: [ { - timestamp: '2026-07-20T10:00:00Z', - event_type: 'tool.invoke', - actor: 'agent-a', - resource: 'read_file', - outcome: 'success', + timestamp: "2026-07-20T10:00:00Z", + event_type: "tool.invoke", + actor: "agent-a", + resource: "read_file", + outcome: "success", }, { - timestamp: '2026-07-20T11:00:00Z', - event_type: 'tool.invoke', - actor: 'agent-a', - resource: 'create_pr', - outcome: 'failure', - details: { reason: 'forbidden' }, + timestamp: "2026-07-20T11:00:00Z", + event_type: "tool.invoke", + actor: "agent-a", + resource: "create_pr", + outcome: "failure", + details: { reason: "forbidden" }, }, ], - attestation: { generator: 'trust-cli', generator_version: '0.0.0-research' }, + attestation: { generator: "trust-cli", generator_version: "0.0.0-research" }, }; const BOM_B: AgentBOM = { - agentbom_version: '0.1', + agentbom_version: "0.1", identity: { - agent_id: 'fleet-b', - agent_name: 'Fleet Agent B', - agent_version: '0.9.0', - deployment_context: 'staging', - generated_at: '2026-07-19T00:00:00Z', + agent_id: "fleet-b", + agent_name: "Fleet Agent B", + agent_version: "0.9.0", + deployment_context: "staging", + generated_at: "2026-07-19T00:00:00Z", }, tool_layer: [ { - tool_id: 'grep', - tool_name: 'grep', - source: 'builtin', - permissions: ['fs:read'], + tool_id: "grep", + tool_name: "grep", + source: "builtin", + permissions: ["fs:read"], risk_signals: [], }, ], permission_layer: { - granted_scopes: ['fs:read'], + granted_scopes: ["fs:read"], data_access: [], credential_references: [], }, risk_layer: [], audit_log: [ { - timestamp: '2026-07-18T09:00:00Z', - event_type: 'startup', - actor: 'system', - resource: 'agent-b', - outcome: 'success', + timestamp: "2026-07-18T09:00:00Z", + event_type: "startup", + actor: "system", + resource: "agent-b", + outcome: "success", }, ], - attestation: { generator: 'trust-cli', generator_version: '0.0.0-research' }, + attestation: { generator: "trust-cli", generator_version: "0.0.0-research" }, }; const FLEET = [ - { bom: BOM_A, source: 'agent-a.json' }, - { bom: BOM_B, source: 'agent-b.json' }, + { bom: BOM_A, source: "agent-a.json" }, + { bom: BOM_B, source: "agent-b.json" }, ]; -describe('fleet dashboard fixtures are valid AgentBOMs', () => { - it('BOM_A and BOM_B pass schema validation', () => { +describe("fleet dashboard fixtures are valid AgentBOMs", () => { + it("BOM_A and BOM_B pass schema validation", () => { expect(validateAgentBOM(BOM_A).valid).toBe(true); expect(validateAgentBOM(BOM_B).valid).toBe(true); }); }); -describe('assessControls', () => { - it('returns a control result for each of the six trust controls', () => { +describe("assessControls", () => { + it("returns a control result for each of the six trust controls", () => { const controls = assessControls(BOM_A); expect(controls).toHaveLength(6); expect(controls.map((c) => c.name)).toEqual([ - 'identity', - 'tools', - 'risks', - 'permissions', - 'evidence', - 'attestation', + "identity", + "tools", + "risks", + "permissions", + "evidence", + "attestation", ]); }); - it('flags privilege-escalation tool signals as a warning', () => { - const tools = assessControls(BOM_A).find((c) => c.name === 'tools'); - expect(tools?.status).toBe('warn'); + it("flags privilege-escalation tool signals as a warning", () => { + const tools = assessControls(BOM_A).find((c) => c.name === "tools"); + expect(tools?.status).toBe("warn"); }); - it('flags a single open high risk as a warning (not a failure)', () => { - const risks = assessControls(BOM_A).find((c) => c.name === 'risks'); - expect(risks?.status).toBe('warn'); + it("flags a single open high risk as a warning (not a failure)", () => { + const risks = assessControls(BOM_A).find((c) => c.name === "risks"); + expect(risks?.status).toBe("warn"); }); - it('fails evidence control when no evidence or AEP references exist', () => { - const evidence = assessControls(BOM_B).find((c) => c.name === 'evidence'); - expect(evidence?.status).toBe('fail'); + it("fails evidence control when no evidence or AEP references exist", () => { + const evidence = assessControls(BOM_B).find((c) => c.name === "evidence"); + expect(evidence?.status).toBe("fail"); }); }); -describe('postureScore', () => { - it('returns an integer between 0 and 100', () => { +describe("postureScore", () => { + it("returns an integer between 0 and 100", () => { const score = postureScore(BOM_A); expect(Number.isInteger(score)).toBe(true); expect(score).toBeGreaterThanOrEqual(0); expect(score).toBeLessThanOrEqual(100); }); - it('scores a clean agent higher than one with open warnings', () => { + it("scores a clean agent higher than one with open warnings", () => { expect(postureScore(BOM_B)).toBeGreaterThan(postureScore(BOM_A)); }); }); -describe('maxSeverity', () => { - it('returns the highest severity among risks', () => { - expect(maxSeverity(BOM_A)).toBe('high'); +describe("maxSeverity", () => { + it("returns the highest severity among risks", () => { + expect(maxSeverity(BOM_A)).toBe("high"); }); - it('returns an empty string when there are no risks', () => { - expect(maxSeverity(BOM_B)).toBe(''); + it("returns an empty string when there are no risks", () => { + expect(maxSeverity(BOM_B)).toBe(""); }); }); -describe('buildDependencyGraph', () => { - it('links the agent to its model, MCP servers, tool groups, and scopes', () => { +describe("buildDependencyGraph", () => { + it("links the agent to its model, MCP servers, tool groups, and scopes", () => { const { nodes, edges } = buildDependencyGraph(BOM_A); const labels = nodes.map((n) => n.label); const agentId = BOM_A.identity?.agent_id as string; expect(labels).toEqual( - expect.arrayContaining(['claude-fable-5', 'github', 'fs:read', 'network:outbound']), + expect.arrayContaining([ + "claude-fable-5", + "github", + "fs:read", + "network:outbound", + ]), ); - expect(labels.some((l) => l.startsWith('builtin tools'))).toBe(true); + expect(labels.some((l) => l.startsWith("builtin tools"))).toBe(true); // Every dependent node has an edge back to the agent. for (const n of nodes) { if (n.id === agentId) continue; @@ -212,167 +227,198 @@ describe('buildDependencyGraph', () => { }); }); -describe('renderDependencyGraphSVG', () => { - it('emits an inline SVG with edges and nodes', () => { +describe("renderDependencyGraphSVG", () => { + it("emits an inline SVG with edges and nodes", () => { const svg = renderDependencyGraphSVG(BOM_A); - expect(svg).toContain(' { - it('merges audit logs across the fleet sorted oldest-first', () => { +describe("mergeAuditEntries", () => { + it("merges audit logs across the fleet sorted oldest-first", () => { const entries = mergeAuditEntries(FLEET); expect(entries).toHaveLength(3); // BOM_B's startup event is the oldest and should sort first. - expect(entries[0].event_type).toBe('startup'); - expect(entries[0].agent).toBe('Fleet Agent B'); + expect(entries[0].event_type).toBe("startup"); + expect(entries[0].agent).toBe("Fleet Agent B"); // Remaining entries are BOM_A's, in chronological order. - expect(entries[1].resource).toBe('read_file'); - expect(entries[2].resource).toBe('create_pr'); + expect(entries[1].resource).toBe("read_file"); + expect(entries[2].resource).toBe("create_pr"); }); }); -describe('generateFleetDashboardHTML', () => { +describe("generateFleetDashboardHTML", () => { const html = generateFleetDashboardHTML(FLEET); - it('renders the four required analytics sections', () => { - expect(html).toContain('Fleet Trust Analytics Dashboard'); - expect(html).toContain('Trust Posture Across the Fleet'); - expect(html).toContain('Compliance Heatmap'); - expect(html).toContain('BOM Dependency Graphs'); - expect(html).toContain('Audit Log Search'); + it("renders the four required analytics sections", () => { + expect(html).toContain("Fleet Trust Analytics Dashboard"); + expect(html).toContain("Trust Posture Across the Fleet"); + expect(html).toContain("Compliance Heatmap"); + expect(html).toContain("BOM Dependency Graphs"); + expect(html).toContain("Audit Log Search"); }); - it('renders the fleet aggregate stats', () => { - expect(html).toContain('2 agent(s)'); - expect(html).toContain('Open Critical/High'); + it("renders the fleet aggregate stats", () => { + expect(html).toContain("2 agent(s)"); + expect(html).toContain("Open Critical/High"); }); - it('renders a dependency graph SVG per agent', () => { - expect(html).toContain(' { + expect(html).toContain(" { - expect(html).toContain('hm-pass'); - expect(html).toContain('hm-warn'); - expect(html).toContain('hm-fail'); + it("renders the compliance heatmap cells", () => { + expect(html).toContain("hm-pass"); + expect(html).toContain("hm-warn"); + expect(html).toContain("hm-fail"); }); - it('wires up temporal (date-range) audit filtering in-browser', () => { + it("wires up temporal (date-range) audit filtering in-browser", () => { // Temporal filter inputs + the in-browser filter routine. expect(html).toContain('id="f-from"'); expect(html).toContain('id="f-to"'); - expect(html).toContain('filterAudit'); + expect(html).toContain("filterAudit"); // Audit rows carry epoch-ms timestamps used by the date-range filter. expect(html).toContain('data-ts="'); - expect(html).toContain('audit-body'); + expect(html).toContain("audit-body"); }); - it('escapes agent-controlled content in audit details', () => { - expect(html).toContain('create_pr'); - expect(html).not.toContain('