@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.
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/SKILL.md +97 -0
- package/demo/data/openclaw-demo.tc.json +397 -0
- package/demo/data/openclaw-nemoclaw-demo.tc.json +475 -0
- package/demo/index.html +556 -0
- package/dist/adapters/a2a.d.ts +82 -0
- package/dist/adapters/a2a.d.ts.map +1 -0
- package/dist/adapters/a2a.js +215 -0
- package/dist/adapters/a2a.js.map +1 -0
- package/dist/adapters/a2a.test.d.ts +2 -0
- package/dist/adapters/a2a.test.d.ts.map +1 -0
- package/dist/adapters/a2a.test.js +196 -0
- package/dist/adapters/a2a.test.js.map +1 -0
- package/dist/adapters/langfuse.d.ts +28 -0
- package/dist/adapters/langfuse.d.ts.map +1 -0
- package/dist/adapters/langfuse.js +192 -0
- package/dist/adapters/langfuse.js.map +1 -0
- package/dist/adapters/n8n.d.ts +4 -0
- package/dist/adapters/n8n.d.ts.map +1 -0
- package/dist/adapters/n8n.js +89 -0
- package/dist/adapters/n8n.js.map +1 -0
- package/dist/adapters/openclaw.d.ts +58 -0
- package/dist/adapters/openclaw.d.ts.map +1 -0
- package/dist/adapters/openclaw.js +275 -0
- package/dist/adapters/openclaw.js.map +1 -0
- package/dist/adapters/types.d.ts +33 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/aivss/atlas/coverage-matrix.d.ts +12 -0
- package/dist/aivss/atlas/coverage-matrix.d.ts.map +1 -0
- package/dist/aivss/atlas/coverage-matrix.js +272 -0
- package/dist/aivss/atlas/coverage-matrix.js.map +1 -0
- package/dist/aivss/atlas/loader.d.ts +28 -0
- package/dist/aivss/atlas/loader.d.ts.map +1 -0
- package/dist/aivss/atlas/loader.js +126 -0
- package/dist/aivss/atlas/loader.js.map +1 -0
- package/dist/aivss/atlas/loader.test.d.ts +11 -0
- package/dist/aivss/atlas/loader.test.d.ts.map +1 -0
- package/dist/aivss/atlas/loader.test.js +111 -0
- package/dist/aivss/atlas/loader.test.js.map +1 -0
- package/dist/aivss/atlas/resolve-path.d.ts +34 -0
- package/dist/aivss/atlas/resolve-path.d.ts.map +1 -0
- package/dist/aivss/atlas/resolve-path.js +58 -0
- package/dist/aivss/atlas/resolve-path.js.map +1 -0
- package/dist/aivss/batch/batch-builder.d.ts +11 -0
- package/dist/aivss/batch/batch-builder.d.ts.map +1 -0
- package/dist/aivss/batch/batch-builder.js +75 -0
- package/dist/aivss/batch/batch-builder.js.map +1 -0
- package/dist/aivss/index.d.ts +21 -0
- package/dist/aivss/index.d.ts.map +1 -0
- package/dist/aivss/index.js +19 -0
- package/dist/aivss/index.js.map +1 -0
- package/dist/aivss/knowledge-base/kb-client.d.ts +9 -0
- package/dist/aivss/knowledge-base/kb-client.d.ts.map +1 -0
- package/dist/aivss/knowledge-base/kb-client.js +139 -0
- package/dist/aivss/knowledge-base/kb-client.js.map +1 -0
- package/dist/aivss/knowledge-base/kb-types.d.ts +21 -0
- package/dist/aivss/knowledge-base/kb-types.d.ts.map +1 -0
- package/dist/aivss/knowledge-base/kb-types.js +2 -0
- package/dist/aivss/knowledge-base/kb-types.js.map +1 -0
- package/dist/aivss/knowledge-base/search-builder.d.ts +7 -0
- package/dist/aivss/knowledge-base/search-builder.d.ts.map +1 -0
- package/dist/aivss/knowledge-base/search-builder.js +119 -0
- package/dist/aivss/knowledge-base/search-builder.js.map +1 -0
- package/dist/aivss/metrics/aa.d.ts +3 -0
- package/dist/aivss/metrics/aa.d.ts.map +1 -0
- package/dist/aivss/metrics/aa.js +26 -0
- package/dist/aivss/metrics/aa.js.map +1 -0
- package/dist/aivss/metrics/ad.d.ts +3 -0
- package/dist/aivss/metrics/ad.d.ts.map +1 -0
- package/dist/aivss/metrics/ad.js +30 -0
- package/dist/aivss/metrics/ad.js.map +1 -0
- package/dist/aivss/metrics/cs.d.ts +3 -0
- package/dist/aivss/metrics/cs.d.ts.map +1 -0
- package/dist/aivss/metrics/cs.js +50 -0
- package/dist/aivss/metrics/cs.js.map +1 -0
- package/dist/aivss/metrics/dc.d.ts +3 -0
- package/dist/aivss/metrics/dc.d.ts.map +1 -0
- package/dist/aivss/metrics/dc.js +30 -0
- package/dist/aivss/metrics/dc.js.map +1 -0
- package/dist/aivss/metrics/ds.d.ts +3 -0
- package/dist/aivss/metrics/ds.d.ts.map +1 -0
- package/dist/aivss/metrics/ds.js +26 -0
- package/dist/aivss/metrics/ds.js.map +1 -0
- package/dist/aivss/metrics/ei.d.ts +3 -0
- package/dist/aivss/metrics/ei.d.ts.map +1 -0
- package/dist/aivss/metrics/ei.js +30 -0
- package/dist/aivss/metrics/ei.js.map +1 -0
- package/dist/aivss/metrics/gv.d.ts +3 -0
- package/dist/aivss/metrics/gv.d.ts.map +1 -0
- package/dist/aivss/metrics/gv.js +34 -0
- package/dist/aivss/metrics/gv.js.map +1 -0
- package/dist/aivss/metrics/ll.d.ts +3 -0
- package/dist/aivss/metrics/ll.d.ts.map +1 -0
- package/dist/aivss/metrics/ll.js +30 -0
- package/dist/aivss/metrics/ll.js.map +1 -0
- package/dist/aivss/metrics/mr.d.ts +3 -0
- package/dist/aivss/metrics/mr.d.ts.map +1 -0
- package/dist/aivss/metrics/mr.js +26 -0
- package/dist/aivss/metrics/mr.js.map +1 -0
- package/dist/aivss/metrics/shared.d.ts +22 -0
- package/dist/aivss/metrics/shared.d.ts.map +1 -0
- package/dist/aivss/metrics/shared.js +70 -0
- package/dist/aivss/metrics/shared.js.map +1 -0
- package/dist/aivss/prompts/base-system-prompt.d.ts +2 -0
- package/dist/aivss/prompts/base-system-prompt.d.ts.map +1 -0
- package/dist/aivss/prompts/base-system-prompt.js +31 -0
- package/dist/aivss/prompts/base-system-prompt.js.map +1 -0
- package/dist/aivss/prompts/prompt-composer.d.ts +13 -0
- package/dist/aivss/prompts/prompt-composer.d.ts.map +1 -0
- package/dist/aivss/prompts/prompt-composer.js +21 -0
- package/dist/aivss/prompts/prompt-composer.js.map +1 -0
- package/dist/aivss/scoring/a2a-corpus.test.d.ts +28 -0
- package/dist/aivss/scoring/a2a-corpus.test.d.ts.map +1 -0
- package/dist/aivss/scoring/a2a-corpus.test.js +109 -0
- package/dist/aivss/scoring/a2a-corpus.test.js.map +1 -0
- package/dist/aivss/scoring/determinism.test.d.ts +16 -0
- package/dist/aivss/scoring/determinism.test.d.ts.map +1 -0
- package/dist/aivss/scoring/determinism.test.js +169 -0
- package/dist/aivss/scoring/determinism.test.js.map +1 -0
- package/dist/aivss/scoring/heuristic-scorer.d.ts +21 -0
- package/dist/aivss/scoring/heuristic-scorer.d.ts.map +1 -0
- package/dist/aivss/scoring/heuristic-scorer.js +299 -0
- package/dist/aivss/scoring/heuristic-scorer.js.map +1 -0
- package/dist/aivss/scoring/heuristic-scorer.test.d.ts +8 -0
- package/dist/aivss/scoring/heuristic-scorer.test.d.ts.map +1 -0
- package/dist/aivss/scoring/heuristic-scorer.test.js +150 -0
- package/dist/aivss/scoring/heuristic-scorer.test.js.map +1 -0
- package/dist/aivss/scoring/scoring-mode.d.ts +48 -0
- package/dist/aivss/scoring/scoring-mode.d.ts.map +1 -0
- package/dist/aivss/scoring/scoring-mode.js +64 -0
- package/dist/aivss/scoring/scoring-mode.js.map +1 -0
- package/dist/aivss/scoring/v4-engine.d.ts +15 -0
- package/dist/aivss/scoring/v4-engine.d.ts.map +1 -0
- package/dist/aivss/scoring/v4-engine.js +84 -0
- package/dist/aivss/scoring/v4-engine.js.map +1 -0
- package/dist/aivss/scoring/v4-engine.test.d.ts +16 -0
- package/dist/aivss/scoring/v4-engine.test.d.ts.map +1 -0
- package/dist/aivss/scoring/v4-engine.test.js +156 -0
- package/dist/aivss/scoring/v4-engine.test.js.map +1 -0
- package/dist/aivss/types.d.ts +92 -0
- package/dist/aivss/types.d.ts.map +1 -0
- package/dist/aivss/types.js +25 -0
- package/dist/aivss/types.js.map +1 -0
- package/dist/baseline/baseline.test.d.ts +2 -0
- package/dist/baseline/baseline.test.d.ts.map +1 -0
- package/dist/baseline/baseline.test.js +219 -0
- package/dist/baseline/baseline.test.js.map +1 -0
- package/dist/baseline/diff.d.ts +42 -0
- package/dist/baseline/diff.d.ts.map +1 -0
- package/dist/baseline/diff.js +124 -0
- package/dist/baseline/diff.js.map +1 -0
- package/dist/baseline/store.d.ts +51 -0
- package/dist/baseline/store.d.ts.map +1 -0
- package/dist/baseline/store.js +92 -0
- package/dist/baseline/store.js.map +1 -0
- package/dist/batch/anthropic-batch-runner.d.ts +50 -0
- package/dist/batch/anthropic-batch-runner.d.ts.map +1 -0
- package/dist/batch/anthropic-batch-runner.js +91 -0
- package/dist/batch/anthropic-batch-runner.js.map +1 -0
- package/dist/batch/bedrock-batch-runner.d.ts +90 -0
- package/dist/batch/bedrock-batch-runner.d.ts.map +1 -0
- package/dist/batch/bedrock-batch-runner.js +199 -0
- package/dist/batch/bedrock-batch-runner.js.map +1 -0
- package/dist/batch/providers/anthropic.d.ts +55 -0
- package/dist/batch/providers/anthropic.d.ts.map +1 -0
- package/dist/batch/providers/anthropic.js +103 -0
- package/dist/batch/providers/anthropic.js.map +1 -0
- package/dist/batch/providers/bedrock.d.ts +31 -0
- package/dist/batch/providers/bedrock.d.ts.map +1 -0
- package/dist/batch/providers/bedrock.js +42 -0
- package/dist/batch/providers/bedrock.js.map +1 -0
- package/dist/batch/providers/harness.d.ts +36 -0
- package/dist/batch/providers/harness.d.ts.map +1 -0
- package/dist/batch/providers/harness.js +65 -0
- package/dist/batch/providers/harness.js.map +1 -0
- package/dist/batch/providers/harness.test.d.ts +7 -0
- package/dist/batch/providers/harness.test.d.ts.map +1 -0
- package/dist/batch/providers/harness.test.js +84 -0
- package/dist/batch/providers/harness.test.js.map +1 -0
- package/dist/batch/providers/index.d.ts +82 -0
- package/dist/batch/providers/index.d.ts.map +1 -0
- package/dist/batch/providers/index.js +82 -0
- package/dist/batch/providers/index.js.map +1 -0
- package/dist/batch/providers/ollama.d.ts +68 -0
- package/dist/batch/providers/ollama.d.ts.map +1 -0
- package/dist/batch/providers/ollama.js +108 -0
- package/dist/batch/providers/ollama.js.map +1 -0
- package/dist/batch/response-parser.d.ts +45 -0
- package/dist/batch/response-parser.d.ts.map +1 -0
- package/dist/batch/response-parser.js +161 -0
- package/dist/batch/response-parser.js.map +1 -0
- package/dist/batch/types.d.ts +87 -0
- package/dist/batch/types.d.ts.map +1 -0
- package/dist/batch/types.js +8 -0
- package/dist/batch/types.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1037 -0
- package/dist/cli.js.map +1 -0
- package/dist/examples/a2a-assessment.d.ts +20 -0
- package/dist/examples/a2a-assessment.d.ts.map +1 -0
- package/dist/examples/a2a-assessment.js +115 -0
- package/dist/examples/a2a-assessment.js.map +1 -0
- package/dist/examples/batch-assessment.d.ts +18 -0
- package/dist/examples/batch-assessment.d.ts.map +1 -0
- package/dist/examples/batch-assessment.js +112 -0
- package/dist/examples/batch-assessment.js.map +1 -0
- package/dist/examples/batch-submission-flow.d.ts +2 -0
- package/dist/examples/batch-submission-flow.d.ts.map +1 -0
- package/dist/examples/batch-submission-flow.js +279 -0
- package/dist/examples/batch-submission-flow.js.map +1 -0
- package/dist/examples/demo-agent-definition.json +58 -0
- package/dist/examples/langfuse-assessment.d.ts +2 -0
- package/dist/examples/langfuse-assessment.d.ts.map +1 -0
- package/dist/examples/langfuse-assessment.js +46 -0
- package/dist/examples/langfuse-assessment.js.map +1 -0
- package/dist/examples/model-eval.d.ts +25 -0
- package/dist/examples/model-eval.d.ts.map +1 -0
- package/dist/examples/model-eval.js +94 -0
- package/dist/examples/model-eval.js.map +1 -0
- package/dist/examples/openclaw-assessment.d.ts +17 -0
- package/dist/examples/openclaw-assessment.d.ts.map +1 -0
- package/dist/examples/openclaw-assessment.js +79 -0
- package/dist/examples/openclaw-assessment.js.map +1 -0
- package/dist/examples/simple-assessment.d.ts +2 -0
- package/dist/examples/simple-assessment.d.ts.map +1 -0
- package/dist/examples/simple-assessment.js +146 -0
- package/dist/examples/simple-assessment.js.map +1 -0
- package/dist/examples/threat-model-demo.d.ts +2 -0
- package/dist/examples/threat-model-demo.d.ts.map +1 -0
- package/dist/examples/threat-model-demo.js +103 -0
- package/dist/examples/threat-model-demo.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/index.js.map +1 -0
- package/dist/output/likec4-generator.d.ts +43 -0
- package/dist/output/likec4-generator.d.ts.map +1 -0
- package/dist/output/likec4-generator.js +287 -0
- package/dist/output/likec4-generator.js.map +1 -0
- package/dist/output/slot-filler.d.ts +6 -0
- package/dist/output/slot-filler.d.ts.map +1 -0
- package/dist/output/slot-filler.js +71 -0
- package/dist/output/slot-filler.js.map +1 -0
- package/dist/output/tc-assembler.d.ts +30 -0
- package/dist/output/tc-assembler.d.ts.map +1 -0
- package/dist/output/tc-assembler.js +227 -0
- package/dist/output/tc-assembler.js.map +1 -0
- package/dist/output/tc-types.d.ts +41 -0
- package/dist/output/tc-types.d.ts.map +1 -0
- package/dist/output/tc-types.js +5 -0
- package/dist/output/tc-types.js.map +1 -0
- package/dist/output/tc-validator.test.d.ts +9 -0
- package/dist/output/tc-validator.test.d.ts.map +1 -0
- package/dist/output/tc-validator.test.js +194 -0
- package/dist/output/tc-validator.test.js.map +1 -0
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +30 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/bedrock.d.ts +9 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +38 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/index.d.ts +6 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +14 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/types.d.ts +12 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +5 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/references/acsc-risks.d.ts +18 -0
- package/dist/references/acsc-risks.d.ts.map +1 -0
- package/dist/references/acsc-risks.js +232 -0
- package/dist/references/acsc-risks.js.map +1 -0
- package/dist/references/aicm.d.ts +41 -0
- package/dist/references/aicm.d.ts.map +1 -0
- package/dist/references/aicm.js +447 -0
- package/dist/references/aicm.js.map +1 -0
- package/dist/references/dstg-meaid.d.ts +59 -0
- package/dist/references/dstg-meaid.d.ts.map +1 -0
- package/dist/references/dstg-meaid.js +352 -0
- package/dist/references/dstg-meaid.js.map +1 -0
- package/dist/references/index.d.ts +20 -0
- package/dist/references/index.d.ts.map +1 -0
- package/dist/references/index.js +31 -0
- package/dist/references/index.js.map +1 -0
- package/dist/references/nemoclaw.d.ts +48 -0
- package/dist/references/nemoclaw.d.ts.map +1 -0
- package/dist/references/nemoclaw.js +140 -0
- package/dist/references/nemoclaw.js.map +1 -0
- package/dist/references/nist-ai-rmf.d.ts +31 -0
- package/dist/references/nist-ai-rmf.d.ts.map +1 -0
- package/dist/references/nist-ai-rmf.js +627 -0
- package/dist/references/nist-ai-rmf.js.map +1 -0
- package/dist/references/owasp-agentic-top10.d.ts +50 -0
- package/dist/references/owasp-agentic-top10.d.ts.map +1 -0
- package/dist/references/owasp-agentic-top10.js +202 -0
- package/dist/references/owasp-agentic-top10.js.map +1 -0
- package/dist/references/registry-parity.test.d.ts +17 -0
- package/dist/references/registry-parity.test.d.ts.map +1 -0
- package/dist/references/registry-parity.test.js +87 -0
- package/dist/references/registry-parity.test.js.map +1 -0
- package/dist/references/registry.d.ts +90 -0
- package/dist/references/registry.d.ts.map +1 -0
- package/dist/references/registry.js +29 -0
- package/dist/references/registry.js.map +1 -0
- package/dist/tools/model-eval.d.ts +97 -0
- package/dist/tools/model-eval.d.ts.map +1 -0
- package/dist/tools/model-eval.js +307 -0
- package/dist/tools/model-eval.js.map +1 -0
- package/package.json +105 -0
- package/schemas/threat-composer-v1.schema.json +966 -0
- package/vendor/atlas-release/ATLAS-2026.07.yaml +17877 -0
- 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
|