@waratahlabs/canopy 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/SKILL.md +97 -0
  4. package/demo/data/openclaw-demo.tc.json +397 -0
  5. package/demo/data/openclaw-nemoclaw-demo.tc.json +475 -0
  6. package/demo/index.html +556 -0
  7. package/dist/adapters/a2a.d.ts +82 -0
  8. package/dist/adapters/a2a.d.ts.map +1 -0
  9. package/dist/adapters/a2a.js +215 -0
  10. package/dist/adapters/a2a.js.map +1 -0
  11. package/dist/adapters/a2a.test.d.ts +2 -0
  12. package/dist/adapters/a2a.test.d.ts.map +1 -0
  13. package/dist/adapters/a2a.test.js +196 -0
  14. package/dist/adapters/a2a.test.js.map +1 -0
  15. package/dist/adapters/langfuse.d.ts +28 -0
  16. package/dist/adapters/langfuse.d.ts.map +1 -0
  17. package/dist/adapters/langfuse.js +192 -0
  18. package/dist/adapters/langfuse.js.map +1 -0
  19. package/dist/adapters/n8n.d.ts +4 -0
  20. package/dist/adapters/n8n.d.ts.map +1 -0
  21. package/dist/adapters/n8n.js +89 -0
  22. package/dist/adapters/n8n.js.map +1 -0
  23. package/dist/adapters/openclaw.d.ts +58 -0
  24. package/dist/adapters/openclaw.d.ts.map +1 -0
  25. package/dist/adapters/openclaw.js +275 -0
  26. package/dist/adapters/openclaw.js.map +1 -0
  27. package/dist/adapters/types.d.ts +33 -0
  28. package/dist/adapters/types.d.ts.map +1 -0
  29. package/dist/adapters/types.js +2 -0
  30. package/dist/adapters/types.js.map +1 -0
  31. package/dist/aivss/atlas/coverage-matrix.d.ts +12 -0
  32. package/dist/aivss/atlas/coverage-matrix.d.ts.map +1 -0
  33. package/dist/aivss/atlas/coverage-matrix.js +272 -0
  34. package/dist/aivss/atlas/coverage-matrix.js.map +1 -0
  35. package/dist/aivss/atlas/loader.d.ts +28 -0
  36. package/dist/aivss/atlas/loader.d.ts.map +1 -0
  37. package/dist/aivss/atlas/loader.js +126 -0
  38. package/dist/aivss/atlas/loader.js.map +1 -0
  39. package/dist/aivss/atlas/loader.test.d.ts +11 -0
  40. package/dist/aivss/atlas/loader.test.d.ts.map +1 -0
  41. package/dist/aivss/atlas/loader.test.js +111 -0
  42. package/dist/aivss/atlas/loader.test.js.map +1 -0
  43. package/dist/aivss/atlas/resolve-path.d.ts +34 -0
  44. package/dist/aivss/atlas/resolve-path.d.ts.map +1 -0
  45. package/dist/aivss/atlas/resolve-path.js +58 -0
  46. package/dist/aivss/atlas/resolve-path.js.map +1 -0
  47. package/dist/aivss/batch/batch-builder.d.ts +11 -0
  48. package/dist/aivss/batch/batch-builder.d.ts.map +1 -0
  49. package/dist/aivss/batch/batch-builder.js +75 -0
  50. package/dist/aivss/batch/batch-builder.js.map +1 -0
  51. package/dist/aivss/index.d.ts +21 -0
  52. package/dist/aivss/index.d.ts.map +1 -0
  53. package/dist/aivss/index.js +19 -0
  54. package/dist/aivss/index.js.map +1 -0
  55. package/dist/aivss/knowledge-base/kb-client.d.ts +9 -0
  56. package/dist/aivss/knowledge-base/kb-client.d.ts.map +1 -0
  57. package/dist/aivss/knowledge-base/kb-client.js +139 -0
  58. package/dist/aivss/knowledge-base/kb-client.js.map +1 -0
  59. package/dist/aivss/knowledge-base/kb-types.d.ts +21 -0
  60. package/dist/aivss/knowledge-base/kb-types.d.ts.map +1 -0
  61. package/dist/aivss/knowledge-base/kb-types.js +2 -0
  62. package/dist/aivss/knowledge-base/kb-types.js.map +1 -0
  63. package/dist/aivss/knowledge-base/search-builder.d.ts +7 -0
  64. package/dist/aivss/knowledge-base/search-builder.d.ts.map +1 -0
  65. package/dist/aivss/knowledge-base/search-builder.js +119 -0
  66. package/dist/aivss/knowledge-base/search-builder.js.map +1 -0
  67. package/dist/aivss/metrics/aa.d.ts +3 -0
  68. package/dist/aivss/metrics/aa.d.ts.map +1 -0
  69. package/dist/aivss/metrics/aa.js +26 -0
  70. package/dist/aivss/metrics/aa.js.map +1 -0
  71. package/dist/aivss/metrics/ad.d.ts +3 -0
  72. package/dist/aivss/metrics/ad.d.ts.map +1 -0
  73. package/dist/aivss/metrics/ad.js +30 -0
  74. package/dist/aivss/metrics/ad.js.map +1 -0
  75. package/dist/aivss/metrics/cs.d.ts +3 -0
  76. package/dist/aivss/metrics/cs.d.ts.map +1 -0
  77. package/dist/aivss/metrics/cs.js +50 -0
  78. package/dist/aivss/metrics/cs.js.map +1 -0
  79. package/dist/aivss/metrics/dc.d.ts +3 -0
  80. package/dist/aivss/metrics/dc.d.ts.map +1 -0
  81. package/dist/aivss/metrics/dc.js +30 -0
  82. package/dist/aivss/metrics/dc.js.map +1 -0
  83. package/dist/aivss/metrics/ds.d.ts +3 -0
  84. package/dist/aivss/metrics/ds.d.ts.map +1 -0
  85. package/dist/aivss/metrics/ds.js +26 -0
  86. package/dist/aivss/metrics/ds.js.map +1 -0
  87. package/dist/aivss/metrics/ei.d.ts +3 -0
  88. package/dist/aivss/metrics/ei.d.ts.map +1 -0
  89. package/dist/aivss/metrics/ei.js +30 -0
  90. package/dist/aivss/metrics/ei.js.map +1 -0
  91. package/dist/aivss/metrics/gv.d.ts +3 -0
  92. package/dist/aivss/metrics/gv.d.ts.map +1 -0
  93. package/dist/aivss/metrics/gv.js +34 -0
  94. package/dist/aivss/metrics/gv.js.map +1 -0
  95. package/dist/aivss/metrics/ll.d.ts +3 -0
  96. package/dist/aivss/metrics/ll.d.ts.map +1 -0
  97. package/dist/aivss/metrics/ll.js +30 -0
  98. package/dist/aivss/metrics/ll.js.map +1 -0
  99. package/dist/aivss/metrics/mr.d.ts +3 -0
  100. package/dist/aivss/metrics/mr.d.ts.map +1 -0
  101. package/dist/aivss/metrics/mr.js +26 -0
  102. package/dist/aivss/metrics/mr.js.map +1 -0
  103. package/dist/aivss/metrics/shared.d.ts +22 -0
  104. package/dist/aivss/metrics/shared.d.ts.map +1 -0
  105. package/dist/aivss/metrics/shared.js +70 -0
  106. package/dist/aivss/metrics/shared.js.map +1 -0
  107. package/dist/aivss/prompts/base-system-prompt.d.ts +2 -0
  108. package/dist/aivss/prompts/base-system-prompt.d.ts.map +1 -0
  109. package/dist/aivss/prompts/base-system-prompt.js +31 -0
  110. package/dist/aivss/prompts/base-system-prompt.js.map +1 -0
  111. package/dist/aivss/prompts/prompt-composer.d.ts +13 -0
  112. package/dist/aivss/prompts/prompt-composer.d.ts.map +1 -0
  113. package/dist/aivss/prompts/prompt-composer.js +21 -0
  114. package/dist/aivss/prompts/prompt-composer.js.map +1 -0
  115. package/dist/aivss/scoring/a2a-corpus.test.d.ts +28 -0
  116. package/dist/aivss/scoring/a2a-corpus.test.d.ts.map +1 -0
  117. package/dist/aivss/scoring/a2a-corpus.test.js +109 -0
  118. package/dist/aivss/scoring/a2a-corpus.test.js.map +1 -0
  119. package/dist/aivss/scoring/determinism.test.d.ts +16 -0
  120. package/dist/aivss/scoring/determinism.test.d.ts.map +1 -0
  121. package/dist/aivss/scoring/determinism.test.js +169 -0
  122. package/dist/aivss/scoring/determinism.test.js.map +1 -0
  123. package/dist/aivss/scoring/heuristic-scorer.d.ts +21 -0
  124. package/dist/aivss/scoring/heuristic-scorer.d.ts.map +1 -0
  125. package/dist/aivss/scoring/heuristic-scorer.js +299 -0
  126. package/dist/aivss/scoring/heuristic-scorer.js.map +1 -0
  127. package/dist/aivss/scoring/heuristic-scorer.test.d.ts +8 -0
  128. package/dist/aivss/scoring/heuristic-scorer.test.d.ts.map +1 -0
  129. package/dist/aivss/scoring/heuristic-scorer.test.js +150 -0
  130. package/dist/aivss/scoring/heuristic-scorer.test.js.map +1 -0
  131. package/dist/aivss/scoring/scoring-mode.d.ts +48 -0
  132. package/dist/aivss/scoring/scoring-mode.d.ts.map +1 -0
  133. package/dist/aivss/scoring/scoring-mode.js +64 -0
  134. package/dist/aivss/scoring/scoring-mode.js.map +1 -0
  135. package/dist/aivss/scoring/v4-engine.d.ts +15 -0
  136. package/dist/aivss/scoring/v4-engine.d.ts.map +1 -0
  137. package/dist/aivss/scoring/v4-engine.js +84 -0
  138. package/dist/aivss/scoring/v4-engine.js.map +1 -0
  139. package/dist/aivss/scoring/v4-engine.test.d.ts +16 -0
  140. package/dist/aivss/scoring/v4-engine.test.d.ts.map +1 -0
  141. package/dist/aivss/scoring/v4-engine.test.js +156 -0
  142. package/dist/aivss/scoring/v4-engine.test.js.map +1 -0
  143. package/dist/aivss/types.d.ts +92 -0
  144. package/dist/aivss/types.d.ts.map +1 -0
  145. package/dist/aivss/types.js +25 -0
  146. package/dist/aivss/types.js.map +1 -0
  147. package/dist/baseline/baseline.test.d.ts +2 -0
  148. package/dist/baseline/baseline.test.d.ts.map +1 -0
  149. package/dist/baseline/baseline.test.js +219 -0
  150. package/dist/baseline/baseline.test.js.map +1 -0
  151. package/dist/baseline/diff.d.ts +42 -0
  152. package/dist/baseline/diff.d.ts.map +1 -0
  153. package/dist/baseline/diff.js +124 -0
  154. package/dist/baseline/diff.js.map +1 -0
  155. package/dist/baseline/store.d.ts +51 -0
  156. package/dist/baseline/store.d.ts.map +1 -0
  157. package/dist/baseline/store.js +92 -0
  158. package/dist/baseline/store.js.map +1 -0
  159. package/dist/batch/anthropic-batch-runner.d.ts +50 -0
  160. package/dist/batch/anthropic-batch-runner.d.ts.map +1 -0
  161. package/dist/batch/anthropic-batch-runner.js +91 -0
  162. package/dist/batch/anthropic-batch-runner.js.map +1 -0
  163. package/dist/batch/bedrock-batch-runner.d.ts +90 -0
  164. package/dist/batch/bedrock-batch-runner.d.ts.map +1 -0
  165. package/dist/batch/bedrock-batch-runner.js +199 -0
  166. package/dist/batch/bedrock-batch-runner.js.map +1 -0
  167. package/dist/batch/providers/anthropic.d.ts +55 -0
  168. package/dist/batch/providers/anthropic.d.ts.map +1 -0
  169. package/dist/batch/providers/anthropic.js +103 -0
  170. package/dist/batch/providers/anthropic.js.map +1 -0
  171. package/dist/batch/providers/bedrock.d.ts +31 -0
  172. package/dist/batch/providers/bedrock.d.ts.map +1 -0
  173. package/dist/batch/providers/bedrock.js +42 -0
  174. package/dist/batch/providers/bedrock.js.map +1 -0
  175. package/dist/batch/providers/harness.d.ts +36 -0
  176. package/dist/batch/providers/harness.d.ts.map +1 -0
  177. package/dist/batch/providers/harness.js +65 -0
  178. package/dist/batch/providers/harness.js.map +1 -0
  179. package/dist/batch/providers/harness.test.d.ts +7 -0
  180. package/dist/batch/providers/harness.test.d.ts.map +1 -0
  181. package/dist/batch/providers/harness.test.js +84 -0
  182. package/dist/batch/providers/harness.test.js.map +1 -0
  183. package/dist/batch/providers/index.d.ts +82 -0
  184. package/dist/batch/providers/index.d.ts.map +1 -0
  185. package/dist/batch/providers/index.js +82 -0
  186. package/dist/batch/providers/index.js.map +1 -0
  187. package/dist/batch/providers/ollama.d.ts +68 -0
  188. package/dist/batch/providers/ollama.d.ts.map +1 -0
  189. package/dist/batch/providers/ollama.js +108 -0
  190. package/dist/batch/providers/ollama.js.map +1 -0
  191. package/dist/batch/response-parser.d.ts +45 -0
  192. package/dist/batch/response-parser.d.ts.map +1 -0
  193. package/dist/batch/response-parser.js +161 -0
  194. package/dist/batch/response-parser.js.map +1 -0
  195. package/dist/batch/types.d.ts +87 -0
  196. package/dist/batch/types.d.ts.map +1 -0
  197. package/dist/batch/types.js +8 -0
  198. package/dist/batch/types.js.map +1 -0
  199. package/dist/cli.d.ts +15 -0
  200. package/dist/cli.d.ts.map +1 -0
  201. package/dist/cli.js +1037 -0
  202. package/dist/cli.js.map +1 -0
  203. package/dist/examples/a2a-assessment.d.ts +20 -0
  204. package/dist/examples/a2a-assessment.d.ts.map +1 -0
  205. package/dist/examples/a2a-assessment.js +115 -0
  206. package/dist/examples/a2a-assessment.js.map +1 -0
  207. package/dist/examples/batch-assessment.d.ts +18 -0
  208. package/dist/examples/batch-assessment.d.ts.map +1 -0
  209. package/dist/examples/batch-assessment.js +112 -0
  210. package/dist/examples/batch-assessment.js.map +1 -0
  211. package/dist/examples/batch-submission-flow.d.ts +2 -0
  212. package/dist/examples/batch-submission-flow.d.ts.map +1 -0
  213. package/dist/examples/batch-submission-flow.js +279 -0
  214. package/dist/examples/batch-submission-flow.js.map +1 -0
  215. package/dist/examples/demo-agent-definition.json +58 -0
  216. package/dist/examples/langfuse-assessment.d.ts +2 -0
  217. package/dist/examples/langfuse-assessment.d.ts.map +1 -0
  218. package/dist/examples/langfuse-assessment.js +46 -0
  219. package/dist/examples/langfuse-assessment.js.map +1 -0
  220. package/dist/examples/model-eval.d.ts +25 -0
  221. package/dist/examples/model-eval.d.ts.map +1 -0
  222. package/dist/examples/model-eval.js +94 -0
  223. package/dist/examples/model-eval.js.map +1 -0
  224. package/dist/examples/openclaw-assessment.d.ts +17 -0
  225. package/dist/examples/openclaw-assessment.d.ts.map +1 -0
  226. package/dist/examples/openclaw-assessment.js +79 -0
  227. package/dist/examples/openclaw-assessment.js.map +1 -0
  228. package/dist/examples/simple-assessment.d.ts +2 -0
  229. package/dist/examples/simple-assessment.d.ts.map +1 -0
  230. package/dist/examples/simple-assessment.js +146 -0
  231. package/dist/examples/simple-assessment.js.map +1 -0
  232. package/dist/examples/threat-model-demo.d.ts +2 -0
  233. package/dist/examples/threat-model-demo.d.ts.map +1 -0
  234. package/dist/examples/threat-model-demo.js +103 -0
  235. package/dist/examples/threat-model-demo.js.map +1 -0
  236. package/dist/index.d.ts +60 -0
  237. package/dist/index.d.ts.map +1 -0
  238. package/dist/index.js +155 -0
  239. package/dist/index.js.map +1 -0
  240. package/dist/output/likec4-generator.d.ts +43 -0
  241. package/dist/output/likec4-generator.d.ts.map +1 -0
  242. package/dist/output/likec4-generator.js +287 -0
  243. package/dist/output/likec4-generator.js.map +1 -0
  244. package/dist/output/slot-filler.d.ts +6 -0
  245. package/dist/output/slot-filler.d.ts.map +1 -0
  246. package/dist/output/slot-filler.js +71 -0
  247. package/dist/output/slot-filler.js.map +1 -0
  248. package/dist/output/tc-assembler.d.ts +30 -0
  249. package/dist/output/tc-assembler.d.ts.map +1 -0
  250. package/dist/output/tc-assembler.js +227 -0
  251. package/dist/output/tc-assembler.js.map +1 -0
  252. package/dist/output/tc-types.d.ts +41 -0
  253. package/dist/output/tc-types.d.ts.map +1 -0
  254. package/dist/output/tc-types.js +5 -0
  255. package/dist/output/tc-types.js.map +1 -0
  256. package/dist/output/tc-validator.test.d.ts +9 -0
  257. package/dist/output/tc-validator.test.d.ts.map +1 -0
  258. package/dist/output/tc-validator.test.js +194 -0
  259. package/dist/output/tc-validator.test.js.map +1 -0
  260. package/dist/providers/anthropic.d.ts +9 -0
  261. package/dist/providers/anthropic.d.ts.map +1 -0
  262. package/dist/providers/anthropic.js +30 -0
  263. package/dist/providers/anthropic.js.map +1 -0
  264. package/dist/providers/bedrock.d.ts +9 -0
  265. package/dist/providers/bedrock.d.ts.map +1 -0
  266. package/dist/providers/bedrock.js +38 -0
  267. package/dist/providers/bedrock.js.map +1 -0
  268. package/dist/providers/index.d.ts +6 -0
  269. package/dist/providers/index.d.ts.map +1 -0
  270. package/dist/providers/index.js +14 -0
  271. package/dist/providers/index.js.map +1 -0
  272. package/dist/providers/types.d.ts +12 -0
  273. package/dist/providers/types.d.ts.map +1 -0
  274. package/dist/providers/types.js +5 -0
  275. package/dist/providers/types.js.map +1 -0
  276. package/dist/references/acsc-risks.d.ts +18 -0
  277. package/dist/references/acsc-risks.d.ts.map +1 -0
  278. package/dist/references/acsc-risks.js +232 -0
  279. package/dist/references/acsc-risks.js.map +1 -0
  280. package/dist/references/aicm.d.ts +41 -0
  281. package/dist/references/aicm.d.ts.map +1 -0
  282. package/dist/references/aicm.js +447 -0
  283. package/dist/references/aicm.js.map +1 -0
  284. package/dist/references/dstg-meaid.d.ts +59 -0
  285. package/dist/references/dstg-meaid.d.ts.map +1 -0
  286. package/dist/references/dstg-meaid.js +352 -0
  287. package/dist/references/dstg-meaid.js.map +1 -0
  288. package/dist/references/index.d.ts +20 -0
  289. package/dist/references/index.d.ts.map +1 -0
  290. package/dist/references/index.js +31 -0
  291. package/dist/references/index.js.map +1 -0
  292. package/dist/references/nemoclaw.d.ts +48 -0
  293. package/dist/references/nemoclaw.d.ts.map +1 -0
  294. package/dist/references/nemoclaw.js +140 -0
  295. package/dist/references/nemoclaw.js.map +1 -0
  296. package/dist/references/nist-ai-rmf.d.ts +31 -0
  297. package/dist/references/nist-ai-rmf.d.ts.map +1 -0
  298. package/dist/references/nist-ai-rmf.js +627 -0
  299. package/dist/references/nist-ai-rmf.js.map +1 -0
  300. package/dist/references/owasp-agentic-top10.d.ts +50 -0
  301. package/dist/references/owasp-agentic-top10.d.ts.map +1 -0
  302. package/dist/references/owasp-agentic-top10.js +202 -0
  303. package/dist/references/owasp-agentic-top10.js.map +1 -0
  304. package/dist/references/registry-parity.test.d.ts +17 -0
  305. package/dist/references/registry-parity.test.d.ts.map +1 -0
  306. package/dist/references/registry-parity.test.js +87 -0
  307. package/dist/references/registry-parity.test.js.map +1 -0
  308. package/dist/references/registry.d.ts +90 -0
  309. package/dist/references/registry.d.ts.map +1 -0
  310. package/dist/references/registry.js +29 -0
  311. package/dist/references/registry.js.map +1 -0
  312. package/dist/tools/model-eval.d.ts +97 -0
  313. package/dist/tools/model-eval.d.ts.map +1 -0
  314. package/dist/tools/model-eval.js +307 -0
  315. package/dist/tools/model-eval.js.map +1 -0
  316. package/package.json +105 -0
  317. package/schemas/threat-composer-v1.schema.json +966 -0
  318. package/vendor/atlas-release/ATLAS-2026.07.yaml +17877 -0
  319. package/vendor/atlas-release/README.md +41 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1037 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Canopy CLI — multi-framework threat modelling for agentic AI systems.
4
+ *
5
+ * Usage:
6
+ * bunx @waratahlabs/canopy assess --input agents.json [--out model.tc.json]
7
+ * bunx @waratahlabs/canopy schema
8
+ * bunx @waratahlabs/canopy demo [--port 3000]
9
+ *
10
+ * Claude Code / Cursor agents: run `canopy schema` to get the AgentDefinition
11
+ * shape, construct the input JSON from the codebase under review, then run
12
+ * `canopy assess` to produce the threat model artifact.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import * as readline from "node:readline";
17
+ import { assessWorkflow, submitBedrockBatch, fetchBedrockBatch, submitAnthropicBatch, fetchAnthropicBatch } from "./index.js";
18
+ import { buildBaseline, saveBaseline, loadRecentBaselines } from "./baseline/store.js";
19
+ import { diffBaselines, formatDiff } from "./baseline/diff.js";
20
+ import { walkA2AGraph } from "./adapters/a2a.js";
21
+ import { resolveAtlasYaml } from "./aivss/atlas/resolve-path.js";
22
+ import { generateLikeC4, likec4Filename } from "./output/likec4-generator.js";
23
+ import { DEPLOYMENT_CONTEXTS } from "./aivss/types.js";
24
+ // ---------------------------------------------------------------------------
25
+ // TC schema validation — inline structural checks against TC schema constraints.
26
+ // Covers the invariants that actually bite: status enum, custom: prefix, key
27
+ // length, and no root fields the schema rejects. No external validator needed.
28
+ // ---------------------------------------------------------------------------
29
+ const VALID_STATUSES = new Set([
30
+ "threatIdentified",
31
+ "threatResolved",
32
+ "threatResolvedNotUseful",
33
+ ]);
34
+ const ROOT_ALLOWED = new Set(["schema", "applicationInfo", "threats", "mitigations", "mitigationLinks"]);
35
+ function validateTCOutput(data) {
36
+ const errors = [];
37
+ if (data.schema !== 1)
38
+ errors.push(`/ schema must be 1, got ${data.schema}`);
39
+ for (const key of Object.keys(data)) {
40
+ if (!ROOT_ALLOWED.has(key))
41
+ errors.push(`/ unexpected root field "${key}"`);
42
+ }
43
+ for (const t of data.threats ?? []) {
44
+ if (t.status !== undefined && !VALID_STATUSES.has(t.status)) {
45
+ errors.push(`/threats[${t.id}] invalid status "${t.status}"`);
46
+ }
47
+ for (const m of t.metadata ?? []) {
48
+ if (m.key.length > 50)
49
+ errors.push(`/threats[${t.id}]/metadata key too long (>50): "${m.key}"`);
50
+ if (!["Priority", "Comments", "STRIDE", "source"].includes(m.key) && !m.key.startsWith("custom:")) {
51
+ errors.push(`/threats[${t.id}]/metadata key missing custom: prefix: "${m.key}"`);
52
+ }
53
+ }
54
+ }
55
+ for (const m of data.mitigations ?? []) {
56
+ for (const meta of m.metadata ?? []) {
57
+ errors.push(`/mitigations[${m.id}] has metadata (schema rejects it): key="${meta.key}"`);
58
+ }
59
+ }
60
+ for (const lnk of data.mitigationLinks ?? []) {
61
+ const extra = Object.keys(lnk).filter((k) => k !== "mitigationId" && k !== "linkedId");
62
+ if (extra.length)
63
+ errors.push(`/mitigationLinks unexpected fields: ${extra.join(", ")}`);
64
+ }
65
+ return { valid: errors.length === 0, errors };
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+ // ---------------------------------------------------------------------------
70
+ function usage() {
71
+ console.log(`
72
+ Canopy — multi-framework AI threat modelling
73
+
74
+ COMMANDS
75
+
76
+ assess Generate a .tc.json threat model from agent definitions
77
+ a2a-assess Walk an A2A delegation graph and generate a threat model
78
+ schema Print the AgentDefinition input schema (for LLM context)
79
+ demo Serve the interactive threat model viewer
80
+
81
+ USAGE
82
+
83
+ canopy assess [options]
84
+ --input <file> Path to input JSON file (default: stdin)
85
+ --out <file> Path for .tc.json output (default: stdout)
86
+ --diagram Also write a LikeC4 .c4 architecture diagram
87
+ --name <name> Application name (overrides applicationName in input)
88
+ --deterministic Score with the heuristic scorer: a pure function of the agent
89
+ definition. Reproducible across runs and machines, auditable,
90
+ no credentials, no network. Use this for CI gating and audit
91
+ trails.
92
+ --harness No API key, no subprocess: writes assessment prompts to
93
+ <outdir>/canopy-batch-requests.json for the calling LLM coding agent to
94
+ answer directly (its own reasoning, no extra model call), then reads
95
+ <outdir>/canopy-batch-responses.json for real severity scores + threat
96
+ slots. First run always exits with next-step instructions; re-run once
97
+ the responses file exists. Mutually exclusive with --deterministic.
98
+ --save Save a baseline snapshot after assessment for future diffing.
99
+ --diff Save baseline and print diff against the previous run (implies --save).
100
+ Exits with code 1 if any severity regressions are detected — CI-safe.
101
+
102
+ canopy batch-submit [options]
103
+ Submit a batch inference job for live severity scoring and exit immediately.
104
+ Jobs complete in minutes to hours. Returns a handle file; use 'canopy batch-fetch' to collect.
105
+ --provider <p> "bedrock" (AWS) or "anthropic" (direct API). Default: anthropic.
106
+ --input <file> Agent definitions JSON (required)
107
+ --handle <file> Where to write the job handle (default: canopy-batch-handle.json)
108
+ --model <m> Model override (anthropic default: claude-haiku-4-5-20251001)
109
+ Bedrock only:
110
+ --s3-bucket <b> S3 bucket for JSONL input/output (required for bedrock)
111
+ --role-arn <a> IAM role ARN with bedrock + s3 permissions (required for bedrock)
112
+ --region <r> AWS region (default: us-east-1)
113
+
114
+ canopy batch-fetch [options]
115
+ Check a submitted job and, if complete, collect results into a .tc.json file.
116
+ Re-run until it reports complete — the handle is idempotent.
117
+ Provider is detected automatically from the handle file.
118
+ --handle <file> Job handle written by batch-submit (default: canopy-batch-handle.json)
119
+ --input <file> Original agent definitions JSON (needed to assemble the threat model)
120
+ --out <file> Path for .tc.json output (default: stdout)
121
+ --diagram Also write a LikeC4 .c4 architecture diagram
122
+
123
+ canopy diff [options]
124
+ Compare the two most recent baseline snapshots for an application.
125
+ --name <name> Application name to look up history for (required if ambiguous)
126
+ --dir <dir> Directory containing .canopy-history/ (default: current directory)
127
+ --exit-code Exit with code 1 if regressions exist (default when run in CI via --diff on assess)
128
+
129
+ canopy a2a-assess [options]
130
+ Fetch Agent Cards from one or more A2A-compliant URLs, walk the delegation
131
+ graph, and produce a .tc.json threat model.
132
+ --url <url> Entry-point agent URL (repeat for multiple). Required.
133
+ --depth <n> Max delegation hops to follow (default: 3)
134
+ --out <file> Path for .tc.json output (default: stdout)
135
+ --diagram Also write a LikeC4 .c4 architecture diagram
136
+ --name <name> Application name override
137
+ --timeout <ms> Per-card fetch timeout in ms (default: 8000)
138
+ --deterministic Score with the heuristic scorer (reproducible, no credentials)
139
+ --save Save a baseline snapshot
140
+ --diff Save + diff against prior run; exits 1 on regressions
141
+
142
+ canopy schema
143
+ Prints the AgentDefinition JSON schema and an example input document.
144
+ Pipe to a file or paste into your agent's context window.
145
+
146
+ canopy demo [--port <n>]
147
+ Serves the built-in threat model explorer (default port 3000).
148
+
149
+ EXAMPLES
150
+
151
+ # Assess from file, write to file
152
+ bunx @waratahlabs/canopy assess --input my-agents.json --out threat-model.tc.json
153
+
154
+ # Assess from stdin (pipe from another tool)
155
+ cat agents.json | bunx @waratahlabs/canopy assess --out threat-model.tc.json
156
+
157
+ # CI shape validation — structural mapping only, no reasoning
158
+ bunx @waratahlabs/canopy assess --input agents.json --deterministic --out threat-model.tc.json
159
+
160
+ # Agentic mode — calling LLM coding agent provides the actual reasoning,
161
+ # no API key needed. First run writes the prompts and exits with instructions;
162
+ # answer them, then re-run the same command.
163
+ bunx @waratahlabs/canopy assess --input agents.json --harness --out threat-model.tc.json
164
+
165
+ # Include architecture diagram
166
+ bunx @waratahlabs/canopy assess --input agents.json --diagram --out threat-model.tc.json
167
+
168
+ # Get the schema for LLM context
169
+ bunx @waratahlabs/canopy schema > canopy-schema.json
170
+ `);
171
+ }
172
+ function die(msg) {
173
+ console.error(`canopy: ${msg}`);
174
+ process.exit(1);
175
+ }
176
+ async function readStdin() {
177
+ const rl = readline.createInterface({ input: process.stdin });
178
+ const lines = [];
179
+ for await (const line of rl)
180
+ lines.push(line);
181
+ return lines.join("\n");
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Schema command — prints the AgentDefinition shape so an LLM can read it
185
+ // ---------------------------------------------------------------------------
186
+ function cmdSchema() {
187
+ const schema = {
188
+ $schema: "https://json-schema.org/draft/2020-12/schema",
189
+ title: "CanopyInput",
190
+ description: "Input document for `canopy assess`. Describe each agent in the system under assessment. " +
191
+ "Canopy maps these definitions to OWASP AIVSS V4 metrics, MITRE ATLAS techniques, " +
192
+ "and registered framework adapters (ACSC, DSTG MEAID) to produce a .tc.json threat model. " +
193
+ "For threat-statement phrasing and depth, the explorer fixtures in demo/data/ are illustrative " +
194
+ "(they predate the current metadata shape, so treat them as prose references, not schema examples): " +
195
+ "demo/data/openclaw-demo.tc.json (personal AI assistant), " +
196
+ "demo/data/openclaw-nemoclaw-demo.tc.json (sandboxed vs baseline comparison). " +
197
+ "See SKILL.md for the full agentic workflow.",
198
+ type: "object",
199
+ required: ["applicationName", "agents"],
200
+ properties: {
201
+ applicationName: {
202
+ type: "string",
203
+ description: "Human-readable name for the system being assessed. Appears in the threat model header.",
204
+ example: "My Agentic Sales Pipeline",
205
+ },
206
+ agents: {
207
+ type: "array",
208
+ minItems: 1,
209
+ description: "One entry per distinct agent or autonomous process in the system.",
210
+ items: {
211
+ type: "object",
212
+ required: [
213
+ "id", "name", "model", "systemPrompt", "tools", "permissions",
214
+ "deploymentContext", "modelComplexity", "mitigationMultiplier",
215
+ "finetuned", "dependencies", "secretManagement", "deployment",
216
+ ],
217
+ properties: {
218
+ id: { type: "string", description: "Stable unique identifier (kebab-case). e.g. 'sales-agent-01'" },
219
+ name: { type: "string", description: "Display name. e.g. 'Sales Data Agent'" },
220
+ model: { type: "string", description: "Model identifier. e.g. 'gpt-4o-mini', 'qwen3.5:4b', 'claude-haiku-4-5'" },
221
+ systemPrompt: { type: "string", description: "The agent's system prompt or a description of its role and constraints." },
222
+ tools: {
223
+ type: "array",
224
+ description: "Tools the agent can invoke.",
225
+ items: {
226
+ type: "object",
227
+ required: ["name", "description"],
228
+ properties: {
229
+ name: { type: "string" },
230
+ description: { type: "string", description: "What the tool does and what systems it touches." },
231
+ dangerous: { type: "boolean", description: "True if the tool has destructive, exfiltration, or privilege-escalation potential." },
232
+ },
233
+ },
234
+ },
235
+ permissions: {
236
+ type: "array",
237
+ items: { type: "string" },
238
+ description: "Access grants. Use 'read:<resource>', 'write:<resource>', 'execute:<resource>' conventions.",
239
+ example: ["read:postgres_salesdb", "write:audit_log", "execute:reporting_api"],
240
+ },
241
+ deploymentContext: {
242
+ type: "string",
243
+ enum: DEPLOYMENT_CONTEXTS,
244
+ description: "Deployment context — drives AIVSS V4 weight profile. Choose the closest match.",
245
+ },
246
+ modelComplexity: {
247
+ type: "string",
248
+ enum: ["Simple", "Moderate", "Complex", "HighlyComplex"],
249
+ description: "Simple = small/local models (≤7B). Moderate = mid-size (7B–70B or GPT-3.5 class). Complex = large frontier (GPT-4 class). HighlyComplex = multi-modal or ensemble.",
250
+ },
251
+ mitigationMultiplier: {
252
+ type: "number",
253
+ minimum: 0.5,
254
+ maximum: 1.0,
255
+ description: "1.0 = no mitigations deployed. Reduce toward 0.5 as guardrails, prompt shields, tool-call validation, etc. are added.",
256
+ },
257
+ finetuned: { type: "boolean", description: "True if the base model has been fine-tuned on domain-specific data." },
258
+ dependencies: {
259
+ type: "array",
260
+ items: { type: "string" },
261
+ description: "External systems this agent contacts. e.g. ['postgres:5432', 'redis:6379', 'langfuse (telemetry)']",
262
+ },
263
+ secretManagement: {
264
+ type: "string",
265
+ description: "How credentials are managed. Be specific — 'env vars, no rotation' vs 'Vault, 24h TTL'.",
266
+ },
267
+ deployment: {
268
+ type: "string",
269
+ description: "How the agent is deployed. e.g. 'Docker container', 'AWS Lambda', 'K8s pod'",
270
+ },
271
+ dataSources: { type: "array", items: { type: "string" }, description: "Data stores the agent reads from." },
272
+ autonomyLevel: {
273
+ type: "string",
274
+ enum: ["supervised", "semi-autonomous", "autonomous"],
275
+ description: "supervised = human approves each action. semi-autonomous = human approves high-risk actions. autonomous = no human in the loop.",
276
+ },
277
+ objectives: { type: "array", items: { type: "string" }, description: "What the agent is trying to accomplish." },
278
+ externalApiCount: { type: "integer", description: "Number of distinct external APIs the agent can reach." },
279
+ },
280
+ },
281
+ },
282
+ handoffs: {
283
+ type: "array",
284
+ description: "Agent-to-agent communication edges. Each edge is a potential context-injection surface (AML.T0080.000).",
285
+ items: {
286
+ type: "object",
287
+ required: ["fromAgentId", "fromAgentName", "toAgentId", "toAgentName"],
288
+ properties: {
289
+ fromAgentId: { type: "string" },
290
+ fromAgentName: { type: "string" },
291
+ toAgentId: { type: "string" },
292
+ toAgentName: { type: "string" },
293
+ },
294
+ },
295
+ },
296
+ },
297
+ example: {
298
+ applicationName: "Sales Data Pipeline",
299
+ agents: [
300
+ {
301
+ id: "operative-agent-01",
302
+ name: "operative-agent",
303
+ model: "qwen3.5:4b",
304
+ systemPrompt: "Retrieve sales data using the reporting API. Fall back to direct DB query only if the API is unavailable.",
305
+ tools: [
306
+ { name: "call_reporting_api", description: "Fetch sales data via the authorised HTTP API.", dangerous: false },
307
+ { name: "query_database", description: "Execute SQL directly against the sales PostgreSQL database. Credentials from env.", dangerous: true },
308
+ ],
309
+ permissions: ["read:reporting_api", "read:postgres_salesdb", "write:audit_log"],
310
+ deploymentContext: "FinancialServices",
311
+ modelComplexity: "Simple",
312
+ mitigationMultiplier: 1.0,
313
+ finetuned: false,
314
+ dependencies: ["reporting-api:8080", "postgres:5432", "redis:6379"],
315
+ secretManagement: "Environment variables, no rotation.",
316
+ deployment: "Docker container",
317
+ autonomyLevel: "semi-autonomous",
318
+ objectives: ["Retrieve monthly sales data"],
319
+ externalApiCount: 1,
320
+ },
321
+ ],
322
+ handoffs: [],
323
+ },
324
+ };
325
+ console.log(JSON.stringify(schema, null, 2));
326
+ }
327
+ // ---------------------------------------------------------------------------
328
+ // Assess command
329
+ // ---------------------------------------------------------------------------
330
+ async function cmdAssess(args) {
331
+ let inputFile;
332
+ let outFile;
333
+ let appNameOverride;
334
+ let deterministic = false;
335
+ let harness = false;
336
+ let diagram = false;
337
+ let save = false;
338
+ let diff = false;
339
+ for (let i = 0; i < args.length; i++) {
340
+ const a = args[i];
341
+ if (a === "--input" || a === "-i")
342
+ inputFile = args[++i];
343
+ else if (a === "--out" || a === "-o")
344
+ outFile = args[++i];
345
+ else if (a === "--name")
346
+ appNameOverride = args[++i];
347
+ else if (a === "--deterministic")
348
+ deterministic = true;
349
+ else if (a === "--harness")
350
+ harness = true;
351
+ else if (a === "--diagram")
352
+ diagram = true;
353
+ else if (a === "--save")
354
+ save = true;
355
+ else if (a === "--diff") {
356
+ diff = true;
357
+ save = true;
358
+ }
359
+ else
360
+ die(`unknown option: ${a}. Run 'canopy --help' for usage.`);
361
+ }
362
+ if (deterministic && harness)
363
+ die("--deterministic and --harness are mutually exclusive.");
364
+ // Read input
365
+ let raw;
366
+ if (inputFile) {
367
+ if (!fs.existsSync(inputFile))
368
+ die(`input file not found: ${inputFile}`);
369
+ raw = fs.readFileSync(inputFile, "utf8");
370
+ }
371
+ else {
372
+ if (process.stdin.isTTY)
373
+ die("no --input file and stdin is a TTY. Pipe JSON or use --input <file>.");
374
+ raw = await readStdin();
375
+ }
376
+ let input;
377
+ try {
378
+ input = JSON.parse(raw);
379
+ }
380
+ catch {
381
+ die("input is not valid JSON. Run 'canopy schema' for the expected format.");
382
+ }
383
+ if (!input.applicationName)
384
+ die("input missing required field: applicationName");
385
+ if (!Array.isArray(input.agents) || input.agents.length === 0)
386
+ die("input must contain at least one agent in agents[]");
387
+ const applicationName = appNameOverride ?? input.applicationName;
388
+ const agents = input.agents;
389
+ const handoffs = input.handoffs ?? [];
390
+ // Run assessment
391
+ let tcFormat;
392
+ let severityMap = new Map();
393
+ if (harness) {
394
+ // Harness path — no API key, no subprocess. Canopy writes the per-agent
395
+ // assessment prompts to a file; the calling LLM coding agent answers them
396
+ // directly with its own reasoning and writes the responses file. First
397
+ // run always throws (no responses yet) with the exact instructions for
398
+ // what to do next; re-run once the responses file exists.
399
+ const { HarnessBatchProvider } = await import("./batch/providers/harness.js");
400
+ const baseDir = outFile ? path.dirname(path.resolve(outFile)) : process.cwd();
401
+ const batchProvider = new HarnessBatchProvider({
402
+ requestsPath: path.join(baseDir, "canopy-batch-requests.json"),
403
+ responsesPath: path.join(baseDir, "canopy-batch-responses.json"),
404
+ });
405
+ tcFormat = await assessWorkflow({
406
+ input: { type: "agents", agents, handoffs },
407
+ provider: { type: "anthropic" }, // only exercised if a batch response fails to parse
408
+ batchProvider,
409
+ applicationName,
410
+ });
411
+ for (const threat of tcFormat.threats ?? []) {
412
+ const agentId = threat.metadata?.find((m) => m.key === "custom:agent-id")?.value;
413
+ const metric = threat.metadata?.find((m) => m.key === "custom:aivss-metric")?.value;
414
+ const subCat = threat.metadata?.find((m) => m.key === "custom:aivss-sub-category")?.value;
415
+ const priority = threat.metadata?.find((m) => m.key === "Priority")?.value;
416
+ if (agentId && metric && subCat && priority) {
417
+ severityMap.set(`${agentId}|${metric}|${subCat}`, priority);
418
+ }
419
+ }
420
+ }
421
+ else if (deterministic || !process.env.ANTHROPIC_API_KEY) {
422
+ if (!deterministic && !process.env.ANTHROPIC_API_KEY) {
423
+ // This used to claim "mock severity (Medium)", which was both wrong and
424
+ // self-deprecating: this path runs the full heuristic scorer, deriving a
425
+ // real per-sub-category severity from the agent definition. It is the
426
+ // reproducible mode, not a placeholder.
427
+ process.stderr.write("canopy: ANTHROPIC_API_KEY not set — scoring deterministically.\n" +
428
+ " The heuristic scorer derives severity from the agent definition:\n" +
429
+ " reproducible across runs, auditable, no credentials required.\n" +
430
+ " Set ANTHROPIC_API_KEY for model-inferred severity, or --harness to\n" +
431
+ " have the calling LLM agent answer the prompts itself.\n" +
432
+ " Pass --deterministic to select this mode explicitly and silence this.\n\n");
433
+ }
434
+ // Deterministic path — heuristic scorer, no provider calls
435
+ const { assembleTCFormat, severityKey, slotKey } = await import("./output/tc-assembler.js");
436
+ const { loadAtlasData } = await import("./aivss/atlas/loader.js");
437
+ const { ATLAS_COVERAGE_MATRIX } = await import("./aivss/atlas/coverage-matrix.js");
438
+ const { heuristicSeverity } = await import("./aivss/scoring/heuristic-scorer.js");
439
+ const atlasYaml = resolveAtlasYaml();
440
+ const atlasData = loadAtlasData(atlasYaml);
441
+ const filledSlots = new Map();
442
+ const severityBySubCategory = new Map();
443
+ for (const agent of agents) {
444
+ for (const entry of ATLAS_COVERAGE_MATRIX) {
445
+ severityBySubCategory.set(severityKey(agent.id, entry.metric, entry.subCategory), heuristicSeverity(agent, entry.metric, entry.subCategory));
446
+ const techniqueId = entry.atlasTechniqueIds[0];
447
+ if (!techniqueId)
448
+ continue;
449
+ const technique = atlasData.techniqueById.get(techniqueId);
450
+ if (!technique)
451
+ continue;
452
+ const severity = severityBySubCategory.get(severityKey(agent.id, entry.metric, entry.subCategory)) ?? "Medium";
453
+ filledSlots.set(slotKey(agent.id, techniqueId, entry.metric, entry.subCategory), {
454
+ threatSource: "A threat actor",
455
+ prerequisites: `with access to ${agent.name}`,
456
+ threatAction: `exploit ${entry.subCategory.toLowerCase()} weaknesses via ${technique.id}`,
457
+ threatImpact: `${severity === "High" ? "critically compromise" : "compromise"} ${agent.name}`,
458
+ impactedGoal: ["confidentiality", "integrity"],
459
+ impactedAssets: [agent.name.toLowerCase()],
460
+ });
461
+ }
462
+ }
463
+ tcFormat = assembleTCFormat({
464
+ agents, handoffs, metricResponses: [], atlasData,
465
+ coverageMatrix: ATLAS_COVERAGE_MATRIX, filledSlots, severityBySubCategory,
466
+ applicationName,
467
+ scoringMode: "deterministic",
468
+ });
469
+ severityMap = severityBySubCategory;
470
+ }
471
+ else {
472
+ tcFormat = await assessWorkflow({
473
+ input: { type: "agents", agents, handoffs },
474
+ provider: { type: "anthropic" },
475
+ applicationName,
476
+ });
477
+ // Reconstruct severity map from TC threat metadata for baseline
478
+ for (const threat of tcFormat.threats ?? []) {
479
+ const agentId = threat.metadata?.find((m) => m.key === "custom:agent-id")?.value;
480
+ const metric = threat.metadata?.find((m) => m.key === "custom:aivss-metric")?.value;
481
+ const subCat = threat.metadata?.find((m) => m.key === "custom:aivss-sub-category")?.value;
482
+ const priority = threat.metadata?.find((m) => m.key === "Priority")?.value;
483
+ if (agentId && metric && subCat && priority) {
484
+ severityMap.set(`${agentId}|${metric}|${subCat}`, priority);
485
+ }
486
+ }
487
+ }
488
+ // Validate against official TC schema
489
+ const { valid, errors } = validateTCOutput(tcFormat);
490
+ if (!valid) {
491
+ process.stderr.write(`\ncanopy: TC schema validation failed (${errors.length} error(s)):\n`);
492
+ for (const e of errors)
493
+ process.stderr.write(` · ${e}\n`);
494
+ process.stderr.write("\nOutput NOT written. Fix the assembler or report a Canopy bug.\n");
495
+ process.exit(1);
496
+ }
497
+ // Write TC output
498
+ const tcJson = JSON.stringify(tcFormat, null, 2);
499
+ if (outFile) {
500
+ fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
501
+ fs.writeFileSync(outFile, tcJson);
502
+ process.stderr.write(`✓ ${(tcFormat.threats ?? []).length} threats · ` +
503
+ `${(tcFormat.mitigations ?? []).length} mitigations → ${outFile}\n`);
504
+ }
505
+ else {
506
+ process.stdout.write(tcJson + "\n");
507
+ }
508
+ // Optional diagram
509
+ if (diagram) {
510
+ const c4 = generateLikeC4({ agents, handoffs, applicationName });
511
+ const c4File = outFile
512
+ ? path.join(path.dirname(outFile), likec4Filename(applicationName))
513
+ : likec4Filename(applicationName);
514
+ fs.writeFileSync(c4File, c4);
515
+ process.stderr.write(`✓ architecture diagram → ${c4File}\n`);
516
+ }
517
+ // Save baseline and optional diff
518
+ if (save) {
519
+ const baseDir = outFile ? path.dirname(path.resolve(outFile)) : process.cwd();
520
+ const baseline = buildBaseline(applicationName, agents.map((a) => ({ id: a.id, name: a.name })), severityMap, outFile);
521
+ const baselinePath = saveBaseline(baseline, baseDir);
522
+ process.stderr.write(`✓ baseline saved → ${baselinePath}\n`);
523
+ if (diff) {
524
+ const recent = loadRecentBaselines(baseDir, applicationName, 2);
525
+ if (recent.length >= 2) {
526
+ const d = diffBaselines(recent[1], recent[0]);
527
+ process.stderr.write("\n" + formatDiff(d) + "\n");
528
+ if (d.hasRegressions)
529
+ process.exit(1);
530
+ }
531
+ else {
532
+ process.stderr.write(" (no prior baseline to diff against — first run saved)\n");
533
+ }
534
+ }
535
+ }
536
+ }
537
+ // ---------------------------------------------------------------------------
538
+ // Diff command — compare two most-recent baselines for an application
539
+ // ---------------------------------------------------------------------------
540
+ async function cmdDiff(args) {
541
+ let appName;
542
+ let baseDir = process.cwd();
543
+ let exitCode = false;
544
+ for (let i = 0; i < args.length; i++) {
545
+ const a = args[i];
546
+ if (a === "--app" || a === "--application")
547
+ appName = args[++i];
548
+ else if (a === "--dir")
549
+ baseDir = args[++i] ?? baseDir;
550
+ else if (a === "--exit-code")
551
+ exitCode = true;
552
+ else if (!appName && a && !a.startsWith("--"))
553
+ appName = a;
554
+ }
555
+ if (!appName) {
556
+ process.stderr.write("canopy diff: --app <name> is required\n");
557
+ process.exit(1);
558
+ }
559
+ const recent = loadRecentBaselines(baseDir, appName, 2);
560
+ if (recent.length < 2) {
561
+ process.stderr.write(`canopy diff: fewer than 2 baselines found for '${appName}' in ${baseDir}/.canopy-history/\n` +
562
+ ` Run 'canopy assess --save' at least twice to build a diff history.\n`);
563
+ process.exit(0);
564
+ }
565
+ const d = diffBaselines(recent[1], recent[0]);
566
+ process.stdout.write(formatDiff(d) + "\n");
567
+ if (exitCode && d.hasRegressions)
568
+ process.exit(1);
569
+ }
570
+ // ---------------------------------------------------------------------------
571
+ // Batch commands — fire-and-forget Bedrock batch submit + deferred fetch
572
+ // ---------------------------------------------------------------------------
573
+ async function cmdBatchSubmit(args) {
574
+ let provider = "anthropic";
575
+ let inputFile;
576
+ let handleFile = "canopy-batch-handle.json";
577
+ let model;
578
+ let s3Bucket;
579
+ let roleArn;
580
+ let region;
581
+ for (let i = 0; i < args.length; i++) {
582
+ const a = args[i];
583
+ if (a === "--provider") {
584
+ const v = args[++i];
585
+ if (v)
586
+ provider = v;
587
+ }
588
+ else if (a === "--input" || a === "-i")
589
+ inputFile = args[++i];
590
+ else if (a === "--handle")
591
+ handleFile = args[++i] ?? handleFile;
592
+ else if (a === "--model")
593
+ model = args[++i];
594
+ else if (a === "--s3-bucket")
595
+ s3Bucket = args[++i];
596
+ else if (a === "--role-arn")
597
+ roleArn = args[++i];
598
+ else if (a === "--region")
599
+ region = args[++i];
600
+ else
601
+ die(`unknown option: ${a}`);
602
+ }
603
+ if (!inputFile)
604
+ die("--input <file> is required");
605
+ if (provider !== "anthropic" && provider !== "bedrock")
606
+ die(`unknown provider "${provider}" — use "anthropic" or "bedrock"`);
607
+ if (provider === "bedrock" && (!s3Bucket || !roleArn))
608
+ die("--provider bedrock requires --s3-bucket and --role-arn");
609
+ if (!fs.existsSync(inputFile))
610
+ die(`input file not found: ${inputFile}`);
611
+ let input;
612
+ try {
613
+ input = JSON.parse(fs.readFileSync(inputFile, "utf8"));
614
+ }
615
+ catch {
616
+ die("input is not valid JSON");
617
+ }
618
+ if (!Array.isArray(input.agents) || input.agents.length === 0)
619
+ die("input must contain at least one agent in agents[]");
620
+ process.stderr.write(`canopy: building batch requests for ${input.agents.length} agent(s) via ${provider}...\n`);
621
+ let handle;
622
+ if (provider === "anthropic") {
623
+ handle = await submitAnthropicBatch({ agents: input.agents, config: { model } });
624
+ process.stderr.write(`\n✓ Anthropic batch submitted\n`);
625
+ process.stderr.write(` Batch ID: ${handle.batchId}\n`);
626
+ }
627
+ else {
628
+ handle = await submitBedrockBatch({ agents: input.agents, config: { s3Bucket: s3Bucket, roleArn: roleArn, region } });
629
+ process.stderr.write(`\n✓ Bedrock batch submitted\n`);
630
+ process.stderr.write(` Job ARN: ${handle.jobArn}\n`);
631
+ }
632
+ process.stderr.write(` Submitted: ${handle.submittedAt}\n`);
633
+ process.stderr.write(` Handle: ${handleFile}\n\n`);
634
+ fs.writeFileSync(handleFile, JSON.stringify(handle, null, 2));
635
+ process.stderr.write(`When the job completes (minutes to hours), collect results:\n`);
636
+ process.stderr.write(` canopy batch-fetch --handle ${handleFile} --input ${inputFile} --out threat-model.tc.json\n`);
637
+ }
638
+ async function cmdBatchFetch(args) {
639
+ let handleFile = "canopy-batch-handle.json";
640
+ let inputFile;
641
+ let outFile;
642
+ let diagram = false;
643
+ for (let i = 0; i < args.length; i++) {
644
+ const a = args[i];
645
+ if (a === "--handle")
646
+ handleFile = args[++i] ?? handleFile;
647
+ else if (a === "--input" || a === "-i")
648
+ inputFile = args[++i];
649
+ else if (a === "--out" || a === "-o")
650
+ outFile = args[++i];
651
+ else if (a === "--diagram")
652
+ diagram = true;
653
+ else
654
+ die(`unknown option: ${a}`);
655
+ }
656
+ if (!fs.existsSync(handleFile))
657
+ die(`handle file not found: ${handleFile}. Run 'canopy batch-submit' first.`);
658
+ let handle;
659
+ try {
660
+ handle = JSON.parse(fs.readFileSync(handleFile, "utf8"));
661
+ }
662
+ catch {
663
+ die(`${handleFile} is not valid JSON`);
664
+ }
665
+ if (!handle.provider)
666
+ die(`handle file is missing "provider" field — was it created by an older version of canopy?`);
667
+ let responses;
668
+ let errorCount;
669
+ if (handle.provider === "anthropic") {
670
+ const h = handle;
671
+ process.stderr.write(`canopy: checking Anthropic batch ${h.batchId}...\n`);
672
+ const result = await fetchAnthropicBatch({ handle: h });
673
+ if (!result.ready) {
674
+ process.stderr.write(` Status: ${result.status}`);
675
+ if ("counts" in result && result.counts) {
676
+ process.stderr.write(` — ${result.counts.processing} processing, ${result.counts.succeeded} succeeded, ${result.counts.errored} errored`);
677
+ }
678
+ process.stderr.write(` — not yet complete. Re-run this command later.\n`);
679
+ process.exit(0);
680
+ }
681
+ responses = result.responses;
682
+ errorCount = result.errorCount;
683
+ if (errorCount > 0)
684
+ process.stderr.write(` ⚠ ${errorCount} record(s) errored — degraded responses included.\n`);
685
+ }
686
+ else {
687
+ const h = handle;
688
+ process.stderr.write(`canopy: checking Bedrock job ${h.jobArn}...\n`);
689
+ const result = await fetchBedrockBatch({ handle: h });
690
+ if (!result.ready) {
691
+ process.stderr.write(` Status: ${result.status} — not yet complete. Re-run this command later.\n`);
692
+ process.exit(0);
693
+ }
694
+ responses = result.responses;
695
+ errorCount = result.errorCount;
696
+ if (errorCount > 0)
697
+ process.stderr.write(` ⚠ ${errorCount} record(s) returned errors from Bedrock — degraded responses included.\n`);
698
+ }
699
+ // Assemble threat model from batch responses
700
+ if (!inputFile)
701
+ die("--input <file> is required to assemble the threat model");
702
+ if (!fs.existsSync(inputFile))
703
+ die(`input file not found: ${inputFile}`);
704
+ let input;
705
+ try {
706
+ input = JSON.parse(fs.readFileSync(inputFile, "utf8"));
707
+ }
708
+ catch {
709
+ die("input is not valid JSON");
710
+ }
711
+ const { assembleTCFormat, severityKey } = await import("./output/tc-assembler.js");
712
+ const { loadAtlasData } = await import("./aivss/atlas/loader.js");
713
+ const { ATLAS_COVERAGE_MATRIX } = await import("./aivss/atlas/coverage-matrix.js");
714
+ const atlasData = loadAtlasData(resolveAtlasYaml());
715
+ const agents = input.agents;
716
+ const handoffs = input.handoffs ?? [];
717
+ const applicationName = input.applicationName;
718
+ // Build severity map from real batch responses (per-agent)
719
+ const severityBySubCategory = new Map();
720
+ const byAgentMetric = new Map();
721
+ for (const r of responses) {
722
+ if (r.agentId)
723
+ byAgentMetric.set(`${r.agentId}|${r.metricCode}`, r);
724
+ }
725
+ for (const agent of agents) {
726
+ for (const entry of ATLAS_COVERAGE_MATRIX) {
727
+ const response = byAgentMetric.get(`${agent.id}|${entry.metric}`);
728
+ const severity = response?.subCategoryScores?.[entry.subCategory] ?? "Medium";
729
+ severityBySubCategory.set(severityKey(agent.id, entry.metric, entry.subCategory), severity);
730
+ }
731
+ }
732
+ // Build filled slots from batch response threatSlots; fall back to structural stubs only when absent.
733
+ const { slotKey } = await import("./output/tc-assembler.js");
734
+ const filledSlots = new Map();
735
+ // Index threatSlots by (agentId, metricCode, subCategory) for O(1) lookup.
736
+ const slotIndex = new Map();
737
+ for (const r of responses) {
738
+ if (!r.threatSlots || !r.agentId)
739
+ continue;
740
+ for (const [subCat, slots] of Object.entries(r.threatSlots)) {
741
+ slotIndex.set(`${r.agentId}|${r.metricCode}|${subCat}`, slots);
742
+ }
743
+ }
744
+ for (const agent of agents) {
745
+ for (const entry of ATLAS_COVERAGE_MATRIX) {
746
+ const techniqueId = entry.atlasTechniqueIds[0];
747
+ if (!techniqueId)
748
+ continue;
749
+ const technique = atlasData.techniqueById.get(techniqueId);
750
+ if (!technique)
751
+ continue;
752
+ const severity = severityBySubCategory.get(severityKey(agent.id, entry.metric, entry.subCategory)) ?? "Medium";
753
+ const live = slotIndex.get(`${agent.id}|${entry.metric}|${entry.subCategory}`);
754
+ filledSlots.set(slotKey(agent.id, techniqueId, entry.metric, entry.subCategory), live ?? {
755
+ threatSource: "A threat actor",
756
+ prerequisites: `with access to ${agent.name}`,
757
+ threatAction: `exploit ${entry.subCategory.toLowerCase()} weaknesses via ${technique.id}`,
758
+ threatImpact: `${severity === "High" || severity === "Critical" ? "critically compromise" : "compromise"} ${agent.name}`,
759
+ impactedGoal: ["confidentiality", "integrity"],
760
+ impactedAssets: [agent.name.toLowerCase()],
761
+ });
762
+ }
763
+ }
764
+ const tcFormat = assembleTCFormat({
765
+ agents, handoffs, metricResponses: responses, atlasData,
766
+ coverageMatrix: ATLAS_COVERAGE_MATRIX, filledSlots, severityBySubCategory, applicationName,
767
+ // batch-fetch collects results from a provider batch job (Bedrock or
768
+ // Anthropic), so severities here are always model-inferred. The harness
769
+ // path is a separate command and stamps its own mode.
770
+ scoringMode: "model",
771
+ });
772
+ const { valid, errors } = validateTCOutput(tcFormat);
773
+ if (!valid) {
774
+ process.stderr.write(`\ncanopy: TC schema validation failed (${errors.length} error(s)):\n`);
775
+ for (const e of errors)
776
+ process.stderr.write(` · ${e}\n`);
777
+ process.exit(1);
778
+ }
779
+ const tcJson = JSON.stringify(tcFormat, null, 2);
780
+ if (outFile) {
781
+ fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
782
+ fs.writeFileSync(outFile, tcJson);
783
+ process.stderr.write(`✓ ${(tcFormat.threats ?? []).length} threats · ${(tcFormat.mitigations ?? []).length} mitigations → ${outFile}\n`);
784
+ }
785
+ else {
786
+ process.stdout.write(tcJson + "\n");
787
+ }
788
+ if (diagram) {
789
+ const c4 = generateLikeC4({ agents, handoffs, applicationName });
790
+ const c4File = outFile
791
+ ? path.join(path.dirname(outFile), likec4Filename(applicationName))
792
+ : likec4Filename(applicationName);
793
+ fs.writeFileSync(c4File, c4);
794
+ process.stderr.write(`✓ architecture diagram → ${c4File}\n`);
795
+ }
796
+ }
797
+ // ---------------------------------------------------------------------------
798
+ // A2A assess command
799
+ // ---------------------------------------------------------------------------
800
+ async function cmdA2AAssess(args) {
801
+ const urls = [];
802
+ let outFile;
803
+ let applicationName;
804
+ let maxDepth = 3;
805
+ let timeoutMs = 8000;
806
+ let diagram = false;
807
+ let noProvider = false;
808
+ let save = false;
809
+ let diff = false;
810
+ for (let i = 0; i < args.length; i++) {
811
+ const a = args[i];
812
+ if (a === "--url" || a === "-u") {
813
+ const v = args[++i];
814
+ if (v)
815
+ urls.push(v);
816
+ }
817
+ else if (a === "--out" || a === "-o")
818
+ outFile = args[++i];
819
+ else if (a === "--name")
820
+ applicationName = args[++i];
821
+ else if (a === "--depth")
822
+ maxDepth = parseInt(args[++i] ?? "3", 10);
823
+ else if (a === "--timeout")
824
+ timeoutMs = parseInt(args[++i] ?? "8000", 10);
825
+ else if (a === "--diagram")
826
+ diagram = true;
827
+ else if (a === "--deterministic")
828
+ noProvider = true;
829
+ else if (a === "--save")
830
+ save = true;
831
+ else if (a === "--diff") {
832
+ diff = true;
833
+ save = true;
834
+ }
835
+ else if (a && !a.startsWith("--"))
836
+ urls.push(a); // bare URL positional arg
837
+ }
838
+ if (urls.length === 0) {
839
+ process.stderr.write("canopy a2a-assess: at least one --url is required\n");
840
+ process.exit(1);
841
+ }
842
+ process.stderr.write(`Fetching Agent Cards from ${urls.length} URL(s), max depth ${maxDepth}…\n`);
843
+ const result = await walkA2AGraph({
844
+ urls,
845
+ maxDepth,
846
+ timeoutMs,
847
+ onCard: (url, card) => {
848
+ if (card)
849
+ process.stderr.write(` ✓ ${card.name} (${url})\n`);
850
+ else
851
+ process.stderr.write(` ✗ unreachable: ${url}\n`);
852
+ },
853
+ });
854
+ if (result.agents.length === 0) {
855
+ process.stderr.write("canopy a2a-assess: no agents discovered — check URLs and connectivity\n");
856
+ process.exit(1);
857
+ }
858
+ process.stderr.write(`Discovered ${result.agents.length} agent(s), ${result.handoffs.length} handoff(s), depth ${result.depth}\n`);
859
+ if (result.unreachable.length > 0) {
860
+ process.stderr.write(`Unreachable (skipped): ${result.unreachable.join(", ")}\n`);
861
+ }
862
+ const appName = applicationName ?? result.workflowName;
863
+ let tcFormat;
864
+ let severityMap = new Map();
865
+ const useMock = noProvider || !process.env.ANTHROPIC_API_KEY;
866
+ if (useMock) {
867
+ if (!noProvider) {
868
+ process.stderr.write("canopy: ANTHROPIC_API_KEY not set — scoring deterministically (heuristic scorer).\n" +
869
+ " Set ANTHROPIC_API_KEY for live inference, or pass --deterministic to silence this.\n\n");
870
+ }
871
+ const { assembleTCFormat, severityKey, slotKey } = await import("./output/tc-assembler.js");
872
+ const { loadAtlasData } = await import("./aivss/atlas/loader.js");
873
+ const { ATLAS_COVERAGE_MATRIX } = await import("./aivss/atlas/coverage-matrix.js");
874
+ const { heuristicSeverity } = await import("./aivss/scoring/heuristic-scorer.js");
875
+ const atlasYaml = resolveAtlasYaml();
876
+ const atlasData = loadAtlasData(atlasYaml);
877
+ const filledSlots = new Map();
878
+ const severityBySubCategory = new Map();
879
+ for (const agent of result.agents) {
880
+ for (const entry of ATLAS_COVERAGE_MATRIX) {
881
+ severityBySubCategory.set(severityKey(agent.id, entry.metric, entry.subCategory), heuristicSeverity(agent, entry.metric, entry.subCategory));
882
+ const techniqueId = entry.atlasTechniqueIds[0];
883
+ if (!techniqueId)
884
+ continue;
885
+ const technique = atlasData.techniqueById.get(techniqueId);
886
+ if (!technique)
887
+ continue;
888
+ const severity = severityBySubCategory.get(severityKey(agent.id, entry.metric, entry.subCategory)) ?? "Medium";
889
+ filledSlots.set(slotKey(agent.id, techniqueId, entry.metric, entry.subCategory), {
890
+ threatSource: "A threat actor",
891
+ prerequisites: `with access to ${agent.name}`,
892
+ threatAction: `exploit ${entry.subCategory.toLowerCase()} weaknesses via ${technique.id}`,
893
+ threatImpact: `${severity === "High" ? "critically compromise" : "compromise"} ${agent.name}`,
894
+ impactedGoal: ["confidentiality", "integrity"],
895
+ impactedAssets: [agent.name.toLowerCase()],
896
+ });
897
+ }
898
+ }
899
+ tcFormat = assembleTCFormat({
900
+ agents: result.agents, handoffs: result.handoffs, metricResponses: [], atlasData,
901
+ coverageMatrix: ATLAS_COVERAGE_MATRIX, filledSlots, severityBySubCategory,
902
+ applicationName: appName,
903
+ scoringMode: "deterministic",
904
+ });
905
+ severityMap = severityBySubCategory;
906
+ }
907
+ else {
908
+ tcFormat = await assessWorkflow({
909
+ input: { type: "agents", agents: result.agents, handoffs: result.handoffs },
910
+ provider: { type: "anthropic" },
911
+ applicationName: appName,
912
+ });
913
+ for (const threat of tcFormat.threats ?? []) {
914
+ const agentId = threat.metadata?.find((m) => m.key === "custom:agent-id")?.value;
915
+ const metric = threat.metadata?.find((m) => m.key === "custom:aivss-metric")?.value;
916
+ const subCat = threat.metadata?.find((m) => m.key === "custom:aivss-sub-category")?.value;
917
+ const priority = threat.metadata?.find((m) => m.key === "Priority")?.value;
918
+ if (agentId && metric && subCat && priority) {
919
+ severityMap.set(`${agentId}|${metric}|${subCat}`, priority);
920
+ }
921
+ }
922
+ }
923
+ const { valid, errors } = validateTCOutput(tcFormat);
924
+ if (!valid) {
925
+ process.stderr.write(`\ncanopy: TC schema validation failed (${errors.length} error(s)):\n`);
926
+ for (const e of errors)
927
+ process.stderr.write(` · ${e}\n`);
928
+ process.exit(1);
929
+ }
930
+ const tcJson = JSON.stringify(tcFormat, null, 2);
931
+ if (outFile) {
932
+ fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
933
+ fs.writeFileSync(outFile, tcJson);
934
+ process.stderr.write(`✓ ${(tcFormat.threats ?? []).length} threats · ${(tcFormat.mitigations ?? []).length} mitigations → ${outFile}\n`);
935
+ }
936
+ else {
937
+ process.stdout.write(tcJson + "\n");
938
+ }
939
+ if (diagram) {
940
+ const c4 = generateLikeC4({ agents: result.agents, handoffs: result.handoffs, applicationName: appName });
941
+ const c4File = outFile ? path.join(path.dirname(outFile), likec4Filename(appName)) : likec4Filename(appName);
942
+ fs.writeFileSync(c4File, c4);
943
+ process.stderr.write(`✓ architecture diagram → ${c4File}\n`);
944
+ }
945
+ if (save) {
946
+ const baseDir = outFile ? path.dirname(path.resolve(outFile)) : process.cwd();
947
+ const baseline = buildBaseline(appName, result.agents.map((a) => ({ id: a.id, name: a.name })), severityMap, outFile);
948
+ const baselinePath = saveBaseline(baseline, baseDir);
949
+ process.stderr.write(`✓ baseline saved → ${baselinePath}\n`);
950
+ if (diff) {
951
+ const recent = loadRecentBaselines(baseDir, appName, 2);
952
+ if (recent.length >= 2) {
953
+ const d = diffBaselines(recent[1], recent[0]);
954
+ process.stderr.write("\n" + formatDiff(d) + "\n");
955
+ if (d.hasRegressions)
956
+ process.exit(1);
957
+ }
958
+ else {
959
+ process.stderr.write(" (no prior baseline — first run saved)\n");
960
+ }
961
+ }
962
+ }
963
+ }
964
+ // ---------------------------------------------------------------------------
965
+ // Demo command
966
+ // ---------------------------------------------------------------------------
967
+ async function cmdDemo(args) {
968
+ let port = "3000";
969
+ for (let i = 0; i < args.length; i++) {
970
+ if (args[i] === "--port" || args[i] === "-p")
971
+ port = args[++i] ?? "3000";
972
+ }
973
+ const demoDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../demo");
974
+ if (!fs.existsSync(demoDir)) {
975
+ die(`demo directory not found at ${demoDir}. Is the package installed correctly?`);
976
+ }
977
+ process.stderr.write(`canopy demo serving at http://localhost:${port}\n`);
978
+ Bun.serve({
979
+ port: parseInt(port, 10),
980
+ async fetch(req) {
981
+ const url = new URL(req.url);
982
+ const filePath = path.join(demoDir, url.pathname === "/" ? "index.html" : url.pathname);
983
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
984
+ return new Response("Not found", { status: 404 });
985
+ }
986
+ const ext = path.extname(filePath);
987
+ const mime = {
988
+ ".html": "text/html", ".js": "text/javascript",
989
+ ".json": "application/json", ".css": "text/css",
990
+ };
991
+ return new Response(fs.readFileSync(filePath), {
992
+ headers: { "Content-Type": mime[ext] ?? "application/octet-stream" },
993
+ });
994
+ },
995
+ });
996
+ // Keep process alive
997
+ await new Promise(() => { });
998
+ }
999
+ // ---------------------------------------------------------------------------
1000
+ // Entry point
1001
+ // ---------------------------------------------------------------------------
1002
+ async function main() {
1003
+ const [, , cmd, ...rest] = process.argv;
1004
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
1005
+ usage();
1006
+ process.exit(0);
1007
+ }
1008
+ if (cmd === "schema") {
1009
+ cmdSchema();
1010
+ }
1011
+ else if (cmd === "assess") {
1012
+ await cmdAssess(rest);
1013
+ }
1014
+ else if (cmd === "batch-submit") {
1015
+ await cmdBatchSubmit(rest);
1016
+ }
1017
+ else if (cmd === "batch-fetch") {
1018
+ await cmdBatchFetch(rest);
1019
+ }
1020
+ else if (cmd === "demo") {
1021
+ await cmdDemo(rest);
1022
+ }
1023
+ else if (cmd === "a2a-assess") {
1024
+ await cmdA2AAssess(rest);
1025
+ }
1026
+ else if (cmd === "diff") {
1027
+ await cmdDiff(rest);
1028
+ }
1029
+ else {
1030
+ die(`unknown command: ${cmd}. Run 'canopy --help' for usage.`);
1031
+ }
1032
+ }
1033
+ main().catch((err) => {
1034
+ console.error(err instanceof Error ? err.message : String(err));
1035
+ process.exit(1);
1036
+ });
1037
+ //# sourceMappingURL=cli.js.map