From c6e3d6c3ec635f68dec8dd70c726cfa4235d9533 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 29 Jul 2026 22:48:16 +0800 Subject: [PATCH] feat: migrate agentbom CLI from agent-trust-infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @wasmagent/agentbom-cli package — full developer CLI for AgentBOM workflows: Commands: chain, agentbom (validate/generate/diff/inspect/pipeline), mcp-posture (validate/diff/inspect), passport (validate/inspect/sign/verify), compliance-check, report, trust (publish/pull/subscribe/verify-chain/diff), sigstore-verify, compose-team, export-dashboard, export-marketplace, migrate. Includes compliance profiles: soc2-2024, iso27001-2022, eidas-controlled, eu-ai-act-annex-iv (new in Milestone 5). Imports updated from relative ../../packages paths to published package names: - @wasmagent/agentbom-core (workspace) - @wasmagent/agentbom-core/pipeline (new subpath export) - @wasmagent/mcp-posture (^0.2.0 from wasmagent-js) - @openagentaudit/passport (^0.5.2) 348 tests pass across 11 test files. --- .changeset/cli-migration.md | 29 + bun.lock | 51 +- .../examples/bscode-agent/agentbom.json | 142 ++ .../examples/bscode-agent/posture.json | 121 ++ .../examples/bscode-agent/trust-passport.json | 68 + packages/agentbom-cli/package.json | 60 + .../profiles/eidas-controlled.json | 43 + .../profiles/eu-ai-act-annex-iv.json | 138 ++ .../agentbom-cli/profiles/iso27001-2022.json | 41 + packages/agentbom-cli/profiles/soc2-2024.json | 42 + packages/agentbom-cli/src/agentbom-diff.ts | 80 + packages/agentbom-cli/src/agentbom-inspect.ts | 37 + .../agentbom-cli/src/agentbom-pipeline.ts | 84 + .../agentbom-cli/src/audit-report.test.ts | 399 ++++ packages/agentbom-cli/src/audit-report.ts | 890 +++++++++ packages/agentbom-cli/src/bom-generate.ts | 352 ++++ packages/agentbom-cli/src/chain.test.ts | 125 ++ packages/agentbom-cli/src/chain.ts | 459 +++++ .../agentbom-cli/src/compliance-check.test.ts | 703 +++++++ packages/agentbom-cli/src/compliance-check.ts | 833 +++++++++ .../eidas-controlled-known-bad.json | 94 + .../eidas-controlled-known-good.json | 68 + .../iso27001-2022-known-bad.json | 80 + .../iso27001-2022-known-good.json | 90 + .../soc2-2024-known-bad.json | 80 + .../soc2-2024-known-good.json | 82 + .../agentbom-cli/src/compose-team.test.ts | 330 ++++ packages/agentbom-cli/src/compose-team.ts | 108 ++ .../agentbom-cli/src/export-dashboard.test.ts | 378 ++++ packages/agentbom-cli/src/export-dashboard.ts | 1663 +++++++++++++++++ .../agentbom-cli/src/export-marketplace.ts | 256 +++ packages/agentbom-cli/src/index.ts | 829 ++++++++ packages/agentbom-cli/src/mcp-posture-diff.ts | 81 + .../agentbom-cli/src/mcp-posture-inspect.ts | 40 + .../agentbom-cli/src/mcp-posture-validate.ts | 35 + packages/agentbom-cli/src/passport-inspect.ts | 37 + packages/agentbom-cli/src/passport-sign.ts | 197 ++ .../agentbom-cli/src/passport-validate.ts | 67 + .../src/passport-verify-signed.ts | 276 +++ .../src/regulatory-report.test.ts | 370 ++++ .../agentbom-cli/src/regulatory-report.ts | 1093 +++++++++++ packages/agentbom-cli/src/sigstore-verify.ts | 473 +++++ packages/agentbom-cli/src/trust-diff.test.ts | 637 +++++++ packages/agentbom-cli/src/trust-diff.ts | 412 ++++ .../agentbom-cli/src/trust-publish.test.ts | 582 ++++++ packages/agentbom-cli/src/trust-publish.ts | 325 ++++ packages/agentbom-cli/src/trust-pull.test.ts | 699 +++++++ packages/agentbom-cli/src/trust-pull.ts | 512 +++++ .../agentbom-cli/src/trust-subscribe.test.ts | 456 +++++ packages/agentbom-cli/src/trust-subscribe.ts | 464 +++++ .../src/trust-verify-chain.test.ts | 582 ++++++ .../agentbom-cli/src/trust-verify-chain.ts | 590 ++++++ packages/agentbom-cli/tsconfig.json | 10 + packages/agentbom-core/package.json | 4 + 54 files changed, 16693 insertions(+), 4 deletions(-) create mode 100644 .changeset/cli-migration.md create mode 100644 packages/agentbom-cli/examples/bscode-agent/agentbom.json create mode 100644 packages/agentbom-cli/examples/bscode-agent/posture.json create mode 100644 packages/agentbom-cli/examples/bscode-agent/trust-passport.json create mode 100644 packages/agentbom-cli/package.json create mode 100644 packages/agentbom-cli/profiles/eidas-controlled.json create mode 100644 packages/agentbom-cli/profiles/eu-ai-act-annex-iv.json create mode 100644 packages/agentbom-cli/profiles/iso27001-2022.json create mode 100644 packages/agentbom-cli/profiles/soc2-2024.json create mode 100644 packages/agentbom-cli/src/agentbom-diff.ts create mode 100644 packages/agentbom-cli/src/agentbom-inspect.ts create mode 100644 packages/agentbom-cli/src/agentbom-pipeline.ts create mode 100644 packages/agentbom-cli/src/audit-report.test.ts create mode 100644 packages/agentbom-cli/src/audit-report.ts create mode 100644 packages/agentbom-cli/src/bom-generate.ts create mode 100644 packages/agentbom-cli/src/chain.test.ts create mode 100644 packages/agentbom-cli/src/chain.ts create mode 100644 packages/agentbom-cli/src/compliance-check.test.ts create mode 100644 packages/agentbom-cli/src/compliance-check.ts create mode 100644 packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-bad.json create mode 100644 packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-good.json create mode 100644 packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-bad.json create mode 100644 packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-good.json create mode 100644 packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-bad.json create mode 100644 packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-good.json create mode 100644 packages/agentbom-cli/src/compose-team.test.ts create mode 100644 packages/agentbom-cli/src/compose-team.ts create mode 100644 packages/agentbom-cli/src/export-dashboard.test.ts create mode 100644 packages/agentbom-cli/src/export-dashboard.ts create mode 100644 packages/agentbom-cli/src/export-marketplace.ts create mode 100644 packages/agentbom-cli/src/index.ts create mode 100644 packages/agentbom-cli/src/mcp-posture-diff.ts create mode 100644 packages/agentbom-cli/src/mcp-posture-inspect.ts create mode 100644 packages/agentbom-cli/src/mcp-posture-validate.ts create mode 100644 packages/agentbom-cli/src/passport-inspect.ts create mode 100644 packages/agentbom-cli/src/passport-sign.ts create mode 100644 packages/agentbom-cli/src/passport-validate.ts create mode 100644 packages/agentbom-cli/src/passport-verify-signed.ts create mode 100644 packages/agentbom-cli/src/regulatory-report.test.ts create mode 100644 packages/agentbom-cli/src/regulatory-report.ts create mode 100644 packages/agentbom-cli/src/sigstore-verify.ts create mode 100644 packages/agentbom-cli/src/trust-diff.test.ts create mode 100644 packages/agentbom-cli/src/trust-diff.ts create mode 100644 packages/agentbom-cli/src/trust-publish.test.ts create mode 100644 packages/agentbom-cli/src/trust-publish.ts create mode 100644 packages/agentbom-cli/src/trust-pull.test.ts create mode 100644 packages/agentbom-cli/src/trust-pull.ts create mode 100644 packages/agentbom-cli/src/trust-subscribe.test.ts create mode 100644 packages/agentbom-cli/src/trust-subscribe.ts create mode 100644 packages/agentbom-cli/src/trust-verify-chain.test.ts create mode 100644 packages/agentbom-cli/src/trust-verify-chain.ts create mode 100644 packages/agentbom-cli/tsconfig.json diff --git a/.changeset/cli-migration.md b/.changeset/cli-migration.md new file mode 100644 index 0000000..dd11799 --- /dev/null +++ b/.changeset/cli-migration.md @@ -0,0 +1,29 @@ +--- +"@wasmagent/agentbom-cli": minor +"@wasmagent/agentbom-core": patch +--- + +feat: migrate agentbom CLI from agent-trust-infra; add pipeline subpath export + +**@wasmagent/agentbom-cli** (new package): +Full developer CLI for AgentBOM workflows: +- BOM generation, validation, inspection, and diff +- MCP Posture validation, inspection, and diff +- Trust chain end-to-end demo (`chain` command) +- Compliance checking against SOC2, ISO27001, eIDAS, and EU AI Act Annex IV profiles +- Regulatory reporting (`report --framework soc2|iso27001|ai-act`) +- Trust chain verification and drift monitoring +- Sigstore bundle verification +- HTML fleet dashboard export +- Marketplace trust package export +- Multi-agent team composition validation +- AgentBOM migration across schema versions + +Includes compliance profile data: +- `profiles/soc2-2024.json` +- `profiles/iso27001-2022.json` +- `profiles/eidas-controlled.json` +- `profiles/eu-ai-act-annex-iv.json` (new in Milestone 5) + +**@wasmagent/agentbom-core** (patch): +- Add `./pipeline` subpath export so CLI can import `PipelineConfig` and `runPipeline` directly diff --git a/bun.lock b/bun.lock index 96e7062..75eafa7 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "packages/agentbom-autogen": { "name": "@wasmagent/agentbom-autogen", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@wasmagent/agentbom-core": "workspace:*", }, @@ -23,9 +23,24 @@ "typescript": "^5.9.3", }, }, + "packages/agentbom-cli": { + "name": "@wasmagent/agentbom-cli", + "version": "0.1.0", + "bin": { + "agentbom": "./dist/index.js", + "agent-trust": "./dist/index.js", + }, + "dependencies": { + "@openagentaudit/passport": "^0.5.2", + "@wasmagent/aep": "^1.21.1", + "@wasmagent/agentbom-core": "workspace:*", + "@wasmagent/mcp-posture": "^0.2.0", + "prom-client": "^15.1.0", + }, + }, "packages/agentbom-core": { "name": "@wasmagent/agentbom-core", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@wasmagent/protocol": "^0.1.7", "ajv": "^8.20.0", @@ -38,7 +53,7 @@ }, "packages/agentbom-langchain": { "name": "@wasmagent/agentbom-langchain", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@wasmagent/agentbom-core": "workspace:*", }, @@ -50,7 +65,7 @@ }, "packages/agentbom-llamaindex": { "name": "@wasmagent/agentbom-llamaindex", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@wasmagent/agentbom-core": "workspace:*", }, @@ -122,12 +137,24 @@ "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@noble/ed25519": ["@noble/ed25519@2.3.0", "", {}, "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@openagentaudit/core": ["@openagentaudit/core@0.5.2", "", { "dependencies": { "@openagentaudit/schema": "0.5.1" } }, "sha512-dkPFIPuegWWJXTD5wjFMDYBjIO9WrKvLXXBl/ZPUmvn1h8TAFCM9+pwtS7qx/WJaeWTqiGeQtcoXAaPR6vFTAg=="], + + "@openagentaudit/passport": ["@openagentaudit/passport@0.5.2", "", { "dependencies": { "@noble/ed25519": "^2.2.3", "@noble/hashes": "^1.7.2", "@openagentaudit/core": "0.5.2", "@openagentaudit/schema": "0.5.1" } }, "sha512-bvZRBS7g77uq2k9ZHYfeWWFVA8tx+T091xVajKOVNh7YFJSLoy8skJRInd85M7Vj/9jm/1Sh9DnRM4Kj3/QxAw=="], + + "@openagentaudit/schema": ["@openagentaudit/schema@0.5.1", "", { "dependencies": { "zod": "^3.23.0" } }, "sha512-yMmIE9IjZnmbaZ6eTYRhHYtxo6HUZxPFXFC5BAKeZXi7KSgVCIOGIjEwp/mwkSa0ioXBJjDGeTrRlgSe31uMaA=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/c9cSBRermWDv85oufLhoH6XRLOVbvzJLRd+WLyfJCP+i0HFLQj4PVNDrHcY17/ve5l8X0Oua4bJBqJUgJPnZA=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8lpCCGWZBl9PIF8w8f2iEWrLMbHBWIfJeV6l2UEGqysD6HRIM4ySj/8R7HGEzbECJ4r/gnJcHmxEoG8yjFe64A=="], @@ -144,14 +171,20 @@ "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + "@wasmagent/aep": ["@wasmagent/aep@1.21.1", "", { "dependencies": { "@noble/ed25519": "^3.1.0", "zod": "^3.23.0" } }, "sha512-Dy27Tncrzn+MoEsgJMO1LQaKRMBcf8mQ0V0M1oX32WRj7C1mNNGB2g6c/iw6am8HiFRkHfxsN+2JfE8JpYYiJA=="], + "@wasmagent/agentbom-autogen": ["@wasmagent/agentbom-autogen@workspace:packages/agentbom-autogen"], + "@wasmagent/agentbom-cli": ["@wasmagent/agentbom-cli@workspace:packages/agentbom-cli"], + "@wasmagent/agentbom-core": ["@wasmagent/agentbom-core@workspace:packages/agentbom-core"], "@wasmagent/agentbom-langchain": ["@wasmagent/agentbom-langchain@workspace:packages/agentbom-langchain"], "@wasmagent/agentbom-llamaindex": ["@wasmagent/agentbom-llamaindex@workspace:packages/agentbom-llamaindex"], + "@wasmagent/mcp-posture": ["@wasmagent/mcp-posture@0.2.0", "", { "dependencies": { "@wasmagent/protocol": "0.1.7" } }, "sha512-6qfmE/in0zhNUUOmUZJW59rpCTl0DcREcxVd0xoUFK6omoLxsJo3U9ox148P8hLw07xqzjOKMTlrRN7UthFukw=="], + "@wasmagent/protocol": ["@wasmagent/protocol@0.1.7", "", { "bin": { "wasmagent-protocol": "bin/cli.js" } }, "sha512-XOVNiky/7yGswggSr9h3IorHZal0eytCrirmV8tEJxs9pZbasKmCHdUwaZp5veGVxf7qLapsfmrq3x6nRxqA2Q=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -166,6 +199,8 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + "bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -266,6 +301,8 @@ "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -300,6 +337,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], + "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -314,6 +353,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -322,6 +363,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@wasmagent/aep/@noble/ed25519": ["@noble/ed25519@3.1.0", "", {}, "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], diff --git a/packages/agentbom-cli/examples/bscode-agent/agentbom.json b/packages/agentbom-cli/examples/bscode-agent/agentbom.json new file mode 100644 index 0000000..30b89dc --- /dev/null +++ b/packages/agentbom-cli/examples/bscode-agent/agentbom.json @@ -0,0 +1,142 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "bscode-agent-demo-001", + "agent_name": "bscode agent", + "agent_version": "0.1.0", + "deployment_context": "development", + "generated_at": "2026-06-29T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "model_version": "2025-06", + "capabilities": ["tool_use", "code_generation", "code_review", "file_editing"] + }, + "tool_layer": [ + { + "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"], + "risk_signals": [] + }, + { + "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": "content-grep", + "tool_name": "Grep", + "source": "builtin", + "permissions": ["fs:read"], + "risk_signals": [] + }, + { + "tool_id": "file-glob", + "tool_name": "Glob", + "source": "builtin", + "permissions": ["fs:read"], + "risk_signals": [] + }, + { + "tool_id": "cloudflare-docs-mcp", + "tool_name": "search_cloudflare_documentation", + "source": "mcp", + "mcp_server_id": "cloudflare-docs", + "permissions": ["network:outbound"], + "risk_signals": ["ssrf"] + }, + { + "tool_id": "github-mcp", + "tool_name": "create_or_update_pull_request", + "source": "mcp", + "mcp_server_id": "github", + "permissions": ["network:outbound", "api:github"], + "risk_signals": ["exfiltration", "privilege_escalation"] + }, + { + "tool_id": "lsp-diagnostics", + "tool_name": "get_diagnostics", + "source": "plugin", + "permissions": ["fs:read"], + "risk_signals": [] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "template_ids": ["bscode-system-v1", "bscode-implementer-v1"] + }, + "permission_layer": { + "granted_scopes": [ + "fs:read", + "fs:write", + "process:exec", + "network:outbound", + "api:github" + ], + "data_access": ["local_workspace", "github_api"], + "credential_references": ["github_token"] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [ + { + "type": "system_prompt", + "hash": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "timestamp": "2026-06-29T00:00:00Z" + } + ] + }, + "risk_layer": [ + { + "risk_id": "risk-bash-001", + "severity": "medium", + "category": "command_execution", + "description": "Bash tool allows arbitrary process execution within workspace sandbox", + "status": "accepted" + }, + { + "risk_id": "risk-github-mcp-001", + "severity": "high", + "category": "privilege_escalation", + "description": "GitHub MCP create_or_update_pull_request can modify repository state and merge code", + "status": "open" + }, + { + "risk_id": "risk-github-mcp-002", + "severity": "medium", + "category": "exfiltration", + "description": "GitHub MCP tools can read repository data and potentially exfiltrate via PR descriptions or comments", + "status": "mitigated" + }, + { + "risk_id": "risk-cf-docs-001", + "severity": "low", + "category": "ssrf", + "description": "Cloudflare Docs MCP makes outbound HTTP requests to docs.cloudflare.com", + "status": "accepted" + } + ], + "attestation": { + "generator": "agent-trust-infra/examples/bscode-agent", + "generator_version": "0.0.0-research" + } +} diff --git a/packages/agentbom-cli/examples/bscode-agent/posture.json b/packages/agentbom-cli/examples/bscode-agent/posture.json new file mode 100644 index 0000000..2441f8f --- /dev/null +++ b/packages/agentbom-cli/examples/bscode-agent/posture.json @@ -0,0 +1,121 @@ +{ + "posture_version": "0.1", + "identity": { + "snapshot_id": "posture-bscode-demo-001", + "agent_id": "bscode-agent-demo-001", + "captured_at": "2026-06-29T00:00:00Z" + }, + "servers": [ + { + "server_id": "cloudflare-docs", + "server_name": "Cloudflare Docs MCP", + "version": "1.0.0", + "provenance": "verified", + "tools": [ + { + "tool_id": "search-cf-docs", + "tool_name": "search_cloudflare_documentation", + "permissions": ["network:outbound"], + "risk_categories": ["ssrf"], + "risk_severity": "low" + } + ] + }, + { + "server_id": "github-actions", + "server_name": "GitHub Actions MCP", + "version": "2.1.3", + "provenance": "unverified", + "tools": [ + { + "tool_id": "run-workflow", + "tool_name": "run_workflow_dispatch", + "permissions": ["network:outbound", "workflow:trigger"], + "risk_categories": ["command_execution", "privilege_escalation"], + "risk_severity": "high" + }, + { + "tool_id": "create-issue", + "tool_name": "create_repository_issue", + "permissions": ["network:outbound"], + "risk_categories": ["exfiltration"], + "risk_severity": "medium" + } + ] + }, + { + "server_id": "local-filesystem", + "server_name": "Local Filesystem MCP", + "version": "0.4.2", + "provenance": "verified", + "tools": [ + { + "tool_id": "read-file", + "tool_name": "read_file", + "permissions": ["fs:read"], + "risk_categories": ["credential_access"], + "risk_severity": "medium" + }, + { + "tool_id": "write-file", + "tool_name": "write_file", + "permissions": ["fs:write"], + "risk_categories": ["command_execution"], + "risk_severity": "high" + } + ] + } + ], + "permission_graph": { + "total_tools": 5, + "total_permissions": 5, + "high_risk_tools": 2, + "permission_scopes": ["network:outbound", "workflow:trigger", "fs:read", "fs:write"] + }, + "risk_summary": [ + { + "finding_id": "finding-001", + "severity": "low", + "category": "ssrf", + "description": "search_cloudflare_documentation makes outbound HTTP requests. Scope is limited to docs.cloudflare.com.", + "tool_id": "search-cf-docs", + "owasp_mcp_ref": "MCP-02" + }, + { + "finding_id": "finding-002", + "severity": "high", + "category": "command_execution", + "description": "run_workflow_dispatch can trigger arbitrary GitHub Actions workflows, which may execute untrusted code in the CI environment.", + "tool_id": "run-workflow", + "owasp_mcp_ref": "MCP-04" + }, + { + "finding_id": "finding-003", + "severity": "medium", + "category": "exfiltration", + "description": "create_repository_issue can write arbitrary content to public repositories, potentially leaking sensitive data.", + "tool_id": "create-issue", + "owasp_mcp_ref": "MCP-01" + }, + { + "finding_id": "finding-004", + "severity": "high", + "category": "command_execution", + "description": "write_file allows arbitrary file creation on the local filesystem, which could be used to overwrite critical configuration files or inject scripts.", + "tool_id": "write-file", + "owasp_mcp_ref": "MCP-04" + }, + { + "finding_id": "finding-005", + "severity": "medium", + "category": "credential_access", + "description": "read_file can access any file on the local filesystem, including credential files, SSH keys, and environment configuration.", + "tool_id": "read-file", + "owasp_mcp_ref": "MCP-03" + } + ], + "drift": {}, + "attestation": { + "generator": "agent-trust-infra/examples/bscode-agent" + } +} diff --git a/packages/agentbom-cli/examples/bscode-agent/trust-passport.json b/packages/agentbom-cli/examples/bscode-agent/trust-passport.json new file mode 100644 index 0000000..011fbe5 --- /dev/null +++ b/packages/agentbom-cli/examples/bscode-agent/trust-passport.json @@ -0,0 +1,68 @@ +{ + "passport_version": "0.1", + "identity": { + "passport_id": "passport-bscode-demo-001", + "agent_id": "bscode-agent-demo-001", + "agent_name": "bscode agent", + "issuer": "agent-trust-infra/examples/bscode-agent", + "issuance_context": "self-issued" + }, + "agentbom_ref": { + "agentbom_id": "bscode-agent-demo-001", + "agentbom_hash": "sha256:a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", + "captured_at": "2026-06-29T00:00:00Z" + }, + "audit_ref": { + "report_id": "placeholder", + "report_hash": "sha256:b4e5d6c7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5", + "generated_at": "2026-06-29T00:00:00Z" + }, + "posture_ref": { + "snapshot_id": "posture-bscode-demo-001", + "snapshot_hash": "sha256:c5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7", + "captured_at": "2026-06-29T00:00:00Z" + }, + "evidence_summary": { + "evidence_quality": "low", + "framework_mappings": [ + { + "framework": "OWASP-MCP-Top10", + "coverage": "partial", + "note": "Demo fixture only. Not a real audit." + }, + { + "framework": "NIST-AI-RMF", + "coverage": "none", + "note": "Demo fixture only. AI risk management not yet assessed." + } + ] + }, + "risk_summary": { + "critical": 0, + "high": 0, + "medium": 1, + "low": 1, + "open_findings": 1 + }, + "validity": { + "issued_at": "2026-06-29T00:00:00Z", + "expires_at": "2026-09-27T00:00:00Z", + "renewal_triggers": [ + "agentbom_changes", + "new_high_risk_finding", + "mcp_posture_drift", + "audit_report_updated" + ] + }, + "revocation": { + "revoked": false, + "revocation_triggers": [ + "critical_security_finding", + "evidence_falsified", + "agent_decommissioned" + ] + }, + "attestation": { + "issuer": "agent-trust-infra/examples/bscode-agent" + } +} diff --git a/packages/agentbom-cli/package.json b/packages/agentbom-cli/package.json new file mode 100644 index 0000000..4c10193 --- /dev/null +++ b/packages/agentbom-cli/package.json @@ -0,0 +1,60 @@ +{ + "name": "@wasmagent/agentbom-cli", + "version": "0.1.0", + "description": "AgentBOM developer CLI \u2014 BOM generation, compliance checking, regulatory reporting, trust chain verification, drift monitoring, and marketplace export", + "type": "module", + "main": "./dist/index.js", + "bin": { + "agentbom": "./dist/index.js", + "agent-trust": "./dist/index.js" + }, + "files": [ + "dist/", + "scripts/", + "profiles/", + "README.md" + ], + "keywords": [ + "agentbom", + "mcp-posture", + "ai-trust", + "trust-passport", + "compliance", + "cli", + "mcp", + "ai-agent", + "security", + "eu-ai-act", + "soc2", + "iso27001" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/WasmAgent/agentbom.git", + "directory": "packages/agentbom-cli" + }, + "homepage": "https://github.com/WasmAgent/agentbom", + "bugs": { + "url": "https://github.com/WasmAgent/agentbom/issues" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@openagentaudit/passport": "^0.5.2", + "@wasmagent/agentbom-core": "workspace:*", + "@wasmagent/aep": "^1.21.1", + "prom-client": "^15.1.0", + "@wasmagent/mcp-posture": "^0.2.0" + }, + "scripts": { + "build": "mkdir -p dist && bun build ./src/index.ts --target node --outfile dist/index.js", + "test": "bun test src/", + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/agentbom-cli/profiles/eidas-controlled.json b/packages/agentbom-cli/profiles/eidas-controlled.json new file mode 100644 index 0000000..26f1d9a --- /dev/null +++ b/packages/agentbom-cli/profiles/eidas-controlled.json @@ -0,0 +1,43 @@ +{ + "profile_version": "0.1", + "profile_id": "eidas-controlled", + "framework": { + "name": "EIDAS", + "version": "controlled", + "description": "eIDAS compliant profile for controlled digital identity services in AI agent systems" + }, + "rules": { + "identity": { + "required_fields": ["agent_version", "deployment_context"], + "allowed_contexts": ["production"], + "requires_version": true + }, + "tool_layer": { + "max_severity": "low", + "requires_tool_inventory": true, + "blocked_permissions": [ + "filesystem:write", + "network:external", + "system:execute" + ], + "blocked_sources": ["external", "unverified"] + }, + "risk_layer": { + "requires_risk_assessment": true, + "max_unmitigated_critical": 0, + "max_unmitigated_high": 0, + "max_unmitigated_medium": 1, + "requires_mitigation_for": ["critical", "high", "medium"] + }, + "attestation": { + "requires_signature": true, + "requires_timestamp": true + } + }, + "metadata": { + "author": "WasmAgent", + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + "documentation_url": "https://digital-strategy.ec.europa.eu/en/policies/european-identity-trust" + } +} diff --git a/packages/agentbom-cli/profiles/eu-ai-act-annex-iv.json b/packages/agentbom-cli/profiles/eu-ai-act-annex-iv.json new file mode 100644 index 0000000..01941bf --- /dev/null +++ b/packages/agentbom-cli/profiles/eu-ai-act-annex-iv.json @@ -0,0 +1,138 @@ +{ + "profile_version": "0.1", + "profile_id": "eu-ai-act-annex-iv", + "framework": { + "name": "EU AI Act", + "version": "Annex IV (Regulation 2024/1689)", + "description": "EU AI Act Article 11 + Annex IV technical documentation requirements for high-risk AI systems. Compliance deadline: 2026-08-02 for GPAI systems; ongoing for Annex III systems." + }, + "rules": { + "identity": { + "required_fields": [ + "agent_id", + "agent_name", + "agent_version", + "deployment_context" + ], + "allowed_contexts": [ + "staging", + "production", + "research", + "enterprise" + ], + "requires_version": true, + "annex_iv_ref": "§1 — General description: intended purpose, version, deployment context" + }, + "model_layer": { + "requires_model_declaration": true, + "required_fields": [ + "model_id", + "provider" + ], + "annex_iv_ref": "§2 — Development process: model provenance and training procedures" + }, + "tool_layer": { + "requires_tool_inventory": true, + "max_severity": "high", + "blocked_permissions": [ + "filesystem:write unrestricted", + "network:external unrestricted", + "system:execute arbitrary" + ], + "blocked_sources": [ + "unverified-external", + "unknown" + ], + "annex_iv_ref": "§1 — Combinations with other products; §3 — Cybersecurity measures" + }, + "prompt_layer": { + "requires_prompt_hash": true, + "requires_prompt_version": true, + "annex_iv_ref": "§2 — Design specifications (system prompt is the primary design artifact)" + }, + "workflow_layer": { + "requires_workflow_definitions": false, + "annex_iv_ref": "§2 — Overall logic and key choices; §5 — Human oversight measures" + }, + "policy_definitions": { + "requires_oversight_policy": true, + "annex_iv_ref": "§5 — Human oversight measures: override mechanisms and circuit breakers" + }, + "risk_layer": { + "requires_risk_assessment": true, + "max_unmitigated_critical": 0, + "max_unmitigated_high": 1, + "max_unmitigated_medium": 5, + "requires_mitigation_for": [ + "critical", + "high" + ], + "annex_iv_ref": "§3 — Monitoring: known and foreseeable risks; §9 — Conformity assessment" + }, + "evidence_layer": { + "requires_compliance_attestation": true, + "required_evidence_types": [ + "build_attestation", + "compliance_attestation" + ], + "annex_iv_ref": "§7 — Standards applied; §9 — Conformity assessment procedure" + }, + "audit_log": { + "requires_audit_trail": true, + "annex_iv_ref": "§3 — Audit trail; §6 — Lifecycle changes; §9 — Internal audit evidence" + }, + "attestation": { + "requires_signature": true, + "requires_timestamp": true, + "requires_expiry": true, + "annex_iv_ref": "§8 — EU Declaration of Conformity: signature, timestamp, version binding" + } + }, + "annex_iv_coverage": { + "section_1_general_description": { + "covered_by": ["identity", "tool_layer"], + "status": "full" + }, + "section_2_development_process": { + "covered_by": ["model_layer", "prompt_layer", "workflow_layer", "evidence_layer"], + "status": "full" + }, + "section_3_monitoring_control": { + "covered_by": ["risk_layer", "audit_log", "policy_definitions"], + "status": "full" + }, + "section_4_performance_metrics": { + "covered_by": ["evidence_layer"], + "status": "partial", + "note": "Requires explicit evaluation_artifact entries in evidence_layer" + }, + "section_5_human_oversight": { + "covered_by": ["policy_definitions", "workflow_layer", "audit_log"], + "status": "full" + }, + "section_6_lifecycle_changes": { + "covered_by": ["identity", "audit_log"], + "status": "full" + }, + "section_7_standards_applied": { + "covered_by": ["evidence_layer"], + "status": "partial", + "note": "Requires explicit compliance_attestation entries citing standard references" + }, + "section_8_declaration_of_conformity": { + "covered_by": ["attestation"], + "status": "full" + }, + "section_9_conformity_assessment": { + "covered_by": ["evidence_layer", "audit_log", "risk_layer"], + "status": "full" + } + }, + "metadata": { + "author": "WasmAgent", + "created_at": "2026-07-29T00:00:00Z", + "updated_at": "2026-07-29T00:00:00Z", + "documentation_url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689", + "mapping_document": "specs/agentbom/ai-act-annex-iv-mapping.md" + } +} diff --git a/packages/agentbom-cli/profiles/iso27001-2022.json b/packages/agentbom-cli/profiles/iso27001-2022.json new file mode 100644 index 0000000..cc64845 --- /dev/null +++ b/packages/agentbom-cli/profiles/iso27001-2022.json @@ -0,0 +1,41 @@ +{ + "profile_version": "0.1", + "profile_id": "iso27001-2022", + "framework": { + "name": "ISO27001", + "version": "2022", + "description": "ISO/IEC 27001:2022 compliance profile for information security management in AI agent systems" + }, + "rules": { + "identity": { + "required_fields": ["agent_version", "deployment_context"], + "allowed_contexts": ["production"], + "requires_version": true + }, + "tool_layer": { + "max_severity": "high", + "requires_tool_inventory": true, + "blocked_permissions": [ + "filesystem:write unrestricted", + "network:external unauthenticated" + ], + "blocked_sources": ["unverified-external"] + }, + "risk_layer": { + "requires_risk_assessment": true, + "max_unmitigated_critical": 0, + "max_unmitigated_high": 5, + "requires_mitigation_for": ["critical"] + }, + "attestation": { + "requires_signature": true, + "requires_timestamp": true + } + }, + "metadata": { + "author": "WasmAgent", + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + "documentation_url": "https://www.iso.org/standard/85675.html" + } +} diff --git a/packages/agentbom-cli/profiles/soc2-2024.json b/packages/agentbom-cli/profiles/soc2-2024.json new file mode 100644 index 0000000..c58055f --- /dev/null +++ b/packages/agentbom-cli/profiles/soc2-2024.json @@ -0,0 +1,42 @@ +{ + "profile_version": "0.1", + "profile_id": "soc2-2024", + "framework": { + "name": "SOC2", + "version": "2024", + "description": "SOC 2 Type II compliance profile for AI agent systems, focusing on security, availability, and confidentiality" + }, + "rules": { + "identity": { + "required_fields": ["agent_version", "deployment_context"], + "allowed_contexts": ["staging", "production"], + "requires_version": true + }, + "tool_layer": { + "max_severity": "medium", + "requires_tool_inventory": true, + "blocked_permissions": [ + "filesystem:write unrestricted", + "network:external unrestricted", + "system:execute arbitrary" + ], + "blocked_sources": ["unverified-external", "unknown"] + }, + "risk_layer": { + "requires_risk_assessment": true, + "max_unmitigated_critical": 0, + "max_unmitigated_high": 2, + "requires_mitigation_for": ["critical", "high"] + }, + "attestation": { + "requires_signature": true, + "requires_timestamp": true + } + }, + "metadata": { + "author": "WasmAgent", + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", + "documentation_url": "https://www.aicpa.org/soc4so" + } +} diff --git a/packages/agentbom-cli/src/agentbom-diff.ts b/packages/agentbom-cli/src/agentbom-diff.ts new file mode 100644 index 0000000..d9787e4 --- /dev/null +++ b/packages/agentbom-cli/src/agentbom-diff.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + diffAgentBOM, + formatAgentBOMDiff, + validateAgentBOM, +} from '@wasmagent/agentbom-core'; + +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'); + } catch { + console.error(`Error: cannot read file "${oldPath}"`); + return 1; + } + + let newRaw: string; + try { + newRaw = readFileSync(newPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${newPath}"`); + return 1; + } + + let oldData: unknown; + try { + oldData = JSON.parse(oldRaw); + } catch { + console.error(`Error: "${oldPath}" is not valid JSON`); + return 1; + } + + let newData: unknown; + try { + newData = JSON.parse(newRaw); + } catch { + console.error(`Error: "${newPath}" is not valid JSON`); + return 1; + } + + const oldResult = validateAgentBOM(oldData); + if (!oldResult.valid) { + console.error(`Validation failed for old file "${oldPath}":`); + for (const err of oldResult.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const newResult = validateAgentBOM(newData); + if (!newResult.valid) { + console.error(`Validation failed for new file "${newPath}":`); + for (const err of newResult.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const oldBom = oldData as Record; + const newBom = newData as Record; + const diff = diffAgentBOM(oldBom, newBom); + + console.log('Comparing AgentBOMs:'); + console.log(` old: ${oldPath}`); + console.log(` new: ${newPath}`); + console.log(); + + const output = formatAgentBOMDiff(diff); + console.log(output); + + if (diff.isEmpty()) { + return 0; + } + + return 1; +} diff --git a/packages/agentbom-cli/src/agentbom-inspect.ts b/packages/agentbom-cli/src/agentbom-inspect.ts new file mode 100644 index 0000000..ead4749 --- /dev/null +++ b/packages/agentbom-cli/src/agentbom-inspect.ts @@ -0,0 +1,37 @@ +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'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + const result = validateAgentBOM(data); + if (!result.valid) { + console.error(`Validation failed for "${resolvedPath}":`); + for (const err of result.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const bom = data as Record; + console.log(inspectAgentBOM(bom)); + + return 0; +} diff --git a/packages/agentbom-cli/src/agentbom-pipeline.ts b/packages/agentbom-cli/src/agentbom-pipeline.ts new file mode 100644 index 0000000..bd6bea5 --- /dev/null +++ b/packages/agentbom-cli/src/agentbom-pipeline.ts @@ -0,0 +1,84 @@ +import { resolve } from 'node:path'; +import { type PipelineConfig, runPipeline } from '@wasmagent/agentbom-core/pipeline'; + +/** + * `agentbom pipeline [--partitions N] [--no-incremental]` + * + * Enterprise-grade BOM processing pipeline command. Reads BOM artifacts from a + * file or directory (auto-detecting NDJSON, JSON array, or single BOM format), + * validates each artifact, and reports per-artifact results plus aggregate metrics. + */ +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]'); + return 1; + } + + const filePath = resolve(args[0]); + const config: Partial = {}; + + // Parse optional flags + let i = 1; + while (i < args.length) { + const flag = args[i]; + 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]}"`); + return 1; + } + config.partitionCount = n; + i += 2; + } else if (flag === '--no-incremental') { + config.emitIncremental = false; + i += 1; + } else { + console.error(`Error: unknown flag "${flag}"`); + return 1; + } + } + + const partLabel = config.partitionCount + ? ` (${config.partitionCount} partition${config.partitionCount > 1 ? 's' : ''})` + : ''; + console.log(`Processing BOM artifacts from: ${filePath}${partLabel}`); + + const wallStart = Date.now(); + const { results, metrics } = await runPipeline(filePath, config); + const wallMs = Date.now() - wallStart; + + // Per-artifact results + for (const result of results) { + const status = result.valid ? '✓' : '✗'; + console.log( + ` ${status} ${result.artifactId} — ${result.durationMs.toFixed(1)}ms — ${result.sizeBytes} bytes`, + ); + if (!result.valid) { + for (const err of result.validation.errors.slice(0, 5)) { + console.log(` ${err}`); + } + if (result.validation.errors.length > 5) { + console.log(` ... and ${result.validation.errors.length - 5} more errors`); + } + } + } + + // Summary + console.log(); + 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(` Peak heap: ${metrics.peakMemoryBytes} bytes`); + + if (config.partitionCount && config.partitionCount > 1) { + console.log(' Partition counts:'); + for (const [p, count] of metrics.partitionCounts) { + console.log(` Partition ${p}: ${count}`); + } + } + + return metrics.totalErrors > 0 ? 1 : 0; +} diff --git a/packages/agentbom-cli/src/audit-report.test.ts b/packages/agentbom-cli/src/audit-report.test.ts new file mode 100644 index 0000000..3311707 --- /dev/null +++ b/packages/agentbom-cli/src/audit-report.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from 'bun:test'; +import { generateAuditReport, generateMultiAgentAuditReport } from './audit-report.js'; + +// --- Minimal valid AgentBOM fixture --- + +const baseBOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'agent-001', + agent_name: 'Agent Alpha', + generated_at: '2026-01-15T10:00:00Z', + }, +}; + +const baseBOMv2 = { + agentbom_version: '0.1', + identity: { + 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', () => { + const report = generateAuditReport(baseBOM as never); + expect(report).toContain('AGENT TRUST AUDIT REPORT'); + expect(report).toContain('Agent Alpha'); + expect(report).toContain('agent-001'); + }); + + 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: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'); + }); + + 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: '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'); + }); + + 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' }], + }, + }; + const report = generateAuditReport(bom as never); + expect(report).toContain('EVIDENCE CITATIONS'); + expect(report).toContain('aep://event/123'); + expect(report).toContain('sha256: abc123'); + }); + + it('shows no audit trail when empty', () => { + const report = generateAuditReport(baseBOM as never); + 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'); + }); + + it('includes agent roster with peer counts', () => { + const bom1 = { + ...baseBOM, + agent_collaboration: { + peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + shared_resources: [ + { 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'); + }); + + it('includes collaboration topology summary', () => { + const bom1 = { + ...baseBOM, + agent_collaboration: { + peer_agents: [ + { 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'], + max_delegation_depth: 2, + }, + ], + shared_resources: [ + { + 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'); + }); + + 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' }, + }, + ], + agent_collaboration: { + 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', + }, + ], + }; + 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', () => { + const bom1 = { + ...baseBOM, + audit_log: [ + { + 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' }, + ], + }, + }; + const bom2 = { + ...baseBOMv2, + audit_log: [ + { + 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'); + }); + + 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', + }, + ], + agent_collaboration: { + 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', + }, + ], + }; + 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', () => { + const bom1 = { + ...baseBOM, + audit_log: [ + { + timestamp: '2026-01-15T10:00:00Z', + event_type: 'tool_call', + actor: 'agent-001', + outcome: 'success', + }, + ], + }; + const bom2 = { + ...baseBOMv2, + audit_log: [ + { + 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.'); + }); + + 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: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' }, + ], + }; + const bom2 = { + ...baseBOMv2, + audit_log: [ + { + 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', + }, + ], + }; + 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)', () => { + 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.'); + }); + + 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: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: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'); + 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 new file mode 100644 index 0000000..c25ca24 --- /dev/null +++ b/packages/agentbom-cli/src/audit-report.ts @@ -0,0 +1,890 @@ +#!/usr/bin/env bun +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateAgentBOM } from '@wasmagent/agentbom-core'; + +/** Audit log entry structure */ +interface AuditLogEntry { + timestamp: string; + event_type: string; + actor: string; + resource?: string; + outcome?: 'success' | 'failure' | 'partial'; + details?: Record; +} + +/** Evidence hash entry structure */ +interface EvidenceHash { + type: string; + hash: string; + timestamp?: string; +} + +/** Evidence layer structure */ +interface EvidenceLayer { + aep_references?: string[]; + evidence_hashes?: EvidenceHash[]; +} + +/** Peer agent in a collaboration topology */ +interface PeerAgent { + agent_id: string; + agent_name?: string; + role: string; + trust_level?: string; + agentbom_ref?: string; +} + +/** Delegation boundary rule */ +interface DelegationBoundary { + boundary_id: string; + direction: string; + constraint_type: string; + description?: string; + target_agents?: string[]; + allowed_actions?: string[]; + max_delegation_depth?: number; +} + +/** Shared resource across agent boundaries */ +interface SharedResource { + resource_id: string; + resource_type: string; + access_pattern: string; + description?: string; + accessing_agents?: string[]; + isolation_level?: string; +} + +/** Agent collaboration topology */ +interface AgentCollaboration { + peer_agents?: PeerAgent[]; + delegation_boundaries?: DelegationBoundary[]; + shared_resources?: SharedResource[]; +} + +/** AgentBOM structure (partial) */ +interface AgentBOM { + agentbom_version: string; + identity?: { + agent_id?: string; + agent_name?: string; + generated_at?: string; + }; + audit_log?: AuditLogEntry[]; + evidence_layer?: EvidenceLayer; + risk_layer?: Array<{ + risk_id: string; + severity: string; + category: string; + description: string; + status: string; + }>; + agent_collaboration?: AgentCollaboration; +} + +export function generateAuditReport(bom: AgentBOM): string { + const lines: string[] = []; + + // Header + 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'}`); + } + + // Audit Summary Statistics + const auditLogs = bom.audit_log ?? []; + const evidenceLayer = bom.evidence_layer; + const riskLayer = bom.risk_layer ?? []; + + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' AUDIT SUMMARY'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + + // Event statistics by outcome + const outcomeStats = { + success: 0, + failure: 0, + partial: 0, + unknown: 0, + }; + + 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++; + else outcomeStats.unknown++; + } + + lines.push(` Total Audit Events: ${auditLogs.length}`); + 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}`); + + // Risk Summary + if (riskLayer.length > 0) { + lines.push(''); + lines.push('Risk Summary:'); + const riskBySeverity = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + }; + + for (const risk of riskLayer) { + const severity = risk.severity.toLowerCase() as keyof typeof riskBySeverity; + if (severity in riskBySeverity) { + riskBySeverity[severity]++; + } + } + + lines.push(` Critical: ${riskBySeverity.critical}`); + lines.push(` High: ${riskBySeverity.high}`); + lines.push(` Medium: ${riskBySeverity.medium}`); + lines.push(` Low: ${riskBySeverity.low}`); + lines.push(` Info: ${riskBySeverity.info}`); + } + + // Evidence Citations + if (evidenceLayer) { + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' EVIDENCE CITATIONS'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + + 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'; + lines.push(` [${timestamp}] ${evidence.type}: ${evidence.hash}`); + } + } + + 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) + ) { + lines.push(''); + lines.push(' No evidence citations found.'); + } + } + + // Audit Log Entries + if (auditLogs.length > 0) { + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + 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'; + if (!logsByEventType.has(eventType)) { + logsByEventType.set(eventType, []); + } + logsByEventType.get(eventType)?.push(log); + } + + // Display events sorted by timestamp (most recent first) + const sortedEventTypes = Array.from(logsByEventType.keys()).sort(); + + for (const eventType of sortedEventTypes) { + const events = logsByEventType.get(eventType) ?? []; + + 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(), + ); + + 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' ? '✗' : '○'; + + lines.push(` ${outcomeSymbol} [${timestamp}]`); + lines.push(` Actor: ${actor}`); + lines.push(` Resource: ${resource}`); + lines.push(` Outcome: ${outcome}`); + + if (event.details && Object.keys(event.details).length > 0) { + lines.push(` Details: ${JSON.stringify(event.details)}`); + } + } + } + } else { + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' AUDIT TRAIL'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(''); + lines.push(' No audit log entries found.'); + } + + // Footer + lines.push(''); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(`Report Generated: ${new Date().toISOString()}`); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Multi-agent audit report with causal chain reconstruction +// --------------------------------------------------------------------------- + +/** A reconstructed causal chain step */ +interface CausalStep { + agent_id: string; + agent_name: string; + event_type: string; + timestamp: string; + resource?: string; + outcome?: string; + details?: Record; +} + +/** A full causal chain connecting agents through delegation/tool access */ +interface CausalChain { + chain_id: string; + steps: CausalStep[]; + summary: string; +} + +/** + * Build a lookup map from agent_id → AgentBOM for quick access across the team. + */ +function buildAgentMap(boms: AgentBOM[]): Map { + const map = new Map(); + for (const bom of boms) { + const id = bom.identity?.agent_id; + if (id) map.set(id, bom); + } + return map; +} + +/** + * Build a peer-agent name lookup: agent_id → display name. + * Falls back to agent_id if no name is set. + */ +function buildNameLookup(boms: AgentBOM[]): Map { + const names = new Map(); + for (const bom of boms) { + const id = bom.identity?.agent_id; + const name = bom.identity?.agent_name; + if (id) names.set(id, name ?? id); + // Also index peer names from collaboration data + const peers = bom.agent_collaboration?.peer_agents ?? []; + for (const peer of peers) { + if (peer.agent_id) { + names.set(peer.agent_id, peer.agent_name ?? peer.agent_id); + } + } + } + return names; +} + +/** + * Detect causal links between agents by cross-referencing audit log entries + * with the agent_collaboration topology. + * + * A causal link is identified when: + * - An audit entry's `actor` matches a peer agent's agent_id + * - An audit entry's `details` contains `delegated_from` or `delegated_to` + * - An audit entry's `resource` is a shared resource accessed by multiple agents + */ +function reconstructCausalChains(boms: AgentBOM[]): CausalChain[] { + const agentMap = buildAgentMap(boms); + const nameLookup = buildNameLookup(boms); + + // Collect all shared resource IDs across the team + const sharedResourceIds = new Set(); + for (const bom of boms) { + for (const sr of bom.agent_collaboration?.shared_resources ?? []) { + sharedResourceIds.add(sr.resource_id); + } + } + + // Gather all audit entries with their source agent + interface TaggedEntry { + entry: AuditLogEntry; + source_agent_id: string; + source_agent_name: string; + } + const allEntries: TaggedEntry[] = []; + for (const bom of boms) { + 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 }); + } + } + + // Sort all entries chronologically + allEntries.sort( + (a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime(), + ); + + // Build causal chains by following delegation / shared-resource links + const chains: CausalChain[] = []; + const assigned = new Set(); // indices into allEntries already in a chain + let chainCounter = 0; + + for (let i = 0; i < allEntries.length; i++) { + if (assigned.has(i)) continue; + + const start = allEntries[i]; + const steps: CausalStep[] = [ + { + agent_id: start.source_agent_id, + agent_name: start.source_agent_name, + event_type: start.entry.event_type, + timestamp: start.entry.timestamp, + resource: start.entry.resource, + outcome: start.entry.outcome, + details: start.entry.details, + }, + ]; + assigned.add(i); + + // Try to extend the chain forward + let currentIdx = i; + for (let j = i + 1; j < allEntries.length; j++) { + if (assigned.has(j)) continue; + + const prev = allEntries[currentIdx]; + const candidate = allEntries[j]; + + if (isCausallyLinked(prev, candidate, sharedResourceIds, agentMap)) { + steps.push({ + agent_id: candidate.source_agent_id, + agent_name: candidate.source_agent_name, + event_type: candidate.entry.event_type, + timestamp: candidate.entry.timestamp, + resource: candidate.entry.resource, + outcome: candidate.entry.outcome, + details: candidate.entry.details, + }); + assigned.add(j); + currentIdx = j; + } + } + + // Only emit chains with 2+ steps (cross-agent interaction) + if (steps.length >= 2) { + chainCounter++; + const summary = buildChainSummary(steps, nameLookup); + chains.push({ chain_id: `chain-${chainCounter}`, steps, summary }); + } + } + + return chains; +} + +/** + * Determine whether two tagged audit entries are causally linked. + */ +function isCausallyLinked( + prev: { entry: AuditLogEntry; source_agent_id: string }, + candidate: { entry: AuditLogEntry; source_agent_id: string }, + sharedResourceIds: Set, + agentMap: Map, +): boolean { + const pe = prev.entry; + const ce = candidate.entry; + + // Same agent — not a cross-agent link + if (prev.source_agent_id === candidate.source_agent_id) return false; + + // 1. Delegation via details field + const delegatedFrom = pe.details?.delegated_from as string | undefined; + const delegatedTo = pe.details?.delegated_to as string | undefined; + if (delegatedTo === candidate.source_agent_id) return true; + if (delegatedFrom === candidate.source_agent_id) return true; + + // 2. Candidate's actor matches the previous agent (direct invocation) + if (ce.actor === prev.source_agent_id) return true; + + // 3. Both access the same shared resource + if ( + pe.resource && + ce.resource && + pe.resource === ce.resource && + sharedResourceIds.has(pe.resource) + ) { + return true; + } + + // 4. Candidate's actor is a peer of the previous agent + const prevBom = agentMap.get(prev.source_agent_id); + if (prevBom) { + const peers = prevBom.agent_collaboration?.peer_agents ?? []; + 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(); + if (timeDiff >= 0 && timeDiff <= 60_000) { + return true; + } + } + } + } + + return false; +} + +/** + * 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 ''; + + const parts: string[] = []; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const name = nameLookup.get(step.agent_id) ?? step.agent_id; + + if (i === 0) { + parts.push(name); + } else { + const prevStep = steps[i - 1]; + if (step.event_type.includes('delegat')) { + parts.push(`delegated to ${name}`); + } else if (step.resource) { + parts.push(`${name} accessed ${step.resource}`); + } else { + parts.push(`${name} performed ${step.event_type}`); + } + } + } + + return parts.join(' which '); +} + +/** + * Generate a unified multi-agent audit report spanning multiple AgentBOMs. + */ +export function generateMultiAgentAuditReport(boms: AgentBOM[]): string { + const lines: string[] = []; + + // Header + 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(' AGENT ROSTER'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + + for (const bom of boms) { + 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})`); + lines.push( + ` Peers: ${peers} | Shared Resources: ${sharedRes} | Audit Entries: ${bom.audit_log?.length ?? 0}`, + ); + } + + // Collaboration topology summary + const totalPeers = new Set(); + const totalShared = new Set(); + const totalBoundaries: DelegationBoundary[] = []; + for (const bom of boms) { + for (const peer of bom.agent_collaboration?.peer_agents ?? []) { + totalPeers.add(peer.agent_id); + } + for (const sr of bom.agent_collaboration?.shared_resources ?? []) { + totalShared.add(sr.resource_id); + } + for (const b of bom.agent_collaboration?.delegation_boundaries ?? []) { + totalBoundaries.push(b); + } + } + + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' COLLABORATION TOPOLOGY'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(` Unique Peer Agents: ${totalPeers.size}`); + lines.push(` Shared Resources: ${totalShared.size}`); + lines.push(` Delegation Boundaries: ${totalBoundaries.length}`); + + // Delegation boundary details + if (totalBoundaries.length > 0) { + 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}`); + if (b.description) lines.push(` ${b.description}`); + if (b.max_delegation_depth !== undefined) { + lines.push(` Max delegation depth: ${b.max_delegation_depth}`); + } + } + } + + // Shared resource details + if (totalShared.size > 0) { + 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})`); + lines.push(` Accessed by: ${agents}`); + if (sr.isolation_level) lines.push(` Isolation: ${sr.isolation_level}`); + } + } + } + + // Cross-agent causal chains + const chains = reconstructCausalChains(boms); + + lines.push(''); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' CAUSAL CHAIN ANALYSIS'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + + if (chains.length === 0) { + lines.push(''); + lines.push(' No cross-agent causal chains detected.'); + } else { + lines.push(` Reconstructed Causal Chains: ${chains.length}`); + 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}`); + } + 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( + '────────────────────────────────────────────────────────────────────────────────────', + ); + + const combinedStats = { success: 0, failure: 0, partial: 0, unknown: 0 }; + const eventTypeCounts = new Map(); + let totalRisks = 0; + const riskBySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + + 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++; + else combinedStats.unknown++; + + eventTypeCounts.set(log.event_type, (eventTypeCounts.get(log.event_type) ?? 0) + 1); + } + for (const risk of bom.risk_layer ?? []) { + totalRisks++; + const sev = risk.severity.toLowerCase() as keyof typeof riskBySeverity; + if (sev in riskBySeverity) riskBySeverity[sev]++; + } + } + + const totalEvents = + 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}`); + lines.push(` Partial Success Events: ${combinedStats.partial}`); + lines.push(` Unknown Outcome Events: ${combinedStats.unknown}`); + lines.push(` Open Risk Findings: ${totalRisks}`); + lines.push(` Unique Event Types: ${eventTypeCounts.size}`); + + // 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]); + for (const [type, count] of sortedTypes) { + lines.push(` ${type}: ${count}`); + } + } + + // Risk severity summary + if (totalRisks > 0) { + lines.push(''); + lines.push(' Risk Severity Distribution:'); + lines.push(` Critical: ${riskBySeverity.critical}`); + lines.push(` High: ${riskBySeverity.high}`); + lines.push(` Medium: ${riskBySeverity.medium}`); + lines.push(` Low: ${riskBySeverity.low}`); + lines.push(` Info: ${riskBySeverity.info}`); + } + + // Per-agent risk summary + 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 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`); + } + + // Footer + lines.push(''); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(`Report Generated: ${new Date().toISOString()}`); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + + return lines.join('\n'); +} + +/** + * Load and validate a single AgentBOM from a file path. + */ +function loadAgentBOM(filePath: string): { bom: AgentBOM | null; error: string | null } { + const resolved = resolve(filePath); + let content: string; + try { + content = readFileSync(resolved, 'utf-8'); + } catch { + return { bom: null, error: `cannot read file "${resolved}"` }; + } + + let data: unknown; + try { + data = JSON.parse(content); + } catch { + return { bom: null, error: `"${resolved}" is not valid JSON` }; + } + + const bom = data as AgentBOM; + const validation = validateAgentBOM(bom); + if (!validation.valid) { + return { bom: null, error: `Invalid AgentBOM: ${validation.errors.join('; ')}` }; + } + + return { bom, error: null }; +} + +/** + * CLI command for multi-agent audit report. + * + * Usage: agent-trust audit-report multi [bom2.json ...] + */ +export function multiAgentAuditReportCommand(args: string[]): number { + 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', + ' - 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'), + ); + return 0; + } + + // Support --dir to load all .json files from a directory + const filePaths: string[] = []; + let dirPath: string | null = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dir' && i + 1 < args.length) { + dirPath = args[++i]; + } else if (!args[i].startsWith('--')) { + filePaths.push(args[i]); + } + } + + if (dirPath) { + const resolvedDir = resolve(dirPath); + let entries: string[]; + try { + entries = readdirSync(resolvedDir); + } catch { + console.error(`Error: cannot read directory "${resolvedDir}"`); + return 1; + } + for (const entry of entries) { + if (entry.endsWith('.json')) { + const fullPath = resolve(resolvedDir, entry); + if (statSync(fullPath).isFile()) { + filePaths.push(fullPath); + } + } + } + } + + if (filePaths.length === 0) { + console.error( + 'Error: no AgentBOM files provided. Use: audit-report multi [bom2.json ...] or --dir ', + ); + return 1; + } + + const boms: AgentBOM[] = []; + for (const filePath of filePaths) { + const { bom, error } = loadAgentBOM(filePath); + if (error) { + console.error(`Error: ${error}`); + return 1; + } + if (bom) boms.push(bom); + } + + if (boms.length === 0) { + console.error('Error: no valid AgentBOM files found'); + return 1; + } + + const report = generateMultiAgentAuditReport(boms); + console.log(report); + return 0; +} + +export function auditReportCommand(args: string[]): number { + // Dispatch to multi-agent subcommand + if (args.length > 0 && args[0] === 'multi') { + return multiAgentAuditReportCommand(args.slice(1)); + } + + 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.', + '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'), + ); + return 0; + } + + const bomPath = args[0]; + try { + 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:'); + for (const error of validation.errors) { + console.error(` ${error}`); + } + return 1; + } + + // Generate and print the audit report + const report = generateAuditReport(data); + console.log(report); + return 0; + } catch (err) { + console.error( + `Error: Failed to read or parse AgentBOM file: ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} diff --git a/packages/agentbom-cli/src/bom-generate.ts b/packages/agentbom-cli/src/bom-generate.ts new file mode 100644 index 0000000..917ca22 --- /dev/null +++ b/packages/agentbom-cli/src/bom-generate.ts @@ -0,0 +1,352 @@ +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; + outputPath?: string; +} + +interface ToolDefinition { + tool_id: string; + tool_name: string; + source: 'mcp' | 'builtin' | 'plugin'; + mcp_server_id?: string; + permissions: string[]; + risk_signals: string[]; +} + +interface AgentInfo { + agent_id: string; + agent_name: string; + agent_version?: string; + deployment_context?: 'development' | 'staging' | 'production'; +} + +interface ParsedGenerateArgs { + agentPath: string; + outputPath?: string; +} + +const USAGE = 'Usage: agent-trust generate bom --agent [--out ]'; + +function parseGenerateArgs(args: string[]): ParsedGenerateArgs | null { + let agentPath: string | undefined; + let outputPath: string | undefined; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--agent') { + agentPath = args[i + 1]; + i += 1; + } else if (arg === '--out') { + outputPath = args[i + 1]; + i += 1; + } else { + return null; + } + } + + if (!agentPath) return null; + return { agentPath, outputPath }; +} + +/** + * 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, '-'); + let agentName = agentId; + let agentVersion: string | undefined; + const deploymentContext: 'development' | 'staging' | 'production' = 'development'; + + try { + const pkgContent = readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(pkgContent); + + if (pkg.name) { + agentName = pkg.name.replace(/^@[^/]+\//, ''); + agentId = agentName; + } + if (pkg.version) { + agentVersion = pkg.version; + } + } catch { + // No package.json or couldn't parse it, use directory name + } + + // Generate a more specific agent_id + const timestamp = Date.now(); + agentId = `${agentId}-${timestamp}`; + + return { + agent_id: agentId, + agent_name: agentName, + agent_version: agentVersion, + deployment_context: deploymentContext, + }; +} + +/** + * Scan agent directory for MCP servers, plugins, and tools + */ +function scanAgentDirectory(agentPath: string): ToolDefinition[] { + const tools: ToolDefinition[] = []; + + try { + const entries = readdirSync(agentPath); + + // Look for MCP server definitions + for (const entry of entries) { + const fullPath = resolve(agentPath, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Check for MCP server patterns + const mcpConfigPath = join(fullPath, 'mcp.config.json'); + try { + 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', + mcp_server_id: entry, + permissions: tool.permissions || [], + risk_signals: [], + }); + } + } + } catch { + // No MCP config here, continue + } + } + } + } catch { + // Couldn't read directory + } + + return tools; +} + +/** + * Generate standard tool inventory for a typical agent + */ +function generateStandardToolInventory(): ToolDefinition[] { + return [ + { + 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'], + risk_signals: [], + }, + { + 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: 'content-grep', + tool_name: 'Grep', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + { + tool_id: 'file-glob', + tool_name: 'Glob', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ]; +} + +/** + * Extract all unique permissions from tool inventory + */ +function extractPermissionScope(toolLayer: ToolDefinition[]): string[] { + const permissionSet = new Set(); + + for (const tool of toolLayer) { + for (const perm of tool.permissions) { + permissionSet.add(perm); + } + } + + return Array.from(permissionSet).sort(); +} + +/** + * Generate basic risk assessment for tools + */ +function generateRiskAssessment(toolLayer: ToolDefinition[]): Array<{ + risk_id: string; + severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + category: string; + description: string; + status: 'open' | 'mitigated' | 'accepted'; +}> { + const risks: Array<{ + risk_id: string; + severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + category: string; + description: string; + 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')) { + risks.push({ + risk_id: `risk-${tool.tool_id}-001`, + severity: 'medium', + category: 'command_execution', + description: `${tool.tool_name} tool allows arbitrary process execution`, + status: 'accepted', + }); + } + + // Check for network-related tools + if (tool.source === 'mcp' && tool.permissions.some((p) => p.includes('network'))) { + risks.push({ + risk_id: `risk-${tool.tool_id}-001`, + severity: 'high', + category: 'ssrf', + description: `${tool.tool_name} makes outbound network requests`, + status: 'open', + }); + } + + // Check for file write operations + if (tool.permissions.includes('fs:write')) { + risks.push({ + risk_id: `risk-${tool.tool_id}-002`, + severity: 'low', + category: 'data_modification', + description: `${tool.tool_name} can modify files in the workspace`, + status: 'accepted', + }); + } + } + + return risks; +} + +/** + * Generate AgentBOM JSON from agent path + */ +export function generateAgentBOM(options: GenerateOptions): Record { + const { agentPath } = options; + + // Extract agent information + const agentInfo = extractAgentInfo(agentPath); + + // Scan for tools + const discoveredTools = scanAgentDirectory(agentPath); + const standardTools = generateStandardToolInventory(); + + // Merge tool inventories, avoiding duplicates + const toolMap = new Map(); + for (const tool of [...discoveredTools, ...standardTools]) { + toolMap.set(tool.tool_id, tool); + } + const toolLayer = Array.from(toolMap.values()); + + // Extract permission scopes + const grantedScopes = extractPermissionScope(toolLayer); + + // Generate risk assessment + const riskLayer = generateRiskAssessment(toolLayer); + + // Build AgentBOM + const agentbom: Record = { + agentbom_version: '0.1', + identity: { + agent_id: agentInfo.agent_id, + agent_name: agentInfo.agent_name, + generated_at: new Date().toISOString(), + }, + tool_layer: toolLayer, + permission_layer: { + granted_scopes: grantedScopes, + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: riskLayer, + attestation: { + generator: '@wasmagent/agent-trust-cli', + generator_version: '0.0.0-research', + }, + }; + + // Add optional fields if available + if (agentInfo.agent_version) { + (agentbom.identity as Record).agent_version = agentInfo.agent_version; + } + if (agentInfo.deployment_context) { + (agentbom.identity as Record).deployment_context = + agentInfo.deployment_context; + } + + return agentbom; +} + +/** + * Main command function for generating AgentBOM + */ +export function generateAgentBOMCommand(args: string[]): number { + const parsed = parseGenerateArgs(args); + if (!parsed) { + console.error(USAGE); + return 1; + } + + const agentPath = resolve(parsed.agentPath); + if (!existsSync(agentPath) || !statSync(agentPath).isDirectory()) { + console.error(`Error: agent path is not a directory: ${agentPath}`); + return 1; + } + + // Generate AgentBOM + const agentbom = generateAgentBOM({ agentPath }); + + // Validate the generated AgentBOM + const validation = validateAgentBOM(agentbom); + if (!validation.valid) { + console.error('Error: Generated AgentBOM is invalid:'); + for (const err of validation.errors) { + console.error(` - ${err}`); + } + return 1; + } + + // Output the AgentBOM JSON + const output = `${JSON.stringify(agentbom, null, 2)}\n`; + if (parsed.outputPath) { + writeFileSync(resolve(parsed.outputPath), output, 'utf-8'); + } else { + console.log(output.trimEnd()); + } + + return 0; +} diff --git a/packages/agentbom-cli/src/chain.test.ts b/packages/agentbom-cli/src/chain.test.ts new file mode 100644 index 0000000..098a01c --- /dev/null +++ b/packages/agentbom-cli/src/chain.test.ts @@ -0,0 +1,125 @@ +/** + * End-to-end chain test (P0). + * + * Runs the full `agent-trust chain` in-process against the bscode-agent + * 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'; + +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')); +} + +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.total_steps).toBe(5); + expect(report.overall.valid_steps).toBe(5); + }); + + 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', + ]); + for (const step of report.steps) { + 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}$/); + expect(step.label).toBeTruthy(); + } + }); + + 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(report.repo_sha.length).toBeGreaterThan(0); + expect(report.example).toBe(resolve(DEMO_DIR)); + }); + + 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)); + }); + + 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]); + expect(code).toBe(0); + expect(existsSync(outPath)).toBe(true); + + const report = readJSON(outPath) as { + overall: { status: string }; + steps: { verdict: string }[]; + }; + expect(report.overall.status).toBe('valid'); + expect(report.steps).toHaveLength(5); + 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']); + expect(code).toBe(0); + }); + + 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.valid_steps).toBe(0); + // Every step should record at least one error. + expect(report.steps.every((s) => s.errors.length > 0)).toBe(true); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + 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 { + validity: { expires_at: string }; + }; + 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)); + + 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'); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agentbom-cli/src/chain.ts b/packages/agentbom-cli/src/chain.ts new file mode 100644 index 0000000..3c5296e --- /dev/null +++ b/packages/agentbom-cli/src/chain.ts @@ -0,0 +1,459 @@ +/** + * agent-trust chain — end-to-end trust artifact chain runner. + * + * Walks the full Agent Trust Infrastructure chain in-process and offline: + * + * bscode workload + * ↓ declare capabilities + emit evidence + * CapabilityManifest + AEP + * ↓ compose + * AgentBOM + * ↓ scan MCP surface + * MCP Posture + * ↓ validate evidence + map frameworks + * audit report + * ↓ summarize + * Trust Passport + * + * The runner reuses the existing `packages/*` validators (Zod-style schema + * validators) and the existing fixtures under `examples/bscode-agent/`. It does + * 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 { + inspectMCPPosture, + validateMCPPosture, +} from '@wasmagent/mcp-posture'; + +/** Per-step outcome written to `chain-report.json` and streamed to stdout. */ +export interface ChainStepResult { + /** Stable machine identifier for the step (matches the chain node). */ + step: string; + /** Human-readable label. */ + label: string; + /** "valid" when the step passed, "invalid" otherwise. */ + verdict: 'valid' | 'invalid'; + /** Wall-clock duration of the step in milliseconds. */ + duration_ms: number; + /** Deterministic SHA-256 of the step's canonical output (`sha256:`). */ + output_hash: string; + /** Structured detail about what the step checked / produced. */ + detail: Record; + /** Empty when the step is valid; human-readable failure reasons otherwise. */ + errors: string[]; +} + +/** Aggregate chain report persisted as `chain-report.json`. */ +export interface ChainReport { + /** ISO-8601 timestamp the chain was run. */ + timestamp: string; + /** Repository sha at run time (`git rev-parse HEAD`), or `"unknown"` offline. */ + repo_sha: string; + /** Absolute path of the example directory the chain ran against. */ + example: string; + /** Roll-up of the per-step verdicts. */ + overall: { + status: 'valid' | 'invalid'; + valid_steps: number; + total_steps: number; + }; + /** Ordered per-step results. */ + steps: ChainStepResult[]; +} + +/** Ordered step identifiers — the 6 chain nodes joined by 5 verifiable links. */ +export const CHAIN_STEPS = [ + 'manifest', + 'agentbom', + 'mcp-posture', + 'audit-report', + 'trust-passport', +] as const; + +const DEFAULT_EXAMPLE_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../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'); + +/** 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') { + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf-8').digest('hex'); +} + +function hashObject(value: unknown): string { + return `sha256:${sha256Hex(canonicalize(value))}`; +} + +function hashFile(path: string): string { + return `sha256:${sha256Hex(readFileSync(path, 'utf-8'))}`; +} + +function loadJSON(path: string): 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'], + }).trim(); + } catch { + // Offline / non-git context: no sha available. + return 'unknown'; + } +} + +function now(): string { + return new Date().toISOString(); +} + +type Timer = () => number; +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)) { + return value as Record; + } + return {}; +} + +function stringArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +/** + * Step 1 — manifest: reconstruct the bscode CapabilityManifest + AEP surface + * from the workload composition (AgentBOM) and verify it is well-formed. + * + * A real runtime emits the manifest before the AgentBOM exists; for this + * offline demo we derive it from the existing fixture so the full chain can be + * walked without a live wasmagent-js runtime. + */ +function runManifestStep(exampleDir: string): ChainStepResult { + const start = startTimer(); + const errors: string[] = []; + const bomPath = resolve(exampleDir, 'agentbom.json'); + let manifest: Record = {}; + + try { + const bom = asRecord(loadJSON(bomPath)); + const identity = asRecord(bom.identity); + const toolLayer = stringArray(bom.tool_layer); + const permissionLayer = asRecord(bom.permission_layer); + const modelLayer = asRecord(bom.model_layer); + + manifest = { + manifest_version: '0.1', + agent_id: identity.agent_id ?? null, + capabilities: toolLayer.map((t) => asRecord(t).tool_id ?? null), + permissions: stringArray(permissionLayer.granted_scopes), + model: modelLayer.model_id ?? null, + 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'); + } + } catch (err) { + errors.push(`manifest: ${err instanceof Error ? err.message : String(err)}`); + } + + return { + 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 }, + errors, + }; +} + +/** Step 2 — agentbom: validate the AgentBOM fixture against its schema. */ +function runAgentBomStep(exampleDir: string): ChainStepResult { + const start = startTimer(); + const errors: string[] = []; + const bomPath = resolve(exampleDir, 'agentbom.json'); + let outputHash = ''; + let summary = ''; + + try { + const data = loadJSON(bomPath); + const result = validateAgentBOM(data); + if (!result.valid) errors.push(...result.errors); + outputHash = hashFile(bomPath); + summary = inspectAgentBOM(asRecord(data)); + } catch (err) { + errors.push(`agentbom: ${err instanceof Error ? err.message : String(err)}`); + } + + return { + 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 }, + errors, + }; +} + +/** Step 3 — mcp-posture: validate the MCP Posture fixture. */ +function runPostureStep(exampleDir: string): ChainStepResult { + const start = startTimer(); + const errors: string[] = []; + const posturePath = resolve(exampleDir, 'posture.json'); + let outputHash = ''; + let summary = ''; + + try { + const data = loadJSON(posturePath); + const result = validateMCPPosture(data); + if (!result.valid) errors.push(...result.errors); + outputHash = hashFile(posturePath); + summary = inspectMCPPosture(asRecord(data)); + } catch (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', + duration_ms: elapsedMs(start), + output_hash: outputHash, + detail: { schema: 'specs/mcp-posture/schema.json', inspect: summary }, + errors, + }; +} + +/** + * Step 4 — audit-report: synthesize a deterministic audit-report reference from + * the AgentBOM + MCP Posture (open findings, framework mapping). In production + * this is produced by `open-agent-audit`; here it is a reproducible placeholder. + */ +function runAuditStep(exampleDir: string): ChainStepResult { + const start = startTimer(); + const errors: string[] = []; + let report: Record = {}; + + try { + 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'); + + report = { + 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.', + }; + + 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'); + } + } catch (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', + duration_ms: elapsedMs(start), + output_hash: hashObject(report), + detail: { synthesized: true, reference: 'open-agent-audit (placeholder)' }, + errors, + }; +} + +/** Step 5 — trust-passport: validate the Trust Passport and its cross-references. */ +function runPassportStep(exampleDir: string): ChainStepResult { + const start = startTimer(); + const errors: string[] = []; + 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 passport = loadJSON(passportPath); + + const result = validateTrustPassport(passport); + if (!result.valid) errors.push(...result.errors); + + const passportObj = asRecord(passport); + const agentbomRef = asRecord(passportObj.agentbom_ref); + const postureRef = asRecord(passportObj.posture_ref); + const bomIdentity = asRecord(bom.identity); + const postureIdentity = asRecord(posture.identity); + + if ( + agentbomRef.agentbom_id && + bomIdentity.agent_id && + agentbomRef.agentbom_id !== bomIdentity.agent_id + ) { + errors.push( + 'trust-passport: agentbom_ref.agentbom_id does not match AgentBOM identity.agent_id', + ); + } + if ( + postureRef.snapshot_id && + postureIdentity.snapshot_id && + postureRef.snapshot_id !== postureIdentity.snapshot_id + ) { + errors.push( + 'trust-passport: posture_ref.snapshot_id does not match posture identity.snapshot_id', + ); + } + if (isExpired(passportObj)) { + 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)}`); + } + + return { + 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 }, + errors, + }; +} + +/** + * 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 { + const resolved = resolve(exampleDir); + const steps: ChainStepResult[] = [ + runManifestStep(resolved), + runAgentBomStep(resolved), + runPostureStep(resolved), + runAuditStep(resolved), + runPassportStep(resolved), + ]; + + const validSteps = steps.filter((s) => s.verdict === 'valid').length; + return { + timestamp: now(), + repo_sha: repoSha(), + example: resolved, + overall: { + status: validSteps === steps.length ? 'valid' : 'invalid', + valid_steps: validSteps, + total_steps: steps.length, + }, + steps, + }; +} + +/** CLI entry point for `agent-trust chain`. */ +export function chainCommand(args: string[]): number { + if (args.includes('--help') || args.includes('-h')) { + console.log(CHAIN_USAGE); + return 0; + } + + let exampleDir = DEFAULT_EXAMPLE_DIR; + 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) { + exampleDir = next; + i++; + } else if (arg === '--out' && next) { + outPath = next; + i++; + } else { + console.error(`Error: unknown chain argument "${arg}"`); + console.error(CHAIN_USAGE); + return 1; + } + } + + const report = runChain(exampleDir); + + // Logging contract: one JSON object per step on stdout. + for (const step of report.steps) { + console.log(JSON.stringify(step)); + } + + const resolvedOut = resolve(outPath); + writeFileSync(resolvedOut, `${JSON.stringify(report, null, 2)}\n`, 'utf-8'); + + if (report.overall.status !== 'valid') { + const failed = report.steps + .filter((s) => s.verdict !== 'valid') + .map((s) => s.step) + .join(', '); + console.error(`Chain failed: ${failed}`); + return 1; + } + + console.error( + `Chain valid: ${report.overall.valid_steps}/${report.overall.total_steps} steps → ${resolvedOut}`, + ); + return 0; +} diff --git a/packages/agentbom-cli/src/compliance-check.test.ts b/packages/agentbom-cli/src/compliance-check.test.ts new file mode 100644 index 0000000..b62e7fc --- /dev/null +++ b/packages/agentbom-cli/src/compliance-check.test.ts @@ -0,0 +1,703 @@ +/** + * Compliance check test suite. + * + * Tests all compliance profiles with known-good and known-bad fixtures. + * 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 { + complianceCheckCommand, + upgradeProfileCommand, + verifyProfileCommand, +} 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', () => { + let originalConsoleLog: typeof console.log; + let originalConsoleError: typeof console.error; + let consoleOutput: string[] = []; + let errorOutput: string[] = []; + + beforeEach(() => { + originalConsoleLog = console.log; + originalConsoleError = console.error; + consoleOutput = []; + errorOutput = []; + + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorOutput.push(args.map(String).join(' ')); + }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + function getFixturePath(fixtureName: string): string { + return resolve(__dirname, 'compliance-fixtures', fixtureName); + } + + 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]); + + 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); + }); + + 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); + + const errorText = consoleOutput.join('\n'); + // Known-bad fixture has multiple violations: + // - development context (not in allowed contexts) + // - tools with high/critical severity + // - blocked permissions + // - blocked sources + // - unmitigated critical risks + // - missing signature and timestamp + expect(errorText).toMatch(/development_context|deployment_context/); + expect(errorText).toMatch(/tool_layer/); + expect(errorText).toMatch(/risk_layer/); + expect(errorText).toMatch(/attestation/); + }); + }); + + 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]); + + 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); + }); + + 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); + + const errorText = consoleOutput.join('\n'); + // Known-bad fixture has multiple violations: + // - staging context (only production allowed) + // - tools with critical severity + // - blocked permissions (filesystem:write unrestricted) + // - blocked sources (unverified-external) + // - unmitigated critical risks + // - missing signature and timestamp + expect(errorText).toMatch(/deployment_context|staging/); + expect(errorText).toMatch(/tool_layer/); + expect(errorText).toMatch(/risk_layer/); + expect(errorText).toMatch(/attestation/); + }); + }); + + 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]); + + 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); + }); + + 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); + + const errorText = consoleOutput.join('\n'); + // Known-bad fixture has multiple violations: + // - development context (only production allowed) + // - tools with medium severity (max allowed is low) + // - blocked permissions (filesystem:write, network:external, system:execute) + // - blocked sources (external, unverified) + // - unmitigated critical and high risks + // - 2 unmitigated medium risks (max allowed is 1) + // - missing signature and timestamp + expect(errorText).toMatch(/deployment_context|development/); + expect(errorText).toMatch(/tool_layer/); + expect(errorText).toMatch(/risk_layer/); + expect(errorText).toMatch(/attestation/); + }); + }); + + 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', + ]; + + for (const fixtureName of knownGoodFixtures) { + const fixturePath = getFixturePath(fixtureName); + const fixtureContent = readFileSync(fixturePath, 'utf-8'); + const fixtureData = JSON.parse(fixtureContent); + + const validation = validateAgentBOM(fixtureData); + expect(validation.valid).toBe(true); + expect(validation.errors).toHaveLength(0); + } + }); + + 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', + ]; + + for (const fixtureName of knownBadFixtures) { + const fixturePath = getFixturePath(fixtureName); + const fixtureContent = readFileSync(fixturePath, 'utf-8'); + const fixtureData = JSON.parse(fixtureContent); + + // Schema should be valid + const validation = validateAgentBOM(fixtureData); + expect(validation.valid).toBe(true); + expect(validation.errors).toHaveLength(0); + } + }); + }); + + 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, + ); + }); + + 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); + }); + + 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']; + const fixtures = [ + '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]); + + expect(exitCode).toBe(0); + } + }); + }); +}); + +describe('compliance-verify-profile: backward compatibility checking', () => { + let originalConsoleLog: typeof console.log; + let originalConsoleError: typeof console.error; + let consoleOutput: string[] = []; + let errorOutput: string[] = []; + + beforeEach(() => { + originalConsoleLog = console.log; + originalConsoleError = console.error; + consoleOutput = []; + errorOutput = []; + + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorOutput.push(args.map(String).join(' ')); + }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + 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']); + + expect(exitCode).toBe(0); + 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'); + expect(outputText).toMatch(/AgentBOM schema:\s+v0\.1/); + }); + + it(`${profileId}: reports coverage gaps for uncovered schema sections`, () => { + verifyProfileCommand([profileId, '--schema-version', '0.1']); + + 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/); + expect(outputText).toMatch(/model_layer/); + expect(outputText).toMatch(/permission_layer/); + expect(outputText).toMatch(/workflow_layer/); + expect(outputText).toMatch(/distribution/); + }); + } + }); + + 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', + rules: { + identity: { + required_fields: ['agent_version', 'nonexistent_field'], + }, + }, + }, + '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'), + ).toBe(true); + }); + + 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', + rules: { + attestation: { + requires_signature: true, + }, + }, + }, + '99.0', // unknown version → no fields → everything breaks + ); + + expect(result.compatible).toBe(false); + expect( + result.breaking.some((b: { field: string }) => b.field === 'attestation.signature'), + ).toBe(true); + }); + + it('detects tool_layer rules against schema without tool_layer', () => { + + const result = checkProfileSchemaCompatibility( + { + profile_version: '0.1', + rules: { + tool_layer: { + requires_tool_inventory: true, + blocked_permissions: ['fs:write'], + }, + }, + }, + '99.0', + ); + + expect(result.compatible).toBe(false); + expect(result.breaking.some((b: { field: string }) => b.field === 'tool_layer')).toBe(true); + }); + + it('detects risk_layer rules against schema without risk_layer', () => { + + const result = checkProfileSchemaCompatibility( + { + profile_version: '0.1', + rules: { + risk_layer: { + requires_risk_assessment: true, + requires_mitigation_for: ['critical'], + }, + }, + }, + '99.0', + ); + + expect(result.compatible).toBe(false); + expect(result.breaking.some((b: { field: string }) => b.field === 'risk_layer')).toBe(true); + }); + + it('generates mapping updates for breaking changes', () => { + + // Use a version with no fields to simulate breaking changes + const result = checkProfileSchemaCompatibility( + { + profile_version: '0.1', + rules: { + identity: { required_fields: ['agent_version'] }, + attestation: { requires_signature: true }, + tool_layer: { blocked_permissions: ['fs:write'] }, + }, + }, + '99.0', + ); + + expect(result.mapping_updates.length).toBeGreaterThan(0); + expect(result.breaking.length).toBeGreaterThan(0); + }); + }); + + 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); + }); + + 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', () => { + const exitCode = verifyProfileCommand([]); + + expect(exitCode).toBe(1); + expect(errorOutput.some((line) => line.includes('Usage'))).toBe(true); + }); + + 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, + ); + }); + }); +}); + +describe('upgradeProfileMappings: automated mapping updates', () => { + it('returns unchanged profile when already compatible', () => { + + const profile = { + profile_version: '0.1', + rules: { + identity: { + required_fields: ['agent_version', 'deployment_context'], + allowed_contexts: ['production'], + requires_version: true, + }, + attestation: { + requires_signature: true, + requires_timestamp: true, + }, + }, + }; + + const result = upgradeProfileMappings(profile, '0.1'); + + expect(result.changes_applied).toBe(false); + expect(result.applied_updates).toHaveLength(0); + expect(result.unresolved).toHaveLength(0); + expect(result.upgraded_profile).toEqual(profile); + }); + + it('removes identity.required_fields referencing non-existent fields', () => { + + const profile = { + profile_version: '0.1', + rules: { + identity: { + required_fields: ['agent_version', 'nonexistent_field', 'another_fake'], + }, + }, + }; + + 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.unresolved).toHaveLength(0); + }); + + it('disables attestation.requires_signature when signature field removed', () => { + + const profile = { + profile_version: '0.1', + rules: { + attestation: { + requires_signature: true, + requires_timestamp: true, + }, + }, + }; + + // Unknown version has no fields → attestation.signature is "removed" + 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); + }); + + it('clears tool_layer rules when section removed from schema', () => { + + const profile = { + profile_version: '0.1', + rules: { + tool_layer: { + max_severity: 'medium', + requires_tool_inventory: true, + blocked_permissions: ['fs:write'], + blocked_sources: ['unverified'], + }, + }, + }; + + 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')), + ).toBe(true); + }); + + it('clears risk_layer rules when section removed from schema', () => { + + const profile = { + profile_version: '0.1', + rules: { + risk_layer: { + requires_risk_assessment: true, + requires_mitigation_for: ['critical', 'high'], + max_unmitigated_critical: 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')), + ).toBe(true); + }); + + it('handles multiple breaking changes across sections', () => { + + const profile = { + profile_version: '0.1', + rules: { + identity: { + required_fields: ['agent_version', 'nonexistent_field'], + }, + attestation: { + requires_signature: true, + }, + tool_layer: { + blocked_permissions: ['fs:write'], + }, + risk_layer: { + requires_mitigation_for: ['critical'], + }, + }, + }; + + 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.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', () => { + + const profile = { + profile_version: '0.1', + rules: { + identity: { + required_fields: ['nonexistent_field'], + }, + }, + }; + + 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', () => { + let originalConsoleLog: typeof console.log; + let originalConsoleError: typeof console.error; + let consoleOutput: string[] = []; + let errorOutput: string[] = []; + + beforeEach(() => { + originalConsoleLog = console.log; + originalConsoleError = console.error; + consoleOutput = []; + errorOutput = []; + + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorOutput.push(args.map(String).join(' ')); + }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('returns error when no profile ID given', () => { + const exitCode = upgradeProfileCommand([]); + + expect(exitCode).toBe(1); + expect(errorOutput.some((line) => line.includes('Usage'))).toBe(true); + }); + + 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); + }); + + 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); + }); + + it('reports coverage gaps for existing profiles', () => { + upgradeProfileCommand(['soc2-2024', '--schema-version', '0.1']); + + 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']); + + expect(exitCode).toBe(0); + 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']); + + expect(exitCode).toBe(0); + const outputText = consoleOutput.join('\n'); + expect(outputText).toMatch(/dry-run/); + }); + + it('outputs upgraded profile JSON by default', () => { + upgradeProfileCommand(['soc2-2024', '--schema-version', '99.0']); + + 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']; + + for (const profileId of profiles) { + it(`${profileId}: reports already compatible with v0.1`, () => { + const exitCode = upgradeProfileCommand([profileId, '--schema-version', '0.1']); + + expect(exitCode).toBe(0); + 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 new file mode 100644 index 0000000..11486b7 --- /dev/null +++ b/packages/agentbom-cli/src/compliance-check.ts @@ -0,0 +1,833 @@ +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'; + +interface ComplianceResult { + compliant: boolean; + profile_id: string; + framework_name: string; + framework_version: string; + score: number; + threshold: number; + errors: string[]; + warnings: string[]; + passed_checks: string[]; +} + +interface ComplianceProfile { + profile_version: string; + profile_id: string; + framework: { + name: string; + version: string; + description?: string; + }; + rules: { + identity?: { + weight?: number; + required_fields?: string[]; + allowed_contexts?: string[]; + requires_version?: boolean; + }; + tool_layer?: { + weight?: number; + max_severity?: 'low' | 'medium' | 'high' | 'critical'; + requires_tool_inventory?: boolean; + blocked_permissions?: string[]; + blocked_sources?: string[]; + }; + risk_layer?: { + weight?: number; + requires_risk_assessment?: boolean; + max_unmitigated_critical?: number; + max_unmitigated_high?: number; + max_unmitigated_medium?: number; + requires_mitigation_for?: ('critical' | 'high' | 'medium' | 'low')[]; + }; + attestation?: { + weight?: number; + requires_signature?: boolean; + requires_timestamp?: boolean; + }; + }; + metadata?: { + author?: string; + created_at?: string; + updated_at?: string; + documentation_url?: string; + }; +} + +const DEFAULT_RULE_WEIGHT = 1; + +const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 }; + +function severityLevel(severity: string): number { + return SEVERITY_ORDER[severity as keyof typeof SEVERITY_ORDER] || 0; +} + +function loadProfile(profileId: string): ComplianceProfile | null { + const profilesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../profiles'); + + const profilePath = resolve(profilesDir, `${profileId}.json`); + + try { + const raw = readFileSync(profilePath, 'utf-8'); + return JSON.parse(raw) as ComplianceProfile; + } catch { + return null; + } +} + +function checkIdentity( + data: Record, + profile: ComplianceProfile, +): { errors: string[]; warnings: string[]; passed: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + const passed: string[] = []; + + const identity = data.identity as Record | undefined; + + if (!identity) { + errors.push('identity section is missing'); + return { errors, warnings, passed }; + } + + const rules = profile.rules.identity; + if (!rules) { + 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) { + errors.push(`identity: missing required field "${field}"`); + } else { + passed.push(`identity: field "${field}" present`); + } + } + } + + // Check allowed contexts + if (rules.allowed_contexts && rules.allowed_contexts.length > 0) { + 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(', ')}]`, + ); + } else { + passed.push(`identity: deployment_context "${context}" is allowed`); + } + } + + // 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'); + } else { + passed.push(`identity: agent_version "${identity.agent_version}" present`); + } + } + + return { errors, warnings, passed }; +} + +function checkToolLayer( + data: Record, + profile: ComplianceProfile, +): { errors: string[]; warnings: string[]; passed: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + const passed: string[] = []; + + const toolLayer = data.tool_layer as unknown[] | undefined; + + const rules = profile.rules.tool_layer; + if (!rules) { + 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'); + } else { + passed.push(`tool_layer: tool inventory present (${toolLayer.length} tools)`); + } + } + + if (!toolLayer || toolLayer.length === 0) { + return { errors, warnings, passed }; + } + + // Check each tool for max severity + if (rules.max_severity) { + const maxLevel = severityLevel(rules.max_severity); + for (const tool of toolLayer) { + 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(); + if (severity && severityLevel(severity) > maxLevel) { + errors.push( + `tool_layer: tool "${t.tool_name}" has risk signal "${signal}" exceeding 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}"`); + } + } + + // Check blocked permissions + 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) { + const t = tool as Record; + const permissions = t.permissions as string[] | undefined; + if (permissions) { + for (const perm of permissions) { + for (const blocked of blockedPermissions) { + if (perm.toLowerCase().includes(blocked.toLowerCase())) { + errors.push( + `tool_layer: tool "${t.tool_name}" has blocked permission "${perm}" (matches "${blocked}")`, + ); + } + } + } + } + } + } + } + + // Check blocked sources + 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) { + const t = tool as Record; + 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}"`); + } + } + } + } + } + + if (errors.filter((e) => e.startsWith('tool_layer:')).length === 0) { + passed.push('tool_layer: no blocked permissions or sources found'); + } + + return { errors, warnings, passed }; +} + +function checkRiskLayer( + data: Record, + profile: ComplianceProfile, +): { errors: string[]; warnings: string[]; passed: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + const passed: string[] = []; + + const riskLayer = data.risk_layer as unknown[] | undefined; + + const rules = profile.rules.risk_layer; + if (!rules) { + 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'); + } else { + passed.push(`risk_layer: risk assessment present (${riskLayer.length} risks)`); + } + } + + if (!riskLayer || riskLayer.length === 0) { + return { errors, warnings, passed }; + } + + // Count unmitigated risks by severity + const unmitigatedCounts: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + + for (const risk of riskLayer) { + if (typeof risk === 'object' && risk !== null) { + const r = risk as Record; + const severity = String(r.severity ?? '').toLowerCase(); + const status = String(r.status ?? '').toLowerCase(); + + if (status !== 'mitigated' && status !== 'accepted') { + if (severity in unmitigatedCounts) { + unmitigatedCounts[severity]++; + } + } + } + } + + // Check max unmitigated critical + if (rules.max_unmitigated_critical !== undefined) { + const count = unmitigatedCounts.critical; + if (count > rules.max_unmitigated_critical) { + errors.push( + `risk_layer: ${count} unmitigated critical risks (max allowed: ${rules.max_unmitigated_critical})`, + ); + } else { + passed.push( + `risk_layer: unmitigated critical risks within limit (${count}/${rules.max_unmitigated_critical})`, + ); + } + } + + // Check max unmitigated high + if (rules.max_unmitigated_high !== undefined) { + const count = unmitigatedCounts.high; + if (count > rules.max_unmitigated_high) { + errors.push( + `risk_layer: ${count} unmitigated high risks (max allowed: ${rules.max_unmitigated_high})`, + ); + } else { + passed.push( + `risk_layer: unmitigated high risks within limit (${count}/${rules.max_unmitigated_high})`, + ); + } + } + + // Check max unmitigated medium + if (rules.max_unmitigated_medium !== undefined) { + const count = unmitigatedCounts.medium; + if (count > rules.max_unmitigated_medium) { + errors.push( + `risk_layer: ${count} unmitigated medium risks (max allowed: ${rules.max_unmitigated_medium})`, + ); + } else { + passed.push( + `risk_layer: unmitigated medium risks within limit (${count}/${rules.max_unmitigated_medium})`, + ); + } + } + + // Check mitigation requirements + if (rules.requires_mitigation_for && rules.requires_mitigation_for.length > 0) { + for (const risk of riskLayer) { + if (typeof risk === 'object' && risk !== null) { + const r = risk as Record; + const severity = String(r.severity ?? '').toLowerCase(); + const status = String(r.status ?? '').toLowerCase(); + + if ( + rules.requires_mitigation_for?.includes( + severity as 'critical' | 'high' | 'medium' | 'low', + ) + ) { + if (status !== 'mitigated' && status !== 'accepted') { + warnings.push( + `risk_layer: risk "${r.risk_id}" has severity "${severity}" without mitigation status`, + ); + } + } + } + } + } + + return { errors, warnings, passed }; +} + +function checkAttestation( + data: Record, + profile: ComplianceProfile, +): { errors: string[]; warnings: string[]; passed: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + const passed: string[] = []; + + const attestation = data.attestation as Record | undefined; + + if (!attestation) { + errors.push('attestation section is missing'); + return { errors, warnings, passed }; + } + + const rules = profile.rules.attestation; + if (!rules) { + 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'); + } else { + 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'); + } else { + passed.push('attestation: timestamp present'); + } + } + + return { errors, warnings, passed }; +} + +/** + * Compute a weighted compliance score from check results. + * + * Each rule section (identity, tool_layer, risk_layer, attestation) can carry + * an optional `weight` (float ≥ 0). The default weight for any section is 1. + * + * A section is considered *passing* if it produced no errors. + * The score is the fraction: + * + * score = Σ(weight_i for passing sections) / Σ(weight_i for all sections) + * + * Returns a value in [0, 1] where 1 means every enabled rule section passed. + */ +function computeWeightedScore( + checkResults: { errors: string[]; warnings: string[]; passed: string[] }[], + profile: ComplianceProfile, +): number { + const ruleKeys: (keyof typeof profile.rules)[] = [ + 'identity', + 'tool_layer', + 'risk_layer', + 'attestation', + ]; + let totalWeight = 0; + let passedWeight = 0; + + for (let i = 0; i < ruleKeys.length; i++) { + const key = ruleKeys[i]; + const section = profile.rules[key]; + const weight = + section && typeof section === 'object' && 'weight' in section + ? ((section as { weight?: number }).weight ?? DEFAULT_RULE_WEIGHT) + : DEFAULT_RULE_WEIGHT; + + if (weight <= 0) continue; // Skip zero-weight sections + + totalWeight += weight; + if (checkResults[i].errors.length === 0) { + passedWeight += weight; + } + } + + return totalWeight > 0 ? passedWeight / totalWeight : 1; +} + +export function complianceCheckCommand(args: string[]): number { + if (args.length < 3) { + console.error( + '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'); + return 1; + } + + const bomPath = args[0]; + const profileArg = args[1]; + + if (profileArg !== '--profile') { + console.error(`Error: expected "--profile" argument, got "${profileArg}"`); + return 1; + } + + const profileId = args[2]; + + // 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) { + 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]}"`); + return 1; + } + break; + } + } + + // Load AgentBOM + const resolvedBomPath = resolve(bomPath); + let bomRaw: string; + try { + bomRaw = readFileSync(resolvedBomPath, 'utf-8'); + } catch { + console.error(`Error: cannot read AgentBOM file "${resolvedBomPath}"`); + return 1; + } + + let bomData: unknown; + try { + bomData = JSON.parse(bomRaw); + } catch { + console.error(`Error: "${resolvedBomPath}" is not valid JSON`); + return 1; + } + + // Validate AgentBOM schema + const bomValidation = validateAgentBOM(bomData); + if (!bomValidation.valid) { + console.error(`Error: AgentBOM validation failed for "${resolvedBomPath}":`); + for (const err of bomValidation.errors) { + console.error(` - ${err}`); + } + return 1; + } + + // Load compliance profile + const profile = loadProfile(profileId); + if (!profile) { + console.error(`Error: cannot load compliance profile "${profileId}"`); + console.error(`Expected file: profiles/${profileId}.json`); + return 1; + } + + // Run compliance checks + const checks = [ + checkIdentity(bomData as Record, profile), + checkToolLayer(bomData as Record, profile), + checkRiskLayer(bomData as Record, profile), + checkAttestation(bomData as Record, profile), + ]; + + // Compute weighted score + const score = computeWeightedScore(checks, profile); + + const result: ComplianceResult = { + compliant: score >= minScore, + profile_id: profile.profile_id, + framework_name: profile.framework.name, + framework_version: profile.framework.version, + score, + threshold: minScore, + errors: [], + warnings: [], + passed_checks: [], + }; + + for (const check of checks) { + result.errors.push(...check.errors); + result.warnings.push(...check.warnings); + result.passed_checks.push(...check.passed); + } + + // Output results + 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(''); + + if (result.passed_checks.length > 0) { + console.log('✓ Passed checks:'); + for (const check of result.passed_checks) { + console.log(` ${check}`); + } + console.log(''); + } + + if (result.warnings.length > 0) { + console.log('⚠ Warnings:'); + for (const warning of result.warnings) { + console.log(` ${warning}`); + } + console.log(''); + } + + if (result.errors.length > 0) { + console.log('✗ Failed checks:'); + for (const error of result.errors) { + console.log(` ${error}`); + } + console.log(''); + } + + if (result.compliant) { + console.log( + `✓ COMPLIANT (score ${(score * 100).toFixed(1)}% ≥ ${(minScore * 100).toFixed(0)}%)`, + ); + return 0; + } + console.log( + `✗ NON-COMPLIANT (score ${(score * 100).toFixed(1)}% < ${(minScore * 100).toFixed(0)}%)`, + ); + return 1; +} + +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'); + return 1; + } + + const profileId = args[0]; + + // 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) { + schemaVersion = args[i + 1]; + break; + } + } + + // Load compliance profile + const profile = loadProfile(profileId); + if (!profile) { + console.error(`Error: cannot load compliance profile "${profileId}"`); + console.error(`Expected file: profiles/${profileId}.json`); + return 1; + } + + const input: CompatibilityProfileInput = { + profile_version: profile.profile_version, + rules: { + identity: profile.rules.identity + ? { + required_fields: profile.rules.identity.required_fields, + allowed_contexts: profile.rules.identity.allowed_contexts, + requires_version: profile.rules.identity.requires_version, + } + : undefined, + tool_layer: profile.rules.tool_layer + ? { + max_severity: profile.rules.tool_layer.max_severity, + 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, + 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, + } + : undefined, + attestation: profile.rules.attestation + ? { + requires_signature: profile.rules.attestation.requires_signature, + requires_timestamp: profile.rules.attestation.requires_timestamp, + } + : undefined, + }, + }; + + const result = checkProfileSchemaCompatibility(input, schemaVersion); + + 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(''); + + 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(''); + } + + if (result.gaps.length > 0) { + console.log(`ℹ Coverage gaps (${result.gaps.length}): + Schema sections not covered by any profile rule:`); + for (const gap of result.gaps) { + console.log(` ${gap.path} — ${gap.description}`); + } + 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'); + 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(''); + } + if (optional.length > 0) { + console.log(`Optional mapping updates (${optional.length}):`); + for (const update of optional) { + console.log(` [${update.profile_section}] ${update.description}`); + console.log(` Action: ${update.action}`); + } + console.log(''); + } + } + + if (result.breaking.length === 0 && result.gaps.length === 0) { + console.log(`✓ Profile is fully compatible with AgentBOM schema v${schemaVersion}`); + } + + return result.compatible ? 0 : 1; +} + +export function upgradeProfileCommand(args: string[]): number { + if (args.length < 1) { + console.error( + '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'); + return 1; + } + + const profileId = args[0]; + + let schemaVersion = getLatestVersion(); + let dryRun = false; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--schema-version' && i + 1 < args.length) { + schemaVersion = args[i + 1]; + i++; + } else if (args[i] === '--dry-run') { + dryRun = true; + } + } + + const profile = loadProfile(profileId); + if (!profile) { + console.error(`Error: cannot load compliance profile "${profileId}"`); + console.error(`Expected file: profiles/${profileId}.json`); + return 1; + } + + const input: CompatibilityProfileInput = { + profile_version: profile.profile_version, + rules: { + identity: profile.rules.identity + ? { + required_fields: profile.rules.identity.required_fields, + allowed_contexts: profile.rules.identity.allowed_contexts, + requires_version: profile.rules.identity.requires_version, + } + : undefined, + tool_layer: profile.rules.tool_layer + ? { + max_severity: profile.rules.tool_layer.max_severity, + 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, + 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, + } + : undefined, + attestation: profile.rules.attestation + ? { + requires_signature: profile.rules.attestation.requires_signature, + requires_timestamp: profile.rules.attestation.requires_timestamp, + } + : undefined, + }, + }; + + const result = upgradeProfileMappings(input, schemaVersion); + + console.log(`Profile Upgrade: ${profileId}`); + console.log(` Profile version: ${result.compatibility.profile_version}`); + console.log(` AgentBOM schema: v${result.compatibility.agentbom_version}`); + console.log(''); + + 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(''); + } + + if (!result.changes_applied) { + 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).`, + ); + } + return 0; + } + + console.log(`Auto-applied fixes (${result.applied_updates.length}):`); + for (const update of result.applied_updates) { + console.log(` ✓ ${update}`); + } + console.log(''); + + if (result.unresolved.length > 0) { + 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(''); + } + + if (dryRun) { + console.log('(dry-run — no output written)'); + return 0; + } + + console.log(JSON.stringify(result.upgraded_profile, null, 2)); + return 0; +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-bad.json b/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-bad.json new file mode 100644 index 0000000..67b6cbb --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-bad.json @@ -0,0 +1,94 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "eidas-agent-bad-001", + "agent_name": "EIDAS Non-Compliant Agent", + "agent_version": "3.0.0", + "deployment_context": "development", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-haiku-4-5", + "model_version": "2025-06", + "capabilities": ["tool_use"] + }, + "tool_layer": [ + { + "tool_id": "file-write", + "tool_name": "FileWrite", + "source": "builtin", + "permissions": ["filesystem:write"], + "risk_signals": ["medium:file_write"] + }, + { + "tool_id": "external-network", + "tool_name": "ExternalNetwork", + "source": "mcp", + "mcp_server_id": "external-server", + "permissions": ["network:external"], + "risk_signals": ["high:external_network"] + }, + { + "tool_id": "system-execute", + "tool_name": "SystemExecute", + "source": "builtin", + "permissions": ["system:execute"], + "risk_signals": ["critical:system_execute"] + }, + { + "tool_id": "unverified-tool", + "tool_name": "UnverifiedTool", + "source": "plugin", + "permissions": ["api:call"], + "risk_signals": ["medium:unverified_source"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7", + "template_ids": ["eidas-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "fs:write", "network:external", "system:execute"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-critical-001", + "severity": "critical", + "category": "system_execute", + "description": "Critical risk: system execution capability", + "status": "open" + }, + { + "risk_id": "risk-high-001", + "severity": "high", + "category": "external_network", + "description": "High risk: external network access", + "status": "open" + }, + { + "risk_id": "risk-medium-001", + "severity": "medium", + "category": "file_write", + "description": "Medium risk: file write capability", + "status": "open" + }, + { + "risk_id": "risk-medium-002", + "severity": "medium", + "category": "unverified_source", + "description": "Medium risk: unverified tool source", + "status": "open" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research" + } +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-good.json b/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-good.json new file mode 100644 index 0000000..a9c78a6 --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/eidas-controlled-known-good.json @@ -0,0 +1,68 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "eidas-agent-001", + "agent_name": "EIDAS Compliant Agent", + "agent_version": "3.0.0", + "deployment_context": "production", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-haiku-4-5", + "model_version": "2025-06", + "capabilities": ["tool_use"] + }, + "tool_layer": [ + { + "tool_id": "file-read-only", + "tool_name": "ReadOnlyRead", + "source": "builtin", + "permissions": ["fs:read"], + "risk_signals": [] + }, + { + "tool_id": "memory-read", + "tool_name": "MemoryRead", + "source": "builtin", + "permissions": ["memory:read"], + "risk_signals": [] + }, + { + "tool_id": "verified-internal-api", + "tool_name": "InternalAPI", + "source": "plugin", + "permissions": ["api:internal"], + "risk_signals": ["low:internal_access"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "template_ids": ["eidas-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "memory:read", "api:internal"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-internal-001", + "severity": "low", + "category": "internal_access", + "description": "Internal API access to verified endpoints", + "status": "mitigated" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research", + "agentbom_hash": "sha256:hashfedcba0987654321", + "signature": "MEUCIQC3vZQnZJhKZ8hPYmFZNtjX6QTQ7RZnZXHQXG3QSY2FIwIgYKxLjKYqQYHBxO5H7nXZ9hYmFZNtjX6QTQ7RZnZXHQXG3Q=", + "timestamp": "2026-07-07T00:00:00Z" + } +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-bad.json b/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-bad.json new file mode 100644 index 0000000..18c1984 --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-bad.json @@ -0,0 +1,80 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "iso-agent-bad-001", + "agent_name": "ISO27001 Non-Compliant Agent", + "agent_version": "2.0.0", + "deployment_context": "staging", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-opus-4-8", + "model_version": "2025-06", + "capabilities": ["tool_use"] + }, + "tool_layer": [ + { + "tool_id": "unrestricted-write", + "tool_name": "UnrestrictedWrite", + "source": "builtin", + "permissions": ["filesystem:write unrestricted"], + "risk_signals": ["high:unrestricted_filesystem"] + }, + { + "tool_id": "unauthenticated-external", + "tool_name": "UnauthenticatedExternal", + "source": "mcp", + "mcp_server_id": "unverified-external-server", + "permissions": ["network:external unauthenticated"], + "risk_signals": ["critical:unauthenticated_external"] + }, + { + "tool_id": "critical-risk-tool", + "tool_name": "CriticalRiskTool", + "source": "builtin", + "permissions": ["system:execute"], + "risk_signals": ["critical:arbitrary_execution"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + "template_ids": ["iso-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "fs:write", "network:outbound"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-critical-001", + "severity": "critical", + "category": "arbitrary_execution", + "description": "Critical risk: arbitrary execution capability", + "status": "open" + }, + { + "risk_id": "risk-critical-002", + "severity": "critical", + "category": "unauthenticated_external", + "description": "Critical risk: unauthenticated external network access", + "status": "open" + }, + { + "risk_id": "risk-high-001", + "severity": "high", + "category": "unrestricted_filesystem", + "description": "High risk: unrestricted filesystem write", + "status": "open" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research" + } +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-good.json b/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-good.json new file mode 100644 index 0000000..ea5f193 --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/iso27001-2022-known-good.json @@ -0,0 +1,90 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "iso-agent-001", + "agent_name": "ISO27001 Compliant Agent", + "agent_version": "2.0.0", + "deployment_context": "production", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-opus-4-8", + "model_version": "2025-06", + "capabilities": ["tool_use", "code_generation"] + }, + "tool_layer": [ + { + "tool_id": "file-read", + "tool_name": "Read", + "source": "builtin", + "permissions": ["fs:read"], + "risk_signals": [] + }, + { + "tool_id": "file-write-restricted", + "tool_name": "WriteRestricted", + "source": "builtin", + "permissions": ["fs:write:restricted"], + "risk_signals": [] + }, + { + "tool_id": "network-authenticated", + "tool_name": "AuthenticatedHttpClient", + "source": "builtin", + "permissions": ["network:outbound:authenticated"], + "risk_signals": ["high:data_exfiltration"] + }, + { + "tool_id": "verified-mcp", + "tool_name": "VerifiedMCP", + "source": "mcp", + "mcp_server_id": "verified-server", + "permissions": ["network:outbound"], + "risk_signals": ["medium:api_access"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3", + "template_ids": ["iso-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "fs:write:restricted", "network:outbound:authenticated"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-data-exfil-001", + "severity": "high", + "category": "data_exfiltration", + "description": "Authenticated HTTP client could potentially exfiltrate data", + "status": "mitigated" + }, + { + "risk_id": "risk-api-access-001", + "severity": "medium", + "category": "api_access", + "description": "Verified MCP server has API access capabilities", + "status": "open" + }, + { + "risk_id": "risk-fs-write-001", + "severity": "low", + "category": "file_system", + "description": "Restricted write operations to specific paths", + "status": "accepted" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research", + "agentbom_hash": "sha256:hashabcdef1234567890", + "signature": "MEUCIQC3vZQnZJhKZ8hPYmFZNtjX6QTQ7RZnZXHQXG3QSY2FIwIgYKxLjKYqQYHBxO5H7nXZ9hYmFZNtjX6QTQ7RZnZXHQXG3Q=", + "timestamp": "2026-07-07T00:00:00Z" + } +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-bad.json b/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-bad.json new file mode 100644 index 0000000..1760bbb --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-bad.json @@ -0,0 +1,80 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "soc2-agent-bad-001", + "agent_name": "SOC2 Non-Compliant Agent", + "agent_version": "1.0.0", + "deployment_context": "development", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "model_version": "2025-06", + "capabilities": ["tool_use"] + }, + "tool_layer": [ + { + "tool_id": "arbitrary-exec", + "tool_name": "ArbitraryExecute", + "source": "plugin", + "permissions": ["system:execute arbitrary"], + "risk_signals": ["high:arbitrary_execution"] + }, + { + "tool_id": "unrestricted-write", + "tool_name": "UnrestrictedWrite", + "source": "builtin", + "permissions": ["filesystem:write unrestricted"], + "risk_signals": ["critical:unrestricted_filesystem"] + }, + { + "tool_id": "external-network", + "tool_name": "ExternalNetwork", + "source": "mcp", + "mcp_server_id": "external-server", + "permissions": ["network:external unrestricted"], + "risk_signals": ["critical:external_network"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5", + "template_ids": ["soc2-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "fs:write", "process:exec"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-arbitrary-exec-001", + "severity": "critical", + "category": "arbitrary_execution", + "description": "Arbitrary execution tool allows running any command", + "status": "open" + }, + { + "risk_id": "risk-unrestricted-fs-001", + "severity": "critical", + "category": "unrestricted_filesystem", + "description": "Unrestricted filesystem write access", + "status": "open" + }, + { + "risk_id": "risk-external-net-001", + "severity": "high", + "category": "external_network", + "description": "Unrestricted external network access", + "status": "open" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research" + } +} diff --git a/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-good.json b/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-good.json new file mode 100644 index 0000000..1ca1e83 --- /dev/null +++ b/packages/agentbom-cli/src/compliance-fixtures/soc2-2024-known-good.json @@ -0,0 +1,82 @@ +{ + "agentbom_version": "0.1", + "identity": { + "agent_id": "soc2-agent-001", + "agent_name": "SOC2 Compliant Agent", + "agent_version": "1.0.0", + "deployment_context": "production", + "generated_at": "2026-07-07T00:00:00Z" + }, + "model_layer": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "model_version": "2025-06", + "capabilities": ["tool_use", "code_generation"] + }, + "tool_layer": [ + { + "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"], + "risk_signals": [] + }, + { + "tool_id": "bash-exec", + "tool_name": "Bash", + "source": "builtin", + "permissions": ["process:exec", "fs:read"], + "risk_signals": ["medium:command_execution"] + }, + { + "tool_id": "http-client", + "tool_name": "HttpClient", + "source": "builtin", + "permissions": ["network:outbound"], + "risk_signals": ["low:external_network"] + } + ], + "prompt_layer": { + "system_prompt_hash": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "template_ids": ["soc2-system-v1"] + }, + "permission_layer": { + "granted_scopes": ["fs:read", "fs:write", "process:exec", "network:outbound"], + "data_access": ["local_workspace"], + "credential_references": [] + }, + "evidence_layer": { + "aep_references": ["aep-tool-invocation-001"], + "evidence_hashes": [] + }, + "risk_layer": [ + { + "risk_id": "risk-bash-001", + "severity": "medium", + "category": "command_execution", + "description": "Bash tool allows process execution within workspace", + "status": "mitigated" + }, + { + "risk_id": "risk-network-001", + "severity": "low", + "category": "external_network", + "description": "HTTP client can make outbound network requests", + "status": "accepted" + } + ], + "attestation": { + "generator": "agent-trust-infra", + "generator_version": "0.0.0-research", + "agentbom_hash": "sha256:hash1234567890abcdef", + "signature": "MEUCIQC3vZQnZJhKZ8hPYmFZNtjX6QTQ7RZnZXHQXG3QSY2FIwIgYKxLjKYqQYHBxO5H7nXZ9hYmFZNtjX6QTQ7RZnZXHQXG3Q=", + "timestamp": "2026-07-07T00:00:00Z" + } +} diff --git a/packages/agentbom-cli/src/compose-team.test.ts b/packages/agentbom-cli/src/compose-team.test.ts new file mode 100644 index 0000000..060eac1 --- /dev/null +++ b/packages/agentbom-cli/src/compose-team.test.ts @@ -0,0 +1,330 @@ +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', () => { + let tmpDir: string; + let logOutput: string[]; + let errorOutput: string[]; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + tmpDir = join( + tmpdir(), + `compose-team-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + 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(' ')); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // --- Argument validation --- + + 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(logOutput).toEqual([]); + }); + + 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']); + }); + + // --- 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']); + expect(result).toBe(1); + 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{{{'); + + 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'); + }); + + 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']); + expect(result).toBe(1); + 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', () => { + const bom1 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-001', + agent_name: 'Alpha', + generated_at: '2026-01-15T10:00:00Z', + }, + attestation: { generator: 'test' }, + }); + const bom2 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-002', + agent_name: 'Beta', + generated_at: '2026-01-15T10:05:00Z', + }, + attestation: { generator: 'test' }, + }); + + 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 manifest = JSON.parse(output); + + 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/); + expect(manifest.aggregated_capabilities).toEqual([]); + expect(manifest.trust_relationships).toEqual([]); + }); + + it('includes correct agent entries with bom_path', () => { + const bom1 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-001', + agent_name: 'Alpha', + generated_at: '2026-01-15T10:00:00Z', + }, + attestation: { generator: 'test' }, + }); + const bom2 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-002', + agent_name: 'Beta', + generated_at: '2026-01-15T10:05:00Z', + }, + attestation: { generator: 'test' }, + }); + + 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')); + + expect(manifest.agents[0]).toEqual({ + agent_id: 'agent-001', + agent_name: 'Alpha', + bom_path: path1, + }); + expect(manifest.agents[1]).toEqual({ + agent_id: 'agent-002', + agent_name: 'Beta', + bom_path: path2, + }); + }); + + // --- Capabilities aggregation --- + + it('aggregates capabilities as sorted union from model_layer.capabilities', () => { + const bom1 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + 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'], + }, + attestation: { generator: 'test' }, + }); + const bom2 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + 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'], + }, + attestation: { generator: 'test' }, + }); + + 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')); + + // Union of capabilities, sorted + expect(manifest.aggregated_capabilities).toEqual(['analysis', 'code-generation', 'planning']); + }); + + // --- Trust relationships from peer_agents --- + + it('collects trust relationships from agent_collaboration.peer_agents', () => { + const bom1 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-001', + agent_name: 'Alpha', + generated_at: '2026-01-15T10:00:00Z', + }, + agent_collaboration: { + peer_agents: [{ agent_id: 'agent-002', role: 'delegate' }], + }, + attestation: { generator: 'test' }, + }); + const bom2 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-002', + agent_name: 'Beta', + generated_at: '2026-01-15T10:05:00Z', + }, + agent_collaboration: { + peer_agents: [{ agent_id: 'agent-001', role: 'supervisor' }], + }, + attestation: { generator: 'test' }, + }); + + 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')); + + expect(manifest.trust_relationships).toHaveLength(2); + expect(manifest.trust_relationships).toContainEqual({ + from: 'agent-001', + to: 'agent-002', + type: 'delegate', + }); + expect(manifest.trust_relationships).toContainEqual({ + 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', () => { + const bom1 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-001', + agent_name: 'Alpha', + generated_at: '2026-01-15T10:00:00Z', + }, + attestation: { generator: 'test' }, + }); + const bom2 = JSON.stringify({ + agentbom_version: '0.1', + identity: { + agent_id: 'agent-002', + agent_name: 'Beta', + generated_at: '2026-01-15T10:05:00Z', + }, + attestation: { generator: 'test' }, + }); + + 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'); + expect(() => JSON.parse(raw)).not.toThrow(); + const manifest = JSON.parse(raw); + expect(manifest).toHaveProperty('schema', 'composite-trust-manifest/v1'); + }); + + // --- 3+ 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', + identity: { + agent_id: `agent-${String(i).padStart(3, '0')}`, + agent_name: `Agent ${i}`, + generated_at: '2026-01-15T10:00:00Z', + }, + attestation: { generator: 'test' }, + }), + ); + paths.push(path); + } + + const result = composeTeamCommand(paths); + expect(result).toBe(0); + + 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', + ]); + }); +}); diff --git a/packages/agentbom-cli/src/compose-team.ts b/packages/agentbom-cli/src/compose-team.ts new file mode 100644 index 0000000..3b4d4f3 --- /dev/null +++ b/packages/agentbom-cli/src/compose-team.ts @@ -0,0 +1,108 @@ +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'; + generated_at: string; + agent_count: number; + agents: Array<{ agent_id: string; agent_name: string; bom_path: string }>; + aggregated_capabilities: string[]; + trust_relationships: Array<{ from: string; to: string; type: string }>; +} + +/** + * Compose multiple AgentBOMs into a composite trust manifest. + * Requires at least 2 BOM file paths. + */ +export function composeTeamCommand(args: string[]): number { + if (args.length < 2) { + console.error('Error: compose-team requires at least 2 agent BOM files'); + return 1; + } + + const boms: Array<{ data: Record; path: string }> = []; + + for (const filePath of args) { + const resolvedPath = resolve(filePath); + + // Read file + let raw: string; + try { + raw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: Cannot read BOM file: ${resolvedPath}`); + return 1; + } + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + 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]}`); + return 1; + } + + boms.push({ data: data as Record, path: resolvedPath }); + } + + // Build composite manifest + const agents: CompositeManifest['agents'] = []; + const allCapabilities = new Set(); + 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'); + + agents.push({ agent_id: agentId, agent_name: agentName, bom_path: path }); + + // Collect capabilities from model_layer.capabilities + const modelLayer = data.model_layer as Record | undefined; + const capabilities = modelLayer?.capabilities; + if (Array.isArray(capabilities)) { + for (const cap of capabilities) { + if (typeof cap === 'string') { + allCapabilities.add(cap); + } + } + } + + // Collect trust relationships from agent_collaboration.peer_agents + 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'), + }); + } + } + } + + const manifest: CompositeManifest = { + schema: 'composite-trust-manifest/v1', + generated_at: new Date().toISOString(), + agent_count: boms.length, + agents, + aggregated_capabilities: Array.from(allCapabilities).sort(), + trust_relationships: trustRelationships, + }; + + console.log(JSON.stringify(manifest, null, 2)); + return 0; +} diff --git a/packages/agentbom-cli/src/export-dashboard.test.ts b/packages/agentbom-cli/src/export-dashboard.test.ts new file mode 100644 index 0000000..87680cf --- /dev/null +++ b/packages/agentbom-cli/src/export-dashboard.test.ts @@ -0,0 +1,378 @@ +/** + * Fleet Trust Analytics Dashboard — tests for the Milestone 8 bullet: + * "Trust analytics dashboard — web UI for visualizing trust posture across agent + * 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 { + type AgentBOM, + assessControls, + buildDependencyGraph, + exportFleetDashboardCommand, + generateFleetDashboardHTML, + maxSeverity, + mergeAuditEntries, + postureScore, + renderDependencyGraphSVG, +} from './export-dashboard.js'; +import { runCommand } from './index.js'; + +const BOM_A: AgentBOM = { + 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', + }, + model_layer: { + 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'], + risk_signals: [], + }, + { + 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'], + data_access: [], + credential_references: [], + }, + evidence_layer: { + aep_references: ['aep-1'], + evidence_hashes: [ + { 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', + }, + ], + audit_log: [ + { + 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' }, + }, + ], + attestation: { generator: 'trust-cli', generator_version: '0.0.0-research' }, +}; + +const BOM_B: AgentBOM = { + 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', + }, + tool_layer: [ + { + tool_id: 'grep', + tool_name: 'grep', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + 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', + }, + ], + 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' }, +]; + +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', () => { + const controls = assessControls(BOM_A); + expect(controls).toHaveLength(6); + expect(controls.map((c) => c.name)).toEqual([ + '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 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'); + }); +}); + +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', () => { + expect(postureScore(BOM_B)).toBeGreaterThan(postureScore(BOM_A)); + }); +}); + +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(''); + }); +}); + +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(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; + expect(edges.some((e) => e.from === agentId && e.to === n.id)).toBe(true); + } + }); +}); + +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', () => { + 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'); + // Remaining entries are BOM_A's, in chronological order. + expect(entries[1].resource).toBe('read_file'); + expect(entries[2].resource).toBe('create_pr'); + }); +}); + +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 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('hm-pass'); + expect(html).toContain('hm-warn'); + expect(html).toContain('hm-fail'); + }); + + 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'); + // Audit rows carry epoch-ms timestamps used by the date-range filter. + expect(html).toContain('data-ts="'); + expect(html).toContain('audit-body'); + }); + + it('escapes agent-controlled content in audit details', () => { + expect(html).toContain('create_pr'); + expect(html).not.toContain(' + +`; +} + +/** Command handler for `export-dashboard fleet --output `. */ +export function exportFleetDashboardCommand(args: string[]): number { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log( + [ + 'Usage: agent-trust export-dashboard fleet --output ', + '', + 'Generates a fleet trust analytics dashboard from every AgentBOM (*.json) in .', + '', + 'Arguments:', + ' Directory containing AgentBOM JSON files (one per agent)', + ' --output Directory to write fleet-dashboard.html (required)', + '', + 'The dashboard visualizes:', + ' - Trust posture across the agent fleet (per-agent overview + scores)', + ' - BOM dependency graphs (agent -> model / MCP servers / tools / scopes)', + ' - Compliance heatmap (per-agent x trust-control posture grid)', + ' - Audit log search with temporal (date-range), text, and event-type filters', + ].join('\n'), + ); + return 0; + } + + let inputDir = ''; + let outputDir = ''; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--output' && i + 1 < args.length) { + outputDir = args[i + 1]; + i++; + } else if (!args[i].startsWith('--')) { + inputDir = args[i]; + } + } + + if (!inputDir) { + console.error('Error: Missing required argument '); + return 1; + } + if (!outputDir) { + console.error('Error: Missing required argument --output '); + return 1; + } + + let entries: string[]; + try { + entries = readdirSync(resolve(inputDir)).filter((f) => f.endsWith('.json')); + } catch { + console.error(`Error: cannot read fleet directory "${resolve(inputDir)}"`); + return 1; + } + if (entries.length === 0) { + console.error(`Error: no AgentBOM (*.json) files found in "${resolve(inputDir)}"`); + return 1; + } + + const boms: { bom: AgentBOM; source: string }[] = []; + for (const f of entries.sort()) { + const filePath = resolve(inputDir, f); + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch { + console.warn(`Warning: skipping unreadable file "${f}"`); + continue; + } + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.error(`Error: "${filePath}" is not valid JSON`); + return 1; + } + const validation = validateAgentBOM(data); + if (!validation.valid) { + console.error(`Error: invalid AgentBOM in "${f}":`); + for (const error of validation.errors) console.error(` ${error}`); + return 1; + } + boms.push({ bom: data as AgentBOM, source: f }); + } + + if (boms.length === 0) { + console.error('Error: no valid AgentBOM files found in fleet directory'); + return 1; + } + + const html = generateFleetDashboardHTML(boms); + + const outputPath = resolve(outputDir); + try { + mkdirSync(outputPath, { recursive: true }); + } catch { + // Directory may already exist + } + const outputFile = resolve(outputPath, 'fleet-dashboard.html'); + writeFileSync(outputFile, html, 'utf-8'); + + console.log(`✅ Fleet dashboard generated: ${outputFile} (${boms.length} agents)`); + return 0; +} + +/** Command handler for export-dashboard */ +export function exportDashboardCommand(args: string[]): number { + if (args[0] === 'fleet') { + return exportFleetDashboardCommand(args.slice(1)); + } + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log( + [ + 'Usage: agent-trust export-dashboard --output ', + '', + 'Generates a static HTML dashboard from an AgentBOM file.', + '', + 'Arguments:', + ' Path to the AgentBOM JSON file', + ' --output Directory to write the HTML dashboard (required)', + '', + 'The dashboard includes:', + ' - Agent identity and model information', + ' - Tool inventory with permissions and risk signals', + ' - Risk assessment with severity breakdown', + ' - Permissions and access overview', + ' - Evidence and attestation data', + ' - Audit log entries (if available)', + ].join('\n'), + ); + return 0; + } + + let bomPath = ''; + let outputDir = ''; + + // Parse arguments + for (let i = 0; i < args.length; i++) { + if (args[i] === '--output' && i + 1 < args.length) { + outputDir = args[i + 1]; + i++; + } else if (!args[i].startsWith('--')) { + bomPath = args[i]; + } + } + + if (!bomPath) { + console.error('Error: Missing required argument '); + return 1; + } + + if (!outputDir) { + console.error('Error: Missing required argument --output '); + return 1; + } + + try { + 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:'); + for (const error of validation.errors) { + console.error(` ${error}`); + } + return 1; + } + + // Generate HTML + const html = generateDashboardHTML(data); + + // Create output directory if it doesn't exist + const outputPath = resolve(outputDir); + try { + mkdirSync(dirname(outputPath), { recursive: true }); + } catch { + // Directory might already exist + } + + // Write HTML file + const outputFile = resolve(outputPath, 'dashboard.html'); + writeFileSync(outputFile, html, 'utf-8'); + + console.log(`✅ Dashboard generated successfully: ${outputFile}`); + return 0; + } catch (err) { + console.error( + `Error: Failed to generate dashboard: ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} diff --git a/packages/agentbom-cli/src/export-marketplace.ts b/packages/agentbom-cli/src/export-marketplace.ts new file mode 100644 index 0000000..cfe6726 --- /dev/null +++ b/packages/agentbom-cli/src/export-marketplace.ts @@ -0,0 +1,256 @@ +/** + * `export-marketplace.ts` — data model and pure builder for marketplace trust + * packages (schema `marketplace-trust-package/v1`). + * + * Split from #332 (marketplace trust export). This module owns only the data + * model and the pure {@link buildMarketplacePackage} builder; the CLI command + * wiring is a follow-up issue. + * + * The builder reads fields from an AgentBOM using safe property access and never + * throws on missing or malformed input — absent fields fall back to documented + * defaults. The caller computes the content-addressable identifier (`cas_id`) + * of the source BOM (e.g. with `createHash('sha256')` over its canonical JSON, + * mirroring `trust-publish.ts`) and passes it in; this module touches neither + * the filesystem nor hashing. + */ + +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// ---- Types ---- + +/** Schema version identifier for marketplace trust packages. */ +export const MARKETPLACE_PACKAGE_SCHEMA = 'marketplace-trust-package/v1' as const; + +/** A trust attestation referenced by a marketplace package. */ +export interface TrustAttestation { + /** Attestation type, e.g. 'passport', 'audit-log'. */ + type: string; + /** Identifier or path to the attestation artifact. */ + reference: string; +} + +/** Compliance summary surfaced in a marketplace package. */ +export interface ComplianceSummary { + /** Compliance framework IDs derived from the BOM's compliance mappings. */ + frameworks: string[]; + /** Number of compliance checks that passed. */ + passed_checks: number; + /** Total number of compliance checks. */ + total_checks: number; +} + +/** A marketplace trust package derived from an AgentBOM. */ +export interface MarketplacePackage { + schema: 'marketplace-trust-package/v1'; + /** ISO-8601 timestamp of package generation. */ + generated_at: string; + agent_id: string; + agent_name: string; + /** From `bom.identity.agent_version` / `bom.agent_version`, or 'unknown'. */ + agent_version: string; + /** From `bom.maintainer`, or 'unknown'. */ + publisher: string; + /** From `bom.capabilities.declared` / `bom.model_layer.capabilities`, or []. */ + capabilities: string[]; + compliance_summary: ComplianceSummary; + trust_attestations: TrustAttestation[]; + /** sha256 of the canonical JSON of the source BOM (passed in by the caller). */ + cas_id: string; + /** Human-readable one-liner for operators. */ + verification_instructions: string; +} + +// ---- Pure helpers ---- + +/** + * Coerce an unknown value to a non-empty string, returning `fallback` when the + * value is absent or not a usable string. Keeps field extraction resilient to + * malformed BOM input. + */ +function asString(value: unknown, fallback = 'unknown'): string { + return typeof value === 'string' && value.length > 0 ? value : fallback; +} + +/** + * Coerce an unknown value to an array of strings, dropping non-string entries. + * Returns `fallback` (default `[]`) when the value is not an array. + */ +function asStringArray(value: unknown, fallback: string[] = []): string[] { + if (!Array.isArray(value)) return fallback; + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +/** + * Narrow an unknown value to a plain record. + * Returns `undefined` when the value is absent, an array, or not an object. + */ +function asRecord(value: unknown): Record | undefined { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +/** One-liner shown to operators describing how to verify the package. */ +const VERIFICATION_INSTRUCTIONS = + 'Run: trust-cli compliance-check --profile default to verify'; + +/** + * Extract agent identity fields from a BOM. + * + * Supports both the canonical AgentBOM layout (`bom.identity.*`) and the flat + * layout referenced in the marketplace export spec (`bom.agent_id`, etc.). + * Falls back to `'unknown'` when neither is present. + */ +function extractIdentity(bom: Record): { + agent_id: string; + agent_name: string; + agent_version: string; +} { + const identity = asRecord(bom.identity); + return { + agent_id: asString(identity?.agent_id ?? bom.agent_id), + agent_name: asString(identity?.agent_name ?? bom.agent_name), + agent_version: asString(identity?.agent_version ?? bom.agent_version), + }; +} + +/** + * Extract declared agent capabilities from a BOM. + * + * Supports the marketplace spec shape (`bom.capabilities.declared`), the + * canonical AgentBOM model layer (`bom.model_layer.capabilities`), and a flat + * array (`bom.capabilities`). Falls back to `[]` when absent. + */ +function extractCapabilities(bom: Record): string[] { + const capabilitiesObject = asRecord(bom.capabilities); + const modelLayer = asRecord(bom.model_layer); + return asStringArray( + capabilitiesObject?.declared ?? modelLayer?.capabilities ?? bom.capabilities, + ); +} + +/** + * Extract the compliance summary from a BOM. + * + * Framework IDs are read from each entry of `bom.compliance_mappings[].framework_id`. + * Pass/total check counts are not carried by the source BOM and default to `0`. + */ +function extractComplianceSummary(bom: Record): ComplianceSummary { + const mappings = Array.isArray(bom.compliance_mappings) ? bom.compliance_mappings : []; + const frameworks: string[] = []; + for (const entry of mappings) { + const frameworkId = asString(asRecord(entry)?.framework_id, ''); + if (frameworkId) frameworks.push(frameworkId); + } + return { frameworks, passed_checks: 0, total_checks: 0 }; +} + +/** + * Build a {@link MarketplacePackage} from an AgentBOM and its precomputed CAS ID. + * + * Pure data-layer function for the marketplace trust export. It extracts every + * field using safe property access and never throws on missing or malformed + * input — absent fields fall back to documented defaults: + * + * - `agent_id`, `agent_name`, `agent_version`, `publisher` → `'unknown'` + * - `capabilities`, `frameworks`, `trust_attestations` → `[]` + * - `passed_checks` / `total_checks` → `0` + * + * `cas_id` is the caller-computed SHA-256 of the source BOM's canonical JSON; + * this function does not recompute it. + */ +export function buildMarketplacePackage( + bom: Record, + bomCasId: string, +): MarketplacePackage { + const identity = extractIdentity(bom); + + return { + schema: MARKETPLACE_PACKAGE_SCHEMA, + generated_at: new Date().toISOString(), + agent_id: identity.agent_id, + agent_name: identity.agent_name, + agent_version: identity.agent_version, + publisher: asString(bom.maintainer), + capabilities: extractCapabilities(bom), + compliance_summary: extractComplianceSummary(bom), + trust_attestations: [], + cas_id: bomCasId, + verification_instructions: VERIFICATION_INSTRUCTIONS, + }; +} + +// ---- CLI command ---- + +/** + * CLI entry point for `agent-trust export-marketplace --output `. + * + * Reads an AgentBOM JSON file, builds a {@link MarketplacePackage} from it, + * and writes `marketplace-package.json` to the requested output directory. + */ +export function exportMarketplaceCommand(args: string[]): number { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log( + [ + 'Usage: agent-trust export-marketplace --output ', + '', + 'Generates a standardized marketplace trust package from an AgentBOM file.', + '', + 'Arguments:', + ' Path to the AgentBOM JSON file', + ' --output Directory to write the package (required)', + '', + 'Output:', + ' /marketplace-package.json — the generated trust package', + '', + 'The package contains agent identity, capabilities, compliance summary,', + 'and trust attestations in the marketplace-trust-package/v1 schema.', + ].join('\n'), + ); + return 0; + } + + let bomPath = ''; + let outputDir = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--output' && i + 1 < args.length) { + outputDir = args[++i]; + } else if (!args[i].startsWith('--')) { + bomPath = args[i]; + } + } + + if (!bomPath) { + console.error('Error: Missing required argument '); + return 1; + } + if (!outputDir) { + console.error('Error: Missing required argument --output '); + return 1; + } + + try { + const content = readFileSync(resolve(bomPath), 'utf-8'); + const bom = JSON.parse(content) as Record; + const casId = `sha256:${createHash('sha256').update(content, 'utf-8').digest('hex')}`; + const pkg = buildMarketplacePackage(bom, casId); + + mkdirSync(resolve(outputDir), { recursive: true }); + const outPath = resolve(outputDir, 'marketplace-package.json'); + writeFileSync( + outPath, + `${JSON.stringify(pkg, null, 2)} +`, + 'utf-8', + ); + console.log(`Marketplace package written to ${outPath}`); + return 0; + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} diff --git a/packages/agentbom-cli/src/index.ts b/packages/agentbom-cli/src/index.ts new file mode 100644 index 0000000..37b6d00 --- /dev/null +++ b/packages/agentbom-cli/src/index.ts @@ -0,0 +1,829 @@ +#!/usr/bin/env bun +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + getLatestVersion as getLatestAgentBOMVersion, + getSupportedVersions as getSupportedAgentBOMVersions, + migrateAgentBOM, +} from '@wasmagent/agentbom-core'; +import { + getLatestVersion as getLatestPostureVersion, + getSupportedVersions as getSupportedPostureVersions, + migrateMCPPosture, +} from '@wasmagent/mcp-posture'; +import { diffAgentBOMCommand } from './agentbom-diff.js'; +import { inspectAgentBOMCommand } from './agentbom-inspect.js'; +import { agentbomPipelineCommand } from './agentbom-pipeline.js'; +import { auditReportCommand } from './audit-report.js'; +import { generateAgentBOMCommand } from './bom-generate.js'; +import { chainCommand } from './chain.js'; +import { + complianceCheckCommand, + upgradeProfileCommand, + verifyProfileCommand, +} from './compliance-check.js'; +import { composeTeamCommand } from './compose-team.js'; +import { exportDashboardCommand } from './export-dashboard.js'; +import { exportMarketplaceCommand } from './export-marketplace.js'; +import { diffMCPPostureCommand } from './mcp-posture-diff.js'; +import { inspectMCPPostureCommand } from './mcp-posture-inspect.js'; +import { validateMCPPostureCommand } from './mcp-posture-validate.js'; +import { inspectPassportCommand } from './passport-inspect.js'; +import { signPassportCommand } from './passport-sign.js'; +import { validatePassportCommand } from './passport-validate.js'; +import { verifySignedPassportCommand } from './passport-verify-signed.js'; +import { reportCommand } from './regulatory-report.js'; +import { verifySigstoreCommand } from './sigstore-verify.js'; +import { trustDiffCommand } from './trust-diff.js'; +import { publishCommand } from './trust-publish.js'; +import { pullCommand } from './trust-pull.js'; +import { subscribeCommand } from './trust-subscribe.js'; +import { verifyChainCommand } from './trust-verify-chain.js'; + +const USAGE = [ + 'Usage: agent-trust [args]', + '', + 'Commands:', + ' chain [--example ] [--out ] Run the full trust chain end-to-end (offline)', + ' passport validate Validate a trust passport file', + ' passport inspect Inspect a trust passport file', + ' passport sign --key Sign a passport as JWT (EdDSA)', + ' passport verify-signed [--key ] Verify a signed passport JWT', + ' passport verify-sigstore [--artifact ] [--offline] [--fips] [--issuer ] Verify with Sigstore bundle', + ' agentbom inspect Inspect an AgentBOM file', + ' agentbom diff Diff two AgentBOM files', + ' agentbom pipeline [--partitions N] [--no-incremental] Stream-process BOM artifacts', + ' agentbom generate --agent Generate AgentBOM JSON from agent directory', + ' generate bom --agent Generate AgentBOM JSON from agent directory (alias)', + ' agentbom migrate [--target ] [--dry-run] Migrate AgentBOM to target schema version', + ' mcp-posture inspect Inspect an MCP posture file', + ' mcp-posture validate Validate an MCP posture file', + ' mcp-posture diff Diff two MCP posture snapshots', + ' mcp-posture migrate [--target ] [--dry-run] Migrate MCP Posture to target schema version', + ' audit-report Generate human-readable audit summary with evidence citations', + ' audit-report multi [--dir ] Generate multi-agent audit report with causal chain reconstruction', + ' compliance-check --profile [--min-score ] Validate AgentBOM against compliance profile with adaptive weighted scoring', + ' compliance-verify-profile [--schema-version ] Check profile backward compatibility against AgentBOM schema', + ' compliance-upgrade-profile [--schema-version ] [--dry-run] Auto-resolve breaking mapping changes in a compliance profile', + ' export-dashboard --output Generate static HTML dashboard', + ' export-dashboard fleet --output Generate fleet trust analytics dashboard (posture, dependency graphs, compliance heatmap, audit search)', + ' export-marketplace --output Generate standardized marketplace trust package', + ' enforce-policy --policy [--enforcement warn|block|quarantine] [--format json|text] Validate agent artifacts against organization trust rules', + ' subscribe --baseline [--watch ] [--callback ] [--interval ] [--once] Monitor trust artifact drift for an agent', + ' publish [--registry ] [--tag ] Publish trust artifact to registry with CAS identifier', + ' pull [--registry ] [--output ] [--with-deps] Retrieve trust artifact from registry with integrity verification', + ' diff [--json] Structured diff of trust artifacts (auto-detects type)', + ' verify-chain --depth N [--key ] [--registry ] Recursive trust chain verification with configurable depth and caching', + ' compose-team [...] Compose multiple AgentBOMs into a composite trust manifest', + ' report --framework [--period ] [--format text|json] [--evidence-level summary|detailed] Generate compliance-ready regulatory report with evidence citations', +].join('\n'); + +/** Parse --target and --dry-run flags from a CLI arg slice. */ +function parseMigrateArgs(args: string[]): { filePath: string; target?: string; dryRun: boolean } { + let filePath = ''; + let target: string | undefined; + let dryRun = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--target' && i + 1 < args.length) { + target = args[i + 1]; + i++; + } else if (args[i] === '--dry-run') { + dryRun = true; + } else if (!args[i].startsWith('--')) { + filePath = args[i]; + } + } + + return { filePath, target, dryRun }; +} + +/** Read and parse a JSON file, returning an error code on failure. */ +function readJsonFile(filePath: string): { data: Record; error: number } { + const resolved = resolve(filePath); + let raw: string; + try { + raw = readFileSync(resolved, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolved}"`); + return { data: {}, error: 1 }; + } + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolved}" is not valid JSON`); + return { data: {}, error: 1 }; + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + console.error(`Error: "${resolved}" does not contain a JSON object`); + return { data: {}, error: 1 }; + } + return { data: data as Record, error: 0 }; +} + +function agentbomMigrateCommand(args: string[]): number { + const { filePath, target, dryRun } = parseMigrateArgs(args); + if (!filePath) { + console.error('Error: agentbom migrate requires a argument'); + return 1; + } + + const { data, error } = readJsonFile(filePath); + if (error) return error; + + const agentbomLatest = `v${getLatestAgentBOMVersion()} (latest)`; + console.log( + `AgentBOM migration: v${data.agentbom_version ?? 'unknown'} → ${target ?? agentbomLatest}`, + ); + console.log(` Supported versions: ${getSupportedAgentBOMVersions().join(', ')}`); + + const result = migrateAgentBOM(data, target); + + if (!result.success) { + console.error(' Migration failed:'); + for (const e of result.errors) console.error(` - ${e}`); + return 1; + } + + if (result.stepsApplied.length === 0) { + console.log(' Already at target version — no migration needed.'); + } else { + for (const step of result.stepsApplied) { + const breaking = step.breaking ? ' (breaking)' : ''; + console.log( + ` Step: ${step.fromVersion} → ${step.toVersion}: ${step.description}${breaking}`, + ); + } + } + + for (const w of result.warnings) { + console.warn(` Warning: ${w}`); + } + + if (dryRun) { + console.log(' (dry-run — no output written)'); + return 0; + } + + console.log(JSON.stringify(result.data, null, 2)); + return 0; +} + +function postureMigrateCommand(args: string[]): number { + const { filePath, target, dryRun } = parseMigrateArgs(args); + if (!filePath) { + console.error('Error: mcp-posture migrate requires a argument'); + return 1; + } + + const { data, error } = readJsonFile(filePath); + if (error) return error; + + const postureLatest = `v${getLatestPostureVersion()} (latest)`; + console.log( + `MCP Posture migration: v${data.posture_version ?? 'unknown'} → ${target ?? postureLatest}`, + ); + console.log(` Supported versions: ${getSupportedPostureVersions().join(', ')}`); + + const result = migrateMCPPosture(data, target); + + if (!result.success) { + console.error(' Migration failed:'); + for (const e of result.errors) console.error(` - ${e}`); + return 1; + } + + if (result.stepsApplied.length === 0) { + console.log(' Already at target version — no migration needed.'); + } else { + for (const step of result.stepsApplied) { + const breaking = step.breaking ? ' (breaking)' : ''; + console.log( + ` Step: ${step.fromVersion} → ${step.toVersion}: ${step.description}${breaking}`, + ); + } + } + + for (const w of result.warnings) { + console.warn(` Warning: ${w}`); + } + + if (dryRun) { + console.log(' (dry-run — no output written)'); + return 0; + } + + console.log(JSON.stringify(result.data, null, 2)); + return 0; +} + +// --- Policy enforcement engine (mirrors cmd/policy-engine/main.go) --- + +type EnforcementLevel = 'warn' | 'block' | 'quarantine'; + +interface PolicyCondition { + path: string; + op: string; + value?: unknown; + values?: string[]; +} + +interface PolicyRule { + id: string; + description?: string; + effect: string; + when?: PolicyCondition; + assert?: PolicyCondition; + message?: string; + severity?: string; +} + +interface PolicyDocument { + dsl_version?: string; + policy_set_id: string; + version: string; + rules: PolicyRule[]; + includes?: PolicyDocument[]; +} + +interface RuleFinding { + policy_set_id?: string; + version?: string; + rule_id: string; + severity?: string; + description?: string; + message: string; +} + +interface EvaluationResult { + policy_set_id: string; + version: string; + allowed: boolean; + violations: RuleFinding[]; + warnings: RuleFinding[]; + passed_rules: string[]; + metadata: Record; +} + +const SUPPORTED_DSL_VERSION = '1.0'; + +function scalarString(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value); +} + +function appendScalarValues(values: string[], node: unknown): string[] { + if (Array.isArray(node)) { + for (const item of node) { + appendScalarValues(values, item); + } + } else if (typeof node === 'object' && node !== null) { + // nested object — not a scalar leaf + } else { + values.push(scalarString(node)); + } + return values; +} + +function appendArrayItems(items: unknown[], value: unknown): unknown[] { + if (Array.isArray(value)) { + return items.concat(value); + } + return items; +} + +function valuesAtPath(root: unknown, path: string): string[] { + if (!path) throw new Error('condition path is required'); + + let nodes: unknown[] = [root]; + for (const segment of path.split('.')) { + if (!segment) throw new Error(`invalid empty path segment in "${path}"`); + + const arrayMode = segment.endsWith('[]'); + const key = segment.slice(0, -2); + const next: unknown[] = []; + + for (const node of nodes) { + if (typeof node !== 'object' || node === null || Array.isArray(node)) continue; + const obj = node as Record; + const value = obj[key]; + if (value === undefined) continue; + if (arrayMode) { + appendArrayItems(next, value); + } else { + next.push(value); + } + } + nodes = next; + } + + const values: string[] = []; + for (const node of nodes) { + appendScalarValues(values, node); + } + return values; +} + +function anyValueIn(values: string[], expected: Set): boolean { + for (const v of values) { + if (expected.has(v)) return true; + } + return false; +} + +function allValuesIn(values: string[], expected: Set): boolean { + for (const v of values) { + if (!expected.has(v)) return false; + } + return true; +} + +function anyValueOutside(values: string[], expected: Set): boolean { + for (const v of values) { + if (!expected.has(v)) return true; + } + return false; +} + +function anyStringContains(values: string[], expected: Set): boolean { + for (const v of values) { + for (const needle of expected) { + if (v.includes(needle)) return true; + } + } + return false; +} + +function evaluateCondition(cond: PolicyCondition, artifact: unknown): boolean { + const values = valuesAtPath(artifact, cond.path); + + const expected = cond.values ? new Set(cond.values) : null; + const singleValue = cond.value !== undefined ? scalarString(cond.value) : null; + + switch (cond.op) { + case 'exists': + return values.length > 0; + case 'missing': + return values.length === 0; + case 'equals': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return anyValueIn(values, set); + } + case 'not_equals': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return values.length > 0 && !anyValueIn(values, set); + } + case 'in': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return values.length > 0 && allValuesIn(values, set); + } + case 'not_in': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return anyValueOutside(values, set); + } + case 'contains': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return anyStringContains(values, set); + } + case 'intersects': { + const set = new Set(); + if (expected) for (const v of expected) set.add(v); + if (singleValue !== null) set.add(singleValue); + return anyValueIn(values, set); + } + default: + throw new Error(`unsupported op "${cond.op}"`); + } +} + +function composePolicyRules( + policy: PolicyDocument, +): { policySetId: string; version: string; rule: PolicyRule }[] { + const rules: { policySetId: string; version: string; rule: PolicyRule }[] = []; + if (policy.includes) { + for (const included of policy.includes) { + rules.push(...composePolicyRules(included)); + } + } + for (const rule of policy.rules) { + rules.push({ policySetId: policy.policy_set_id, version: policy.version, rule }); + } + return rules; +} + +function countPolicyDocuments(policy: PolicyDocument): number { + let count = 1; + if (policy.includes) { + for (const included of policy.includes) { + count += countPolicyDocuments(included); + } + } + return count; +} + +function findingForRule( + composed: { policySetId: string; version: string; rule: PolicyRule }, + fallback: string, +): RuleFinding { + return { + policy_set_id: composed.policySetId, + version: composed.version, + rule_id: composed.rule.id, + severity: composed.rule.severity, + description: composed.rule.description, + message: composed.rule.message || fallback, + }; +} + +function evaluatePolicy(policy: PolicyDocument, artifact: unknown): EvaluationResult { + const rules = composePolicyRules(policy); + const result: EvaluationResult = { + policy_set_id: policy.policy_set_id, + version: policy.version, + allowed: true, + violations: [], + warnings: [], + passed_rules: [], + metadata: { + policy_sets_composed: countPolicyDocuments(policy), + rules_evaluated: rules.length, + }, + }; + + for (const composed of rules) { + const rule = composed.rule; + if (!rule.when) continue; + + const matches = evaluateCondition(rule.when, artifact); + if (!matches) { + result.passed_rules.push(rule.id); + continue; + } + + switch (rule.effect) { + case 'deny': + result.allowed = false; + result.violations.push(findingForRule(composed, 'deny condition matched')); + break; + case 'warn': + result.warnings.push(findingForRule(composed, 'warn condition matched')); + result.passed_rules.push(rule.id); + break; + case 'require': + if (!rule.assert) { + result.allowed = false; + result.violations.push( + findingForRule(composed, 'required rule missing assert condition'), + ); + break; + } + if (evaluateCondition(rule.assert, artifact)) { + result.passed_rules.push(rule.id); + } else { + result.allowed = false; + result.violations.push(findingForRule(composed, 'required assertion failed')); + } + break; + } + } + + result.passed_rules.sort(); + result.metadata.rules_passed = result.passed_rules.length; + result.metadata.violations = result.violations.length; + result.metadata.warnings = result.warnings.length; + return result; +} + +function validatePolicyDocument(policy: PolicyDocument): string | null { + if (policy.dsl_version && policy.dsl_version !== SUPPORTED_DSL_VERSION) { + return `policy "${policy.policy_set_id}" has unsupported dsl_version "${policy.dsl_version}"`; + } + if (!policy.policy_set_id) return 'policy missing policy_set_id'; + if (!policy.version) return 'policy missing version'; + if ( + (!policy.rules || policy.rules.length === 0) && + (!policy.includes || policy.includes.length === 0) + ) { + return 'policy must contain at least one rule'; + } + for (let i = 0; i < policy.rules.length; i++) { + const rule = policy.rules[i] as PolicyRule; + if (!rule.id) return `policy rule ${i} missing id`; + switch (rule.effect) { + case 'deny': + case 'warn': + if (!rule.when) return `policy rule "${rule.id}" missing when condition`; + break; + case 'require': + if (!rule.when || !rule.assert) + return `policy rule "${rule.id}" requires both when and assert conditions`; + break; + default: + return `policy rule "${rule.id}" has unsupported effect "${rule.effect}"`; + } + } + if (policy.includes) { + for (let i = 0; i < policy.includes.length; i++) { + const err = validatePolicyDocument(policy.includes[i] as PolicyDocument); + if (err) return `included policy ${i}: ${err}`; + } + } + return null; +} + +function enforcePolicyCommand(args: string[]): number { + let artifactPath = ''; + let policyPath = ''; + let enforcement: EnforcementLevel = 'block'; + let format = 'json'; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--policy' && i + 1 < args.length) { + policyPath = args[++i]; + } else if (args[i] === '--enforcement' && i + 1 < args.length) { + const val = args[++i]; + if (val !== 'warn' && val !== 'block' && val !== 'quarantine') { + console.error(`Error: invalid --enforcement "${val}"; must be warn, block, or quarantine`); + return 1; + } + enforcement = val as EnforcementLevel; + } else if (args[i] === '--format' && i + 1 < args.length) { + format = args[++i]; + } else if (!args[i].startsWith('--')) { + artifactPath = args[i]; + } + } + + if (!artifactPath) { + console.error('Error: enforce-policy requires a argument'); + return 1; + } + if (!policyPath) { + console.error('Error: enforce-policy requires --policy '); + return 1; + } + + const { data: policyData, error: policyErr } = readJsonFile(policyPath); + if (policyErr) return policyErr; + + const { data: artifactData, error: artifactErr } = readJsonFile(artifactPath); + if (artifactErr) return artifactErr; + + const policy = policyData as unknown as PolicyDocument; + const artifact = artifactData; + + const validationErr = validatePolicyDocument(policy); + if (validationErr) { + console.error(`Error: ${validationErr}`); + return 1; + } + + let result: EvaluationResult; + try { + result = evaluatePolicy(policy, artifact); + } catch (err) { + console.error(`Error: policy evaluation failed: ${(err as Error).message}`); + return 1; + } + + switch (format) { + case 'json': + console.log(JSON.stringify(result, null, 2)); + break; + case 'text': { + const status = result.allowed ? 'allowed' : 'rejected'; + console.log(`${status} ${result.policy_set_id}@${result.version}`); + for (const v of result.violations) { + console.log(`violation ${v.rule_id}: ${v.message}`); + } + for (const w of result.warnings) { + console.log(`warning ${w.rule_id}: ${w.message}`); + } + console.log( + ` ${result.passed_rules.length} rules passed, ${result.violations.length} violations, ${result.warnings.length} warnings`, + ); + break; + } + default: + console.error(`Error: unsupported --format "${format}"; use json or text`); + return 1; + } + + if (!result.allowed) { + switch (enforcement) { + case 'warn': + console.warn('Policy violations detected (enforcement=warn: proceeding anyway)'); + return 0; + case 'quarantine': + console.error('Policy violations detected (enforcement=quarantine: artifact quarantined)'); + return 2; + default: + return 1; + } + } + + return 0; +} + +export function runCommand(args: string[]): number | Promise { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log(USAGE); + return 0; + } + + if (args[0] === 'chain') { + return chainCommand(args.slice(1)); + } + + if (args[0] === 'passport') { + if (args[1] === 'validate') { + if (args.length < 3) { + console.error('Error: passport validate requires a argument'); + return 1; + } + return validatePassportCommand(args[2]); + } + if (args[1] === 'inspect') { + if (args.length < 3) { + console.error('Error: passport inspect requires a argument'); + return 1; + } + return inspectPassportCommand(args[2]); + } + if (args[1] === 'sign') { + return signPassportCommand(args.slice(2)); + } + if (args[1] === 'verify-signed') { + return verifySignedPassportCommand(args.slice(2)); + } + if (args[1] === 'verify-sigstore') { + return verifySigstoreCommand(args.slice(2)); + } + console.error(`Error: unknown passport subcommand "${args[1]}"`); + return 1; + } + + if (args[0] === 'agentbom') { + if (args[1] === 'inspect') { + if (args.length < 3) { + console.error('Error: agentbom inspect requires a argument'); + return 1; + } + return inspectAgentBOMCommand(args[2]); + } + if (args[1] === 'diff') { + if (args.length < 4) { + console.error('Error: agentbom diff requires and path arguments'); + return 1; + } + return diffAgentBOMCommand(args[2], args[3]); + } + if (args[1] === 'generate') { + return generateAgentBOMCommand(args.slice(2)); + } + if (args[1] === 'pipeline') { + return agentbomPipelineCommand(args.slice(2)); + } + if (args[1] === 'migrate') { + if (args.length < 3) { + console.error('Error: agentbom migrate requires a argument'); + return 1; + } + return agentbomMigrateCommand(args.slice(2)); + } + console.error(`Error: unknown agentbom subcommand "${args[1]}"`); + return 1; + } + + if (args[0] === 'mcp-posture') { + if (args[1] === 'inspect') { + if (args.length < 3) { + console.error('Error: mcp-posture inspect requires a argument'); + return 1; + } + return inspectMCPPostureCommand(args[2]); + } + if (args[1] === 'validate') { + if (args.length < 3) { + console.error('Error: mcp-posture validate requires a argument'); + return 1; + } + return validateMCPPostureCommand(args[2]); + } + if (args[1] === 'diff') { + if (args.length < 4) { + console.error('Error: mcp-posture diff requires and path arguments'); + return 1; + } + return diffMCPPostureCommand(args[2], args[3]); + } + if (args[1] === 'migrate') { + if (args.length < 3) { + console.error('Error: mcp-posture migrate requires a argument'); + return 1; + } + return postureMigrateCommand(args.slice(2)); + } + console.error(`Error: unknown mcp-posture subcommand "${args[1]}"`); + return 1; + } + + if (args[0] === 'generate') { + if (args[1] === 'bom') { + return generateAgentBOMCommand(args.slice(2)); + } + console.error(`Error: unknown generate subcommand "${args[1]}"`); + return 1; + } + + if (args[0] === 'audit-report') { + if (args.length < 2) { + console.error('Error: audit-report requires a argument'); + return 1; + } + return auditReportCommand(args.slice(1)); + } + + if (args[0] === 'compliance-check') { + return complianceCheckCommand(args.slice(1)); + } + + if (args[0] === 'compliance-verify-profile') { + return verifyProfileCommand(args.slice(1)); + } + + if (args[0] === 'compliance-upgrade-profile') { + return upgradeProfileCommand(args.slice(1)); + } + + if (args[0] === 'export-dashboard') { + return exportDashboardCommand(args.slice(1)); + } + + if (args[0] === 'enforce-policy' || args[0] === 'enf') { + return enforcePolicyCommand(args.slice(1)); + } + + if (args[0] === 'publish') { + return publishCommand(args.slice(1)); + } + + if (args[0] === 'pull') { + return pullCommand(args.slice(1)); + } + + if (args[0] === 'diff') { + return trustDiffCommand(args.slice(1)); + } + + if (args[0] === 'subscribe') { + return subscribeCommand(args.slice(1)); + } + + if (args[0] === 'verify-chain') { + return verifyChainCommand(args.slice(1)); + } + + if (args[0] === 'report') { + return reportCommand(args.slice(1)); + } + + if (args[0] === 'export-marketplace') { + return exportMarketplaceCommand(args.slice(1)); + } + + if (args[0] === 'compose-team') { + return composeTeamCommand(args.slice(1)); + } + + console.error(`Error: unknown command "${args[0]}"`); + return 1; +} + +// Only auto-run main when executed directly (not when imported for testing) +const isDirectRun = process.argv[1]?.endsWith('index.ts') || process.argv[1]?.endsWith('index.js'); +if (isDirectRun) { + const args = process.argv.slice(2); + const result = runCommand(args); + if (result instanceof Promise) { + result + .then((code) => process.exit(code)) + .catch((err) => { + console.error(err); + process.exit(1); + }); + } else { + process.exit(result); + } +} diff --git a/packages/agentbom-cli/src/mcp-posture-diff.ts b/packages/agentbom-cli/src/mcp-posture-diff.ts new file mode 100644 index 0000000..90d2071 --- /dev/null +++ b/packages/agentbom-cli/src/mcp-posture-diff.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + diffMCPPosture, + formatPostureDiff, + validateMCPPosture, +} from '@wasmagent/mcp-posture'; + +export function diffMCPPostureCommand(oldFilePath: string, newFilePath: string): number { + const oldPath = resolve(oldFilePath); + const newPath = resolve(newFilePath); + + let oldRaw: string; + try { + oldRaw = readFileSync(oldPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${oldPath}"`); + return 1; + } + + let newRaw: string; + try { + newRaw = readFileSync(newPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${newPath}"`); + return 1; + } + + let oldData: unknown; + try { + oldData = JSON.parse(oldRaw); + } catch { + console.error(`Error: "${oldPath}" is not valid JSON`); + return 1; + } + + let newData: unknown; + try { + newData = JSON.parse(newRaw); + } catch { + console.error(`Error: "${newPath}" is not valid JSON`); + return 1; + } + + const oldResult = validateMCPPosture(oldData); + if (!oldResult.valid) { + console.error(`Validation failed for old file "${oldPath}":`); + for (const err of oldResult.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const newResult = validateMCPPosture(newData); + if (!newResult.valid) { + console.error(`Validation failed for new file "${newPath}":`); + for (const err of newResult.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const diff = diffMCPPosture( + oldData as Record, + newData as Record, + ); + + console.log('Comparing MCP Posture snapshots:'); + console.log(` old: ${oldPath}`); + console.log(` new: ${newPath}`); + console.log(); + + const output = formatPostureDiff(diff); + console.log(output); + + if (diff.isEmpty()) { + return 0; + } + + return 1; +} diff --git a/packages/agentbom-cli/src/mcp-posture-inspect.ts b/packages/agentbom-cli/src/mcp-posture-inspect.ts new file mode 100644 index 0000000..10f3ee5 --- /dev/null +++ b/packages/agentbom-cli/src/mcp-posture-inspect.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + inspectMCPPosture, + validateMCPPosture, +} from '@wasmagent/mcp-posture'; + +export function inspectMCPPostureCommand(filePath: string): number { + const resolvedPath = resolve(filePath); + + let raw: string; + try { + raw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + const result = validateMCPPosture(data); + if (!result.valid) { + console.error(`Validation failed for "${resolvedPath}":`); + for (const err of result.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const posture = data as Record; + console.log(inspectMCPPosture(posture)); + + return 0; +} diff --git a/packages/agentbom-cli/src/mcp-posture-validate.ts b/packages/agentbom-cli/src/mcp-posture-validate.ts new file mode 100644 index 0000000..f7247c4 --- /dev/null +++ b/packages/agentbom-cli/src/mcp-posture-validate.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateMCPPosture } from '@wasmagent/mcp-posture'; + +export function validateMCPPostureCommand(filePath: string): number { + const resolvedPath = resolve(filePath); + + let raw: string; + try { + raw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + const result = validateMCPPosture(data); + if (!result.valid) { + console.error(`Validation failed for "${resolvedPath}":`); + for (const err of result.errors) { + console.error(` - ${err}`); + } + return 1; + } + + console.log(`Valid MCP Posture v${(data as Record).posture_version}`); + return 0; +} diff --git a/packages/agentbom-cli/src/passport-inspect.ts b/packages/agentbom-cli/src/passport-inspect.ts new file mode 100644 index 0000000..19632c3 --- /dev/null +++ b/packages/agentbom-cli/src/passport-inspect.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { inspectTrustPassport, isExpired } from '@openagentaudit/passport'; + +export function inspectPassportCommand(filePath: string): number { + const resolvedPath = resolve(filePath); + + let raw: string; + try { + raw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + console.error(`Error: "${resolvedPath}" does not contain a valid passport object`); + return 1; + } + + const passport = data as Record; + const identity = passport.identity as Record | undefined; + + console.log(inspectTrustPassport(passport)); + console.log(` Issuer: ${identity?.issuer ?? '?'}`); + console.log(` Status: ${isExpired(passport) ? 'EXPIRED' : 'Active'}`); + + return 0; +} diff --git a/packages/agentbom-cli/src/passport-sign.ts b/packages/agentbom-cli/src/passport-sign.ts new file mode 100644 index 0000000..baa54da --- /dev/null +++ b/packages/agentbom-cli/src/passport-sign.ts @@ -0,0 +1,197 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateTrustPassport } from '@openagentaudit/passport'; +/** + * passport sign — Sign a Trust Passport JSON as a JWT using Ed25519 (EdDSA). + * + * Uses @wasmagent/aep's LocalEd25519Signer (backed by @noble/ed25519) to align + * with the signing implementation used across the WasmAgent ecosystem. + * Previously used node:crypto with manual PKCS#8 DER construction. + */ +import { createLocalSignerFromSeed, LocalEd25519Signer } from '@wasmagent/aep'; + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Base64url encode a Buffer or Uint8Array or string. */ +function base64url(input: Buffer | Uint8Array | string): string { + const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : Buffer.from(input); + return buf.toString('base64url'); +} + +/** Read an Ed25519 private key seed (32 bytes) from PEM or raw hex format. */ +export function readKeySeed(keyPath: string): Uint8Array { + const raw = readFileSync(keyPath, 'utf-8').trim(); + + if (raw.startsWith('-----BEGIN')) { + // PEM format — extract raw seed bytes from PKCS#8 DER + // PKCS#8 Ed25519 DER: 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 <32-byte seed> + const b64 = raw.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''); + const der = Buffer.from(b64, 'base64'); + // Seed starts at offset 16 in standard PKCS#8 Ed25519 DER + if (der.length >= 48) { + return new Uint8Array(der.slice(16, 48)); + } + throw new Error( + `Cannot extract seed from PEM in "${keyPath}" — unexpected DER length ${der.length}`, + ); + } + + // Raw hex format: 64 hex chars = 32 bytes seed + const hexClean = raw.replace(/\s+/g, ''); + if (/^[0-9a-fA-F]{64}$/.test(hexClean)) { + return new Uint8Array(Buffer.from(hexClean, 'hex')); + } + + throw new Error( + `Unsupported key format in "${keyPath}". Expected PEM (-----BEGIN PRIVATE KEY-----) or 64-char hex seed.`, + ); +} + +/** Parse a duration string like "1y", "90d", "6m" into milliseconds. */ +function parseDuration(duration: string): number { + const match = duration.match(/^(\d+)\s*(y|m|d|h)$/i); + if (!match) throw new Error(`Invalid duration format: "${duration}". Use e.g. 1y, 90d, 6m, 24h`); + const value = Number.parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + switch (unit) { + case 'y': + return value * 365 * 24 * 60 * 60 * 1000; + case 'm': + return value * 30 * 24 * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + default: + throw new Error(`Unknown duration unit: ${unit}`); + } +} + +export interface SignOptions { + artifactPath: string; + keyPath: string; + expires?: string; // duration like "1y", "90d" +} + +/** + * Sign a Trust Passport JSON and return the JWT string. + * Uses LocalEd25519Signer from @wasmagent/aep for signing. + */ +export async function signPassport(options: SignOptions): Promise { + const { artifactPath, keyPath, expires } = options; + + const passportRaw = readFileSync(resolve(artifactPath), 'utf-8'); + const parsedPassport = JSON.parse(passportRaw) as unknown; + if (!isRecord(parsedPassport)) { + throw new Error('Invalid passport format: root must be an object'); + } + + // Pre-fill expires_at before validation so signPassport can accept passports + // without an explicit expiry and add a default (the test contract: "sign adds + // default expiry when not present"). + const passport = parsedPassport; + const existingValidity = passport.validity; + if (existingValidity !== undefined && !isRecord(existingValidity)) { + throw new Error('Invalid passport format: validity must be an object'); + } + const validity = existingValidity ?? {}; + if (!('expires_at' in validity)) { + const expiryMs = expires ? parseDuration(expires) : 365 * 24 * 60 * 60 * 1000; + validity.expires_at = new Date(Date.now() + expiryMs).toISOString(); + passport.validity = validity; + } + + // Validate structure after default expiry is applied + const structureResult = validateTrustPassport(passport); + if (!structureResult.valid) { + throw new Error(`Invalid passport format: ${structureResult.errors.join('; ')}`); + } + + const seed = readKeySeed(resolve(keyPath)); + // Use createLocalSignerFromSeed for hex seeds (v1.21.1 convenience API), + // fall back to LocalEd25519Signer for raw Uint8Array seeds from PEM. + const hexSeed = Buffer.from(seed).toString('hex'); + const signer = createLocalSignerFromSeed(hexSeed, 'trust-passport-key'); + + const header = { alg: 'EdDSA', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const expiresAtStr = typeof validity.expires_at === 'string' ? validity.expires_at : undefined; + const exp = expiresAtStr + ? Math.floor(new Date(expiresAtStr).getTime() / 1000) + : now + 365 * 24 * 60 * 60; + + const payload = { ...passport, iat: now, exp }; + + const headerB64 = base64url(JSON.stringify(header)); + const payloadB64 = base64url(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + // AEPSigner.sign() returns base64 — we need base64url for JWT + const sigBase64 = await signer.sign(Buffer.from(signingInput, 'utf-8')); + const signatureB64url = sigBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + + return `${signingInput}.${signatureB64url}`; +} + +/** CLI entry point for `passport sign`. */ +export async function signPassportCommand(args: string[]): Promise { + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log( + [ + 'Usage: agent-trust passport sign --key [--expires ]', + '', + 'Signs a Trust Passport JSON as a JWT using Ed25519 (EdDSA).', + 'Signing uses @wasmagent/aep LocalEd25519Signer (@noble/ed25519).', + '', + 'Options:', + ' --key Path to Ed25519 private key (PEM or 64-char hex seed)', + ' --expires Expiry duration if not set in passport (default: 1y)', + ' Formats: 1y, 90d, 6m, 24h', + '', + 'Output: signed JWT printed to stdout.', + ].join('\n'), + ); + return 0; + } + + let artifactPath = ''; + let keyPath = ''; + let expires: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + if (arg === '--key' && next) { + keyPath = next; + i++; + } else if (arg === '--expires' && next) { + expires = next; + i++; + } else if (!arg.startsWith('--') && !artifactPath) { + artifactPath = arg; + } else if (!arg.startsWith('--')) { + console.error(`Error: unexpected argument "${arg}"`); + return 1; + } + } + + if (!artifactPath) { + console.error('Error: passport sign requires an argument'); + return 1; + } + if (!keyPath) { + console.error('Error: passport sign requires --key '); + return 1; + } + + try { + const jwt = await signPassport({ artifactPath, keyPath, expires }); + console.log(jwt); + return 0; + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} diff --git a/packages/agentbom-cli/src/passport-validate.ts b/packages/agentbom-cli/src/passport-validate.ts new file mode 100644 index 0000000..238dd65 --- /dev/null +++ b/packages/agentbom-cli/src/passport-validate.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { inspectTrustPassport, isExpired, validateTrustPassport } from '@openagentaudit/passport'; + +const WARN_EXPIRY_DAYS = 14; + +function expiresWithinDays( + passport: { validity?: { expires_at?: string } }, + days: number, +): boolean { + const expiresAt = passport.validity?.expires_at; + if (!expiresAt) return false; + const expiry = new Date(expiresAt).getTime(); + const now = Date.now(); + const diffMs = expiry - now; + return diffMs > 0 && diffMs <= days * 24 * 60 * 60 * 1000; +} + +export function validatePassportCommand(filePath: string): number { + const resolvedPath = resolve(filePath); + + let raw: string; + try { + raw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + const result = validateTrustPassport(data); + if (!result.valid) { + console.error(`Validation failed for "${resolvedPath}":`); + for (const err of result.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const passport = data as Record; + console.log(inspectTrustPassport(passport)); + + if (isExpired(passport)) { + console.error('\nPassport has EXPIRED.'); + return 1; + } + + if (expiresWithinDays(passport, WARN_EXPIRY_DAYS)) { + const expiresAt = (passport.validity as Record)?.expires_at ?? ''; + const daysLeft = Math.ceil( + (new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); + console.warn( + `\nWarning: passport expires within ${WARN_EXPIRY_DAYS} days (${daysLeft} days remaining).`, + ); + } + + console.log('\nPassport is valid.'); + return 0; +} diff --git a/packages/agentbom-cli/src/passport-verify-signed.ts b/packages/agentbom-cli/src/passport-verify-signed.ts new file mode 100644 index 0000000..a8b8613 --- /dev/null +++ b/packages/agentbom-cli/src/passport-verify-signed.ts @@ -0,0 +1,276 @@ +/** + * passport verify-signed — Verify a signed Trust Passport JWT. + * + * Reads a signed JWT, verifies the EdDSA signature, checks expiry, + * validates passport structure, and prints a verification report. + */ +import { createPublicKey, verify } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { isExpired, validateTrustPassport } from '@openagentaudit/passport'; + +// isRecord is a private utility not exported by @openagentaudit/passport +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Decode a base64url string to a Buffer. */ +function base64urlDecode(input: string): Buffer { + // Add padding if needed + const padded = input + '='.repeat((4 - (input.length % 4)) % 4); + return Buffer.from(padded, 'base64url'); +} + +/** Read an Ed25519 public key from PEM or raw hex format. */ +export function readPublicKey(keyPath: string): ReturnType { + const raw = readFileSync(keyPath, 'utf-8').trim(); + + if (raw.startsWith('-----BEGIN')) { + // PEM format + return createPublicKey(raw); + } + + // Raw hex format: 64 hex chars = 32 bytes public key + const hexClean = raw.replace(/\s+/g, ''); + if (/^[0-9a-fA-F]{64}$/.test(hexClean)) { + const pubBytes = Buffer.from(hexClean, 'hex'); + // Wrap raw public key in SubjectPublicKeyInfo DER for Ed25519 + // Ed25519 SPKI prefix: 302a300506032b6570032100 + const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex'); + const der = Buffer.concat([spkiPrefix, pubBytes]); + return createPublicKey({ key: der, format: 'der', type: 'spki' }); + } + + throw new Error( + `Unsupported key format in "${keyPath}". Expected PEM (-----BEGIN PUBLIC KEY-----) or 64-char hex.`, + ); +} + +export interface VerifyResult { + valid: boolean; + signatureValid: boolean; + expired: boolean; + structureValid: boolean; + structureErrors: string[]; + payload: Record | null; + errors: string[]; +} + +export interface VerifyOptions { + jwtPath?: string; + jwtString?: string; + publicKeyPath?: string; +} + +/** + * Verify a signed Trust Passport JWT. + * Returns a detailed verification result. + */ +export function verifySignedPassport(options: VerifyOptions): VerifyResult { + const errors: string[] = []; + let jwtString: string; + + if (options.jwtString) { + jwtString = options.jwtString.trim(); + } else if (options.jwtPath) { + jwtString = readFileSync(resolve(options.jwtPath), 'utf-8').trim(); + } else { + return { + valid: false, + signatureValid: false, + expired: false, + structureValid: false, + structureErrors: [], + payload: null, + errors: ['No JWT input provided (path or string)'], + }; + } + + // Parse JWT parts + const parts = jwtString.split('.'); + if (parts.length !== 3) { + return { + valid: false, + signatureValid: false, + expired: false, + structureValid: false, + structureErrors: [], + payload: null, + errors: ['Invalid JWT format: expected 3 dot-separated parts'], + }; + } + + const [headerB64, payloadB64, signatureB64] = parts; + + // Decode header + let header: Record; + try { + const decodedHeader = JSON.parse(base64urlDecode(headerB64).toString('utf-8')) as unknown; + if (!isRecord(decodedHeader)) { + throw new Error('header root is not an object'); + } + header = decodedHeader; + } catch { + return { + valid: false, + signatureValid: false, + expired: false, + structureValid: false, + structureErrors: [], + payload: null, + errors: ['Failed to decode JWT header'], + }; + } + + if (header.alg !== 'EdDSA') { + errors.push(`Unexpected algorithm: "${header.alg}" (expected "EdDSA")`); + } + + // Decode payload + let payload: Record; + try { + const decodedPayload = JSON.parse(base64urlDecode(payloadB64).toString('utf-8')) as unknown; + if (!isRecord(decodedPayload)) { + throw new Error('payload root is not an object'); + } + payload = decodedPayload; + } catch { + return { + valid: false, + signatureValid: false, + expired: false, + structureValid: false, + structureErrors: [], + payload: null, + errors: ['Failed to decode JWT payload'], + }; + } + + // Verify signature + let signatureValid = false; + if (options.publicKeyPath) { + try { + const publicKey = readPublicKey(resolve(options.publicKeyPath)); + const signingInput = `${headerB64}.${payloadB64}`; + const signature = base64urlDecode(signatureB64); + signatureValid = verify(null, Buffer.from(signingInput, 'utf-8'), publicKey, signature); + if (!signatureValid) { + errors.push('Signature verification failed'); + } + } catch (err) { + errors.push( + `Signature verification error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else { + // No public key provided — cannot verify signature + errors.push('No public key provided; signature not verified'); + } + + // Check expiry via JWT exp claim + let expired = false; + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === 'number' && payload.exp < now) { + expired = true; + errors.push(`JWT has expired (exp: ${new Date(payload.exp * 1000).toISOString()})`); + } + + // Also check passport validity.expires_at + if (isExpired(payload as { validity?: { expires_at?: string } })) { + expired = true; + const expiresAt = (payload.validity as Record)?.expires_at; + if (expiresAt && !errors.some((e) => e.includes('expired'))) { + errors.push(`Passport validity has expired (expires_at: ${expiresAt})`); + } + } + + // Validate passport structure (excluding JWT-specific fields) + const passportPayload = { ...payload }; + passportPayload.iat = undefined; + passportPayload.exp = undefined; + + const structureResult = validateTrustPassport(passportPayload); + const structureValid = structureResult.valid; + if (!structureValid) { + errors.push(...structureResult.errors.map((e) => `Structure: ${e}`)); + } + + const valid = signatureValid && !expired && structureValid && errors.length === 0; + + return { + valid, + signatureValid, + expired, + structureValid, + structureErrors: structureResult.errors, + payload, + errors, + }; +} + +/** CLI entry point for `passport verify-signed`. */ +export function verifySignedPassportCommand(args: string[]): number { + if (args.includes('--help') || args.includes('-h')) { + console.log( + [ + 'Usage: agent-trust passport verify-signed [--key ]', + '', + 'Verifies a signed Trust Passport JWT (EdDSA/Ed25519).', + '', + 'Options:', + ' --key Path to Ed25519 public key (PEM or 64-char hex)', + '', + 'Exit codes:', + ' 0 Verification passed', + ' 1 Verification failed', + ].join('\n'), + ); + return 0; + } + + let jwtPath = ''; + let publicKeyPath: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + if (arg === '--key' && next) { + publicKeyPath = next; + i++; + } else if (!arg.startsWith('--') && !jwtPath) { + jwtPath = arg; + } else if (!arg.startsWith('--')) { + console.error(`Error: unexpected argument "${arg}"`); + return 1; + } + } + + if (!jwtPath) { + console.error('Error: passport verify-signed requires a argument'); + return 1; + } + + try { + const result = verifySignedPassport({ jwtPath, publicKeyPath }); + + // Print structured result + console.log( + JSON.stringify( + { + valid: result.valid, + signature: result.signatureValid ? 'valid' : 'invalid', + expired: result.expired, + structure: result.structureValid ? 'valid' : 'invalid', + errors: result.errors, + }, + null, + 2, + ), + ); + + return result.valid ? 0 : 1; + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} diff --git a/packages/agentbom-cli/src/regulatory-report.test.ts b/packages/agentbom-cli/src/regulatory-report.test.ts new file mode 100644 index 0000000..9cd4ebd --- /dev/null +++ b/packages/agentbom-cli/src/regulatory-report.test.ts @@ -0,0 +1,370 @@ +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 { reportCommand } from './regulatory-report.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = join( + tmpdir(), + `regulatory-report-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeTmpFile(name: string, content: string): string { + const p = join(tmpDir, name); + writeFileSync(p, content, 'utf-8'); + return p; +} + +/** Minimal valid AgentBOM that passes schema validation */ +const MINIMAL_BOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'agent-report-001', + agent_name: 'Report Test Agent', + deployment_context: 'production', + generated_at: '2026-06-28T00:00:00Z', + }, + attestation: { generator: 'test' }, +}; + +/** Full AgentBOM with all sections populated */ +const FULL_BOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'agent-full-001', + agent_name: 'Full Report Agent', + agent_version: '1.2.3', + deployment_context: 'production', + generated_at: '2026-07-01T00:00:00Z', + }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + }, + { + tool_id: 'fs-write', + tool_name: 'write_file', + source: 'builtin', + permissions: ['fs:write'], + }, + ], + risk_layer: [ + { + risk_id: 'risk-001', + severity: 'medium', + category: 'data_access', + description: 'file system write access', + status: 'mitigated', + }, + ], + audit_log: [ + { + timestamp: '2026-07-01T10:00:00Z', + event_type: 'tool_invocation', + actor: 'user', + resource: 'read_file', + outcome: 'success', + }, + { + timestamp: '2026-07-01T10:01:00Z', + event_type: 'policy_check', + actor: 'system', + outcome: 'failure', + }, + ], + evidence_layer: { + aep_references: ['aep://ref/001'], + evidence_hashes: [{ type: 'sha256', hash: 'abc123', timestamp: '2026-07-01T00:00:00Z' }], + }, + attestation: { + generator: 'trust-cli', + signature: 'test-sig-001', + timestamp: '2026-07-01T00:00:00Z', + }, +}; + +describe('reportCommand', () => { + describe('argument parsing', () => { + it('returns 0 with help when --help flag is passed', () => { + const spy = spyOn(console, 'log'); + expect(reportCommand(['--help'])).toBe(0); + expect(spy).toHaveBeenCalled(); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage: agent-trust report'); + expect(output).toContain('soc2'); + expect(output).toContain('iso27001'); + expect(output).toContain('ai-act'); + }); + + it('returns 0 with help when -h flag is passed', () => { + const spy = spyOn(console, 'log'); + expect(reportCommand(['-h'])).toBe(0); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 when no arguments provided', () => { + expect(reportCommand([])).toBe(1); + }); + + it('returns 1 when bom path missing', () => { + expect(reportCommand(['--framework', 'soc2'])).toBe(1); + }); + + it('returns 1 when --framework missing', () => { + const path = writeTmpFile('bom.json', JSON.stringify(MINIMAL_BOM)); + expect(reportCommand([path])).toBe(1); + }); + + it('returns 1 for unsupported framework', () => { + const path = writeTmpFile('bom.json', JSON.stringify(MINIMAL_BOM)); + expect(reportCommand([path, '--framework', 'gdpr'])).toBe(1); + }); + + it('returns 1 for invalid --format', () => { + const path = writeTmpFile('bom.json', JSON.stringify(MINIMAL_BOM)); + expect(reportCommand([path, '--framework', 'soc2', '--format', 'pdf'])).toBe(1); + }); + + it('returns 1 for invalid --evidence-level', () => { + const path = writeTmpFile('bom.json', JSON.stringify(MINIMAL_BOM)); + expect(reportCommand([path, '--framework', 'soc2', '--evidence-level', 'verbose'])).toBe(1); + }); + + it('returns 1 for non-existent file', () => { + expect(reportCommand(['/nonexistent/bom.json', '--framework', 'soc2'])).toBe(1); + }); + + it('returns 1 for invalid JSON', () => { + const path = writeTmpFile('bad.json', '{ not valid json'); + expect(reportCommand([path, '--framework', 'soc2'])).toBe(1); + }); + }); + + describe('SOC2 framework', () => { + it('generates text report with SOC 2 control objectives', () => { + const path = writeTmpFile('soc2-bom.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + expect(reportCommand([path, '--framework', 'soc2'])).toBe(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('REGULATORY COMPLIANCE REPORT'); + expect(output).toContain('SOC 2'); + expect(output).toContain('EXECUTIVE SUMMARY'); + expect(output).toContain('CONTROL OBJECTIVES'); + expect(output).toContain('SOC2.CC6.1'); + expect(output).toContain('SOC2.CC6.2'); + expect(output).toContain('SOC2.CC6.3'); + expect(output).toContain('SOC2.CC7.1'); + expect(output).toContain('SOC2.CC7.2'); + expect(output).toContain('SOC2.CC8.1'); + expect(output).toContain('SOC2.A1.2'); + expect(output).toContain('Full Report Agent'); + }); + + it('generates JSON report with structured data', () => { + const path = writeTmpFile('soc2-json.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + expect(reportCommand([path, '--framework', 'soc2', '--format', 'json'])).toBe(0); + + // Find the JSON output call (last console.log call with a JSON object string) + const jsonCalls = spy.mock.calls.map((c) => c.join(' ')).filter((c) => c.startsWith('{')); + expect(jsonCalls.length).toBeGreaterThan(0); + const report = JSON.parse(jsonCalls[jsonCalls.length - 1]); + expect(report.report_metadata.framework).toContain('SOC 2'); + expect(report.executive_summary.controls_assessed).toBeGreaterThan(0); + expect(report.executive_summary.controls_satisfied).toBeGreaterThan(0); + expect(report.control_objectives.length).toBeGreaterThan(0); + expect(report.control_objectives[0]).toHaveProperty('id'); + expect(report.control_objectives[0]).toHaveProperty('title'); + expect(report.control_objectives[0]).toHaveProperty('status'); + expect(report.control_objectives[0]).toHaveProperty('evidence'); + expect(report.control_objectives[0]).toHaveProperty('findings'); + }); + + it('includes period in report metadata', () => { + const path = writeTmpFile('soc2-period.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + reportCommand([path, '--framework', 'soc2', '--period', 'Q1-2026', '--format', 'json']); + + const jsonCalls = spy.mock.calls.map((c) => c.join(' ')).filter((c) => c.startsWith('{')); + const report = JSON.parse(jsonCalls[jsonCalls.length - 1]); + expect(report.report_metadata.period).toBe('Q1-2026'); + }); + + it('detailed evidence level includes more evidence citations', () => { + const path = writeTmpFile('soc2-detailed.json', JSON.stringify(FULL_BOM)); + const logSpy = spyOn(console, 'log'); + + reportCommand([path, '--framework', 'soc2', '--format', 'json']); + // Find the last call that starts with '{' (JSON output) + const summaryCalls = logSpy.mock.calls + .map((c) => c.join(' ')) + .filter((c) => c.startsWith('{')); + const summaryReport = JSON.parse(summaryCalls[summaryCalls.length - 1]); + + logSpy.mockClear(); + reportCommand([ + path, + '--framework', + 'soc2', + '--evidence-level', + 'detailed', + '--format', + 'json', + ]); + const detailedCalls = logSpy.mock.calls + .map((c) => c.join(' ')) + .filter((c) => c.startsWith('{')); + const detailedReport = JSON.parse(detailedCalls[detailedCalls.length - 1]); + + // Detailed should have at least as many evidence citations as summary + expect(detailedReport.executive_summary.evidence_citations).toBeGreaterThanOrEqual( + summaryReport.executive_summary.evidence_citations, + ); + }); + }); + + describe('ISO 27001 framework', () => { + it('generates report with ISO 27001 control objectives', () => { + const path = writeTmpFile('iso-bom.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + expect(reportCommand([path, '--framework', 'iso27001'])).toBe(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('ISO'); + expect(output).toContain('ISO27001.A.5.15'); + expect(output).toContain('ISO27001.A.5.16'); + expect(output).toContain('ISO27001.A.8.9'); + expect(output).toContain('ISO27001.A.6.1'); + }); + }); + + describe('EU AI Act framework', () => { + it('generates report with AI Act control objectives using inline profile', () => { + const path = writeTmpFile('aiact-bom.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + expect(reportCommand([path, '--framework', 'ai-act'])).toBe(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('AI Act'); + expect(output).toContain('AI-ACT.ANNEX-IV.1'); + expect(output).toContain('AI-ACT.ANNEX-IV.2'); + expect(output).toContain('AI-ACT.ANNEX-IV.3'); + expect(output).toContain('AI-ACT.ANNEX-IV.4'); + expect(output).toContain('AI-ACT.ANNEX-IV.5'); + expect(output).toContain('AI-ACT.ANNEX-IV.6'); + }); + + it('AI Act JSON report has correct structure', () => { + const path = writeTmpFile('aiact-json.json', JSON.stringify(FULL_BOM)); + const spy = spyOn(console, 'log'); + + reportCommand([path, '--framework', 'ai-act', '--format', 'json']); + + const jsonCalls = spy.mock.calls.map((c) => c.join(' ')).filter((c) => c.startsWith('{')); + const report = JSON.parse(jsonCalls[jsonCalls.length - 1]); + expect(report.report_metadata.framework).toContain('AI Act'); + expect(report.control_objectives.length).toBe(6); + }); + }); + + describe('compliance status determination', () => { + it('returns 0 for fully compliant BOM', () => { + const path = writeTmpFile('compliant.json', JSON.stringify(FULL_BOM)); + expect(reportCommand([path, '--framework', 'soc2'])).toBe(0); + }); + + it('returns 1 for BOM with missing identity (non-compliant)', () => { + // Schema-valid BOM but without meaningful identity for SOC2 controls + const bom = { + agentbom_version: '0.1', + identity: { + agent_id: 'minimal-agent', + deployment_context: 'development', + generated_at: '2026-07-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + }; + const path = writeTmpFile('no-identity.json', JSON.stringify(bom)); + // development context not in SOC2 allowed contexts [staging, production] + expect(reportCommand([path, '--framework', 'soc2'])).toBe(1); + }); + + it('returns 1 for BOM with missing attestation when required', () => { + const bom = { + ...MINIMAL_BOM, + attestation: { generator: 'test' }, + }; + const path = writeTmpFile('no-attestation.json', JSON.stringify(bom)); + // SOC2 profile requires attestation signature — MINIMAL_BOM has no signature + const result = reportCommand([path, '--framework', 'soc2']); + expect(result).toBe(1); + }); + }); + + describe('edge cases', () => { + it('handles BOM with empty tool_layer', () => { + const bom = { + ...FULL_BOM, + tool_layer: [], + }; + const path = writeTmpFile('empty-tools.json', JSON.stringify(bom)); + const spy = spyOn(console, 'log'); + + const result = reportCommand([path, '--framework', 'soc2']); + // Should still produce a report (not crash) + expect(result).toBeGreaterThanOrEqual(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('REGULATORY COMPLIANCE REPORT'); + }); + + it('handles BOM with empty risk_layer', () => { + const bom = { + ...FULL_BOM, + risk_layer: [], + }; + const path = writeTmpFile('empty-risks.json', JSON.stringify(bom)); + const spy = spyOn(console, 'log'); + + const result = reportCommand([path, '--framework', 'soc2']); + expect(result).toBeGreaterThanOrEqual(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('REGULATORY COMPLIANCE REPORT'); + }); + + it('handles BOM with no audit_log', () => { + const bom = { + ...FULL_BOM, + audit_log: undefined, + }; + const path = writeTmpFile('no-audit.json', JSON.stringify(bom)); + const spy = spyOn(console, 'log'); + + const result = reportCommand([path, '--framework', 'soc2']); + // Missing audit log should affect incident response controls + expect(result).toBeGreaterThanOrEqual(0); + + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('REGULATORY COMPLIANCE REPORT'); + }); + }); +}); diff --git a/packages/agentbom-cli/src/regulatory-report.ts b/packages/agentbom-cli/src/regulatory-report.ts new file mode 100644 index 0000000..a70d646 --- /dev/null +++ b/packages/agentbom-cli/src/regulatory-report.ts @@ -0,0 +1,1093 @@ +#!/usr/bin/env bun +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateAgentBOM } from '@wasmagent/agentbom-core'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Supported regulatory frameworks */ +type FrameworkId = 'soc2' | 'iso27001' | 'ai-act'; + +const SUPPORTED_FRAMEWORKS: FrameworkId[] = ['soc2', 'iso27001', 'ai-act']; + +/** Maps CLI framework id to the compliance profile file */ +const FRAMEWORK_TO_PROFILE: Record = { + soc2: 'soc2-2024', + iso27001: 'iso27001-2022', + 'ai-act': 'eu-ai-act-annex-iv', +}; + +/** Framework display names */ +const FRAMEWORK_DISPLAY: Record = { + soc2: 'SOC 2 Type II (2024)', + iso27001: 'ISO/IEC 27001:2022', + 'ai-act': 'EU AI Act — High-Risk AI Systems', +}; + +/** Evidence detail level */ +type EvidenceLevel = 'summary' | 'detailed'; + +/** Output format */ +type ReportFormat = 'text' | 'json'; + +interface ComplianceProfile { + profile_version: string; + profile_id: string; + framework: { + name: string; + version: string; + description?: string; + }; + rules: { + identity?: { + required_fields?: string[]; + allowed_contexts?: string[]; + requires_version?: boolean; + }; + tool_layer?: { + max_severity?: string; + requires_tool_inventory?: boolean; + blocked_permissions?: string[]; + blocked_sources?: string[]; + }; + risk_layer?: { + requires_risk_assessment?: boolean; + max_unmitigated_critical?: number; + max_unmitigated_high?: number; + max_unmitigated_medium?: number; + requires_mitigation_for?: string[]; + }; + attestation?: { + requires_signature?: boolean; + requires_timestamp?: boolean; + }; + }; + metadata?: { + author?: string; + documentation_url?: string; + }; +} + +/** A single control objective in the report */ +interface ControlObjective { + id: string; + title: string; + description: string; + status: 'satisfied' | 'partial' | 'not_satisfied' | 'not_applicable'; + evidence: EvidenceCitation[]; + findings: string[]; +} + +/** Evidence citation linking AgentBOM data to a control */ +interface EvidenceCitation { + source: string; + description: string; + timestamp?: string; +} + +/** The full regulatory report */ +interface RegulatoryReport { + report_metadata: { + framework: string; + framework_version: string; + period?: string; + generated_at: string; + agent_id?: string; + agent_name?: string; + evidence_level: EvidenceLevel; + }; + executive_summary: { + overall_status: 'compliant' | 'partial' | 'non_compliant'; + controls_assessed: number; + controls_satisfied: number; + controls_partial: number; + controls_not_satisfied: number; + evidence_citations: number; + }; + control_objectives: ControlObjective[]; + evidence_inventory: EvidenceCitation[]; +} + +// --------------------------------------------------------------------------- +// Control objective definitions per framework +// --------------------------------------------------------------------------- + +interface ControlDef { + id: string; + title: string; + description: string; + /** Which AgentBOM section(s) provide evidence */ + assess: ( + bom: Record, + profile: ComplianceProfile, + ) => { + status: ControlObjective['status']; + evidence: EvidenceCitation[]; + findings: string[]; + }; +} + +function getControlDefs(framework: FrameworkId, evidenceLevel: EvidenceLevel): ControlDef[] { + switch (framework) { + case 'soc2': + return soc2Controls(evidenceLevel); + case 'iso27001': + return iso27001Controls(evidenceLevel); + case 'ai-act': + return aiActControls(evidenceLevel); + } +} + +function soc2Controls(ev: EvidenceLevel): ControlDef[] { + return [ + { + id: 'SOC2.CC6.1', + title: 'Logical and Physical Access Controls', + description: + 'The entity implements logical and physical access controls over information technology resources.', + assess: (bom, profile) => assessIdentityAccess(bom, profile, ev), + }, + { + id: 'SOC2.CC6.2', + title: 'User Authentication', + description: + 'The entity authenticates users and authorizes access to facilitate accountability.', + assess: (bom, profile) => assessIdentityAuth(bom, profile, ev), + }, + { + id: 'SOC2.CC6.3', + title: 'Role-Based Access', + description: + 'The entity authorizes, modifies, and removes access to data and resources based on roles.', + assess: (bom, profile) => assessToolPermissions(bom, profile, ev), + }, + { + id: 'SOC2.CC7.1', + title: 'Detection and Monitoring', + description: + 'The entity detects and monitors system defects, potential attacks, and intrusions.', + assess: (bom, profile) => assessRiskMonitoring(bom, profile, ev), + }, + { + id: 'SOC2.CC7.2', + title: 'Incident Response', + description: 'The entity responds to identified incidents to mitigate impact.', + assess: (bom, profile) => assessRiskIncidentResponse(bom, profile, ev), + }, + { + id: 'SOC2.CC8.1', + title: 'Change Management', + description: + 'The entity authorizes, designs, develops, configures, documents, tests, approves, and implements changes.', + assess: (bom, profile) => assessChangeManagement(bom, profile, ev), + }, + { + id: 'SOC2.A1.2', + title: 'Attestation Integrity', + description: + 'Management provides an assertion that includes a description of the system and a description of the complementary subservices.', + assess: (bom, profile) => assessAttestation(bom, profile, ev), + }, + ]; +} + +function iso27001Controls(ev: EvidenceLevel): ControlDef[] { + return [ + { + id: 'ISO27001.A.5.15', + title: 'Access Control', + description: + 'Rules to control physical and logical access to information and other associated assets.', + assess: (bom, profile) => assessIdentityAccess(bom, profile, ev), + }, + { + id: 'ISO27001.A.5.16', + title: 'Identity Management', + description: 'Identity management life cycle for people and non-person entities.', + assess: (bom, profile) => assessIdentityAuth(bom, profile, ev), + }, + { + id: 'ISO27001.A.8.9', + title: 'Configuration Management', + description: 'Secure configuration of hardware, software, services and networks.', + assess: (bom, profile) => assessToolPermissions(bom, profile, ev), + }, + { + id: 'ISO27001.A.8.20', + title: 'Networks Security', + description: 'Security of networks and network devices.', + assess: (bom, profile) => assessToolPermissions(bom, profile, ev), + }, + { + id: 'ISO27001.A.6.1', + title: 'Risk Assessment', + description: 'Risk assessment process defined and applied.', + assess: (bom, profile) => assessRiskMonitoring(bom, profile, ev), + }, + { + id: 'ISO27001.A.6.8', + title: 'Information Security Event Management', + description: 'Information security events and weaknesses to be reported and managed.', + assess: (bom, profile) => assessRiskIncidentResponse(bom, profile, ev), + }, + { + id: 'ISO27001.A.8.25', + title: 'Secure Development Lifecycle', + description: 'Rules for secure development of software and systems.', + assess: (bom, profile) => assessChangeManagement(bom, profile, ev), + }, + { + id: 'ISO27001.A.5.35', + title: 'Independent Security Review', + description: 'Independent review of information security to be performed.', + assess: (bom, profile) => assessAttestation(bom, profile, ev), + }, + ]; +} + +function aiActControls(ev: EvidenceLevel): ControlDef[] { + return [ + { + id: 'AI-ACT.ANNEX-IV.1', + title: 'System Overview and Architecture', + description: + 'Technical documentation shall include a general description of the high-risk AI system.', + assess: (bom, profile) => assessIdentityAccess(bom, profile, ev), + }, + { + id: 'AI-ACT.ANNEX-IV.2', + title: 'Data Governance and Training', + description: 'Description of data collection, preparation, and training processes.', + assess: (bom, profile) => assessAIActData(bom, profile, ev), + }, + { + id: 'AI-ACT.ANNEX-IV.3', + title: 'Transparency and Human Oversight', + description: 'Design and development choices for enabling effective oversight of the system.', + assess: (bom, profile) => assessAIActOversight(bom, profile, ev), + }, + { + id: 'AI-ACT.ANNEX-IV.4', + title: 'Risk Management', + description: 'Risk management system and mitigation measures implemented.', + assess: (bom, profile) => assessRiskMonitoring(bom, profile, ev), + }, + { + id: 'AI-ACT.ANNEX-IV.5', + title: 'Performance Metrics and Accuracy', + description: 'Performance metrics for accuracy, robustness, and cybersecurity of the system.', + assess: (bom, profile) => assessRiskIncidentResponse(bom, profile, ev), + }, + { + id: 'AI-ACT.ANNEX-IV.6', + title: 'Conformity Assessment', + description: 'Evidence of conformity assessment and technical documentation.', + assess: (bom, profile) => assessAttestation(bom, profile, ev), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Control assessment functions +// --------------------------------------------------------------------------- + +function assessIdentityAccess( + bom: Record, + profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const identity = bom.identity as Record | undefined; + + if (!identity) { + findings.push('Identity section missing — cannot verify agent identity or access controls'); + return { status: 'not_satisfied', evidence, findings }; + } + + // Agent ID present + if (identity.agent_id) { + evidence.push({ + source: 'agentbom.identity.agent_id', + description: `Agent identity: ${identity.agent_id}`, + }); + } else { + findings.push('agent_id missing from identity section'); + } + + // Check deployment context + const allowedContexts = profile.rules.identity?.allowed_contexts ?? []; + const context = String(identity.deployment_context ?? ''); + if (context && (allowedContexts.length === 0 || allowedContexts.includes(context))) { + evidence.push({ + source: 'agentbom.identity.deployment_context', + description: `Deployment context: ${context}`, + }); + } else if (allowedContexts.length > 0) { + findings.push( + `Deployment context "${context}" not in allowed list [${allowedContexts.join(', ')}]`, + ); + } + + // Version check + if (identity.agent_version) { + evidence.push({ + source: 'agentbom.identity.agent_version', + description: `Agent version: ${identity.agent_version}`, + }); + } else if (profile.rules.identity?.requires_version) { + findings.push('agent_version required but missing'); + } + + // Timestamp for evidence + const generatedAt = String(identity.generated_at ?? ''); + if (generatedAt && ev === 'detailed') { + evidence.push({ + source: 'agentbom.identity.generated_at', + description: `BOM generated at: ${generatedAt}`, + timestamp: generatedAt, + }); + } + + const status: ControlObjective['status'] = + findings.length === 0 ? 'satisfied' : evidence.length > 0 ? 'partial' : 'not_satisfied'; + return { status, evidence, findings }; +} + +function assessIdentityAuth( + bom: Record, + _profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const identity = bom.identity as Record | undefined; + + if (!identity) { + findings.push('Identity section missing — cannot verify authentication mechanism'); + return { status: 'not_satisfied', evidence, findings }; + } + + // Agent name provides identity accountability + if (identity.agent_name) { + evidence.push({ + source: 'agentbom.identity.agent_name', + description: `Named agent: ${identity.agent_name}`, + }); + } + + if (identity.agent_version) { + evidence.push({ + source: 'agentbom.identity.agent_version', + description: `Versioned release: ${identity.agent_version}`, + }); + } + + if (ev === 'detailed' && identity.generated_at) { + evidence.push({ + source: 'agentbom.identity.generated_at', + description: `Identity established at: ${identity.generated_at}`, + timestamp: String(identity.generated_at), + }); + } + + return { + status: evidence.length > 0 ? 'satisfied' : 'not_satisfied', + evidence, + findings, + }; +} + +function assessToolPermissions( + bom: Record, + profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const toolLayer = bom.tool_layer as unknown[] | undefined; + + if (!toolLayer || toolLayer.length === 0) { + findings.push('Tool layer missing or empty — cannot assess permission controls'); + return { status: 'not_satisfied', evidence, findings }; + } + + evidence.push({ + source: 'agentbom.tool_layer', + description: `${toolLayer.length} tool(s) inventoried`, + }); + + const blockedPerms = new Set(profile.rules.tool_layer?.blocked_permissions ?? []); + const blockedSources = new Set(profile.rules.tool_layer?.blocked_sources ?? []); + + for (const tool of toolLayer) { + if (typeof tool !== 'object' || tool === null) continue; + const t = tool as Record; + + // Check permissions + const permissions = (t.permissions as string[] | undefined) ?? []; + const hasBlocked = permissions.some((p) => + Array.from(blockedPerms).some((bp) => p.toLowerCase().includes(bp.toLowerCase())), + ); + if (hasBlocked) { + findings.push(`Tool "${t.tool_name}" has blocked permission(s)`); + } else if (ev === 'detailed') { + evidence.push({ + source: `agentbom.tool_layer[${t.tool_name}].permissions`, + description: `Permissions reviewed: [${permissions.join(', ')}]`, + }); + } + + // Check source + const source = String(t.source ?? ''); + const isBlocked = Array.from(blockedSources).some((bs) => + source.toLowerCase().includes(bs.toLowerCase()), + ); + if (isBlocked) { + findings.push(`Tool "${t.tool_name}" has blocked source "${source}"`); + } else if (ev === 'detailed') { + evidence.push({ + source: `agentbom.tool_layer[${t.tool_name}].source`, + description: `Source: ${source || 'internal'}`, + }); + } + } + + const status: ControlObjective['status'] = + findings.length === 0 ? 'satisfied' : evidence.length > 0 ? 'partial' : 'not_satisfied'; + return { status, evidence, findings }; +} + +function assessRiskMonitoring( + bom: Record, + profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const riskLayer = bom.risk_layer as unknown[] | undefined; + + if (!riskLayer || riskLayer.length === 0) { + if (profile.rules.risk_layer?.requires_risk_assessment) { + findings.push('Risk assessment required but risk_layer is empty'); + return { status: 'not_satisfied', evidence, findings }; + } + return { status: 'not_applicable', evidence, findings }; + } + + evidence.push({ + source: 'agentbom.risk_layer', + description: `${riskLayer.length} risk(s) assessed`, + }); + + // Count unmitigated by severity + const unmitigated: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + const mitigatedCount = { critical: 0, high: 0, medium: 0, low: 0 }; + + for (const risk of riskLayer) { + if (typeof risk !== 'object' || risk === null) continue; + const r = risk as Record; + const severity = String(r.severity ?? '').toLowerCase(); + const status = String(r.status ?? '').toLowerCase(); + const isMitigated = status === 'mitigated' || status === 'accepted'; + + if (severity in unmitigated) { + if (isMitigated) { + mitigatedCount[severity]++; + } else { + unmitigated[severity]++; + } + } + + if (ev === 'detailed') { + evidence.push({ + source: `agentbom.risk_layer[${r.risk_id}]`, + description: `${r.description ?? r.risk_id}: ${severity} — ${status}`, + }); + } + } + + // Check thresholds from profile + const maxCritical = + profile.rules.risk_layer?.max_unmitigated_critical ?? Number.POSITIVE_INFINITY; + const maxHigh = profile.rules.risk_layer?.max_unmitigated_high ?? Number.POSITIVE_INFINITY; + const maxMedium = profile.rules.risk_layer?.max_unmitigated_medium ?? Number.POSITIVE_INFINITY; + + if (unmitigated.critical > maxCritical) { + findings.push( + `${unmitigated.critical} unmitigated critical risk(s) exceeds threshold (${maxCritical})`, + ); + } + if (unmitigated.high > maxHigh) { + findings.push(`${unmitigated.high} unmitigated high risk(s) exceeds threshold (${maxHigh})`); + } + if (unmitigated.medium > maxMedium) { + findings.push( + `${unmitigated.medium} unmitigated medium risk(s) exceeds threshold (${maxMedium})`, + ); + } + + if (ev === 'detailed') { + evidence.push({ + source: 'agentbom.risk_layer (aggregate)', + description: `Mitigated: ${Object.values(mitigatedCount).reduce((a, b) => a + b, 0)} / Unmitigated: ${Object.values(unmitigated).reduce((a, b) => a + b, 0)}`, + }); + } + + const resultStatus: ControlObjective['status'] = + findings.length === 0 ? 'satisfied' : evidence.length > 0 ? 'partial' : 'not_satisfied'; + return { status: resultStatus, evidence, findings }; +} + +function assessRiskIncidentResponse( + bom: Record, + _profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const auditLog = bom.audit_log as unknown[] | undefined; + + if (!auditLog || auditLog.length === 0) { + findings.push('No audit log entries — cannot verify incident detection and response'); + return { status: 'not_satisfied', evidence, findings }; + } + + evidence.push({ + source: 'agentbom.audit_log', + description: `${auditLog.length} audit event(s) recorded`, + }); + + // Count events by outcome + const outcomes = { success: 0, failure: 0, partial: 0 }; + for (const entry of auditLog) { + if (typeof entry !== 'object' || entry === null) continue; + const e = entry as Record; + const outcome = String(e.outcome ?? ''); + if (outcome in outcomes) { + (outcomes as Record)[outcome]++; + } + if (ev === 'detailed') { + evidence.push({ + source: `agentbom.audit_log[${e.timestamp ?? 'unknown'}]`, + description: `${e.event_type ?? 'unknown'} by ${e.actor ?? 'unknown'} — ${outcome}`, + timestamp: String(e.timestamp ?? ''), + }); + } + } + + if (outcomes.failure > 0) { + evidence.push({ + source: 'agentbom.audit_log (aggregate)', + description: `${outcomes.failure} failure(s) detected and logged`, + }); + } + + return { + status: evidence.length > 0 ? 'satisfied' : 'not_satisfied', + evidence, + findings, + }; +} + +function assessChangeManagement( + bom: Record, + _profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const identity = bom.identity as Record | undefined; + + if (!identity) { + findings.push('Identity section missing — cannot assess versioning and change tracking'); + return { status: 'not_satisfied', evidence, findings }; + } + + if (identity.agent_version) { + evidence.push({ + source: 'agentbom.identity.agent_version', + description: `Version tracked: ${identity.agent_version}`, + }); + } else { + findings.push('No agent_version — change management cannot be verified'); + } + + if (identity.generated_at) { + evidence.push({ + source: 'agentbom.identity.generated_at', + description: `BOM generated: ${identity.generated_at}`, + timestamp: String(identity.generated_at), + }); + } + + // Check for tool layer which shows current inventory state + const toolLayer = bom.tool_layer as unknown[] | undefined; + if (toolLayer && toolLayer.length > 0) { + evidence.push({ + source: 'agentbom.tool_layer', + description: `Tool inventory snapshot: ${toolLayer.length} tool(s)`, + }); + } + + if (ev === 'detailed') { + const agentbomVersion = String(bom.agentbom_version ?? 'unknown'); + evidence.push({ + source: 'agentbom.agentbom_version', + description: `Schema version: ${agentbomVersion}`, + }); + } + + const status: ControlObjective['status'] = evidence.length > 0 ? 'satisfied' : 'not_satisfied'; + return { status, evidence, findings }; +} + +function assessAttestation( + bom: Record, + profile: ComplianceProfile, + ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const attestation = bom.attestation as Record | undefined; + + if (!attestation) { + if ( + profile.rules.attestation?.requires_signature || + profile.rules.attestation?.requires_timestamp + ) { + findings.push('Attestation section missing — required for independent review'); + return { status: 'not_satisfied', evidence, findings }; + } + return { status: 'not_applicable', evidence, findings }; + } + + // Signature check + if (attestation.signature) { + evidence.push({ + source: 'agentbom.attestation.signature', + description: 'Signed attestation present', + }); + } else if (profile.rules.attestation?.requires_signature) { + findings.push('Attestation signature required but missing'); + } + + // Timestamp check + if (attestation.timestamp) { + evidence.push({ + source: 'agentbom.attestation.timestamp', + description: `Attested at: ${attestation.timestamp}`, + timestamp: String(attestation.timestamp), + }); + } else if (profile.rules.attestation?.requires_timestamp) { + findings.push('Attestation timestamp required but missing'); + } + + if (ev === 'detailed' && attestation.authority) { + evidence.push({ + source: 'agentbom.attestation.authority', + description: `Attesting authority: ${attestation.authority}`, + }); + } + + const status: ControlObjective['status'] = + findings.length === 0 ? 'satisfied' : evidence.length > 0 ? 'partial' : 'not_satisfied'; + return { status, evidence, findings }; +} + +function assessAIActData( + bom: Record, + _profile: ComplianceProfile, + _ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const toolLayer = bom.tool_layer as unknown[] | undefined; + + // For AI Act data governance, look at tool layer sources and permissions + // as proxy indicators for data handling practices + if (toolLayer && toolLayer.length > 0) { + evidence.push({ + source: 'agentbom.tool_layer', + description: `${toolLayer.length} tool(s) — data handling can be assessed per tool`, + }); + } else { + findings.push('No tool inventory — data governance scope cannot be assessed'); + } + + // Check if evidence layer exists (AEP references indicate evidence trail) + const evidenceLayer = bom.evidence_layer as Record | undefined; + if (evidenceLayer) { + const aepRefs = (evidenceLayer.aep_references as string[] | undefined) ?? []; + if (aepRefs.length > 0) { + evidence.push({ + source: 'agentbom.evidence_layer.aep_references', + description: `${aepRefs.length} AEP evidence reference(s) — training data provenance trail`, + }); + } + } + + return { + status: evidence.length > 0 ? 'satisfied' : 'not_satisfied', + evidence, + findings, + }; +} + +function assessAIActOversight( + bom: Record, + _profile: ComplianceProfile, + _ev: EvidenceLevel, +): { status: ControlObjective['status']; evidence: EvidenceCitation[]; findings: string[] } { + const evidence: EvidenceCitation[] = []; + const findings: string[] = []; + const attestation = bom.attestation as Record | undefined; + const identity = bom.identity as Record | undefined; + + if (attestation?.signature) { + evidence.push({ + source: 'agentbom.attestation.signature', + description: 'Attestation signature present — supports human oversight chain', + }); + } + + if (identity?.agent_name) { + evidence.push({ + source: 'agentbom.identity.agent_name', + description: `Agent "${identity.agent_name}" — identifiable for oversight`, + }); + } + + if (evidence.length === 0) { + findings.push('Insufficient evidence for human oversight assessment'); + } + + return { + status: evidence.length > 0 ? 'satisfied' : 'not_satisfied', + evidence, + findings, + }; +} + +// --------------------------------------------------------------------------- +// Profile loading +// --------------------------------------------------------------------------- + +function loadProfile(profileId: string): ComplianceProfile | null { + const profilesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../profiles'); + const profilePath = resolve(profilesDir, `${profileId}.json`); + try { + const raw = readFileSync(profilePath, 'utf-8'); + return JSON.parse(raw) as ComplianceProfile; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Report generation +// --------------------------------------------------------------------------- + +function buildReport( + framework: FrameworkId, + bom: Record, + profile: ComplianceProfile, + period: string | undefined, + evidenceLevel: EvidenceLevel, +): RegulatoryReport { + const controlDefs = getControlDefs(framework, evidenceLevel); + const controlObjectives: ControlObjective[] = []; + + const allEvidence: EvidenceCitation[] = []; + + for (const def of controlDefs) { + const result = def.assess(bom, profile); + controlObjectives.push({ + id: def.id, + title: def.title, + description: def.description, + status: result.status, + evidence: result.evidence, + findings: result.findings, + }); + allEvidence.push(...result.evidence); + } + + const satisfied = controlObjectives.filter((c) => c.status === 'satisfied').length; + const partial = controlObjectives.filter((c) => c.status === 'partial').length; + const notSatisfied = controlObjectives.filter((c) => c.status === 'not_satisfied').length; + + const overallStatus: RegulatoryReport['executive_summary']['overall_status'] = + notSatisfied === 0 && partial === 0 + ? 'compliant' + : notSatisfied === 0 + ? 'partial' + : partial > notSatisfied + ? 'partial' + : 'non_compliant'; + + const identity = bom.identity as Record | undefined; + + return { + report_metadata: { + framework: FRAMEWORK_DISPLAY[framework], + framework_version: profile.framework.version, + period, + generated_at: new Date().toISOString(), + agent_id: String(identity?.agent_id ?? ''), + agent_name: String(identity?.agent_name ?? ''), + evidence_level: evidenceLevel, + }, + executive_summary: { + overall_status: overallStatus, + controls_assessed: controlObjectives.length, + controls_satisfied: satisfied, + controls_partial: partial, + controls_not_satisfied: notSatisfied, + evidence_citations: allEvidence.length, + }, + control_objectives: controlObjectives, + evidence_inventory: allEvidence, + }; +} + +// --------------------------------------------------------------------------- +// Text formatter +// --------------------------------------------------------------------------- + +function formatReportText(report: RegulatoryReport): string { + const lines: string[] = []; + const meta = report.report_metadata; + const summary = report.executive_summary; + + lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(' REGULATORY COMPLIANCE REPORT'); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(''); + lines.push(` Framework: ${meta.framework} (${meta.framework_version})`); + if (meta.period) lines.push(` Period: ${meta.period}`); + if (meta.agent_id) lines.push(` Agent ID: ${meta.agent_id}`); + if (meta.agent_name) lines.push(` Agent Name: ${meta.agent_name}`); + lines.push(` Generated: ${meta.generated_at}`); + lines.push(` Evidence Level: ${meta.evidence_level}`); + lines.push(''); + + // Executive summary + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' EXECUTIVE SUMMARY'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(''); + + const statusIcon = + summary.overall_status === 'compliant' ? '✓' : summary.overall_status === 'partial' ? '⚠' : '✗'; + const statusLabel = summary.overall_status.replace('_', ' ').toUpperCase(); + lines.push(` Overall Status: ${statusIcon} ${statusLabel}`); + lines.push(` Controls Assessed: ${summary.controls_assessed}`); + lines.push(` Satisfied: ${summary.controls_satisfied}`); + lines.push(` Partial: ${summary.controls_partial}`); + lines.push(` Not Satisfied: ${summary.controls_not_satisfied}`); + lines.push(` Evidence Citations: ${summary.evidence_citations}`); + lines.push(''); + + // Control objectives + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(' CONTROL OBJECTIVES'); + lines.push( + '────────────────────────────────────────────────────────────────────────────────────', + ); + lines.push(''); + + for (const control of report.control_objectives) { + const icon = + control.status === 'satisfied' + ? '✓' + : control.status === 'partial' + ? '⚠' + : control.status === 'not_applicable' + ? '—' + : '✗'; + const statusLabel = control.status.replace('_', ' ').toUpperCase(); + + lines.push(` ${icon} [${control.id}] ${control.title} — ${statusLabel}`); + lines.push(` ${control.description}`); + lines.push(''); + + if (control.evidence.length > 0) { + lines.push(' Evidence:'); + for (const ev of control.evidence) { + lines.push(` → ${ev.source}: ${ev.description}`); + } + lines.push(''); + } + + if (control.findings.length > 0) { + lines.push(' Findings:'); + for (const finding of control.findings) { + lines.push(` ⚠ ${finding}`); + } + lines.push(''); + } + } + + // Footer + lines.push('════════════════════════════════════════════════════════════════════════════════'); + lines.push(`Report generated by Agent Trust Infrastructure — ${meta.generated_at}`); + lines.push('════════════════════════════════════════════════════════════════════════════════'); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI command +// --------------------------------------------------------------------------- + +export function reportCommand(args: string[]): number { + // Parse arguments + let framework: FrameworkId | undefined; + let period: string | undefined; + let format: ReportFormat = 'text'; + let evidenceLevel: EvidenceLevel = 'summary'; + let bomPath: string | undefined; + let showHelp = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--help' || args[i] === '-h') { + showHelp = true; + } else if (args[i] === '--framework' && i + 1 < args.length) { + framework = args[++i] as FrameworkId; + } else if (args[i] === '--period' && i + 1 < args.length) { + period = args[++i]; + } else if (args[i] === '--format' && i + 1 < args.length) { + const val = args[++i]; + if (val !== 'text' && val !== 'json') { + console.error(`Error: invalid --format "${val}"; use text or json`); + return 1; + } + format = val as ReportFormat; + } else if (args[i] === '--evidence-level' && i + 1 < args.length) { + const val = args[++i]; + if (val !== 'summary' && val !== 'detailed') { + console.error(`Error: invalid --evidence-level "${val}"; use summary or detailed`); + return 1; + } + evidenceLevel = val as EvidenceLevel; + } else if (!args[i].startsWith('--')) { + bomPath = args[i]; + } + } + + if (showHelp) { + console.log( + [ + 'Usage: agent-trust report --framework [--period ] [--format text|json] [--evidence-level summary|detailed]', + '', + 'Generates a compliance-ready regulatory report from AgentBOM data,', + 'mapping trust artifacts to framework-specific control objectives with evidence citations.', + '', + 'Arguments:', + ' Path to the AgentBOM JSON file', + '', + 'Required options:', + ' --framework Regulatory framework: soc2, iso27001, ai-act', + '', + 'Optional options:', + ' --period Reporting period (e.g., Q1-2026, FY2025)', + ' --format Output format: text (default) or json', + ' --evidence-level Evidence detail: summary (default) or detailed', + '', + 'Supported frameworks:', + ' soc2 SOC 2 Type II (2024) — security, availability, confidentiality', + ' iso27001 ISO/IEC 27001:2022 — information security management', + ' ai-act EU AI Act — high-risk AI systems (Art. 11, Annex IV)', + ].join('\n'), + ); + return 0; + } + + // Validate required args + if (!bomPath) { + console.error('Error: report requires a argument'); + console.error('Usage: agent-trust report --framework '); + return 1; + } + + if (!framework) { + console.error('Error: report requires --framework '); + return 1; + } + + if (!SUPPORTED_FRAMEWORKS.includes(framework)) { + console.error( + `Error: unsupported framework "${framework}"; must be one of: ${SUPPORTED_FRAMEWORKS.join(', ')}`, + ); + return 1; + } + + // Load AgentBOM + const resolvedPath = resolve(bomPath); + let bomRaw: string; + try { + bomRaw = readFileSync(resolvedPath, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolvedPath}"`); + return 1; + } + + let bomData: unknown; + try { + bomData = JSON.parse(bomRaw); + } catch { + console.error(`Error: "${resolvedPath}" is not valid JSON`); + return 1; + } + + // Validate AgentBOM + const validation = validateAgentBOM(bomData); + if (!validation.valid) { + console.error(`Error: AgentBOM validation failed for "${resolvedPath}":`); + for (const err of validation.errors) { + console.error(` - ${err}`); + } + return 1; + } + + // Load compliance profile + const profileId = FRAMEWORK_TO_PROFILE[framework]; + + // Load compliance profile from file + let profile: ComplianceProfile; + const loaded = loadProfile(profileId); + if (!loaded) { + console.error(`Error: cannot load compliance profile "${profileId}"`); + console.error(`Expected file: profiles/${profileId}.json`); + return 1; + } + profile = loaded; + + // Build report + const report = buildReport( + framework, + bomData as Record, + profile, + period, + evidenceLevel, + ); + + // Output + if (format === 'json') { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatReportText(report)); + } + + return report.executive_summary.overall_status === 'non_compliant' ? 1 : 0; +} diff --git a/packages/agentbom-cli/src/sigstore-verify.ts b/packages/agentbom-cli/src/sigstore-verify.ts new file mode 100644 index 0000000..ecf1fb0 --- /dev/null +++ b/packages/agentbom-cli/src/sigstore-verify.ts @@ -0,0 +1,473 @@ +/** + * passport verify-sigstore — Verify a Trust Passport artifact with a Sigstore bundle. + * + * Implements Sigstore bundle verification (v0.3) for production hardening: + * - Certificate chain validation and signature verification + * - Air-gapped mode (--offline): skip Rekor/transparency log checks + * - FIPS-compliant crypto (--fips): enforce FIPS-approved algorithms only + * - Enterprise SSO (--issuer): verify OIDC issuer in certificate SANs + * + * Uses node:crypto for all cryptographic operations (no external deps). + */ +import { createHash, createVerify, X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateTrustPassport } from '@openagentaudit/passport'; + +// isRecord is a private utility not exported by @openagentaudit/passport +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +// --------------------------------------------------------------------------- +// Bundle types +// --------------------------------------------------------------------------- + +export interface SigstoreBundle { + mediaType: string; + content: { + $case: string; + messageDigest?: { algorithm: string; digest: string }; + signature: string; + }; + verificationMaterial: { + content: { + $case: string; + certificates: Array<{ rawBytes: string }>; + }; + tlogEntries?: Array<{ + logIndex?: number; + integratedTime?: number; + inclusionPromise?: { signedEntryTimestamp: string }; + }>; + }; +} + +// --------------------------------------------------------------------------- +// Options and result types +// --------------------------------------------------------------------------- + +export interface SigstoreVerifyOptions { + bundlePath?: string; + artifactPath?: string; + artifactContent?: Buffer; + offline?: boolean; + fips?: boolean; + trustedIssuer?: string; +} + +export interface SigstoreVerifyResult { + valid: boolean; + signatureValid: boolean; + certificateValid: boolean; + tlogVerified: boolean; + artifactValid: boolean; + fipsCompliant: boolean; + issuerMatch: boolean; + errors: string[]; + warnings: string[]; +} + +// --------------------------------------------------------------------------- +// FIPS-approved algorithm sets +// --------------------------------------------------------------------------- + +const FIPS_APPROVED_HASHES = new Set(['SHA256', 'SHA384', 'SHA512']); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function base64ToBuffer(b64: string): Buffer { + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + return Buffer.from(padded, 'base64'); +} + +/** + * Parse a Sigstore bundle JSON string into a typed structure. + * Throws on structural issues so callers can surface clear errors. + */ +function parseBundle(raw: string): SigstoreBundle { + const data = JSON.parse(raw) as unknown; + if (!isRecord(data)) throw new Error('Invalid bundle: root must be an object'); + if (typeof data.mediaType !== 'string') throw new Error('Invalid bundle: missing mediaType'); + + const content = data.content; + if (!isRecord(content)) throw new Error('Invalid bundle: missing content'); + if (typeof content.signature !== 'string') + throw new Error('Invalid bundle: content.signature must be a string'); + + const vm = data.verificationMaterial; + if (!isRecord(vm)) throw new Error('Invalid bundle: missing verificationMaterial'); + const vmContent = vm.content; + if (!isRecord(vmContent)) throw new Error('Invalid bundle: missing verificationMaterial.content'); + const certs = vmContent.certificates; + if (!Array.isArray(certs) || certs.length === 0) + throw new Error('Invalid bundle: certificates must be a non-empty array'); + + return { + mediaType: data.mediaType as string, + content: { + $case: (content.$case as string) ?? 'MessageSignature', + messageDigest: isRecord(content.messageDigest) + ? { + algorithm: content.messageDigest.algorithm as string, + digest: content.messageDigest.digest as string, + } + : undefined, + signature: content.signature as string, + }, + verificationMaterial: { + content: { + $case: (vmContent.$case as string) ?? 'x509CertificateChain', + certificates: certs.map((c: unknown) => ({ + rawBytes: isRecord(c) && typeof c.rawBytes === 'string' ? c.rawBytes : '', + })), + }, + tlogEntries: Array.isArray(vm.tlogEntries) + ? (vm.tlogEntries as SigstoreBundle['verificationMaterial']['tlogEntries']) + : undefined, + }, + }; +} + +/** + * Verify a raw artifact buffer against a certificate's public key. + * Supports Ed25519, RSA, and ECDSA key types. + */ +function verifyArtifactSignature( + artifact: Buffer, + signature: Buffer, + cert: X509Certificate, +): boolean { + try { + const keyType = cert.publicKey.asymmetricKeyType; + if (keyType === 'ed25519') { + const v = createVerify('Ed25519'); + v.update(artifact); + return v.verify(cert.publicKey, signature); + } + // RSA / ECDSA — try SHA-256, then SHA-384, then SHA-512 + for (const hash of ['sha256', 'sha384', 'sha512'] as const) { + try { + const v = createVerify(hash); + v.update(artifact); + if (v.verify(cert.publicKey, signature)) return true; + } catch { + // swallow unsupported hash algorithm + } + } + return false; + } catch { + return false; + } +} + +/** + * Check whether a trusted OIDC issuer appears in the certificate's + * Subject Alternative Names (type 6 = URI). + * + * Method 1: cert.getExtension('subjectAltName') (Node.js ≥ 17.3). + * Method 2: search raw DER for the issuer URL — URI SANs encode the URL + * as UTF-8 bytes (tag 0x86) inside the DER. This fallback works in runtimes + * that don't implement getExtension (e.g. Bun). + */ +function checkOidcIssuer(certDer: Buffer, cert: X509Certificate, trustedIssuer: string): boolean { + // Method 1: structured extension access (Node.js ≥ 17.3) + try { + const san = cert.getExtension('subjectAltName'); + if (san && typeof san === 'object') { + const obj = san as Record; + const altNames = obj.altNames as Array<{ type: number; value: string }> | undefined; + if (Array.isArray(altNames)) { + return altNames.some((e) => e.type === 6 && e.value === trustedIssuer); + } + const val = obj.value as string | undefined; + return val?.includes(trustedIssuer) ?? false; + } + } catch { + /* getExtension not supported in this runtime — fall through to DER search */ + } + + // Method 2: search DER encoding for the issuer URL as a UTF-8 substring. + // URI SANs contain the URL verbatim in the DER, so a substring match is + // reliable for standard certificates. + return certDer.includes(Buffer.from(trustedIssuer, 'utf-8')); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Verify a Trust Passport artifact against a Sigstore bundle. + * + * Returns a detailed result with per-check outcomes: + * - `certificateValid`: leaf certificate parsed successfully + * - `signatureValid`: artifact signature matches leaf certificate + * - `artifactValid`: artifact is a structurally valid Trust Passport + * - `tlogVerified`: Rekor transparency log entry confirmed (false in offline mode) + * - `fipsCompliant`: all algorithms are FIPS-approved (when `fips: true`) + * - `issuerMatch`: OIDC issuer found in certificate SANs (when `trustedIssuer` set) + */ +export function verifySigstoreBundle(options: SigstoreVerifyOptions): SigstoreVerifyResult { + const errors: string[] = []; + const warnings: string[] = []; + let signatureValid = false; + let signatureChecked = false; + let certificateValid = false; + let tlogVerified = false; + let artifactValid = false; + let fipsCompliant = true; + let issuerMatch = true; + + // ---- 1. Load and parse bundle ---- + let bundle: SigstoreBundle; + if (options.bundlePath) { + try { + const raw = readFileSync(resolve(options.bundlePath), 'utf-8').trim(); + bundle = parseBundle(raw); + } catch (err) { + return { + valid: false, + signatureValid: false, + certificateValid: false, + tlogVerified: false, + artifactValid: false, + fipsCompliant: true, + issuerMatch: true, + errors: [`Bundle load error: ${err instanceof Error ? err.message : String(err)}`], + warnings: [], + }; + } + } else { + return { + valid: false, + signatureValid: false, + certificateValid: false, + tlogVerified: false, + artifactValid: false, + fipsCompliant: true, + issuerMatch: true, + errors: ['No bundle input provided (bundlePath required)'], + warnings: [], + }; + } + + // ---- 2. Parse leaf certificate and run crypto checks ---- + try { + const leafRaw = base64ToBuffer(bundle.verificationMaterial.content.certificates[0].rawBytes); + const leafCert = new X509Certificate(leafRaw); + certificateValid = true; + + // 2a. FIPS mode: enforce approved key algorithm and hash + if (options.fips) { + const keyType = leafCert.publicKey.asymmetricKeyType; + const approvedKeyTypes = new Set(['rsa', 'ec', 'ed25519']); + if (!approvedKeyTypes.has(keyType ?? '')) { + fipsCompliant = false; + errors.push(`FIPS mode: unapproved key algorithm "${keyType}"`); + } + if (bundle.content.messageDigest) { + const hashAlg = bundle.content.messageDigest.algorithm.toUpperCase(); + if (!FIPS_APPROVED_HASHES.has(hashAlg)) { + fipsCompliant = false; + errors.push( + `FIPS mode: unapproved hash algorithm "${bundle.content.messageDigest.algorithm}" (approved: SHA256, SHA384, SHA512)`, + ); + } + } + } + + // 2b. Verify artifact signature against leaf certificate + const artifact = + options.artifactContent ?? + (options.artifactPath ? readFileSync(resolve(options.artifactPath)) : null); + + if (artifact) { + signatureChecked = true; + const sig = base64ToBuffer(bundle.content.signature); + signatureValid = verifyArtifactSignature(artifact, sig, leafCert); + if (!signatureValid) { + errors.push('Signature verification failed'); + } + + // 2c. Verify message digest when present + if (bundle.content.messageDigest && artifact) { + const digestAlg = bundle.content.messageDigest.algorithm.toLowerCase(); + const expectedDigest = base64ToBuffer(bundle.content.messageDigest.digest); + const actualDigest = createHash(digestAlg).update(artifact).digest(); + if (!expectedDigest.equals(actualDigest)) { + errors.push('Message digest mismatch'); + signatureValid = false; + } + } + } else { + warnings.push('No artifact provided; signature verification skipped'); + } + + // 2d. Enterprise SSO: verify OIDC issuer in certificate SANs + if (options.trustedIssuer) { + issuerMatch = checkOidcIssuer(leafRaw, leafCert, options.trustedIssuer); + if (!issuerMatch) { + errors.push(`SSO issuer mismatch: expected "${options.trustedIssuer}" in certificate SANs`); + } + } + } catch (err) { + certificateValid = false; + errors.push(`Certificate error: ${err instanceof Error ? err.message : String(err)}`); + } + + // ---- 3. Transparency log ---- + const tlogEntries = bundle.verificationMaterial.tlogEntries; + if (tlogEntries && tlogEntries.length > 0) { + if (options.offline) { + warnings.push('Offline mode: transparency log entries present but Rekor check skipped'); + tlogVerified = false; + } else { + const entry = tlogEntries[0]; + if (entry.logIndex !== undefined && entry.integratedTime !== undefined) { + warnings.push( + 'Transparency log entry found; full Rekor verification requires @sigstore/client', + ); + tlogVerified = false; + } else { + warnings.push('Transparency log entry is incomplete'); + tlogVerified = false; + } + } + } else if (!options.offline) { + warnings.push('No transparency log entries in bundle'); + } + + // ---- 4. Validate artifact as Trust Passport ---- + if (options.artifactContent || options.artifactPath) { + try { + const artifactRaw = + options.artifactContent?.toString('utf-8') ?? + (options.artifactPath ? readFileSync(resolve(options.artifactPath), 'utf-8') : ''); + const parsed = JSON.parse(artifactRaw) as unknown; + if (isRecord(parsed)) { + const result = validateTrustPassport(parsed); + artifactValid = result.valid; + if (!result.valid) { + for (const e of result.errors) errors.push(`Artifact: ${e}`); + } + } + } catch { + artifactValid = false; + } + } + + // ---- 5. Overall validity ---- + const valid = + certificateValid && + (!signatureChecked || signatureValid) && + errors.length === 0 && + (artifactValid || (!options.artifactContent && !options.artifactPath)) && + (fipsCompliant || !options.fips) && + (issuerMatch || !options.trustedIssuer); + + return { + valid, + signatureValid, + certificateValid, + tlogVerified, + artifactValid, + fipsCompliant, + issuerMatch, + errors, + warnings, + }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +/** CLI entry point for `passport verify-sigstore`. */ +export function verifySigstoreCommand(args: string[]): number { + if (args.includes('--help') || args.includes('-h')) { + console.log( + [ + 'Usage: agent-trust passport verify-sigstore [options]', + '', + 'Verifies a Trust Passport artifact with a Sigstore bundle.', + '', + 'Options:', + ' --artifact Path to the Trust Passport artifact to verify', + ' --offline Air-gapped mode: skip Rekor/transparency log verification', + ' --fips Require FIPS-compliant algorithms (SHA-256/384/512 only)', + ' --issuer Verify OIDC issuer in certificate SANs (enterprise SSO)', + '', + 'Exit codes:', + ' 0 Verification passed', + ' 1 Verification failed', + ].join('\n'), + ); + return 0; + } + + let bundlePath = ''; + let artifactPath: string | undefined; + let offline = false; + let fips = false; + let trustedIssuer: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + if (arg === '--artifact' && next) { + artifactPath = next; + i++; + } else if (arg === '--offline') { + offline = true; + } else if (arg === '--fips') { + fips = true; + } else if (arg === '--issuer' && next) { + trustedIssuer = next; + i++; + } else if (!arg.startsWith('--') && !bundlePath) { + bundlePath = arg; + } else if (!arg.startsWith('--')) { + console.error(`Error: unexpected argument "${arg}"`); + return 1; + } + } + + if (!bundlePath) { + console.error('Error: passport verify-sigstore requires a argument'); + return 1; + } + + try { + const result = verifySigstoreBundle({ + bundlePath, + artifactPath, + offline, + fips, + trustedIssuer, + }); + console.log( + JSON.stringify( + { + valid: result.valid, + signature: result.signatureValid ? 'valid' : 'invalid', + certificate: result.certificateValid ? 'valid' : 'invalid', + tlog: result.tlogVerified ? 'verified' : 'not_verified', + artifact: result.artifactValid ? 'valid' : 'not_checked', + fips: result.fipsCompliant ? 'compliant' : 'non_compliant', + issuer: result.issuerMatch ? 'match' : 'not_checked', + errors: result.errors, + warnings: result.warnings, + }, + null, + 2, + ), + ); + return result.valid ? 0 : 1; + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} diff --git a/packages/agentbom-cli/src/trust-diff.test.ts b/packages/agentbom-cli/src/trust-diff.test.ts new file mode 100644 index 0000000..b20ec15 --- /dev/null +++ b/packages/agentbom-cli/src/trust-diff.test.ts @@ -0,0 +1,637 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { runCommand } from './index.js'; +import { + diffTrustArtifacts, + extractChangesFromFormatted, + type FieldChange, + formatGenericDiff, + parseDiffArgs, + trustDiffCommand, +} from './trust-diff.js'; + +// ---- Fixtures ---- + +const VALID_AGENTBOM_OLD = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-diff', + agent_name: 'Test Agent v1', + deployment_context: 'development', + generated_at: '2026-01-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read'], + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: [], +}; + +const VALID_AGENTBOM_NEW = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-diff', + agent_name: 'Test Agent v2', + deployment_context: 'production', + generated_at: '2026-02-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read', 'fs:write'], + risk_signals: [], + }, + { + tool_id: 'web-fetch', + tool_name: 'fetch_url', + source: 'mcp', + permissions: ['network:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read', 'fs:write', 'network:read'], + data_access: ['local_workspace', 'remote_api'], + credential_references: [], + }, + risk_layer: [], +}; + +const VALID_MCP_POSTURE_OLD = { + posture_version: '0.1', + identity: { + agent_id: 'test-mcp-diff', + snapshot_id: 'snap-001', + captured_at: '2026-01-01T00:00:00Z', + }, + servers: [ + { + server_id: 'filesystem', + server_name: 'Filesystem Server', + transport: 'stdio', + capabilities: ['read'], + }, + ], + attestation: { generator: 'test' }, +}; + +const VALID_MCP_POSTURE_NEW = { + posture_version: '0.1', + identity: { + agent_id: 'test-mcp-diff', + snapshot_id: 'snap-002', + captured_at: '2026-02-01T00:00:00Z', + }, + servers: [ + { + server_id: 'filesystem', + server_name: 'Filesystem Server v2', + transport: 'stdio', + capabilities: ['read', 'write'], + }, + { + server_id: 'web', + server_name: 'Web Server', + transport: 'sse', + capabilities: ['fetch'], + }, + ], + attestation: { generator: 'test' }, +}; + +const VALID_TRUST_PASSPORT_OLD = { + passport_version: '0.1', + identity: { + passport_id: 'passport-001', + agent_id: 'test-passport-diff', + agent_name: 'Test Agent v1', + issuer: 'Test Issuer', + issuance_context: 'development', + }, + validity: { + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + }, + attestation: { + generator: 'test', + issuer: 'Test Issuer', + coverage: 'selected_technical_evidence', + }, + revocation: { + revoked: false, + revoked_at: null, + revocation_reason: null, + revocation_triggers: [], + }, + risk_summary: { critical: 0, high: 1, medium: 2, low: 0, open_findings: 3 }, +}; + +const VALID_TRUST_PASSPORT_NEW = { + passport_version: '0.1', + identity: { + passport_id: 'passport-001', + agent_id: 'test-passport-diff', + agent_name: 'Test Agent v2', + issuer: 'Test Issuer', + issuance_context: 'production', + }, + validity: { + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2027-06-01T00:00:00Z', + }, + attestation: { + generator: 'test', + issuer: 'Test Issuer', + coverage: 'partial', + }, + revocation: { + revoked: false, + revoked_at: null, + revocation_reason: null, + revocation_triggers: ['policy_violation'], + }, + risk_summary: { critical: 1, high: 2, medium: 2, low: 0, open_findings: 5 }, + evidence_summary: { + evidence_quality: 'high', + framework_mappings: [{ framework: 'OWASP', coverage: 'partial' }], + }, +}; + +// ---- Test setup ---- + +let tmpDir: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `trust-diff-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): string { + const p = join(tmpDir, name); + writeFileSync(p, JSON.stringify(data), 'utf-8'); + return p; +} + +// ---- parseDiffArgs ---- + +describe('parseDiffArgs', () => { + it('returns usage string for --help', () => { + const result = parseDiffArgs(['--help']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for -h', () => { + const result = parseDiffArgs(['-h']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for empty args', () => { + const result = parseDiffArgs([]); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns error for single positional arg', () => { + const result = parseDiffArgs(['file-a.json']); + expect(typeof result).toBe('string'); + expect(result).toContain('requires two file path arguments'); + }); + + it('parses two positional args', () => { + const result = parseDiffArgs(['a.json', 'b.json']); + expect(typeof result).not.toBe('string'); + const config = result as { oldPath: string; newPath: string; json: boolean }; + expect(config.oldPath).toBe(resolve('a.json')); + expect(config.newPath).toBe(resolve('b.json')); + expect(config.json).toBe(false); + }); + + it('parses --json flag', () => { + const result = parseDiffArgs(['a.json', 'b.json', '--json']); + expect(typeof result).not.toBe('string'); + const config = result as { oldPath: string; newPath: string; json: boolean }; + expect(config.json).toBe(true); + }); + + it('returns error for unknown flag', () => { + const result = parseDiffArgs(['a.json', 'b.json', '--bogus']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); +}); + +// ---- diffTrustArtifacts — error paths ---- + +describe('diffTrustArtifacts — error paths', () => { + it('returns error for non-existent old file', () => { + const newPath = writeJson('new.json', VALID_AGENTBOM_OLD); + const result = diffTrustArtifacts('/nonexistent/a.json', newPath); + expect(typeof result).toBe('string'); + expect(result).toContain('Error'); + }); + + it('returns error for non-existent new file', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const result = diffTrustArtifacts(oldPath, '/nonexistent/b.json'); + expect(typeof result).toBe('string'); + expect(result).toContain('Error'); + }); + + it('returns error for unknown old artifact type', () => { + const oldPath = writeJson('old.json', { foo: 'bar' }); + const newPath = writeJson('new.json', VALID_AGENTBOM_OLD); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).toBe('string'); + expect(result).toContain('does not match any known schema'); + }); + + it('returns error for unknown new artifact type', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', { foo: 'bar' }); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).toBe('string'); + expect(result).toContain('does not match any known schema'); + }); + + it('returns error for type mismatch', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', VALID_MCP_POSTURE_OLD); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).toBe('string'); + expect(result).toContain('type mismatch'); + }); +}); + +// ---- diffTrustArtifacts — AgentBOM ---- + +describe('diffTrustArtifacts — AgentBOM', () => { + it('returns empty result for identical AgentBOMs', () => { + const path = writeJson('same.json', VALID_AGENTBOM_OLD); + const result = diffTrustArtifacts(path, path); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; artifactType: string }; + expect(diff.isEmpty).toBe(true); + expect(diff.artifactType).toBe('agentbom'); + }); + + it('detects tool additions and permission changes', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', VALID_AGENTBOM_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; formatted: string; changes: FieldChange[] }; + expect(diff.isEmpty).toBe(false); + expect(diff.formatted).toContain('added'); + // Should detect new tool (web-fetch) and permission changes (fs:write added) + expect(diff.changes.length).toBeGreaterThan(0); + }); + + it('includes formatted output with tool and permission info', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', VALID_AGENTBOM_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { formatted: string }; + // The formatted output should mention tools and/or permissions + const output = diff.formatted; + expect(output.length).toBeGreaterThan(0); + }); +}); + +// ---- diffTrustArtifacts — MCP Posture ---- + +describe('diffTrustArtifacts — MCP Posture', () => { + it('returns empty result for identical MCP Postures', () => { + const path = writeJson('same.json', VALID_MCP_POSTURE_OLD); + const result = diffTrustArtifacts(path, path); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; artifactType: string }; + expect(diff.isEmpty).toBe(true); + expect(diff.artifactType).toBe('mcp-posture'); + }); + + it('detects server additions and modifications', () => { + const oldPath = writeJson('old.json', VALID_MCP_POSTURE_OLD); + const newPath = writeJson('new.json', VALID_MCP_POSTURE_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; formatted: string; changes: FieldChange[] }; + expect(diff.isEmpty).toBe(false); + expect(diff.changes.length).toBeGreaterThan(0); + // Should mention the new server (web) + expect(diff.formatted).toContain('added'); + }); +}); + +// ---- diffTrustArtifacts — Trust Passport ---- + +describe('diffTrustArtifacts — Trust Passport', () => { + it('returns empty result for identical Trust Passports', () => { + const path = writeJson('same.json', VALID_TRUST_PASSPORT_OLD); + const result = diffTrustArtifacts(path, path); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; artifactType: string }; + expect(diff.isEmpty).toBe(true); + expect(diff.artifactType).toBe('trust-passport'); + }); + + it('detects identity and validity changes', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { isEmpty: boolean; formatted: string; changes: FieldChange[] }; + expect(diff.isEmpty).toBe(false); + expect(diff.changes.length).toBeGreaterThan(0); + // Should detect the agent_name change, expires_at change, etc. + const paths = diff.changes.map((c) => c.path); + expect(paths.some((p) => p.includes('agent_name'))).toBe(true); + expect(paths.some((p) => p.includes('expires_at'))).toBe(true); + }); + + it('detects risk_summary count changes', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { changes: FieldChange[] }; + const paths = diff.changes.map((c) => c.path); + // risk_summary.critical went from 0 to 1, high from 1 to 2, open_findings from 3 to 5 + expect(paths.some((p) => p.includes('risk_summary'))).toBe(true); + }); + + it('detects added fields (evidence_summary)', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { changes: FieldChange[] }; + const added = diff.changes.filter((c) => c.type === 'added'); + expect(added.length).toBeGreaterThan(0); + expect(added.some((c) => c.path.includes('evidence_summary'))).toBe(true); + }); + + it('detects revocation_triggers changes', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { changes: FieldChange[] }; + const paths = diff.changes.map((c) => c.path); + expect(paths.some((p) => p.includes('revocation_triggers'))).toBe(true); + }); + + it('detects coverage change in attestation', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const result = diffTrustArtifacts(oldPath, newPath); + expect(typeof result).not.toBe('string'); + const diff = result as { changes: FieldChange[] }; + const paths = diff.changes.map((c) => c.path); + expect(paths.some((p) => p.includes('coverage'))).toBe(true); + }); +}); + +// ---- formatGenericDiff ---- + +describe('formatGenericDiff', () => { + it('formats added changes', () => { + const changes: FieldChange[] = [ + { path: 'identity.agent_name', type: 'added', new: 'New Agent' }, + ]; + const output = formatGenericDiff(changes); + expect(output).toContain('Fields added (1)'); + expect(output).toContain('+ identity.agent_name:'); + }); + + it('formats removed changes', () => { + const changes: FieldChange[] = [ + { path: 'identity.deprecated', type: 'removed', old: 'old-value' }, + ]; + const output = formatGenericDiff(changes); + expect(output).toContain('Fields removed (1)'); + expect(output).toContain('- identity.deprecated:'); + }); + + it('formats modified changes', () => { + const changes: FieldChange[] = [ + { + path: 'validity.expires_at', + type: 'modified', + old: '2027-01-01T00:00:00Z', + new: '2027-06-01T00:00:00Z', + }, + ]; + const output = formatGenericDiff(changes); + expect(output).toContain('Fields changed (1)'); + expect(output).toContain('~ validity.expires_at:'); + expect(output).toContain('→'); + }); + + it('returns "No changes detected" for empty input', () => { + const output = formatGenericDiff([]); + expect(output).toContain('No changes detected'); + }); + + it('groups all three types together', () => { + const changes: FieldChange[] = [ + { path: 'a', type: 'added', new: 1 }, + { path: 'b', type: 'removed', old: 2 }, + { path: 'c', type: 'modified', old: 3, new: 4 }, + ]; + const output = formatGenericDiff(changes); + expect(output).toContain('Fields added (1)'); + expect(output).toContain('Fields removed (1)'); + expect(output).toContain('Fields changed (1)'); + }); +}); + +// ---- extractChangesFromFormatted ---- + +describe('extractChangesFromFormatted', () => { + it('parses + lines as added', () => { + const changes = extractChangesFromFormatted(' + tool_name: fetch_url'); + expect(changes).toHaveLength(1); + expect(changes[0].type).toBe('added'); + expect(changes[0].path).toContain('tool_name'); + }); + + it('parses - lines as removed', () => { + const changes = extractChangesFromFormatted(' - deprecated_field'); + expect(changes).toHaveLength(1); + expect(changes[0].type).toBe('removed'); + }); + + it('parses ~ lines as modified', () => { + const changes = extractChangesFromFormatted(' ~ permissions: expanded'); + expect(changes).toHaveLength(1); + expect(changes[0].type).toBe('modified'); + }); + + it('ignores non-change lines', () => { + const changes = extractChangesFromFormatted('Tools added (2):\nNo changes detected.'); + expect(changes).toHaveLength(0); + }); +}); + +// ---- Integration: diff command via runCommand ---- + +describe('diff command via runCommand', () => { + it('shows help for diff --help', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', '--help']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage:'); + expect(output).toContain('artifact-a.json'); + expect(output).toContain('artifact-b.json'); + expect(output).toContain('--json'); + }); + + it('shows help for diff -h', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', '-h']); + expect(result).toBe(0); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 for diff without enough args', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['diff', 'only-one.json']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('requires two file path arguments'); + }); + + it('returns 1 for non-existent old file', () => { + const spy = spyOn(console, 'error'); + const newPath = writeJson('new.json', VALID_AGENTBOM_OLD); + const result = runCommand(['diff', '/nonexistent/a.json', newPath]); + expect(result).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 0 for identical AgentBOM files', () => { + const path = writeJson('same.json', VALID_AGENTBOM_OLD); + const spy = spyOn(console, 'log').mockClear(); + const result = runCommand(['diff', path, path]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('AgentBOM'); + expect(output).toContain('No differences found'); + }); + + it('returns 1 for different AgentBOM files', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', VALID_AGENTBOM_NEW); + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', oldPath, newPath]); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('AgentBOM'); + }); + + it('returns 0 for identical MCP Posture files', () => { + const path = writeJson('same.json', VALID_MCP_POSTURE_OLD); + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', path, path]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('MCP Posture'); + }); + + it('returns 1 for different MCP Posture files', () => { + const oldPath = writeJson('old.json', VALID_MCP_POSTURE_OLD); + const newPath = writeJson('new.json', VALID_MCP_POSTURE_NEW); + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', oldPath, newPath]); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('MCP Posture'); + }); + + it('returns 0 for identical Trust Passport files', () => { + const path = writeJson('same.json', VALID_TRUST_PASSPORT_OLD); + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', path, path]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Trust Passport'); + }); + + it('returns 1 for different Trust Passport files', () => { + const oldPath = writeJson('old.json', VALID_TRUST_PASSPORT_OLD); + const newPath = writeJson('new.json', VALID_TRUST_PASSPORT_NEW); + const spy = spyOn(console, 'log'); + const result = runCommand(['diff', oldPath, newPath]); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Trust Passport'); + expect(output).toContain('changed'); + }); + + it('returns 1 for type mismatch', () => { + const oldPath = writeJson('old.json', VALID_AGENTBOM_OLD); + const newPath = writeJson('new.json', VALID_MCP_POSTURE_OLD); + const spy = spyOn(console, 'error'); + const result = runCommand(['diff', oldPath, newPath]); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('type mismatch'); + }); + + it('outputs JSON when --json flag is passed', () => { + const path = writeJson('same.json', VALID_AGENTBOM_OLD); + const spy = spyOn(console, 'log').mockClear(); + const result = runCommand(['diff', path, path, '--json']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + // Should be valid JSON (only the last call, which is the JSON output) + const jsonCall = spy.mock.calls[spy.mock.calls.length - 1].join(' '); + const parsed = JSON.parse(jsonCall); + expect(parsed.artifact_type).toBe('agentbom'); + expect(parsed.is_empty).toBe(true); + expect(parsed).toHaveProperty('changes'); + expect(parsed).toHaveProperty('change_count'); + }); + + it('returns 1 for unknown flag', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['diff', 'a.json', 'b.json', '--bogus']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('unknown argument'); + }); + + it('returns 1 for unknown artifact type', () => { + const oldPath = writeJson('old.json', { foo: 'bar' }); + const newPath = writeJson('new.json', { foo: 'baz' }); + const spy = spyOn(console, 'error'); + const result = runCommand(['diff', oldPath, newPath]); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('does not match any known schema'); + }); +}); diff --git a/packages/agentbom-cli/src/trust-diff.ts b/packages/agentbom-cli/src/trust-diff.ts new file mode 100644 index 0000000..879d652 --- /dev/null +++ b/packages/agentbom-cli/src/trust-diff.ts @@ -0,0 +1,412 @@ +/** + * `trust-cli diff ` — generates a structured + * diff report for trust artifacts that highlights changes to permissions, tool + * additions, and policy modifications. + * + * Auto-detects the artifact type (AgentBOM, MCP Posture, Trust Passport) and + * delegates to the type-specific diff engine. For Trust Passport artifacts, + * uses a generic structured JSON diff since `trust-passport-core` is frozen. + * + * Usage: + * trust-cli diff [--json] [--help] + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateTrustPassport } from '@openagentaudit/passport'; +import { + diffAgentBOM, + formatAgentBOMDiff, + validateAgentBOM, +} from '@wasmagent/agentbom-core'; +import { + diffMCPPosture, + formatPostureDiff, + validateMCPPosture, +} from '@wasmagent/mcp-posture'; +import { type ArtifactType, detectArtifactType, readArtifactFile } from './trust-publish.js'; + +// ---- Types ---- + +/** A single field-level change between two versions of an artifact. */ +export interface FieldChange { + /** Dot-delimited path to the changed field (e.g., "identity.agent_name"). */ + path: string; + /** Type of change: "added", "removed", or "modified". */ + type: 'added' | 'removed' | 'modified'; + /** Value in the old artifact (absent for "added"). */ + old?: unknown; + /** Value in the new artifact (absent for "removed"). */ + new?: unknown; +} + +/** Structured diff result produced by {@link diffTrustArtifacts}. */ +export interface TrustDiffResult { + /** Detected artifact type. */ + artifactType: ArtifactType; + /** Whether the two artifacts are identical. */ + isEmpty: boolean; + /** Human-readable formatted diff string. */ + formatted: string; + /** Structured field-level changes (always populated). */ + changes: FieldChange[]; +} + +// ---- Structured JSON diff (for Trust Passport) ---- + +/** + * Recursively diff two JSON values, producing a list of {@link FieldChange} entries. + * + * Only primitive values and nested objects/arrays are compared. The `path` + * parameter tracks the current location in the JSON tree. + */ +function diffValues(oldVal: unknown, newVal: unknown, path: string, changes: FieldChange[]): void { + // Both are objects (but not arrays) — recurse into keys + if (isPlainObject(oldVal) && isPlainObject(newVal)) { + const oldKeys = Object.keys(oldVal as Record); + const newKeys = Object.keys(newVal as Record); + const allKeys = new Set([...oldKeys, ...newKeys]); + for (const key of allKeys) { + const childPath = path ? `${path}.${key}` : key; + if (!(key in oldVal)) { + changes.push({ + path: childPath, + type: 'added', + new: (newVal as Record)[key], + }); + } else if (!(key in newVal)) { + changes.push({ + path: childPath, + type: 'removed', + old: (oldVal as Record)[key], + }); + } else { + diffValues( + (oldVal as Record)[key], + (newVal as Record)[key], + childPath, + changes, + ); + } + } + return; + } + + // Both are arrays — compare element-wise by index + if (Array.isArray(oldVal) && Array.isArray(newVal)) { + const maxLen = Math.max(oldVal.length, newVal.length); + for (let i = 0; i < maxLen; i++) { + const childPath = `${path}[${i}]`; + if (i >= oldVal.length) { + changes.push({ path: childPath, type: 'added', new: newVal[i] }); + } else if (i >= newVal.length) { + changes.push({ path: childPath, type: 'removed', old: oldVal[i] }); + } else { + diffValues(oldVal[i], newVal[i], childPath, changes); + } + } + return; + } + + // Values differ at this leaf + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes.push({ path, type: 'modified', old: oldVal, new: newVal }); + } +} + +/** Check if a value is a plain object (not null, not array). */ +function isPlainObject(value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Format a generic structured diff into a human-readable string. + * + * Groups changes by type (added, removed, modified) and renders each change + * with +/-/~ prefix and a JSON-encoded value. + */ +export function formatGenericDiff(changes: FieldChange[]): string { + const added = changes.filter((c) => c.type === 'added'); + const removed = changes.filter((c) => c.type === 'removed'); + const modified = changes.filter((c) => c.type === 'modified'); + + const lines: string[] = []; + + if (added.length > 0) { + lines.push(`Fields added (${added.length}):`); + for (const c of added) { + const val = JSON.stringify(c.new); + lines.push(` + ${c.path}: ${val}`); + } + } + + if (removed.length > 0) { + lines.push(`Fields removed (${removed.length}):`); + for (const c of removed) { + const val = JSON.stringify(c.old); + lines.push(` - ${c.path}: ${val}`); + } + } + + if (modified.length > 0) { + lines.push(`Fields changed (${modified.length}):`); + for (const c of modified) { + lines.push(` ~ ${c.path}: ${JSON.stringify(c.old)} → ${JSON.stringify(c.new)}`); + } + } + + if (lines.length === 0) { + lines.push('No changes detected.'); + } + + return lines.join('\n'); +} + +// ---- Core diff logic ---- + +/** + * Diff two trust artifacts of the same type. + * + * Auto-detects the artifact type from the *new* file. If the old and new + * files are different artifact types, returns an error string. + * + * For AgentBOM and MCP Posture, delegates to the type-specific diff engines. + * For Trust Passport, uses a generic structured JSON diff. + * + * Returns a {@link TrustDiffResult} on success, or an error string on failure. + */ +export function diffTrustArtifacts( + oldFilePath: string, + newFilePath: string, +): TrustDiffResult | string { + const { data: oldData, error: oldError } = readArtifactFile(oldFilePath); + if (oldError) return `Error: failed to read old artifact at "${oldFilePath}"`; + + const { data: newData, error: newError } = readArtifactFile(newFilePath); + if (newError) return `Error: failed to read new artifact at "${newFilePath}"`; + + // Validate both artifacts + const oldType = detectArtifactType(oldData); + const newType = detectArtifactType(newData); + + if (oldType === 'unknown') { + return `Error: old artifact at "${oldFilePath}" does not match any known schema (AgentBOM, MCP Posture, or Trust Passport)`; + } + if (newType === 'unknown') { + return `Error: new artifact at "${newFilePath}" does not match any known schema (AgentBOM, MCP Posture, or Trust Passport)`; + } + + if (oldType !== newType) { + return `Error: artifact type mismatch — old is "${oldType}", new is "${newType}"`; + } + + // ---- AgentBOM ---- + if (newType === 'agentbom') { + const oldResult = validateAgentBOM(oldData); + if (!oldResult.valid) { + return `Error: old AgentBOM validation failed: ${oldResult.errors.join('; ')}`; + } + const newResult = validateAgentBOM(newData); + if (!newResult.valid) { + return `Error: new AgentBOM validation failed: ${newResult.errors.join('; ')}`; + } + + const diff = diffAgentBOM(oldData, newData); + const formatted = formatAgentBOMDiff(diff); + const changes = extractChangesFromFormatted(formatted); + return { + artifactType: 'agentbom', + isEmpty: diff.isEmpty(), + formatted, + changes, + }; + } + + // ---- MCP Posture ---- + if (newType === 'mcp-posture') { + const oldResult = validateMCPPosture(oldData); + if (!oldResult.valid) { + return `Error: old MCP Posture validation failed: ${oldResult.errors.join('; ')}`; + } + const newResult = validateMCPPosture(newData); + if (!newResult.valid) { + return `Error: new MCP Posture validation failed: ${newResult.errors.join('; ')}`; + } + + const diff = diffMCPPosture(oldData, newData); + const formatted = formatPostureDiff(diff); + const changes = extractChangesFromFormatted(formatted); + return { + artifactType: 'mcp-posture', + isEmpty: diff.isEmpty(), + formatted, + changes, + }; + } + + // ---- Trust Passport ---- + if (newType === 'trust-passport') { + const oldResult = validateTrustPassport(oldData); + if (!oldResult.valid) { + return `Error: old Trust Passport validation failed: ${oldResult.errors.join('; ')}`; + } + const newResult = validateTrustPassport(newData); + if (!newResult.valid) { + return `Error: new Trust Passport validation failed: ${newResult.errors.join('; ')}`; + } + + const changes: FieldChange[] = []; + diffValues(oldData, newData, '', changes); + const formatted = formatGenericDiff(changes); + return { + artifactType: 'trust-passport', + isEmpty: changes.length === 0, + formatted, + changes, + }; + } + + // Should not reach here given the earlier checks + return 'Error: unexpected artifact type'; +} + +/** + * Extract structured FieldChange entries from a formatted diff string. + * + * Parses the human-readable output from `formatAgentBOMDiff` and + * `formatPostureDiff` into typed change records for the structured `changes` + * array in the result. + */ +export function extractChangesFromFormatted(formatted: string): FieldChange[] { + const changes: FieldChange[] = []; + for (const line of formatted.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('+ ')) { + changes.push({ path: trimmed.slice(2), type: 'added' }); + } else if (trimmed.startsWith('- ')) { + changes.push({ path: trimmed.slice(2), type: 'removed' }); + } else if (trimmed.startsWith('~ ')) { + changes.push({ path: trimmed.slice(2), type: 'modified' }); + } + } + return changes; +} + +// ---- CLI command ---- + +const DIFF_USAGE = [ + 'Usage: agent-trust diff [options]', + '', + 'Generate a structured diff report for trust artifacts that highlights', + 'changes to permissions, tool additions, and policy modifications.', + '', + 'Arguments:', + ' Path to the old/baseline trust artifact', + ' Path to the new trust artifact', + '', + 'Options:', + ' --json Output structured JSON instead of human-readable text', + ' --help, -h Show this help message', + '', + 'Supported artifact types:', + ' AgentBOM, MCP Posture, Trust Passport (auto-detected)', + '', + 'Examples:', + ' agent-trust diff old-agentbom.json new-agentbom.json', + ' agent-trust diff old-posture.json new-posture.json --json', + ' agent-trust diff old-passport.json new-passport.json', +].join('\n'); + +/** + * Parse diff command arguments. + * Returns `--json` flag and the two file paths, or a usage/error string. + */ +export function parseDiffArgs( + args: string[], +): { oldPath: string; newPath: string; json: boolean } | string { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + return DIFF_USAGE; + } + + // Collect positional args and flags + const positional: string[] = []; + let json = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--json') { + json = true; + } else if (args[i].startsWith('--')) { + return `Error: unknown argument "${args[i]}"`; + } else { + positional.push(args[i]); + } + } + + if (positional.length < 2) { + return 'Error: diff requires two file path arguments: '; + } + + return { + oldPath: resolve(positional[0]), + newPath: resolve(positional[1]), + json, + }; +} + +/** + * CLI entry point for `agent-trust diff`. + * + * Returns exit code (0 = no changes, 1 = changes detected or error). + */ +export function trustDiffCommand(args: string[]): number { + const parsed = parseDiffArgs(args); + if (typeof parsed === 'string') { + if (parsed.startsWith('Usage:')) { + console.log(parsed); + return 0; + } + console.error(parsed); + return 1; + } + + const { oldPath, newPath, json } = parsed; + + const result = diffTrustArtifacts(oldPath, newPath); + if (typeof result === 'string') { + console.error(result); + return 1; + } + + // Print artifact type header + const typeLabel = + { + agentbom: 'AgentBOM', + 'mcp-posture': 'MCP Posture', + 'trust-passport': 'Trust Passport', + }[result.artifactType] ?? result.artifactType; + + if (!json) { + console.log(`Comparing ${typeLabel} artifacts:`); + console.log(` old: ${oldPath}`); + console.log(` new: ${newPath}`); + console.log(); + console.log(result.formatted); + } else { + console.log( + JSON.stringify( + { + artifact_type: result.artifactType, + artifact_type_label: typeLabel, + old_path: oldPath, + new_path: newPath, + is_empty: result.isEmpty, + change_count: result.changes.length, + changes: result.changes, + }, + null, + 2, + ), + ); + } + + return result.isEmpty ? 0 : 1; +} diff --git a/packages/agentbom-cli/src/trust-publish.test.ts b/packages/agentbom-cli/src/trust-publish.test.ts new file mode 100644 index 0000000..4daf539 --- /dev/null +++ b/packages/agentbom-cli/src/trust-publish.test.ts @@ -0,0 +1,582 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + computeCasId, + detectArtifactType, + type PublishConfig, + parsePublishArgs, + publishArtifact, + readArtifactFile, + readRegistryManifest, + writeRegistryManifest, + writeTagPointer, +} from './trust-publish.js'; + +// ---- Fixtures ---- + +const VALID_AGENTBOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-pub', + agent_name: 'Test Publish Agent', + deployment_context: 'development', + generated_at: '2026-01-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read'], + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: [], +}; + +const VALID_MCP_POSTURE = { + posture_version: '0.1', + identity: { + agent_id: 'test-mcp-agent', + snapshot_id: 'snap-001', + captured_at: '2026-01-01T00:00:00Z', + }, + servers: [ + { + server_id: 'filesystem', + server_name: 'Filesystem Server', + transport: 'stdio', + capabilities: ['read', 'write'], + }, + ], + attestation: { generator: 'test' }, +}; + +const VALID_TRUST_PASSPORT = { + passport_version: '0.1', + identity: { + passport_id: 'passport-001', + agent_id: 'test-passport-agent', + agent_name: 'Test Passport Agent', + issuer: 'Test Issuer', + issuance_context: 'development', + }, + validity: { + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + }, + attestation: { + generator: 'test', + issuer: 'Test Issuer', + coverage: 'selected_technical_evidence', + }, + revocation: { + revoked: false, + revoked_at: null, + revocation_reason: null, + revocation_triggers: [], + }, +}; + +// ---- Test setup ---- + +let tmpDir: string; +let registryDir: string; + +beforeEach(() => { + tmpDir = join( + tmpdir(), + `trust-publish-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); + registryDir = join(tmpDir, 'registry'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): string { + const p = join(tmpDir, name); + writeFileSync(p, JSON.stringify(data), 'utf-8'); + return p; +} + +// ---- computeCasId ---- + +describe('computeCasId', () => { + it('returns sha256: format', () => { + const casId = computeCasId('hello'); + expect(casId).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it('is deterministic — same content yields same CAS ID', () => { + const casId1 = computeCasId('hello'); + const casId2 = computeCasId('hello'); + expect(casId1).toBe(casId2); + }); + + it('differs for different content', () => { + const casId1 = computeCasId('hello'); + const casId2 = computeCasId('world'); + expect(casId1).not.toBe(casId2); + }); + + it('handles JSON content', () => { + const casId = computeCasId(JSON.stringify({ key: 'value' })); + expect(casId).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it('handles empty string', () => { + const casId = computeCasId(''); + expect(casId).toMatch(/^sha256:[a-f0-9]{64}$/); + // Known SHA-256 of empty string + expect(casId).toBe('sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); +}); + +// ---- detectArtifactType ---- + +describe('detectArtifactType', () => { + it('detects AgentBOM', () => { + expect(detectArtifactType(VALID_AGENTBOM)).toBe('agentbom'); + }); + + it('detects MCP Posture', () => { + expect(detectArtifactType(VALID_MCP_POSTURE)).toBe('mcp-posture'); + }); + + it('detects Trust Passport', () => { + expect(detectArtifactType(VALID_TRUST_PASSPORT)).toBe('trust-passport'); + }); + + it('returns unknown for unrecognized data', () => { + expect(detectArtifactType({ foo: 'bar' })).toBe('unknown'); + }); + + it('returns unknown for non-object', () => { + expect(detectArtifactType('string')).toBe('unknown'); + expect(detectArtifactType(42)).toBe('unknown'); + expect(detectArtifactType(null)).toBe('unknown'); + expect(detectArtifactType([])).toBe('unknown'); + }); +}); + +// ---- readArtifactFile ---- + +describe('readArtifactFile', () => { + it('reads and parses a valid JSON file', () => { + const path = writeJson('valid.json', VALID_AGENTBOM); + const { data, error } = readArtifactFile(path); + expect(error).toBe(0); + expect(data.agentbom_version).toBe('0.1'); + }); + + it('returns error for non-existent file', () => { + const spy = spyOn(console, 'error'); + const { error } = readArtifactFile('/nonexistent/file.json'); + expect(error).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns error for invalid JSON', () => { + const path = join(tmpDir, 'bad.json'); + writeFileSync(path, '{ not valid json', 'utf-8'); + const spy = spyOn(console, 'error'); + const { error } = readArtifactFile(path); + expect(error).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns error for non-object JSON (array)', () => { + const path = join(tmpDir, 'array.json'); + writeFileSync(path, '[1, 2, 3]', 'utf-8'); + const spy = spyOn(console, 'error'); + const { error } = readArtifactFile(path); + expect(error).toBe(1); + expect(spy).toHaveBeenCalled(); + }); +}); + +// ---- parsePublishArgs ---- + +describe('parsePublishArgs', () => { + it('returns usage string for --help', () => { + const result = parsePublishArgs(['--help']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for -h', () => { + const result = parsePublishArgs(['-h']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns error for empty args', () => { + const result = parsePublishArgs([]); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('parses required args and returns config', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath]); + expect(typeof result).not.toBe('string'); + const config = result as PublishConfig; + expect(config.artifactPath).toBe(artifactPath); + expect(config.registryDir).toContain('.trust-registry'); + expect(config.tag).toBeUndefined(); + }); + + it('parses --registry flag', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath, '--registry', '/my-registry']); + expect(typeof result).not.toBe('string'); + const config = result as PublishConfig; + expect(config.registryDir).toBe('/my-registry'); + }); + + it('parses --tag flag', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath, '--tag', 'latest']); + expect(typeof result).not.toBe('string'); + const config = result as PublishConfig; + expect(config.tag).toBe('latest'); + }); + + it('parses all flags together', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath, '--registry', './my-reg', '--tag', 'v1.0']); + expect(typeof result).not.toBe('string'); + const config = result as PublishConfig; + expect(config.artifactPath).toBe(artifactPath); + expect(config.registryDir).toBe(resolve('./my-reg')); + expect(config.tag).toBe('v1.0'); + }); + + it('returns error for unknown flag', () => { + const result = parsePublishArgs(['file.json', '--bogus']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --registry without value', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath, '--registry']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('resolves artifact path to absolute', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = parsePublishArgs([artifactPath]); + expect(typeof result).not.toBe('string'); + const config = result as PublishConfig; + expect(config.artifactPath).toBe(artifactPath); + }); +}); + +// ---- Registry manifest helpers ---- + +describe('readRegistryManifest', () => { + it('returns empty object for non-existent directory', () => { + const manifest = readRegistryManifest('/nonexistent/path'); + expect(manifest).toEqual({}); + }); + + it('returns empty object when manifest file does not exist', () => { + mkdirSync(registryDir, { recursive: true }); + const manifest = readRegistryManifest(registryDir); + expect(manifest).toEqual({}); + }); + + it('reads a valid manifest', () => { + mkdirSync(registryDir, { recursive: true }); + const manifestData = { 'sha256:abc': 1, 'sha256:def': 2 }; + writeFileSync(join(registryDir, 'manifest.json'), JSON.stringify(manifestData), 'utf-8'); + const manifest = readRegistryManifest(registryDir); + expect(manifest).toEqual(manifestData); + }); + + it('returns empty object for corrupted manifest', () => { + mkdirSync(registryDir, { recursive: true }); + writeFileSync(join(registryDir, 'manifest.json'), 'not json', 'utf-8'); + const manifest = readRegistryManifest(registryDir); + expect(manifest).toEqual({}); + }); +}); + +describe('writeRegistryManifest', () => { + it('creates registry directory if missing', () => { + const newDir = join(tmpDir, 'new-registry'); + writeRegistryManifest(newDir, { 'sha256:abc': 1 }); + expect(existsSync(join(newDir, 'manifest.json'))).toBe(true); + }); + + it('writes manifest as JSON', () => { + writeRegistryManifest(registryDir, { 'sha256:abc': 1, 'sha256:def': 2 }); + const raw = readFileSync(join(registryDir, 'manifest.json'), 'utf-8'); + const data = JSON.parse(raw); + expect(data).toEqual({ 'sha256:abc': 1, 'sha256:def': 2 }); + }); +}); + +describe('writeTagPointer', () => { + it('creates tags directory and writes pointer file', () => { + writeTagPointer(registryDir, 'latest', 'sha256:abc123'); + const tagPath = join(registryDir, 'tags', 'latest.json'); + expect(existsSync(tagPath)).toBe(true); + const raw = readFileSync(tagPath, 'utf-8'); + const data = JSON.parse(raw); + expect(data.cas_id).toBe('sha256:abc123'); + expect(data.tagged_at).toBeDefined(); + }); +}); + +// ---- publishArtifact (core) ---- + +describe('publishArtifact', () => { + it('publishes a valid AgentBOM and returns structured result', () => { + const artifactPath = writeJson('agentbom.json', VALID_AGENTBOM); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.casId).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(result.artifactType).toBe('agentbom'); + expect(result.version).toBe(1); + expect(result.publishedAt).toBeDefined(); + expect(result.registryPath).toContain(registryDir); + expect(result.sizeBytes).toBeGreaterThan(0); + + // Verify the artifact was actually stored + expect(existsSync(result.registryPath)).toBe(true); + }); + + it('publishes a valid MCP Posture artifact', () => { + const artifactPath = writeJson('posture.json', VALID_MCP_POSTURE); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.artifactType).toBe('mcp-posture'); + expect(result.version).toBe(1); + expect(existsSync(result.registryPath)).toBe(true); + }); + + it('publishes a valid Trust Passport artifact', () => { + const artifactPath = writeJson('passport.json', VALID_TRUST_PASSPORT); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.artifactType).toBe('trust-passport'); + expect(result.version).toBe(1); + expect(existsSync(result.registryPath)).toBe(true); + }); + + it('rejects unknown artifact type', () => { + const artifactPath = writeJson('unknown.json', { foo: 'bar' }); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).toBe('string'); + expect(result).toContain('any known schema'); + }); + + it('returns error for non-existent artifact file', () => { + const spy = spyOn(console, 'error'); + const result = publishArtifact('/nonexistent/artifact.json', registryDir); + + expect(typeof result).toBe('string'); + expect(result).toContain('Error'); + }); + + it('creates registry directory if missing', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const newRegDir = join(tmpDir, 'new-registry'); + const result = publishArtifact(artifactPath, newRegDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(existsSync(result.registryPath)).toBe(true); + }); + + it('deduplicates identical content — same CAS ID and version', () => { + const artifactPath1 = writeJson('bom1.json', VALID_AGENTBOM); + const artifactPath2 = writeJson('bom2.json', VALID_AGENTBOM); + + const result1 = publishArtifact(artifactPath1, registryDir); + expect(typeof result1).not.toBe('string'); + if (typeof result1 === 'string') return; + + const result2 = publishArtifact(artifactPath2, registryDir); + expect(typeof result2).not.toBe('string'); + if (typeof result2 === 'string') return; + + expect(result1.casId).toBe(result2.casId); + expect(result1.version).toBe(result2.version); + }); + + it('assigns incrementing versions for different content', () => { + const path1 = writeJson('bom1.json', VALID_AGENTBOM); + const path2 = writeJson('bom2.json', VALID_MCP_POSTURE); + + const result1 = publishArtifact(path1, registryDir); + expect(typeof result1).not.toBe('string'); + if (typeof result1 === 'string') return; + + const result2 = publishArtifact(path2, registryDir); + expect(typeof result2).not.toBe('string'); + if (typeof result2 === 'string') return; + + expect(result1.version).toBe(1); + expect(result2.version).toBe(2); + expect(result1.casId).not.toBe(result2.casId); + }); + + it('writes tag pointer when tag is provided', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = publishArtifact(artifactPath, registryDir, 'latest'); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.tag).toBe('latest'); + const tagPath = join(registryDir, 'tags', 'latest.json'); + expect(existsSync(tagPath)).toBe(true); + + const tagData = JSON.parse(readFileSync(tagPath, 'utf-8')); + expect(tagData.cas_id).toBe(result.casId); + }); + + it('stores artifact content correctly in registry', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + const stored = JSON.parse(readFileSync(result.registryPath, 'utf-8')); + expect(stored).toEqual(VALID_AGENTBOM); + }); + + it('stores manifest with correct CAS ID entry', () => { + const artifactPath = writeJson('bom.json', VALID_AGENTBOM); + const result = publishArtifact(artifactPath, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + const manifest = readRegistryManifest(registryDir); + expect(manifest[result.casId]).toBe(result.version); + }); +}); + +// ---- Integration: publish command via runCommand ---- + +import { runCommand } from './index.js'; + +describe('publish command via runCommand', () => { + it('shows help for publish --help', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', '--help']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage:'); + expect(output).toContain('artifact.json'); + expect(output).toContain('--registry'); + expect(output).toContain('--tag'); + }); + + it('shows help for publish -h', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', '-h']); + expect(result).toBe(0); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 for publish without artifact path', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['publish']); + // Empty args returns usage via --help path (returns 0) + // Actually no — publish command gets args.slice(1), so if called as + // runCommand(['publish']), publishCommand gets [] which shows help + // That's fine — it's a valid invocation + }); + + it('returns 1 for publish with unknown flag', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['publish', 'file.json', '--bogus']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('unknown argument'); + }); + + it('returns 1 for publish with non-existent file', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['publish', '/nonexistent/file.json']); + expect(result).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 for publish with unknown artifact type', () => { + const path = writeJson('unknown.json', { foo: 'bar' }); + const spy = spyOn(console, 'error'); + const result = runCommand(['publish', path]); + expect(result).toBe(1); + // The last error call should mention unknown schema + const lastCall = spy.mock.calls[spy.mock.calls.length - 1]; + expect(lastCall.join(' ')).toContain('any known schema'); + }); + + it('returns 0 and prints JSON for valid AgentBOM', () => { + const path = writeJson('agentbom.json', VALID_AGENTBOM); + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', path, '--registry', registryDir]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('sha256:'); + expect(output).toContain('agentbom'); + expect(output).toContain('"version"'); + }); + + it('returns 0 and prints JSON for valid MCP Posture', () => { + const path = writeJson('posture.json', VALID_MCP_POSTURE); + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', path, '--registry', registryDir]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('mcp-posture'); + }); + + it('returns 0 and prints JSON for valid Trust Passport', () => { + const path = writeJson('passport.json', VALID_TRUST_PASSPORT); + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', path, '--registry', registryDir]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('trust-passport'); + }); + + it('handles --tag flag via runCommand', () => { + const path = writeJson('bom.json', VALID_AGENTBOM); + const spy = spyOn(console, 'log'); + const result = runCommand(['publish', path, '--registry', registryDir, '--tag', 'latest']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('latest'); + }); +}); diff --git a/packages/agentbom-cli/src/trust-publish.ts b/packages/agentbom-cli/src/trust-publish.ts new file mode 100644 index 0000000..b78e358 --- /dev/null +++ b/packages/agentbom-cli/src/trust-publish.ts @@ -0,0 +1,325 @@ +/** + * `trust-cli publish ` — publishes signed trust artifacts to a + * local distribution registry with content-addressable storage (CAS) identifiers + * and immutable versioning. + * + * Reads a trust artifact JSON file, validates it against known schemas + * (AgentBOM, MCP Posture, or Trust Passport), computes a SHA-256 CAS digest, + * stores the artifact in a local registry directory, and assigns an immutable + * version number. + * + * Usage: + * trust-cli publish [--registry ] [--tag ] [--help] + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateTrustPassport } from '@openagentaudit/passport'; +import { type ValidationResult, validateAgentBOM } from '@wasmagent/agentbom-core'; +import { validateMCPPosture } from '@wasmagent/mcp-posture'; + +// ---- Types ---- + +/** Supported artifact types for publishing. */ +export type ArtifactType = 'agentbom' | 'mcp-posture' | 'trust-passport' | 'unknown'; + +/** Resolved configuration for the publish command. */ +export interface PublishConfig { + /** Path to the artifact JSON file to publish. */ + artifactPath: string; + /** Path to the local registry directory (default: ~/.trust-registry). */ + registryDir: string; + /** Optional tag to label the publication (e.g., 'latest', 'v1.0'). */ + tag?: string; +} + +/** Result of a publication operation. */ +export interface PublishResult { + /** CAS identifier (sha256:hex). */ + casId: string; + /** Artifact type detected during validation. */ + artifactType: ArtifactType; + /** Immutable version number assigned to this publication. */ + version: number; + /** ISO-8601 timestamp of publication. */ + publishedAt: string; + /** Absolute path to the artifact file in the registry. */ + registryPath: string; + /** Tag applied (if any). */ + tag?: string; + /** Size of the artifact in bytes. */ + sizeBytes: number; +} + +// ---- Pure helpers ---- + +/** + * Compute a SHA-256 content-addressable identifier from a string. + * + * Returns `sha256:`. The same content always yields the same + * identifier, enabling content-addressable storage and deduplication. + */ +export function computeCasId(content: string): string { + return `sha256:${createHash('sha256').update(content, 'utf-8').digest('hex')}`; +} + +/** + * Detect the artifact type by attempting validation against each known schema. + * + * Returns the first matching type. If none match, returns 'unknown'. + */ +export function detectArtifactType(data: unknown): ArtifactType { + if (validateAgentBOM(data).valid) return 'agentbom'; + if (validateMCPPosture(data).valid) return 'mcp-posture'; + if (validateTrustPassport(data).valid) return 'trust-passport'; + return 'unknown'; +} + +/** + * Read and parse a JSON file into a typed record. + * Returns `{ data, error }` — error is non-zero on failure. + */ +export function readArtifactFile(filePath: string): { + data: Record; + error: number; +} { + const resolved = resolve(filePath); + let raw: string; + try { + raw = readFileSync(resolved, 'utf-8'); + } catch { + console.error(`Error: cannot read file "${resolved}"`); + return { data: {}, error: 1 }; + } + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`Error: "${resolved}" is not valid JSON`); + return { data: {}, error: 1 }; + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + console.error(`Error: "${resolved}" does not contain a JSON object`); + return { data: {}, error: 1 }; + } + return { data: data as Record, error: 0 }; +} + +/** + * Read the registry manifest from the registry directory. + * + * The manifest tracks version counters per CAS ID. + * Returns an empty object if the manifest does not exist or cannot be read. + */ +export function readRegistryManifest(registryDir: string): Record { + const manifestPath = resolve(registryDir, 'manifest.json'); + if (!existsSync(manifestPath)) return {}; + try { + const raw = readFileSync(manifestPath, 'utf-8'); + const data = JSON.parse(raw); + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + return data as Record; + } + } catch { + // Corrupted manifest — start fresh + } + return {}; +} + +/** + * Write the registry manifest back to disk. + * + * Creates the registry directory if it does not exist. + */ +export function writeRegistryManifest(registryDir: string, manifest: Record): void { + const manifestPath = resolve(registryDir, 'manifest.json'); + mkdirSync(registryDir, { recursive: true }); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); +} + +/** + * Write a tag pointer file in the registry. + * + * Tag pointer files contain the CAS ID they point to, enabling label-based + * lookups (e.g., 'latest' → 'sha256:abc...'). + */ +export function writeTagPointer(registryDir: string, tag: string, casId: string): void { + const tagsDir = resolve(registryDir, 'tags'); + mkdirSync(tagsDir, { recursive: true }); + const tagPath = resolve(tagsDir, `${tag}.json`); + writeFileSync( + tagPath, + JSON.stringify({ cas_id: casId, tagged_at: new Date().toISOString() }), + 'utf-8', + ); +} + +/** + * Publish an artifact to the local registry. + * + * This is the core pure-logic function. It: + * 1. Ensures the registry directory exists + * 2. Computes the CAS identifier from the canonical JSON content + * 3. Assigns an immutable version number (monotonically increasing per CAS ID) + * 4. Writes the artifact file to the registry under its CAS path + * 5. Updates the registry manifest + * 6. Optionally writes a tag pointer + * + * If the artifact content is identical to an already-published version (same + * CAS ID), it returns the existing publication metadata without duplicating + * storage — content-addressable deduplication. + */ +export function publishArtifact( + artifactPath: string, + registryDir: string, + tag?: string, +): PublishResult | string { + // Read the artifact + const { data, error } = readArtifactFile(artifactPath); + if (error) return `Error: failed to read artifact at "${artifactPath}"`; + + // Detect and validate + const artifactType = detectArtifactType(data); + if (artifactType === 'unknown') { + return 'Error: artifact does not match any known schema (AgentBOM, MCP Posture, or Trust Passport)'; + } + + // Compute CAS identifier from the canonical JSON content + const canonicalJson = JSON.stringify(data); + const casId = computeCasId(canonicalJson); + + // Read manifest for version tracking + const manifest = readRegistryManifest(registryDir); + + // Determine version: if this CAS ID was already published, reuse its version (immutable) + const existingVersion = manifest[casId]; + const version = existingVersion ?? Object.keys(manifest).length + 1; + + // Store the artifact in the registry + // Structure: ////.json + const hexDigest = casId.replace('sha256:', ''); + const objectDir = resolve(registryDir, 'objects', hexDigest.slice(0, 2), hexDigest.slice(2, 4)); + const objectPath = resolve(objectDir, `${hexDigest}.json`); + mkdirSync(objectDir, { recursive: true }); + + writeFileSync(objectPath, canonicalJson, 'utf-8'); + + // Update manifest (only if new) + if (!existingVersion) { + manifest[casId] = version; + writeRegistryManifest(registryDir, manifest); + } + + // Write tag pointer if requested + if (tag) { + writeTagPointer(registryDir, tag, casId); + } + + const stat = statSync(objectPath); + + return { + casId, + artifactType, + version, + publishedAt: new Date().toISOString(), + registryPath: objectPath, + tag, + sizeBytes: stat.size, + }; +} + +// ---- CLI command ---- + +const PUBLISH_USAGE = [ + 'Usage: agent-trust publish [options]', + '', + 'Publish a signed trust artifact to the local distribution registry with', + 'content-addressable storage (CAS) identifiers and immutable versioning.', + '', + 'Arguments:', + ' Path to the trust artifact JSON file to publish', + '', + 'Options:', + ' --registry Path to the local registry directory', + ' (default: ~/.trust-registry)', + ' --tag Label this publication with a tag (e.g., latest, v1.0)', + ' --help, -h Show this help message', + '', + 'Examples:', + ' agent-trust publish agentbom.json', + ' agent-trust publish agentbom.json --tag latest', + ' agent-trust publish agentbom.json --registry ./my-registry', + '', + 'Output:', + ' On success, prints a JSON object with CAS ID, version, artifact type,', + ' publication timestamp, registry path, and tag (if provided).', +].join('\n'); + +/** + * Parse publish command arguments into a {@link PublishConfig}. + * Returns the config on success, or a usage/error string on failure. + */ +export function parsePublishArgs(args: string[]): PublishConfig | string { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + return PUBLISH_USAGE; + } + + const artifactPath = args[0]; + let registryDir: string | undefined; + let tag: string | undefined; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if (arg === '--registry' && next) { + registryDir = next; + i++; + } else if (arg === '--tag' && next) { + tag = next; + i++; + } else { + return `Error: unknown argument "${arg}"`; + } + } + + const homeRegistry = resolve( + process.env.HOME ?? process.env.USERPROFILE ?? '~', + '.trust-registry', + ); + + return { + artifactPath: resolve(artifactPath), + registryDir: registryDir ? resolve(registryDir) : homeRegistry, + tag, + }; +} + +/** + * CLI entry point for `agent-trust publish`. + * + * Returns exit code (0 = success, 1 = error). + */ +export function publishCommand(args: string[]): number { + const parsed = parsePublishArgs(args); + if (typeof parsed === 'string') { + if (parsed.startsWith('Usage:')) { + console.log(parsed); + return 0; + } + console.error(parsed); + return 1; + } + + const config = parsed; + + const result = publishArtifact(config.artifactPath, config.registryDir, config.tag); + if (typeof result === 'string') { + console.error(result); + return 1; + } + + // Success — print structured JSON output + console.log(JSON.stringify(result, null, 2)); + return 0; +} diff --git a/packages/agentbom-cli/src/trust-pull.test.ts b/packages/agentbom-cli/src/trust-pull.test.ts new file mode 100644 index 0000000..6a94144 --- /dev/null +++ b/packages/agentbom-cli/src/trust-pull.test.ts @@ -0,0 +1,699 @@ +import { afterEach, beforeEach, 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 { publishArtifact } from './trust-publish.js'; +import { + extractDependencyIds, + isCasId, + objectPathForCasId, + type PullConfig, + parsePullArgs, + pullArtifact, + type ResolvedDependency, + resolveArtifactId, + resolveDependencies, + resolveTagToCasId, + verifyIntegrity, +} from './trust-pull.js'; + +// ---- Fixtures ---- + +const VALID_AGENTBOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-pull', + agent_name: 'Test Pull Agent', + deployment_context: 'development', + generated_at: '2026-01-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read'], + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: [], +}; + +const VALID_MCP_POSTURE = { + posture_version: '0.1', + identity: { + agent_id: 'test-mcp-agent', + snapshot_id: 'snap-001', + captured_at: '2026-01-01T00:00:00Z', + }, + servers: [ + { + server_id: 'filesystem', + server_name: 'Filesystem Server', + transport: 'stdio', + capabilities: ['read', 'write'], + }, + ], + attestation: { generator: 'test' }, +}; + +const VALID_TRUST_PASSPORT = { + passport_version: '0.1', + identity: { + passport_id: 'passport-001', + agent_id: 'test-passport-agent', + agent_name: 'Test Passport Agent', + issuer: 'Test Issuer', + issuance_context: 'development', + }, + validity: { + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + }, + attestation: { + generator: 'test', + issuer: 'Test Issuer', + coverage: 'selected_technical_evidence', + }, + revocation: { + revoked: false, + revoked_at: null, + revocation_reason: null, + revocation_triggers: [], + }, +}; + +// ---- Test setup ---- + +let tmpDir: string; +let registryDir: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `trust-pull-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + registryDir = join(tmpDir, 'registry'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): string { + const p = join(tmpDir, name); + writeFileSync(p, JSON.stringify(data), 'utf-8'); + return p; +} + +/** Publish a fixture and return its CAS id (fails the test if publishing errored). */ +function publish(data: unknown, tag?: string): string { + const path = writeJson(`artifact-${Math.random().toString(36).slice(2)}.json`, data); + const result = publishArtifact(path, registryDir, tag); + if (typeof result === 'string') { + throw new Error(`setup publish failed: ${result}`); + } + return result.casId; +} + +/** Recursively collect every error string in a resolved-dependency tree. */ +function collectTreeErrors(nodes: ResolvedDependency[], errors: string[] = []): string[] { + for (const node of nodes) { + if (node.error) errors.push(node.error); + if (node.dependencies) collectTreeErrors(node.dependencies, errors); + } + return errors; +} + +// ---- objectPathForCasId ---- + +describe('objectPathForCasId', () => { + it('shards the hex digest into //.json', () => { + const casId = 'sha256:abcdef0123456789'; + const p = objectPathForCasId(casId, registryDir); + expect(p).toBe(join(registryDir, 'objects', 'ab', 'cd', 'abcdef0123456789.json')); + }); + + it('ignores a missing sha256: prefix gracefully', () => { + const p = objectPathForCasId('sha256:abcdef', registryDir); + expect(p).toContain('objects'); + expect(p.endsWith('abcdef.json')).toBe(true); + }); +}); + +// ---- isCasId ---- + +describe('isCasId', () => { + it('accepts a well-formed sha256:<64-hex> id', () => { + expect(isCasId(`sha256:${'a'.repeat(64)}`)).toBe(true); + }); + + it('rejects a tag label', () => { + expect(isCasId('latest')).toBe(false); + }); + + it('rejects a truncated digest', () => { + expect(isCasId('sha256:abc')).toBe(false); + }); + + it('rejects uppercase hex', () => { + expect(isCasId(`sha256:${'A'.repeat(64)}`)).toBe(false); + }); + + it('rejects missing prefix', () => { + expect(isCasId('a'.repeat(64))).toBe(false); + }); +}); + +// ---- resolveTagToCasId ---- + +describe('resolveTagToCasId', () => { + it('returns the cas_id for a known tag', () => { + publish(VALID_AGENTBOM, 'latest'); + const casId = resolveTagToCasId(registryDir, 'latest'); + expect(casId).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it('returns null for an unknown tag', () => { + expect(resolveTagToCasId(registryDir, 'nope')).toBeNull(); + }); + + it('returns null for a corrupted tag pointer', () => { + mkdirSync(join(registryDir, 'tags'), { recursive: true }); + writeFileSync(join(registryDir, 'tags', 'broken.json'), 'not json', 'utf-8'); + expect(resolveTagToCasId(registryDir, 'broken')).toBeNull(); + }); + + it('returns null when the pointer cas_id is malformed', () => { + mkdirSync(join(registryDir, 'tags'), { recursive: true }); + writeFileSync( + join(registryDir, 'tags', 'bad.json'), + JSON.stringify({ cas_id: 'not-a-cas-id' }), + 'utf-8', + ); + expect(resolveTagToCasId(registryDir, 'bad')).toBeNull(); + }); +}); + +// ---- resolveArtifactId ---- + +describe('resolveArtifactId', () => { + it('passes a CAS id through unchanged', () => { + const casId = `sha256:${'a'.repeat(64)}`; + const resolved = resolveArtifactId(casId, registryDir); + expect(resolved).not.toBeNull(); + expect(resolved?.casId).toBe(casId); + expect(resolved?.viaTag).toBeUndefined(); + }); + + it('resolves a tag label via the tag pointer', () => { + publish(VALID_AGENTBOM, 'stable'); + const resolved = resolveArtifactId('stable', registryDir); + expect(resolved).not.toBeNull(); + expect(resolved?.casId).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(resolved?.viaTag).toBe('stable'); + }); + + it('returns null for an unresolvable id', () => { + expect(resolveArtifactId('unknown-tag', registryDir)).toBeNull(); + }); +}); + +// ---- verifyIntegrity ---- + +describe('verifyIntegrity', () => { + it('verifies matching content', () => { + const content = JSON.stringify(VALID_AGENTBOM); + const casId = verifyIntegrity(content, '').computedCasId; + const result = verifyIntegrity(content, casId); + expect(result.ok).toBe(true); + expect(result.computedCasId).toBe(casId); + }); + + it('detects tampered content', () => { + const original = JSON.stringify(VALID_AGENTBOM); + const casId = verifyIntegrity(original, '').computedCasId; + const tampered = JSON.stringify({ ...VALID_AGENTBOM, tampered: true }); + const result = verifyIntegrity(tampered, casId); + expect(result.ok).toBe(false); + expect(result.computedCasId).not.toBe(casId); + }); +}); + +// ---- extractDependencyIds ---- + +describe('extractDependencyIds', () => { + it('reads distribution.supersedes', () => { + const ids = extractDependencyIds({ + distribution: { supersedes: ['sha256:aaa', 'sha256:bbb'] }, + }); + expect(ids).toEqual(['sha256:aaa', 'sha256:bbb']); + }); + + it('reads a string-form dependencies array', () => { + const ids = extractDependencyIds({ dependencies: ['sha256:ccc'] }); + expect(ids).toEqual(['sha256:ccc']); + }); + + it('reads an object-form dependencies array ({ id })', () => { + const ids = extractDependencyIds({ dependencies: [{ id: 'sha256:ddd' }, { name: 'x' }] }); + expect(ids).toEqual(['sha256:ddd']); + }); + + it('merges supersedes and dependencies preserving first-seen order', () => { + const ids = extractDependencyIds({ + distribution: { supersedes: ['a', 'b'] }, + dependencies: ['b', 'c'], + }); + expect(ids).toEqual(['a', 'b', 'c']); + }); + + it('deduplicates repeated ids', () => { + const ids = extractDependencyIds({ + distribution: { supersedes: ['a', 'a'] }, + dependencies: ['a'], + }); + expect(ids).toEqual(['a']); + }); + + it('returns empty when no dependency fields are present', () => { + expect(extractDependencyIds({ foo: 'bar' })).toEqual([]); + }); + + it('ignores non-string and empty-string entries', () => { + const ids = extractDependencyIds({ + distribution: { supersedes: ['', 42, 'keep'] }, + dependencies: [null, ''], + }); + expect(ids).toEqual(['keep']); + }); +}); + +// ---- pullArtifact (core) ---- + +describe('pullArtifact', () => { + it('pulls a published AgentBOM by CAS id and verifies integrity', () => { + const casId = publish(VALID_AGENTBOM); + const result = pullArtifact(casId, registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.casId).toBe(casId); + expect(result.resolvedVia).toBe('cas-id'); + expect(result.artifactType).toBe('agentbom'); + expect(result.integrityVerified).toBe(true); + expect(result.computedCasId).toBe(casId); + expect(result.version).toBe(1); + expect(result.sizeBytes).toBeGreaterThan(0); + expect(result.registryPath).toContain(registryDir); + expect(result.dependencyIds).toEqual([]); + expect(result.artifact).toEqual(VALID_AGENTBOM); + }); + + it('pulls a published MCP Posture artifact', () => { + const casId = publish(VALID_MCP_POSTURE); + const result = pullArtifact(casId, registryDir); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + expect(result.artifactType).toBe('mcp-posture'); + expect(result.integrityVerified).toBe(true); + }); + + it('pulls a published Trust Passport artifact', () => { + const casId = publish(VALID_TRUST_PASSPORT); + const result = pullArtifact(casId, registryDir); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + expect(result.artifactType).toBe('trust-passport'); + expect(result.integrityVerified).toBe(true); + }); + + it('pulls by tag label and reports resolvedVia tag', () => { + const casId = publish(VALID_AGENTBOM, 'latest'); + const result = pullArtifact('latest', registryDir); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.casId).toBe(casId); + expect(result.resolvedVia).toBe('tag'); + expect(result.viaTag).toBe('latest'); + expect(result.integrityVerified).toBe(true); + }); + + it('returns an error string for an unresolvable artifact id', () => { + const result = pullArtifact('unknown-tag', registryDir); + expect(typeof result).toBe('string'); + expect(result).toContain('could not be resolved'); + }); + + it('returns an error string when the CAS id is absent from the registry', () => { + const result = pullArtifact(`sha256:${'0'.repeat(64)}`, registryDir); + expect(typeof result).toBe('string'); + expect(result).toContain('not present in registry'); + }); + + it('reports integrity failure (but still returns a result) when content is tampered', () => { + const casId = publish(VALID_AGENTBOM); + const objectPath = objectPathForCasId(casId, registryDir); + writeFileSync(objectPath, JSON.stringify({ ...VALID_AGENTBOM, tampered: true }), 'utf-8'); + + const result = pullArtifact(casId, registryDir); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.integrityVerified).toBe(false); + expect(result.computedCasId).not.toBe(casId); + expect(result.casId).toBe(casId); + }); + + it('returns an error string when the stored object is corrupt JSON', () => { + const casId = publish(VALID_AGENTBOM); + writeFileSync(objectPathForCasId(casId, registryDir), '{ not valid json', 'utf-8'); + const result = pullArtifact(casId, registryDir); + expect(typeof result).toBe('string'); + expect(result).toContain('not valid JSON'); + }); + + it('extracts distribution.supersedes as dependency ids', () => { + const dep = { ...VALID_AGENTBOM, distribution: { supersedes: ['sha256:predecessor'] } }; + const casId = publish(dep); + const result = pullArtifact(casId, registryDir); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + expect(result.dependencyIds).toEqual(['sha256:predecessor']); + expect(result.resolvedDependencies).toEqual([]); + }); + + it('resolves dependencies transitively when withDeps is set', () => { + const predecessorCasId = publish(VALID_AGENTBOM); + const successor = { + ...VALID_MCP_POSTURE, + distribution: { supersedes: [predecessorCasId] }, + }; + const successorCasId = publish(successor); + + const result = pullArtifact(successorCasId, registryDir, true); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.dependencyIds).toEqual([predecessorCasId]); + expect(result.resolvedDependencies).toHaveLength(1); + const dep = result.resolvedDependencies[0]; + expect(dep.resolved).toBe(true); + expect(dep.casId).toBe(predecessorCasId); + expect(dep.integrityVerified).toBe(true); + expect(dep.artifactType).toBe('agentbom'); + expect(dep.version).toBe(1); + }); + + it('reports unresolved dependencies without throwing', () => { + // An unknown tag label cannot be resolved to a CAS id at all. + const successor = { + ...VALID_AGENTBOM, + distribution: { supersedes: ['unknown-tag-dep'] }, + }; + const successorCasId = publish(successor); + + const result = pullArtifact(successorCasId, registryDir, true); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + const dep = result.resolvedDependencies[0]; + expect(dep.resolved).toBe(false); + expect(dep.casId).toBeUndefined(); + expect(dep.error).toContain('not found'); + }); + + it('reports a dependency given as a CAS id that is absent from the registry', () => { + // A well-formed CAS id that simply has no stored object: we know the id, + // but the object is missing, so resolved=false with a "not present" error. + const absentCasId = `sha256:${'9'.repeat(64)}`; + const successor = { + ...VALID_AGENTBOM, + distribution: { supersedes: [absentCasId] }, + }; + const successorCasId = publish(successor); + + const result = pullArtifact(successorCasId, registryDir, true); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + const dep = result.resolvedDependencies[0]; + expect(dep.resolved).toBe(false); + expect(dep.casId).toBe(absentCasId); + expect(dep.error).toContain('not present'); + }); + + it('is cycle-safe — mutual supersession does not loop forever', () => { + // Publish B (plain) to learn its CAS id, publish A referencing B, then + // rewrite B's stored object so B also references A. This yields a + // dependency cycle A <-> B without a content-hashing fixed point. + const aBase = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'agent-a' }, + }; + const bBase = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'agent-b' }, + }; + + const bCasId = publish(bBase); + const aData = { ...aBase, distribution: { supersedes: [bCasId] } }; + const aCasId = publish(aData); + const bData = { ...bBase, distribution: { supersedes: [aCasId] } }; + writeFileSync(objectPathForCasId(bCasId, registryDir), JSON.stringify(bData), 'utf-8'); + + // Pulling A with deps must terminate (this assertion itself proves no + // infinite loop) and surface a cycle marker somewhere in the tree. + const result = pullArtifact(aCasId, registryDir, true); + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.resolvedDependencies[0].casId).toBe(bCasId); + + const errors = collectTreeErrors(result.resolvedDependencies); + expect(errors.some((e) => e.includes('cycle'))).toBe(true); + }, 10000); +}); + +// ---- resolveDependencies ---- + +describe('resolveDependencies', () => { + it('returns empty for no dependencies', () => { + expect(resolveDependencies([], registryDir, true)).toEqual([]); + }); + + it('does not recurse when recurse is false', () => { + const predecessorCasId = publish(VALID_AGENTBOM); + const successor = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'succ' }, + distribution: { supersedes: [predecessorCasId] }, + }; + const successorCasId = publish(successor); + + const deps = resolveDependencies([successorCasId], registryDir, false); + expect(deps).toHaveLength(1); + expect(deps[0].resolved).toBe(true); + // Without recursion, the child's own dependencies are not expanded. + expect(deps[0].dependencies).toBeUndefined(); + }); +}); + +// ---- parsePullArgs ---- + +describe('parsePullArgs', () => { + it('returns usage string for --help', () => { + const result = parsePullArgs(['--help']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for -h', () => { + const result = parsePullArgs(['-h']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for empty args', () => { + const result = parsePullArgs([]); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('parses required positional artifact id', () => { + const result = parsePullArgs(['sha256:abc']); + expect(typeof result).not.toBe('string'); + const config = result as PullConfig; + expect(config.artifactId).toBe('sha256:abc'); + expect(config.registryDir).toContain('.trust-registry'); + expect(config.withDeps).toBe(false); + expect(config.outputPath).toBeUndefined(); + }); + + it('parses --registry flag', () => { + const result = parsePullArgs(['latest', '--registry', '/my-registry']); + expect(typeof result).not.toBe('string'); + const config = result as PullConfig; + expect(config.registryDir).toBe('/my-registry'); + }); + + it('parses --output flag', () => { + const result = parsePullArgs(['latest', '--output', './out.json']); + expect(typeof result).not.toBe('string'); + const config = result as PullConfig; + expect(config.outputPath).toContain('out.json'); + }); + + it('parses --with-deps flag', () => { + const result = parsePullArgs(['latest', '--with-deps']); + expect(typeof result).not.toBe('string'); + const config = result as PullConfig; + expect(config.withDeps).toBe(true); + }); + + it('parses all flags together', () => { + const result = parsePullArgs([ + 'latest', + '--registry', + './reg', + '--output', + './out.json', + '--with-deps', + ]); + expect(typeof result).not.toBe('string'); + const config = result as PullConfig; + expect(config.artifactId).toBe('latest'); + expect(config.registryDir).toContain('reg'); + expect(config.outputPath).toContain('out.json'); + expect(config.withDeps).toBe(true); + }); + + it('returns error for unknown flag', () => { + const result = parsePullArgs(['latest', '--bogus']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --registry without value', () => { + const result = parsePullArgs(['latest', '--registry']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --output without value', () => { + const result = parsePullArgs(['latest', '--output']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); +}); + +// ---- Integration: pull command via runCommand ---- + +import { runCommand } from './index.js'; + +describe('pull command via runCommand', () => { + it('shows help for pull --help', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['pull', '--help']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage:'); + expect(output).toContain(''); + expect(output).toContain('--registry'); + expect(output).toContain('--with-deps'); + expect(output).toContain('--output'); + }); + + it('returns 1 for pull with unknown flag', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['pull', 'latest', '--bogus']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('unknown argument'); + }); + + it('returns 1 for an unresolvable artifact id', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['pull', 'unknown-tag', '--registry', registryDir]); + expect(result).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 when the CAS id is missing from the registry', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['pull', `sha256:${'0'.repeat(64)}`, '--registry', registryDir]); + expect(result).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 0 and prints JSON for a valid pull by CAS id', () => { + const casId = publish(VALID_AGENTBOM); + const spy = spyOn(console, 'log'); + const result = runCommand(['pull', casId, '--registry', registryDir]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain(casId); + expect(output).toContain('"integrityVerified": true'); + expect(output).toContain('agentbom'); + }); + + it('returns 0 for a valid pull by tag', () => { + publish(VALID_MCP_POSTURE, 'stable'); + const spy = spyOn(console, 'log'); + const result = runCommand(['pull', 'stable', '--registry', registryDir]); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('mcp-posture'); + expect(output).toContain('"resolvedVia": "tag"'); + }); + + it('exits non-zero when integrity verification fails', () => { + const casId = publish(VALID_AGENTBOM); + writeFileSync( + objectPathForCasId(casId, registryDir), + JSON.stringify({ tampered: true }), + 'utf-8', + ); + const errSpy = spyOn(console, 'error'); + const result = runCommand(['pull', casId, '--registry', registryDir]); + expect(result).toBe(1); + const output = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('integrity verification failed'); + }); + + it('writes the artifact to --output and still reports metadata', () => { + const casId = publish(VALID_AGENTBOM); + const outPath = join(tmpDir, 'pulled.json'); + const spy = spyOn(console, 'log'); + const result = runCommand(['pull', casId, '--registry', registryDir, '--output', outPath]); + expect(result).toBe(0); + expect(existsSync(outPath)).toBe(true); + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(written).toEqual(VALID_AGENTBOM); + expect(spy).toHaveBeenCalled(); + }); + + it('resolves dependencies via --with-deps', () => { + const predecessorCasId = publish(VALID_AGENTBOM); + const successor = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'succ-cli' }, + distribution: { supersedes: [predecessorCasId] }, + }; + const successorCasId = publish(successor); + + const spy = spyOn(console, 'log'); + const result = runCommand(['pull', successorCasId, '--registry', registryDir, '--with-deps']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain(predecessorCasId); + expect(output).toContain('"resolvedDependencies"'); + }); +}); diff --git a/packages/agentbom-cli/src/trust-pull.ts b/packages/agentbom-cli/src/trust-pull.ts new file mode 100644 index 0000000..ec3da34 --- /dev/null +++ b/packages/agentbom-cli/src/trust-pull.ts @@ -0,0 +1,512 @@ +/** + * `trust-cli pull ` — retrieves trust artifacts from the registry + * by CAS identifier with integrity verification and dependency resolution. + * + * Resolves an artifact ID (a content-addressable `sha256:` identifier or a tag + * label), locates the stored object in the local registry, recomputes its + * SHA-256 digest to verify content integrity against the CAS identifier, and + * resolves declared dependencies (the `distribution.supersedes` chain and any + * generic `dependencies` references). With `--with-deps` the declared + * dependencies are themselves pulled and integrity-verified transitively + * (cycle-safe via a visited set). + * + * The registry layout is owned by {@link ./trust-publish.ts}: objects are stored + * at `/objects///.json`, the version + * ledger lives at `/manifest.json`, and tag pointers live at + * `/tags/.json`. This module reads that layout and must not + * diverge from it. + * + * Usage: + * trust-cli pull [--registry ] [--output ] [--with-deps] [--help] + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + type ArtifactType, + computeCasId, + detectArtifactType, + readRegistryManifest, +} from './trust-publish.js'; + +// ---- Types ---- + +/** Resolved configuration for the pull command. */ +export interface PullConfig { + /** Artifact identifier — either a `sha256:` CAS id or a tag label. */ + artifactId: string; + /** Path to the local registry directory (default: ~/.trust-registry). */ + registryDir: string; + /** Optional output file path. When set, the retrieved artifact is written here. */ + outputPath?: string; + /** Recursively resolve and verify declared dependencies. */ + withDeps: boolean; +} + +/** Outcome of resolving a single declared dependency. */ +export interface ResolvedDependency { + /** The dependency artifact id as declared by the parent artifact. */ + artifactId: string; + /** Whether the dependency was found in the registry. */ + resolved: boolean; + /** Resolved CAS identifier, when found. */ + casId?: string; + /** Detected artifact type, when found. */ + artifactType?: ArtifactType; + /** Whether the dependency's content hash matched its CAS id. */ + integrityVerified?: boolean; + /** Version recorded in the registry manifest, when found. */ + version?: number; + /** Reason the dependency could not be resolved, when unresolved. */ + error?: string; + /** Transitively resolved dependencies (populated only with `--with-deps`). */ + dependencies?: ResolvedDependency[]; +} + +/** Result of a pull operation. */ +export interface PullResult { + /** Resolved CAS identifier (sha256:hex). */ + casId: string; + /** How the artifact id was resolved. */ + resolvedVia: 'cas-id' | 'tag'; + /** Tag label used to resolve, if any. */ + viaTag?: string; + /** Artifact type detected during validation. */ + artifactType: ArtifactType; + /** Immutable version recorded in the registry manifest, if present. */ + version?: number; + /** Size of the stored artifact in bytes. */ + sizeBytes: number; + /** Whether the recomputed digest matched the CAS identifier. */ + integrityVerified: boolean; + /** Digest recomputed from the stored content. */ + computedCasId: string; + /** Absolute path to the artifact file in the registry. */ + registryPath: string; + /** Artifact ids this artifact declares as dependencies / superseded. */ + dependencyIds: string[]; + /** Resolved dependencies (empty unless `withDeps`). */ + resolvedDependencies: ResolvedDependency[]; + /** The parsed artifact content. */ + artifact: Record; +} + +// ---- Pure helpers ---- + +/** + * Compute the registry object path for a CAS identifier. + * + * Mirrors the layout written by {@link ./trust-publish.ts}: the hex digest is + * sharded into `//.json` under `objects/`. + */ +export function objectPathForCasId(casId: string, registryDir: string): string { + const hexDigest = casId.replace(/^sha256:/, ''); + return resolve( + registryDir, + 'objects', + hexDigest.slice(0, 2), + hexDigest.slice(2, 4), + `${hexDigest}.json`, + ); +} + +/** + * Test whether an artifact id is a CAS identifier (`sha256:`). + */ +export function isCasId(artifactId: string): boolean { + return /^sha256:[a-f0-9]{64}$/.test(artifactId); +} + +/** + * Resolve a tag label to its CAS identifier by reading the registry tag pointer. + * + * Returns the CAS id, or `null` if the tag is unknown or its pointer is malformed. + */ +export function resolveTagToCasId(registryDir: string, tag: string): string | null { + const tagPath = resolve(registryDir, 'tags', `${tag}.json`); + if (!existsSync(tagPath)) return null; + try { + const data = JSON.parse(readFileSync(tagPath, 'utf-8')); + if ( + typeof data === 'object' && + data !== null && + !Array.isArray(data) && + typeof data.cas_id === 'string' && + isCasId(data.cas_id) + ) { + return data.cas_id; + } + } catch { + // Corrupted tag pointer — treat as unknown + } + return null; +} + +/** + * Resolve an artifact id (CAS id or tag label) to a CAS identifier. + * + * CAS ids are used verbatim; anything else is treated as a tag label and + * resolved via the registry's tag pointers. Returns `null` when the id cannot + * be resolved. + */ +export function resolveArtifactId( + artifactId: string, + registryDir: string, +): { casId: string; viaTag?: string } | null { + if (isCasId(artifactId)) { + return { casId: artifactId }; + } + const casId = resolveTagToCasId(registryDir, artifactId); + if (casId) return { casId, viaTag: artifactId }; + return null; +} + +/** + * Recompute the SHA-256 digest of stored content and compare it to the expected + * CAS identifier. Integrity verification for content-addressable retrieval. + */ +export function verifyIntegrity( + rawContent: string, + expectedCasId: string, +): { ok: boolean; computedCasId: string } { + const computedCasId = computeCasId(rawContent); + return { ok: computedCasId === expectedCasId, computedCasId }; +} + +/** + * Extract declared dependency artifact ids from a parsed artifact. + * + * Recognizes: + * - `distribution.supersedes` (AgentBOM artifact lifecycle — artifact ids this + * publication supersedes) + * - a top-level `dependencies` array (generic, forward-compatible — artifact + * ids or `{ id }` objects the artifact depends on) + * + * Returns a de-duplicated list preserving first-seen order. + */ +export function extractDependencyIds(data: Record): string[] { + const ids: string[] = []; + const seen = new Set(); + + const push = (id: unknown): void => { + if (typeof id === 'string' && id.length > 0 && !seen.has(id)) { + seen.add(id); + ids.push(id); + } + }; + + const distribution = data.distribution as Record | undefined; + if (distribution && Array.isArray(distribution.supersedes)) { + for (const id of distribution.supersedes) push(id); + } + + if (Array.isArray(data.dependencies)) { + for (const dep of data.dependencies) { + if (typeof dep === 'string') { + push(dep); + } else if (dep && typeof dep === 'object' && !Array.isArray(dep)) { + const id = (dep as Record).id; + push(id); + } + } + } + + return ids; +} + +/** + * Resolve a single declared dependency, optionally recursing into its own + * dependencies. Cycle-safe via the `visited` set of already-resolved CAS ids. + * + * Pure with respect to the filesystem: reads only. Never throws — unresolved or + * corrupt dependencies are reported via the `error`/`resolved` fields. + */ +function resolveSingleDependency( + depId: string, + registryDir: string, + visited: Set, + recurse: boolean, +): ResolvedDependency { + const base: ResolvedDependency = { artifactId: depId, resolved: false }; + + const resolved = resolveArtifactId(depId, registryDir); + if (!resolved) { + return { ...base, error: 'artifact id not found in registry (not a CAS id or known tag)' }; + } + + const casId = resolved.casId; + if (visited.has(casId)) { + return { ...base, resolved: true, casId, error: 'cycle — already resolved' }; + } + visited.add(casId); + + const objectPath = objectPathForCasId(casId, registryDir); + if (!existsSync(objectPath)) { + return { ...base, resolved: false, casId, error: 'object not present in registry' }; + } + + let raw: string; + try { + raw = readFileSync(objectPath, 'utf-8'); + } catch { + return { ...base, resolved: false, casId, error: 'cannot read object file' }; + } + + const { ok, computedCasId } = verifyIntegrity(raw, casId); + let parsed: Record; + try { + const data = JSON.parse(raw); + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { + ...base, + resolved: true, + casId, + integrityVerified: false, + error: 'object is not a JSON object', + }; + } + parsed = data as Record; + } catch { + return { + ...base, + resolved: true, + casId, + integrityVerified: false, + error: 'object is not valid JSON', + }; + } + + const manifest = readRegistryManifest(registryDir); + const result: ResolvedDependency = { + ...base, + resolved: true, + casId, + artifactType: detectArtifactType(parsed), + integrityVerified: ok, + version: manifest[casId], + }; + + if (recurse) { + const childIds = extractDependencyIds(parsed); + result.dependencies = childIds.map((id) => + resolveSingleDependency(id, registryDir, visited, recurse), + ); + } + + return result; +} + +/** + * Resolve all declared dependencies of an artifact. + * + * When `recurse` is true, each dependency's own dependencies are resolved + * transitively (cycle-safe). Returns one {@link ResolvedDependency} per + * declared id, in declaration order. + */ +export function resolveDependencies( + dependencyIds: string[], + registryDir: string, + recurse: boolean, +): ResolvedDependency[] { + const visited = new Set(); + return dependencyIds.map((id) => resolveSingleDependency(id, registryDir, visited, recurse)); +} + +/** + * Pull an artifact from the local registry. + * + * This is the core pure-logic function. It: + * 1. Resolves the artifact id (CAS id or tag) to a CAS identifier + * 2. Locates and reads the stored object + * 3. Recomputes the SHA-256 digest and verifies it against the CAS id + * 4. Looks up the immutable version in the registry manifest + * 5. Detects the artifact type + * 6. Extracts declared dependency ids + * 7. Optionally resolves dependencies transitively (when `withDeps`) + * + * Returns a {@link PullResult} on success, or an error string when the artifact + * cannot be found, read, or parsed. Integrity mismatch is NOT a hard error + * here — it is reported via `integrityVerified: false` so callers can inspect + * the discrepancy; the CLI layer treats it as a non-zero exit. + */ +export function pullArtifact( + artifactId: string, + registryDir: string, + withDeps = false, +): PullResult | string { + const resolved = resolveArtifactId(artifactId, registryDir); + if (!resolved) { + return `Error: artifact id "${artifactId}" could not be resolved (not a CAS id or known tag)`; + } + + const casId = resolved.casId; + const objectPath = objectPathForCasId(casId, registryDir); + if (!existsSync(objectPath)) { + return `Error: artifact "${casId}" is not present in registry "${registryDir}"`; + } + + let raw: string; + try { + raw = readFileSync(objectPath, 'utf-8'); + } catch { + return `Error: cannot read artifact at "${objectPath}"`; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return `Error: artifact at "${objectPath}" is not valid JSON`; + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return `Error: artifact at "${objectPath}" is not a JSON object`; + } + const artifact = data as Record; + + const { ok, computedCasId } = verifyIntegrity(raw, casId); + const manifest = readRegistryManifest(registryDir); + const dependencyIds = extractDependencyIds(artifact); + + let sizeBytes = 0; + try { + sizeBytes = Buffer.byteLength(raw, 'utf-8'); + } catch { + sizeBytes = raw.length; + } + + const result: PullResult = { + casId, + resolvedVia: resolved.viaTag ? 'tag' : 'cas-id', + viaTag: resolved.viaTag, + artifactType: detectArtifactType(artifact), + version: manifest[casId], + sizeBytes, + integrityVerified: ok, + computedCasId, + registryPath: objectPath, + dependencyIds, + resolvedDependencies: withDeps ? resolveDependencies(dependencyIds, registryDir, true) : [], + artifact, + }; + + return result; +} + +// ---- CLI command ---- + +const PULL_USAGE = [ + 'Usage: agent-trust pull [options]', + '', + 'Retrieve a trust artifact from the local distribution registry by CAS', + 'identifier, with integrity verification and dependency resolution.', + '', + 'Arguments:', + ' CAS identifier (sha256:) or a tag label (e.g. latest)', + '', + 'Options:', + ' --registry Path to the local registry directory', + ' (default: ~/.trust-registry)', + ' --output Write the retrieved artifact JSON to this file', + ' --with-deps Recursively resolve and verify declared dependencies', + ' --help, -h Show this help message', + '', + 'Examples:', + ' agent-trust pull sha256:abc123...', + ' agent-trust pull latest', + ' agent-trust pull latest --registry ./my-registry --output agentbom.json', + ' agent-trust pull sha256:abc123... --with-deps', + '', + 'Output:', + ' On success, prints a JSON object with the resolved CAS id, artifact type,', + ' version, integrity status, and declared dependencies. Exits non-zero if', + ' the artifact is missing or its stored content fails integrity verification.', +].join('\n'); + +/** + * Parse pull command arguments into a {@link PullConfig}. + * Returns the config on success, or a usage/error string on failure. + */ +export function parsePullArgs(args: string[]): PullConfig | string { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + return PULL_USAGE; + } + + const artifactId = args[0]; + let registryDir: string | undefined; + let outputPath: string | undefined; + let withDeps = false; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if (arg === '--registry' && next) { + registryDir = next; + i++; + } else if (arg === '--output' && next) { + outputPath = next; + i++; + } else if (arg === '--with-deps') { + withDeps = true; + } else { + return `Error: unknown argument "${arg}"`; + } + } + + const homeRegistry = resolve( + process.env.HOME ?? process.env.USERPROFILE ?? '~', + '.trust-registry', + ); + + return { + artifactId, + registryDir: registryDir ? resolve(registryDir) : homeRegistry, + outputPath: outputPath ? resolve(outputPath) : undefined, + withDeps, + }; +} + +/** + * CLI entry point for `agent-trust pull`. + * + * Returns exit code (0 = success, 1 = error or integrity failure). + */ +export function pullCommand(args: string[]): number { + const parsed = parsePullArgs(args); + if (typeof parsed === 'string') { + if (parsed.startsWith('Usage:')) { + console.log(parsed); + return 0; + } + console.error(parsed); + return 1; + } + + const config = parsed; + + const result = pullArtifact(config.artifactId, config.registryDir, config.withDeps); + if (typeof result === 'string') { + console.error(result); + return 1; + } + + if (!result.integrityVerified) { + console.error( + `Error: integrity verification failed for "${result.casId}" — ` + + `expected ${result.casId} but stored content hashes to ${result.computedCasId}`, + ); + return 1; + } + + // Persist the artifact if an output path was requested. + if (config.outputPath) { + try { + writeFileSync(config.outputPath, JSON.stringify(result.artifact, null, 2), 'utf-8'); + } catch { + console.error(`Error: cannot write artifact to "${config.outputPath}"`); + return 1; + } + } + + console.log(JSON.stringify(result, null, 2)); + return 0; +} diff --git a/packages/agentbom-cli/src/trust-subscribe.test.ts b/packages/agentbom-cli/src/trust-subscribe.test.ts new file mode 100644 index 0000000..7a05230 --- /dev/null +++ b/packages/agentbom-cli/src/trust-subscribe.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + findAgentBOMFiles, + notifyCallback, + parseSubscribeArgs, + runDriftCheck, + type SubscribeConfig, +} from './trust-subscribe.js'; + +// ---- Fixtures ---- + +const BASELINE_BOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-sub', + agent_name: 'Test Subscribe Agent', + deployment_context: 'development', + generated_at: '2026-01-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read'], + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: [], +}; + +const DRIFTED_BOM = { + ...BASELINE_BOM, + identity: { + ...BASELINE_BOM.identity, + generated_at: '2026-01-02T00:00:00Z', + }, + tool_layer: [ + ...BASELINE_BOM.tool_layer, + { + tool_id: 'fs-write', + tool_name: 'write_file', + source: 'builtin', + permissions: ['fs:write'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read', 'fs:write'], + data_access: ['local_workspace'], + credential_references: [], + }, +}; + +// ---- Test setup ---- + +let tmpDir: string; + +beforeEach(() => { + tmpDir = join( + tmpdir(), + `trust-subscribe-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): string { + const p = join(tmpDir, name); + writeFileSync(p, JSON.stringify(data), 'utf-8'); + return p; +} + +// ---- parseSubscribeArgs ---- + +describe('parseSubscribeArgs', () => { + it('returns usage string for --help', () => { + const result = parseSubscribeArgs(['--help']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for -h', () => { + const result = parseSubscribeArgs(['-h']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns error for empty args', () => { + const result = parseSubscribeArgs([]); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns error when --baseline is missing', () => { + const result = parseSubscribeArgs(['my-agent']); + expect(typeof result).toBe('string'); + expect(result).toContain('--baseline'); + }); + + it('returns error for unknown flag', () => { + const result = parseSubscribeArgs(['my-agent', '--baseline', '/tmp/b.json', '--bogus']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('parses required args and returns config', () => { + const result = parseSubscribeArgs(['my-agent', '--baseline', '/tmp/b.json']); + expect(typeof result).not.toBe('string'); + const config = result as SubscribeConfig; + expect(config.agentIdentity).toBe('my-agent'); + expect(config.baselinePath).toBe('/tmp/b.json'); + expect(config.watchDir).toBe('/tmp'); + expect(config.intervalSeconds).toBe(30); + expect(config.once).toBe(false); + expect(config.callbackUrl).toBeUndefined(); + }); + + it('parses all optional flags', () => { + const result = parseSubscribeArgs([ + 'agent-x', + '--baseline', + '/b.json', + '--watch', + '/artifacts', + '--callback', + 'https://hooks.example.com/drift', + '--interval', + '60', + '--once', + ]); + expect(typeof result).not.toBe('string'); + const config = result as SubscribeConfig; + expect(config.agentIdentity).toBe('agent-x'); + expect(config.baselinePath).toBe('/b.json'); + expect(config.watchDir).toBe('/artifacts'); + expect(config.callbackUrl).toBe('https://hooks.example.com/drift'); + expect(config.intervalSeconds).toBe(60); + expect(config.once).toBe(true); + }); + + it('rejects --interval below minimum of 5', () => { + const result = parseSubscribeArgs(['a', '--baseline', '/b.json', '--interval', '3']); + expect(typeof result).toBe('string'); + expect(result).toContain('≥ 5'); + }); + + it('rejects non-numeric --interval', () => { + const result = parseSubscribeArgs(['a', '--baseline', '/b.json', '--interval', 'abc']); + expect(typeof result).toBe('string'); + expect(result).toContain('integer'); + }); + + it('resolves --watch relative to CWD', () => { + const result = parseSubscribeArgs(['a', '--baseline', '/b.json', '--watch', 'artifacts']); + expect(typeof result).not.toBe('string'); + const config = result as SubscribeConfig; + expect(config.watchDir).toBe(resolve('artifacts')); + }); +}); + +// ---- findAgentBOMFiles ---- + +describe('findAgentBOMFiles', () => { + it('finds matching AgentBOM files in a directory', () => { + writeJson('baseline.json', BASELINE_BOM); + writeJson('current.json', DRIFTED_BOM); + // Non-matching agent + writeJson('other-agent.json', { + ...BASELINE_BOM, + identity: { ...BASELINE_BOM.identity, agent_id: 'other-agent' }, + }); + + const files = findAgentBOMFiles(tmpDir, 'test-agent-sub'); + expect(files.length).toBe(2); + expect(files).toContainEqual(join(tmpDir, 'baseline.json')); + expect(files).toContainEqual(join(tmpDir, 'current.json')); + }); + + it('returns empty for non-existent directory', () => { + const files = findAgentBOMFiles('/nonexistent/path/xyz', 'agent'); + expect(files).toEqual([]); + }); + + it('skips non-JSON files', () => { + writeJson('valid.json', BASELINE_BOM); + writeFileSync(join(tmpDir, 'readme.txt'), 'not json', 'utf-8'); + + const files = findAgentBOMFiles(tmpDir, 'test-agent-sub'); + expect(files.length).toBe(1); + }); + + it('skips invalid JSON files', () => { + writeJson('valid.json', BASELINE_BOM); + writeFileSync(join(tmpDir, 'bad.json'), '{ not valid json', 'utf-8'); + + const files = findAgentBOMFiles(tmpDir, 'test-agent-sub'); + expect(files.length).toBe(1); + }); + + it('skips files that are not valid AgentBOM schema', () => { + writeJson('valid.json', BASELINE_BOM); + writeJson('invalid.json', { foo: 'bar' }); + + const files = findAgentBOMFiles(tmpDir, 'test-agent-sub'); + expect(files.length).toBe(1); + }); + + it('returns empty when no files match agent identity', () => { + writeJson('other.json', BASELINE_BOM); + + const files = findAgentBOMFiles(tmpDir, 'different-agent'); + expect(files).toEqual([]); + }); +}); + +// ---- runDriftCheck ---- + +describe('runDriftCheck', () => { + it('detects no drift when baseline is the only file', () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath, + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.hasDrift).toBe(false); + expect(result.alert.isEmpty()).toBe(true); + }); + + it('detects drift when a modified AgentBOM is present', () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + writeJson('current.json', DRIFTED_BOM); + + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath, + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.hasDrift).toBe(true); + expect(result.alert.isEmpty()).toBe(false); + expect(result.scannedFiles).toContainEqual(join(tmpDir, 'current.json')); + // Should have tool_added and scope_expanded events + const categories = result.alert.events.map((e) => e.category); + expect(categories).toContain('tool_added'); + expect(categories).toContain('scope_expanded'); + }); + + it('detects no drift when current matches baseline exactly', () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + // Write an identical copy with a different name + writeJson('copy.json', { ...BASELINE_BOM }); + + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath, + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.hasDrift).toBe(false); + expect(result.alert.isEmpty()).toBe(true); + }); + + it('returns error message when baseline cannot be read', () => { + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath: '/nonexistent/baseline.json', + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.hasDrift).toBe(false); + expect(result.formatted).toContain('Error'); + }); + + it('ignores files that do not match the agent identity', () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + writeJson('other.json', { + ...DRIFTED_BOM, + identity: { ...DRIFTED_BOM.identity, agent_id: 'other-agent' }, + }); + + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath, + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.hasDrift).toBe(false); + }); + + it('formats alert output with agent_id and timestamps', () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + writeJson('current.json', DRIFTED_BOM); + + const config: SubscribeConfig = { + agentIdentity: 'test-agent-sub', + baselinePath, + watchDir: tmpDir, + once: true, + }; + + const result = runDriftCheck(config); + expect(result.formatted).toContain('test-agent-sub'); + expect(result.formatted).toContain('2026-01-01T00:00:00Z'); + expect(result.formatted).toContain('2026-01-02T00:00:00Z'); + }); +}); + +// ---- notifyCallback ---- + +describe('notifyCallback', () => { + it('returns false for an unreachable URL', async () => { + const result = await notifyCallback('http://localhost:1/impossible', { + hasDrift: true, + alert: { + agent_id: 'a', + baseline_at: '2026-01-01T00:00:00Z', + current_at: '2026-01-02T00:00:00Z', + events: [], + hasHighSeverity: () => false, + isEmpty: () => true, + }, + formatted: 'test', + scannedFiles: [], + }); + expect(result).toBe(false); + }); +}); + +// ---- Integration: subscribe command via runCommand ---- + +import { runCommand } from './index.js'; + +describe('subscribe command via runCommand', () => { + it('shows help for subscribe --help', async () => { + const spy = spyOn(console, 'log'); + // runCommand returns Promise for subscribe + const result = await (runCommand(['subscribe', '--help']) as Promise); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage:'); + expect(output).toContain('--baseline'); + expect(output).toContain('--once'); + expect(output).toContain('--callback'); + }); + + it('returns 1 for subscribe without --baseline', async () => { + const spy = spyOn(console, 'error'); + const result = await (runCommand(['subscribe', 'my-agent']) as Promise); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('--baseline'); + }); + + it('returns 1 for subscribe with non-existent baseline', async () => { + const spy = spyOn(console, 'error'); + const result = await (runCommand([ + 'subscribe', + 'my-agent', + '--baseline', + '/nonexistent/baseline.json', + '--once', + ]) as Promise); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('cannot read baseline'); + }); + + it('returns 0 for subscribe --once with no drift', async () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + const spyLog = spyOn(console, 'log'); + + const result = await (runCommand([ + 'subscribe', + 'test-agent-sub', + '--baseline', + baselinePath, + '--once', + ]) as Promise); + expect(result).toBe(0); + const output = spyLog.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('monitoring agent'); + expect(output).toContain('single-check'); + }); + + it('returns 1 for subscribe --once when drift is detected', async () => { + const baselinePath = writeJson('baseline.json', BASELINE_BOM); + writeJson('current.json', DRIFTED_BOM); + const spyLog = spyOn(console, 'log'); + + const result = await (runCommand([ + 'subscribe', + 'test-agent-sub', + '--baseline', + baselinePath, + '--once', + ]) as Promise); + expect(result).toBe(1); + const output = spyLog.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('DRIFT DETECTED'); + }); + + it('rejects unknown subscribe flags', async () => { + const spy = spyOn(console, 'error'); + const result = await (runCommand([ + 'subscribe', + 'a', + '--baseline', + '/b.json', + '--unknown-flag', + ]) as Promise); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('unknown argument'); + }); + + it('rejects --interval below 5', async () => { + const spy = spyOn(console, 'error'); + const result = await (runCommand([ + 'subscribe', + 'a', + '--baseline', + '/b.json', + '--interval', + '2', + ]) as Promise); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('≥ 5'); + }); +}); diff --git a/packages/agentbom-cli/src/trust-subscribe.ts b/packages/agentbom-cli/src/trust-subscribe.ts new file mode 100644 index 0000000..99f1ad2 --- /dev/null +++ b/packages/agentbom-cli/src/trust-subscribe.ts @@ -0,0 +1,464 @@ +/** + * `trust-cli subscribe ` — continuous monitoring for trust artifact + * updates from a specific agent publisher. + * + * Watches a directory for AgentBOM files belonging to the given agent identity, + * compares them against a baseline snapshot, and produces drift alerts via + * {@link classifyDriftEvents}. Optionally POSTs notifications to a callback URL. + * + * Usage: + * trust-cli subscribe --baseline [--watch ] \ + * [--callback ] [--interval ] [--once] + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + classifyDriftEvents, + type DriftAlert, + diffAgentBOM, + formatDriftAlert, + validateAgentBOM, +} from '@wasmagent/agentbom-core'; + +// ---- Types ---- + +/** Resolved configuration for the subscribe command. */ +export interface SubscribeConfig { + /** Agent identity (agent_id) to monitor. */ + agentIdentity: string; + /** Path to the baseline AgentBOM snapshot. */ + baselinePath: string; + /** Directory to watch for updated artifacts (default: directory containing baseline). */ + watchDir: string; + /** Optional callback URL for drift notifications (best-effort HTTP POST). */ + callbackUrl?: string; + /** Polling interval in seconds (default 30). */ + intervalSeconds: number; + /** Run a single check and exit instead of polling. */ + once: boolean; +} + +/** Result of a single drift-check cycle. */ +export interface DriftCheckResult { + /** Whether drift was detected in this cycle. */ + hasDrift: boolean; + /** The drift alert (empty if no drift). */ + alert: DriftAlert; + /** Human-readable formatted alert string. */ + formatted: string; + /** Files that were scanned. */ + scannedFiles: string[]; +} + +// ---- Pure helpers ---- + +/** + * Read and parse a JSON file into a typed record. + * Returns `null` on any failure. + */ +function readJsonRecord(filePath: string): Record | null { + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + try { + const data = JSON.parse(raw); + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + return data as Record; + } + return null; + } catch { + return null; + } +} + +/** + * Extract the agent_id from a parsed AgentBOM record, if present. + */ +function extractAgentId(data: Record): string | undefined { + const identity = data.identity as Record | undefined; + if (identity && typeof identity.agent_id === 'string') { + return identity.agent_id; + } + return undefined; +} + +/** + * Extract the generated_at timestamp from a parsed AgentBOM record. + */ +function extractTimestamp(data: Record): string { + const identity = data.identity as Record | undefined; + if (identity && typeof identity.generated_at === 'string') { + return identity.generated_at; + } + return new Date().toISOString(); +} + +/** + * Scan a directory for JSON files and return paths of those that parse as + * valid AgentBOM documents matching the given agent identity. + */ +export function findAgentBOMFiles(dir: string, agentIdentity: string): string[] { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + + const matches: string[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const fullPath = resolve(dir, entry); + try { + if (!statSync(fullPath).isFile()) continue; + } catch { + continue; + } + + const data = readJsonRecord(fullPath); + if (!data) continue; + + // Must be a valid AgentBOM + const validation = validateAgentBOM(data); + if (!validation.valid) continue; + + // Must match the target agent identity + const id = extractAgentId(data); + if (id === agentIdentity) { + matches.push(fullPath); + } + } + + return matches; +} + +/** + * Run a single drift-check cycle: find artifacts matching the agent identity + * in the watch directory, diff each against the baseline, and produce a + * combined drift alert. + * + * Pure function — no side effects. Returns a structured result for the caller + * to log or send via callback. + */ +export function runDriftCheck(config: SubscribeConfig): DriftCheckResult { + // Load baseline + const baselineData = readJsonRecord(config.baselinePath); + if (!baselineData) { + // Return an empty result — the caller should handle baseline-missing before calling + return { + hasDrift: false, + alert: { + agent_id: config.agentIdentity, + baseline_at: '', + current_at: new Date().toISOString(), + events: [], + hasHighSeverity: () => false, + isEmpty: () => true, + }, + formatted: `Error: cannot read baseline AgentBOM at "${config.baselinePath}"`, + scannedFiles: [], + }; + } + + const baselineAgentId = extractAgentId(baselineData) ?? config.agentIdentity; + const baselineAt = extractTimestamp(baselineData); + + // Find current artifacts + const currentFiles = findAgentBOMFiles(config.watchDir, config.agentIdentity); + + // If the only file found is the baseline itself, no drift to detect + const nonBaselineFiles = currentFiles.filter((f) => resolve(f) !== resolve(config.baselinePath)); + + // Combine all events from all non-baseline files + const allAlerts: DriftAlert[] = []; + + for (const filePath of nonBaselineFiles) { + const currentData = readJsonRecord(filePath); + if (!currentData) continue; + + const currentAt = extractTimestamp(currentData); + const diff = diffAgentBOM(baselineData, currentData); + const alert = classifyDriftEvents(diff, baselineAgentId, baselineAt, currentAt); + allAlerts.push(alert); + } + + // Merge all alerts into one + const mergedEvents = allAlerts.flatMap((a) => a.events); + const latestAt = allAlerts.length > 0 ? allAlerts[allAlerts.length - 1].current_at : baselineAt; + + const mergedAlert: DriftAlert = { + agent_id: baselineAgentId, + baseline_at: baselineAt, + current_at: latestAt, + events: mergedEvents, + hasHighSeverity: () => + mergedEvents.some((e) => e.severity === 'high' || e.severity === 'critical'), + isEmpty: () => mergedEvents.length === 0, + }; + + return { + hasDrift: !mergedAlert.isEmpty(), + alert: mergedAlert, + formatted: formatDriftAlert(mergedAlert), + scannedFiles: currentFiles, + }; +} + +/** + * Send a drift notification to a callback URL via HTTP POST. + * + * Best-effort: logs warnings on failure but never throws. + */ +export async function notifyCallback( + callbackUrl: string, + result: DriftCheckResult, +): Promise { + try { + const payload = { + agent_id: result.alert.agent_id, + has_drift: result.hasDrift, + event_count: result.alert.events.length, + has_high_severity: result.alert.hasHighSeverity(), + alert: { + agent_id: result.alert.agent_id, + baseline_at: result.alert.baseline_at, + current_at: result.alert.current_at, + events: result.alert.events, + }, + scanned_files: result.scannedFiles, + notified_at: new Date().toISOString(), + }; + + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + console.warn(`Callback warning: HTTP ${response.status} from ${callbackUrl}`); + return false; + } + return true; + } catch (err) { + console.warn(`Callback warning: ${err instanceof Error ? err.message : String(err)}`); + return false; + } +} + +/** + * Sleep for the given number of milliseconds. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +// ---- CLI command ---- + +const SUBSCRIBE_USAGE = [ + 'Usage: agent-trust subscribe --baseline [options]', + '', + 'Set up continuous monitoring for trust artifact updates from a specific', + 'agent publisher. Watches for AgentBOM files matching the given identity,', + 'detects drift against a baseline, and optionally sends notification callbacks.', + '', + 'Arguments:', + ' Agent ID to monitor (matches AgentBOM identity.agent_id)', + '', + 'Required:', + ' --baseline Path to the baseline AgentBOM snapshot', + '', + 'Options:', + ' --watch Directory to watch for updated artifacts', + ' (default: directory containing the baseline file)', + ' --callback URL for drift notification callbacks (HTTP POST)', + ' --interval Polling interval in seconds (default: 30, min: 5)', + ' --once Run a single drift check and exit (no polling loop)', + ' --help, -h Show this help message', + '', + 'Examples:', + ' agent-trust subscribe my-agent --baseline ./baseline/agentbom.json --once', + ' agent-trust subscribe my-agent --baseline ./baseline.json --watch ./artifacts --interval 60', + ' agent-trust subscribe my-agent --baseline ./baseline.json --callback https://hooks.example.com/drift', +].join('\n'); + +/** + * Parse subscribe command arguments into a {@link SubscribeConfig}. + * Returns the config on success, or a usage string (error message) on failure. + */ +export function parseSubscribeArgs(args: string[]): SubscribeConfig | string { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + return SUBSCRIBE_USAGE; + } + + const agentIdentity = args[0]; + let baselinePath: string | undefined; + let watchDir: string | undefined; + let callbackUrl: string | undefined; + let intervalSeconds = 30; + let once = false; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if (arg === '--baseline' && next) { + baselinePath = next; + i++; + } else if (arg === '--watch' && next) { + watchDir = next; + i++; + } else if (arg === '--callback' && next) { + callbackUrl = next; + i++; + } else if (arg === '--interval' && next) { + const n = Number.parseInt(next, 10); + if (Number.isNaN(n) || n < 5) { + return `Error: --interval must be an integer ≥ 5, got "${next}"`; + } + intervalSeconds = n; + i++; + } else if (arg === '--once') { + once = true; + } else { + return `Error: unknown argument "${arg}"`; + } + } + + if (!baselinePath) { + return 'Error: --baseline is required'; + } + + const resolvedBaseline = resolve(baselinePath); + const resolvedWatchDir = watchDir ? resolve(watchDir) : resolve(resolvedBaseline, '..'); + + return { + agentIdentity, + baselinePath: resolvedBaseline, + watchDir: resolvedWatchDir, + callbackUrl, + intervalSeconds, + once, + }; +} + +/** + * CLI entry point for `agent-trust subscribe`. + * + * Returns exit code (0 = no drift / success, 1 = drift detected or error). + * In continuous mode, runs until interrupted (SIGINT/SIGTERM). + */ +export async function subscribeCommand(args: string[]): Promise { + const parsed = parseSubscribeArgs(args); + if (typeof parsed === 'string') { + if (parsed.startsWith('Usage:') || parsed.startsWith('Error:')) { + if (parsed.startsWith('Usage:')) { + console.log(parsed); + return 0; + } + console.error(parsed); + return 1; + } + // Should not happen but handle gracefully + console.error(parsed); + return 1; + } + + const config = parsed; + + // Validate baseline exists and is a valid AgentBOM + const baselineData = readJsonRecord(config.baselinePath); + if (!baselineData) { + console.error(`Error: cannot read baseline AgentBOM at "${config.baselinePath}"`); + return 1; + } + + const baselineValidation = validateAgentBOM(baselineData); + if (!baselineValidation.valid) { + console.error(`Error: baseline AgentBOM validation failed at "${config.baselinePath}":`); + for (const err of baselineValidation.errors) { + console.error(` - ${err}`); + } + return 1; + } + + const baselineAgentId = extractAgentId(baselineData) ?? config.agentIdentity; + + // Mode banner + const modeLabel = config.once + ? 'single-check' + : `continuous (interval: ${config.intervalSeconds}s)`; + console.log(`Trust Subscribe — monitoring agent "${baselineAgentId}"`); + console.log(` Baseline: ${config.baselinePath}`); + console.log(` Watch dir: ${config.watchDir}`); + console.log(` Callback: ${config.callbackUrl ?? '(none)'}`); + console.log(` Mode: ${modeLabel}`); + console.log(''); + + if (config.once) { + // Single-check mode + const result = runDriftCheck(config); + + if (result.hasDrift) { + console.log('DRIFT DETECTED'); + } + console.log(result.formatted); + + if (result.hasDrift && config.callbackUrl) { + console.log(`\nSending notification to ${config.callbackUrl}...`); + const sent = await notifyCallback(config.callbackUrl, result); + if (sent) { + console.log('Notification sent successfully.'); + } else { + console.warn('Notification delivery failed.'); + } + } + + return result.hasDrift ? 1 : 0; + } + + // Continuous mode + let cycles = 0; + let totalDriftAlerts = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + cycles++; + const cycleLabel = `[cycle ${cycles}] ${new Date().toISOString()}`; + const result = runDriftCheck(config); + + if (result.hasDrift) { + totalDriftAlerts++; + console.log(`\n${cycleLabel} — DRIFT DETECTED`); + console.log(result.formatted); + + if (config.callbackUrl) { + // Fire-and-forget callback + notifyCallback(config.callbackUrl, result).then((sent) => { + if (!sent) { + console.warn(' Callback delivery failed.'); + } + }); + } + + // Update baseline to latest artifact to avoid re-alerting on same drift + if (result.scannedFiles.length > 0) { + const latestFile = result.scannedFiles[result.scannedFiles.length - 1]; + const latestData = readJsonRecord(latestFile); + if (latestData) { + config.baselinePath = latestFile; + } + } + } else { + console.log(`${cycleLabel} — no drift (${result.scannedFiles.length} file(s) scanned)`); + } + + await sleep(config.intervalSeconds * 1000); + } +} diff --git a/packages/agentbom-cli/src/trust-verify-chain.test.ts b/packages/agentbom-cli/src/trust-verify-chain.test.ts new file mode 100644 index 0000000..329f4aa --- /dev/null +++ b/packages/agentbom-cli/src/trust-verify-chain.test.ts @@ -0,0 +1,582 @@ +/** + * Tests for `trust-cli verify-chain --depth N` — recursive trust + * chain verification with configurable depth and caching. + */ +import { afterEach, beforeEach, 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 { runCommand } from './index.js'; +import { publishArtifact } from './trust-publish.js'; +import { objectPathForCasId } from './trust-pull.js'; +import { + parseVerifyChainArgs, + type VerifyChainConfig, + verifyChain, + verifyChainCommand, +} from './trust-verify-chain.js'; + +// ---- Fixtures ---- + +const VALID_AGENTBOM = { + agentbom_version: '0.1', + identity: { + agent_id: 'test-agent-chain', + agent_name: 'Test Chain Agent', + deployment_context: 'development', + generated_at: '2026-01-01T00:00:00Z', + }, + attestation: { generator: 'test' }, + tool_layer: [ + { + tool_id: 'fs-read', + tool_name: 'read_file', + source: 'builtin', + permissions: ['fs:read'], + risk_signals: [], + }, + ], + permission_layer: { + granted_scopes: ['fs:read'], + data_access: ['local_workspace'], + credential_references: [], + }, + risk_layer: [], +}; + +const VALID_MCP_POSTURE = { + posture_version: '0.1', + identity: { + agent_id: 'test-mcp-agent', + snapshot_id: 'snap-chain-001', + captured_at: '2026-01-01T00:00:00Z', + }, + servers: [ + { + server_id: 'filesystem', + server_name: 'Filesystem Server', + transport: 'stdio', + capabilities: ['read', 'write'], + }, + ], + attestation: { generator: 'test' }, +}; + +const VALID_TRUST_PASSPORT = { + passport_version: '0.1', + identity: { + passport_id: 'passport-chain-001', + agent_id: 'test-passport-agent', + agent_name: 'Test Passport Agent', + issuer: 'Test Issuer', + issuance_context: 'development', + }, + validity: { + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2030-01-01T00:00:00Z', + }, + attestation: { + generator: 'test', + issuer: 'Test Issuer', + coverage: 'selected_technical_evidence', + }, + revocation: { + revoked: false, + revoked_at: null, + revocation_reason: null, + revocation_triggers: [], + }, +}; + +// ---- Test setup ---- + +let tmpDir: string; +let registryDir: string; + +beforeEach(() => { + tmpDir = join( + tmpdir(), + `trust-verify-chain-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); + registryDir = join(tmpDir, 'registry'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeJson(name: string, data: unknown): string { + const p = join(tmpDir, name); + writeFileSync(p, JSON.stringify(data), 'utf-8'); + return p; +} + +/** Publish a fixture and return its CAS id (fails the test if publishing errored). */ +function publish(data: unknown, tag?: string): string { + const path = writeJson(`artifact-${Math.random().toString(36).slice(2)}.json`, data); + const result = publishArtifact(path, registryDir, tag); + if (typeof result === 'string') { + throw new Error(`setup publish failed: ${result}`); + } + return result.casId; +} + +/** Create a minimal JWT file (unsigned, three base64url parts). */ +function writeMinimalJwt(payload: Record): string { + const headerB64 = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT' })).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signatureB64 = Buffer.from('dummy-signature').toString('base64url'); + const jwt = `${headerB64}.${payloadB64}.${signatureB64}`; + const jwtPath = join(tmpDir, `passport-${Math.random().toString(36).slice(2)}.jwt`); + writeFileSync(jwtPath, jwt, 'utf-8'); + return jwtPath; +} + +// ---- parseVerifyChainArgs ---- + +describe('parseVerifyChainArgs', () => { + it('returns usage string for --help', () => { + const result = parseVerifyChainArgs(['--help']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for -h', () => { + const result = parseVerifyChainArgs(['-h']); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('returns usage string for empty args', () => { + const result = parseVerifyChainArgs([]); + expect(typeof result).toBe('string'); + expect(result).toContain('Usage:'); + }); + + it('parses required positional jwt path', () => { + const result = parseVerifyChainArgs(['passport.jwt']); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.jwtPath).toContain('passport.jwt'); + expect(config.maxDepth).toBe(3); + expect(config.publicKeyPath).toBeUndefined(); + }); + + it('parses --depth flag', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--depth', '5']); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.maxDepth).toBe(5); + }); + + it('parses --depth 0', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--depth', '0']); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.maxDepth).toBe(0); + }); + + it('rejects negative --depth', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--depth', '-1']); + expect(typeof result).toBe('string'); + expect(result).toContain('non-negative integer'); + }); + + it('rejects non-integer --depth', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--depth', 'abc']); + expect(typeof result).toBe('string'); + expect(result).toContain('non-negative integer'); + }); + + it('parses --key flag', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--key', 'pubkey.pem']); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.publicKeyPath).toContain('pubkey.pem'); + }); + + it('parses --registry flag', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--registry', '/my-registry']); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.registryDir).toBe('/my-registry'); + }); + + it('parses all flags together', () => { + const result = parseVerifyChainArgs([ + 'passport.jwt', + '--depth', + '10', + '--key', + 'key.pem', + '--registry', + './reg', + ]); + expect(typeof result).not.toBe('string'); + const config = result as VerifyChainConfig; + expect(config.maxDepth).toBe(10); + expect(config.publicKeyPath).toContain('key.pem'); + expect(config.registryDir).toContain('reg'); + }); + + it('returns error for unknown flag', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--bogus']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --depth without value', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--depth']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --key without value', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--key']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); + + it('returns error for --registry without value', () => { + const result = parseVerifyChainArgs(['passport.jwt', '--registry']); + expect(typeof result).toBe('string'); + expect(result).toContain('unknown argument'); + }); +}); + +// ---- verifyChain (core) ---- + +describe('verifyChain', () => { + it('returns error string for nonexistent JWT file', () => { + const result = verifyChain({ + jwtPath: '/nonexistent/passport.jwt', + maxDepth: 3, + registryDir, + }); + expect(typeof result).toBe('string'); + expect(result).toContain('failed to read JWT'); + }); + + it('returns error string for malformed JWT (not three parts)', () => { + const jwtPath = join(tmpDir, 'bad.jwt'); + writeFileSync(jwtPath, 'not-a-jwt', 'utf-8'); + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + expect(typeof result).toBe('string'); + expect(result).toContain('JWT'); + }); + + it('returns error string for invalid JSON payload in JWT', () => { + const headerB64 = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT' })).toString( + 'base64url', + ); + const payloadB64 = Buffer.from('not-valid-json').toString('base64url'); + const sigB64 = Buffer.from('x').toString('base64url'); + const jwtPath = join(tmpDir, 'bad-payload.jwt'); + writeFileSync(jwtPath, `${headerB64}.${payloadB64}.${sigB64}`, 'utf-8'); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + expect(typeof result).toBe('string'); + expect(result).toContain('payload'); + }); + + it('verifies a valid passport JWT with no references (depth 0)', () => { + const jwtPath = writeMinimalJwt(VALID_TRUST_PASSPORT); + + const result = verifyChain({ + jwtPath, + maxDepth: 0, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + // Root should have structure valid (even without key, structure check passes) + expect(result.root.structureValid).toBe(true); + expect(result.root.payload).toBeTruthy(); + expect(result.valid).toBe(false); // signature not verified without key + expect(result.totalNodes).toBe(0); + expect(result.depthReached).toBe(0); + }); + + it('reports signature errors when no key is provided', () => { + const jwtPath = writeMinimalJwt(VALID_TRUST_PASSPORT); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.root.signatureValid).toBe(false); + expect(result.root.errors).toContain('No public key provided; signature not verified'); + expect(result.valid).toBe(false); + }); + + it('follows agentbom_ref and posture_ref when artifacts exist in registry', () => { + const bomCasId = publish(VALID_AGENTBOM); + const postureCasId = publish(VALID_MCP_POSTURE); + + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: bomCasId }, + posture_ref: { snapshot_id: postureCasId }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.totalNodes).toBe(2); + expect(result.nodes[0].reference).toBe(bomCasId); + expect(result.nodes[0].nodeType).toBe('agentbom'); + expect(result.nodes[0].integrityVerified).toBe(true); + expect(result.nodes[1].reference).toBe(postureCasId); + expect(result.nodes[1].nodeType).toBe('mcp-posture'); + expect(result.nodes[1].integrityVerified).toBe(true); + expect(result.depthReached).toBe(1); + expect(result.cacheMisses).toBe(2); + expect(result.cacheHits).toBe(0); + }); + + it('respects --depth 0 and does not follow references', () => { + const bomCasId = publish(VALID_AGENTBOM); + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: bomCasId }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + const result = verifyChain({ + jwtPath, + maxDepth: 0, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.totalNodes).toBe(0); + expect(result.depthReached).toBe(0); + }); + + it('reports errors for unresolvable artifact references', () => { + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: 'sha256:nonexistent' }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.totalNodes).toBe(1); + expect(result.nodes[0].valid).toBe(false); + expect(result.nodes[0].errors[0]).toContain('could not be resolved'); + expect(result.valid).toBe(false); + }); + + it('reports integrity failure for tampered registry artifacts', () => { + const bomCasId = publish(VALID_AGENTBOM); + // Tamper the stored artifact + const objPath = objectPathForCasId(bomCasId, registryDir); + writeFileSync(objPath, JSON.stringify({ tampered: true }), 'utf-8'); + + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: bomCasId }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + expect(result.totalNodes).toBe(1); + expect(result.nodes[0].integrityVerified).toBe(false); + expect(result.nodes[0].errors.some((e) => e.includes('integrity'))).toBe(true); + expect(result.valid).toBe(false); + }); + + it('detects and prevents cycles', () => { + // Create two AgentBOM artifacts that reference each other via dependencies. + // Publish clean AgentBOMs first (dependencies would break validation), + // then mutate the stored objects to add cross-references. + const aBase = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'agent-a' }, + }; + const bBase = { + ...VALID_AGENTBOM, + identity: { ...VALID_AGENTBOM.identity, agent_id: 'agent-b' }, + }; + + const bCasId = publish(bBase); + const aCasId = publish(aBase); + + // Mutate A to reference B + const aPath = objectPathForCasId(aCasId, registryDir); + writeFileSync(aPath, JSON.stringify({ ...aBase, dependencies: [{ id: bCasId }] }), 'utf-8'); + // Mutate B to reference A (creating a cycle) + const bPath = objectPathForCasId(bCasId, registryDir); + writeFileSync(bPath, JSON.stringify({ ...bBase, dependencies: [{ id: aCasId }] }), 'utf-8'); + + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: aCasId }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + // This should terminate (not infinite loop) — the assertion itself proves termination + const result = verifyChain({ + jwtPath, + maxDepth: 10, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + // Cycle should be detected and reported + const cycleErrors = result.nodes.flatMap((n) => n.errors); + expect(cycleErrors.some((e) => e.includes('cycle'))).toBe(true); + }, 10000); + + it('caches repeated artifact references across the chain', () => { + const sharedCasId = publish(VALID_AGENTBOM); + + // Passport references the same artifact twice via different paths + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: sharedCasId }, + dependencies: [sharedCasId], + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + + const result = verifyChain({ + jwtPath, + maxDepth: 3, + registryDir, + }); + + expect(typeof result).not.toBe('string'); + if (typeof result === 'string') return; + + // Second reference should be a cache hit + expect(result.cacheHits).toBeGreaterThan(0); + expect(result.cacheMisses).toBe(1); + }); +}); + +// ---- Integration: verify-chain command via runCommand ---- + +describe('verify-chain command via runCommand', () => { + it('shows help for verify-chain --help', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['verify-chain', '--help']); + expect(result).toBe(0); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('Usage:'); + expect(output).toContain(''); + expect(output).toContain('--depth'); + expect(output).toContain('--key'); + expect(output).toContain('--registry'); + }); + + it('returns 1 for verify-chain with no args', () => { + const spy = spyOn(console, 'log'); + const result = runCommand(['verify-chain']); + expect(result).toBe(0); // --help for empty args + }); + + it('returns 1 for verify-chain with unknown flag', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['verify-chain', 'passport.jwt', '--bogus']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('unknown argument'); + }); + + it('returns 1 for nonexistent JWT file', () => { + const spy = spyOn(console, 'error'); + const result = runCommand([ + 'verify-chain', + '/nonexistent/passport.jwt', + '--registry', + registryDir, + ]); + expect(result).toBe(1); + expect(spy).toHaveBeenCalled(); + }); + + it('returns 1 for invalid --depth value', () => { + const spy = spyOn(console, 'error'); + const result = runCommand(['verify-chain', 'p.jwt', '--depth', 'abc']); + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('non-negative integer'); + }); + + it('prints JSON output for a valid chain verification', () => { + const jwtPath = writeMinimalJwt(VALID_TRUST_PASSPORT); + const spy = spyOn(console, 'log'); + const result = runCommand(['verify-chain', jwtPath, '--depth', '3', '--registry', registryDir]); + // Result should be non-zero because no key provided (signature not verified) + expect(result).toBe(1); + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('"root"'); + expect(output).toContain('"signatureValid"'); + expect(output).toContain('"structureValid"'); + expect(output).toContain('"totalNodes"'); + expect(output).toContain('"depthReached"'); + expect(output).toContain('"cacheHits"'); + expect(output).toContain('"cacheMisses"'); + }); + + it('prints JSON output with chain nodes when artifacts are in registry', () => { + const bomCasId = publish(VALID_AGENTBOM); + const passportWithRefs = { + ...VALID_TRUST_PASSPORT, + agentbom_ref: { agentbom_id: bomCasId }, + }; + const jwtPath = writeMinimalJwt(passportWithRefs); + const spy = spyOn(console, 'log'); + const result = runCommand(['verify-chain', jwtPath, '--depth', '3', '--registry', registryDir]); + + expect(result).toBe(1); // signature not verified + const output = spy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('"nodes"'); + expect(output).toContain(bomCasId); + expect(output).toContain('"agentbom"'); + expect(output).toContain('"integrityVerified": true'); + }); +}); diff --git a/packages/agentbom-cli/src/trust-verify-chain.ts b/packages/agentbom-cli/src/trust-verify-chain.ts new file mode 100644 index 0000000..5e4d307 --- /dev/null +++ b/packages/agentbom-cli/src/trust-verify-chain.ts @@ -0,0 +1,590 @@ +/** + * `trust-cli verify-chain --depth N` — performs recursive trust + * chain verification with configurable depth and caching for multi-hop trust + * relationships. + * + * Given a signed Trust Passport JWT, this command: + * 1. Verifies the JWT signature (EdDSA) and checks expiry / structure + * 2. Extracts artifact references from the passport payload (agentbom_ref, + * posture_ref, dependencies) + * 3. Resolves each referenced artifact from the local registry + * 4. Validates each artifact against its known schema + * 5. Recursively follows the chain of references up to the configured depth + * 6. Caches verified artifacts to avoid redundant verification in multi-hop chains + * + * The result is a structured JSON report with per-node verification status, + * depth tracking, and cache statistics. + * + * Usage: + * trust-cli verify-chain --depth N [--key ] [--registry ] [--help] + */ +import { resolve } from 'node:path'; +import { validateTrustPassport } from '@openagentaudit/passport'; +import { validateAgentBOM } from '@wasmagent/agentbom-core'; +import { validateMCPPosture } from '@wasmagent/mcp-posture'; +import { verifySignedPassport } from './passport-verify-signed.js'; +import type { ArtifactType } from './trust-publish.js'; +import { pullArtifact, resolveArtifactId } from './trust-pull.js'; + +// ---- Types ---- + +/** Resolved configuration for the verify-chain command. */ +export interface VerifyChainConfig { + /** Path to the signed Trust Passport JWT file. */ + jwtPath: string; + /** Path to the Ed25519 public key for signature verification. */ + publicKeyPath?: string; + /** Maximum recursion depth for chain traversal (default: 3). */ + maxDepth: number; + /** Path to the local registry directory (default: ~/.trust-registry). */ + registryDir: string; +} + +/** Verification status for a single node in the trust chain. */ +export interface ChainNodeResult { + /** Type of artifact this node represents. */ + nodeType: 'passport' | 'agentbom' | 'mcp-posture' | 'unknown'; + /** Reference identifier (CAS id, file path, or tag). */ + reference: string; + /** Whether this node passed all verification checks. */ + valid: boolean; + /** Depth at which this node was encountered (0 = root passport). */ + depth: number; + /** Verification errors, if any. */ + errors: string[]; + /** Child nodes discovered by following this node's references. */ + children: ChainNodeResult[]; + /** Artifact type detected during schema validation. */ + artifactType?: ArtifactType; + /** Whether the artifact's integrity was verified (CAS digest match). */ + integrityVerified?: boolean; +} + +/** Complete result of a verify-chain operation. */ +export interface VerifyChainResult { + /** Overall chain validity — true only when every node is valid. */ + valid: boolean; + /** Root passport verification details. */ + root: { + signatureValid: boolean; + expired: boolean; + structureValid: boolean; + payload: Record | null; + errors: string[]; + }; + /** Ordered chain nodes (root first, then children depth-first). */ + nodes: ChainNodeResult[]; + /** Total number of nodes visited in the chain. */ + totalNodes: number; + /** Maximum depth actually reached during traversal. */ + depthReached: number; + /** Number of cache hits (artifacts already verified). */ + cacheHits: number; + /** Number of cache misses (artifacts verified for the first time). */ + cacheMisses: number; +} + +// ---- Pure helpers ---- + +/** + * Extract artifact reference IDs from a Trust Passport payload. + * + * Recognizes: + * - `agentbom_ref.agentbom_id` — the AgentBOM this passport attests + * - `posture_ref.snapshot_id` — the MCP Posture snapshot this passport references + * - `dependencies` — array of string ids or `{ id }` objects + * + * Returns a list of `{ id, label }` tuples preserving discovery order. + */ +function extractPassportReferences( + payload: Record, +): { id: string; label: string }[] { + const refs: { id: string; label: string }[] = []; + + const agentbomRef = payload.agentbom_ref as Record | undefined; + if (agentbomRef && typeof agentbomRef.agentbom_id === 'string') { + refs.push({ id: agentbomRef.agentbom_id as string, label: 'agentbom_ref' }); + } + + const postureRef = payload.posture_ref as Record | undefined; + if (postureRef && typeof postureRef.snapshot_id === 'string') { + refs.push({ id: postureRef.snapshot_id as string, label: 'posture_ref' }); + } + + if (Array.isArray(payload.dependencies)) { + for (const dep of payload.dependencies) { + if (typeof dep === 'string' && dep.length > 0) { + refs.push({ id: dep, label: 'dependency' }); + } else if ( + dep && + typeof dep === 'object' && + !Array.isArray(dep) && + typeof (dep as Record).id === 'string' + ) { + refs.push({ id: (dep as Record).id as string, label: 'dependency' }); + } + } + } + + return refs; +} + +/** + * Extract artifact reference IDs from a non-passport artifact (AgentBOM, MCP Posture, etc.) + * + * Recognizes: + * - `distribution.supersedes` — predecessor artifact CAS ids + * - `dependencies` — generic dependency array + * + * Returns a list of `{ id, label }` tuples. + */ +function extractArtifactReferences( + artifact: Record, +): { id: string; label: string }[] { + const refs: { id: string; label: string }[] = []; + + const distribution = artifact.distribution as Record | undefined; + if (distribution && Array.isArray(distribution.supersedes)) { + for (const id of distribution.supersedes) { + if (typeof id === 'string' && id.length > 0) { + refs.push({ id, label: 'supersedes' }); + } + } + } + + if (Array.isArray(artifact.dependencies)) { + for (const dep of artifact.dependencies) { + if (typeof dep === 'string' && dep.length > 0) { + refs.push({ id: dep, label: 'dependency' }); + } else if ( + dep && + typeof dep === 'object' && + !Array.isArray(dep) && + typeof (dep as Record).id === 'string' + ) { + refs.push({ id: (dep as Record).id as string, label: 'dependency' }); + } + } + } + + return refs; +} + +/** + * Validate an artifact against known schemas. + * + * Returns `{ valid, nodeType, errors, artifactType }`. + */ +function validateArtifactNode(data: unknown): { + valid: boolean; + nodeType: ChainNodeResult['nodeType']; + errors: string[]; + artifactType: ArtifactType; +} { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { + valid: false, + nodeType: 'unknown', + errors: ['artifact is not a JSON object'], + artifactType: 'unknown', + }; + } + const obj = data as Record; + + const passportResult = validateTrustPassport(data); + const bomResult = validateAgentBOM(data); + const postureResult = validateMCPPosture(data); + + if (bomResult.valid) { + return { valid: true, nodeType: 'agentbom', errors: [], artifactType: 'agentbom' }; + } + if (postureResult.valid) { + return { valid: true, nodeType: 'mcp-posture', errors: [], artifactType: 'mcp-posture' }; + } + if (passportResult.valid) { + return { valid: true, nodeType: 'passport', errors: [], artifactType: 'trust-passport' }; + } + + // Return the best-guess error set + if (obj.agentbom_version !== undefined) { + return { + valid: false, + nodeType: 'agentbom', + errors: bomResult.errors, + artifactType: 'unknown', + }; + } + if (obj.posture_version !== undefined) { + return { + valid: false, + nodeType: 'mcp-posture', + errors: postureResult.errors, + artifactType: 'unknown', + }; + } + if (obj.passport_version !== undefined) { + return { + valid: false, + nodeType: 'passport', + errors: passportResult.errors, + artifactType: 'unknown', + }; + } + + return { + valid: false, + nodeType: 'unknown', + errors: ['artifact does not match any known schema (AgentBOM, MCP Posture, or Trust Passport)'], + artifactType: 'unknown', + }; +} + +/** + * Verify a single artifact node and recursively verify its references. + * + * Cycle-safe via the `visited` set. Depth-limited by `remainingDepth`. + * Results are accumulated into `allNodes` and `stats`. + * + * @param artifactId The artifact identifier to verify + * @param registryDir Local registry directory + * @param remainingDepth How many more hops to follow + * @param visited Set of already-visited CAS ids (prevents cycles) + * @param cache Map from CAS id → pre-computed validation result (prevents re-verification) + * @param allNodes Accumulator for all chain nodes + * @param stats Accumulator for cache statistics + * @returns The chain node result for this artifact + */ +function verifyNode( + artifactId: string, + registryDir: string, + currentDepth: number, + remainingDepth: number, + visited: Set, + cache: Map< + string, + { + valid: boolean; + nodeType: ChainNodeResult['nodeType']; + errors: string[]; + artifactType: ArtifactType; + } + >, + allNodes: ChainNodeResult[], + stats: { cacheHits: number; cacheMisses: number }, +): ChainNodeResult { + const node: ChainNodeResult = { + nodeType: 'unknown', + reference: artifactId, + valid: false, + depth: currentDepth, + errors: [], + children: [], + }; + + // Resolve the artifact id + const resolved = resolveArtifactId(artifactId, registryDir); + if (!resolved) { + node.errors.push(`artifact "${artifactId}" could not be resolved (not a CAS id or known tag)`); + allNodes.push(node); + return node; + } + + const casId = resolved.casId; + + // Cycle check + if (visited.has(casId)) { + node.errors.push('cycle — artifact already visited in this chain'); + node.valid = true; // Already verified earlier + stats.cacheHits++; + allNodes.push(node); + return node; + } + visited.add(casId); + + // Cache check + const cached = cache.get(casId); + if (cached) { + node.nodeType = cached.nodeType; + node.valid = cached.valid; + node.artifactType = cached.artifactType; + node.integrityVerified = true; + stats.cacheHits++; + allNodes.push(node); + return node; + } + stats.cacheMisses++; + + // Pull the artifact from registry + const pullResult = pullArtifact(casId, registryDir); + if (typeof pullResult === 'string') { + node.errors.push(pullResult); + cache.set(casId, { + valid: false, + nodeType: 'unknown', + errors: node.errors, + artifactType: 'unknown', + }); + allNodes.push(node); + return node; + } + + if (!pullResult.integrityVerified) { + node.errors.push( + `integrity verification failed: expected ${casId} but stored content hashes to ${pullResult.computedCasId}`, + ); + node.integrityVerified = false; + } else { + node.integrityVerified = true; + } + + // Validate against known schemas + const validation = validateArtifactNode(pullResult.artifact); + node.nodeType = validation.nodeType; + node.artifactType = validation.artifactType; + if (!validation.valid) { + node.errors.push(...validation.errors); + } + + node.valid = pullResult.integrityVerified && validation.valid && node.errors.length === 0; + + // Cache the result + cache.set(casId, validation); + + // Recurse into references (if depth allows) + if (remainingDepth > 0) { + const refs = validation.valid + ? extractArtifactReferences(pullResult.artifact) + : extractPassportReferences(pullResult.artifact); + + for (const ref of refs) { + const child = verifyNode( + ref.id, + registryDir, + currentDepth + 1, + remainingDepth - 1, + visited, + cache, + allNodes, + stats, + ); + node.children.push(child); + } + } + + allNodes.push(node); + return node; +} + +/** + * Perform recursive trust chain verification. + * + * This is the core pure-logic function. It: + * 1. Verifies the JWT passport (signature, expiry, structure) + * 2. Extracts artifact references from the passport payload + * 3. Resolves and validates each referenced artifact from the registry + * 4. Recursively follows artifact references up to `maxDepth` hops + * 5. Caches verified artifacts for multi-hop efficiency + * + * Returns a {@link VerifyChainResult} on success, or an error string when + * the JWT cannot be read or parsed. + */ +export function verifyChain(config: VerifyChainConfig): VerifyChainResult | string { + // Step 1: Verify the root passport JWT + let verifyResult: ReturnType; + try { + verifyResult = verifySignedPassport({ + jwtPath: config.jwtPath, + publicKeyPath: config.publicKeyPath, + }); + } catch (err) { + return `failed to read JWT: ${err instanceof Error ? err.message : String(err)}`; + } + + const root = { + signatureValid: verifyResult.signatureValid, + expired: verifyResult.expired, + structureValid: verifyResult.structureValid, + payload: verifyResult.payload, + errors: verifyResult.errors, + }; + + // If payload is null, we can't follow any references + if (!verifyResult.payload) { + const firstError = verifyResult.errors[0] ?? 'unknown error'; + return firstError; + } + + const payload = verifyResult.payload; + + // Step 2: Extract references from the passport + const refs = extractPassportReferences(payload); + + // Step 3: Resolve and validate each reference recursively (skip if depth is 0) + const allNodes: ChainNodeResult[] = []; + let cacheHits = 0; + let cacheMisses = 0; + let maxDepthReached = 0; + + if (config.maxDepth > 0) { + const cache = new Map< + string, + { + valid: boolean; + nodeType: ChainNodeResult['nodeType']; + errors: string[]; + artifactType: ArtifactType; + } + >(); + const visited = new Set(); + const stats = { cacheHits: 0, cacheMisses: 0 }; + + for (const ref of refs) { + const child = verifyNode( + ref.id, + config.registryDir, + 1, + config.maxDepth - 1, + visited, + cache, + allNodes, + stats, + ); + + // Track depth across children + const trackDepth = (node: ChainNodeResult): number => { + if (node.children.length === 0) return node.depth; + return Math.max(node.depth, ...node.children.map(trackDepth)); + }; + maxDepthReached = Math.max(maxDepthReached, trackDepth(child)); + } + + cacheHits = stats.cacheHits; + cacheMisses = stats.cacheMisses; + } + + // Step 4: Determine overall validity + const allNodesValid = allNodes.every((n) => n.valid); + const rootValid = + root.signatureValid && !root.expired && root.structureValid && root.errors.length === 0; + const valid = rootValid && (refs.length === 0 || allNodesValid); + + return { + valid, + root, + nodes: allNodes, + totalNodes: allNodes.length, + depthReached: maxDepthReached, + cacheHits, + cacheMisses, + }; +} + +// ---- CLI command ---- + +const VERIFY_CHAIN_USAGE = [ + 'Usage: agent-trust verify-chain [options]', + '', + 'Perform recursive trust chain verification with configurable depth and', + 'caching for multi-hop trust relationships.', + '', + 'Given a signed Trust Passport JWT, verifies the signature and structure,', + 'then follows artifact references (AgentBOM, MCP Posture) through the', + 'local registry, validating each node in the chain.', + '', + 'Arguments:', + ' Path to a signed Trust Passport JWT file', + '', + 'Options:', + ' --depth Maximum recursion depth for chain traversal (default: 3)', + ' --key Path to Ed25519 public key (PEM or 64-char hex)', + ' --registry Path to the local registry directory', + ' (default: ~/.trust-registry)', + ' --help, -h Show this help message', + '', + 'Examples:', + ' agent-trust verify-chain passport.jwt --key pubkey.pem', + ' agent-trust verify-chain passport.jwt --depth 5 --key pubkey.pem', + ' agent-trust verify-chain passport.jwt --depth 2 --key pubkey.pem --registry ./my-registry', + '', + 'Output:', + ' Prints a JSON object with root passport verification status, chain node', + ' results, depth tracking, and cache statistics. Exits non-zero if the', + ' chain is invalid.', +].join('\n'); + +/** + * Parse verify-chain command arguments into a {@link VerifyChainConfig}. + * Returns the config on success, or a usage/error string on failure. + */ +export function parseVerifyChainArgs(args: string[]): VerifyChainConfig | string { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + return VERIFY_CHAIN_USAGE; + } + + const jwtPath = args[0]; + let publicKeyPath: string | undefined; + let maxDepth = 3; + let registryDir: string | undefined; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if (arg === '--depth' && next) { + const parsed = Number.parseInt(next, 10); + if (Number.isNaN(parsed) || parsed < 0) { + return `Error: --depth must be a non-negative integer, got "${next}"`; + } + maxDepth = parsed; + i++; + } else if (arg === '--key' && next) { + publicKeyPath = next; + i++; + } else if (arg === '--registry' && next) { + registryDir = next; + i++; + } else { + return `Error: unknown argument "${arg}"`; + } + } + + const homeRegistry = resolve( + process.env.HOME ?? process.env.USERPROFILE ?? '~', + '.trust-registry', + ); + + return { + jwtPath: resolve(jwtPath), + publicKeyPath: publicKeyPath ? resolve(publicKeyPath) : undefined, + maxDepth, + registryDir: registryDir ? resolve(registryDir) : homeRegistry, + }; +} + +/** + * CLI entry point for `agent-trust verify-chain`. + * + * Returns exit code (0 = valid chain, 1 = verification failed or error). + */ +export function verifyChainCommand(args: string[]): number { + const parsed = parseVerifyChainArgs(args); + if (typeof parsed === 'string') { + if (parsed.startsWith('Usage:')) { + console.log(parsed); + return 0; + } + console.error(parsed); + return 1; + } + + const config = parsed; + + const result = verifyChain(config); + if (typeof result === 'string') { + console.error(`Error: ${result}`); + return 1; + } + + console.log(JSON.stringify(result, null, 2)); + return result.valid ? 0 : 1; +} diff --git a/packages/agentbom-cli/tsconfig.json b/packages/agentbom-cli/tsconfig.json new file mode 100644 index 0000000..0bb59cf --- /dev/null +++ b/packages/agentbom-cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agentbom-core/package.json b/packages/agentbom-core/package.json index e61fa1d..a3dac38 100644 --- a/packages/agentbom-core/package.json +++ b/packages/agentbom-core/package.json @@ -9,6 +9,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./pipeline": { + "import": "./dist/pipeline.js", + "types": "./dist/pipeline.d.ts" } }, "files": [