diff --git a/.github/PULL_REQUEST_TEMPLATE/wave-1-foundation.md b/.github/PULL_REQUEST_TEMPLATE/wave-1-foundation.md new file mode 100644 index 0000000..42ffd43 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/wave-1-foundation.md @@ -0,0 +1,6 @@ +## Wave 1 Foundation + +- [ ] Scope is limited to Wave 1 foundation contracts and identity work. +- [ ] `make lint-fast`, `make test-fast`, and `make test-contracts` passed locally. +- [ ] Contract/additive schema changes are documented and golden fixtures were updated. +- [ ] Downstream Wave 2/3/4 work is not bundled into this PR. diff --git a/.github/PULL_REQUEST_TEMPLATE/wave-2-core-detection-policy.md b/.github/PULL_REQUEST_TEMPLATE/wave-2-core-detection-policy.md new file mode 100644 index 0000000..906239c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/wave-2-core-detection-policy.md @@ -0,0 +1,6 @@ +## Wave 2 Core Detection And Policy + +- [ ] Wave 1 is already merged and green on `main`. +- [ ] Scope is limited to core detection, correlation, privilege, and policy work. +- [ ] Detector precision, scenario, and policy fixtures were updated deterministically. +- [ ] No Wave 3/4 coverage, proof, or docs hardening changes are bundled here. diff --git a/.github/PULL_REQUEST_TEMPLATE/wave-3-coverage-quality.md b/.github/PULL_REQUEST_TEMPLATE/wave-3-coverage-quality.md new file mode 100644 index 0000000..6770e1a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/wave-3-coverage-quality.md @@ -0,0 +1,6 @@ +## Wave 3 Coverage And Quality + +- [ ] Wave 2 is already merged and green on `main`. +- [ ] Scope is limited to coverage expansion, benchmarks, and scenario/contract packs. +- [ ] Benchmark thresholds and scenario packs were updated with deterministic outputs only. +- [ ] No Wave 4 hardening/docs gating changes are bundled here. diff --git a/.github/PULL_REQUEST_TEMPLATE/wave-4-hardening-docs.md b/.github/PULL_REQUEST_TEMPLATE/wave-4-hardening-docs.md new file mode 100644 index 0000000..19a9d18 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/wave-4-hardening-docs.md @@ -0,0 +1,6 @@ +## Wave 4 Hardening And Docs + +- [ ] Waves 1-3 are already merged and green on `main`. +- [ ] Scope is limited to risk, proof, compliance, governance, and docs hardening. +- [ ] `make prepush-full`, scenario/contract suites, and scan contract checks passed locally. +- [ ] User-facing docs were updated for any externally visible behavior changes. diff --git a/.github/required-checks.json b/.github/required-checks.json index 81874d0..cd965f7 100644 --- a/.github/required-checks.json +++ b/.github/required-checks.json @@ -1,6 +1,8 @@ { "required_checks": [ "fast-lane", + "scan-contract", + "wave-sequence", "windows-smoke" ] } diff --git a/.github/wave-gates.json b/.github/wave-gates.json new file mode 100644 index 0000000..9c25e71 --- /dev/null +++ b/.github/wave-gates.json @@ -0,0 +1,93 @@ +{ + "merge_gates": { + "required_pr_checks": [ + "fast-lane", + "scan-contract", + "wave-sequence", + "windows-smoke" + ], + "required_release_commands": [ + "make prepush-full", + "make test-contracts", + "make test-scenarios", + "scripts/run_v1_acceptance.sh --mode=local", + "go run ./cmd/wrkr scan --path scenarios/wrkr/scan-diff-no-noise/input/local-repos --json --quiet" + ] + }, + "waves": [ + { + "id": "wave-1", + "label": "foundation", + "order": 1, + "required_lanes": [ + "fast", + "core", + "acceptance", + "cross_platform", + "risk" + ], + "required_story_checks": [ + "story1_contracts_test.go", + "story2_contracts_test.go", + "story3_contracts_test.go", + "story4_contracts_test.go", + "story5_contracts_test.go" + ], + "successor": "wave-2" + }, + { + "id": "wave-2", + "label": "core-detection-and-policy", + "order": 2, + "required_lanes": [ + "fast", + "core", + "acceptance", + "cross_platform", + "risk" + ], + "required_story_checks": [ + "story10_contracts_test.go", + "story7_contracts_test.go", + "story8_contracts_test.go", + "story9_contracts_test.go" + ], + "requires": "wave-1", + "successor": "wave-3" + }, + { + "id": "wave-3", + "label": "coverage-and-quality", + "order": 3, + "required_lanes": [ + "fast", + "core", + "acceptance", + "cross_platform", + "risk" + ], + "required_story_checks": [ + "story14_contracts_test.go", + "story15_contracts_test.go" + ], + "requires": "wave-2", + "successor": "wave-4" + }, + { + "id": "wave-4", + "label": "hardening-and-docs", + "order": 4, + "required_lanes": [ + "fast", + "core", + "acceptance", + "cross_platform", + "risk" + ], + "required_story_checks": [ + "story21_contracts_test.go" + ], + "requires": "wave-3" + } + ] +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index de1a444..651d12a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,6 +14,23 @@ concurrency: cancel-in-progress: true jobs: + wave-sequence: + name: wave-sequence + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Validate branch protection and wave sequencing contracts + run: | + scripts/check_branch_protection_contract.sh + scripts/check_wave_gates.sh + fast-lane: name: fast-lane runs-on: ubuntu-latest @@ -152,6 +169,26 @@ jobs: if: steps.changes.outputs.go != 'true' && steps.changes.outputs.python != 'true' && steps.changes.outputs.workflow_or_policy != 'true' run: echo "no code, workflow, or policy changes; deep scanners skipped" + scan-contract: + name: scan-contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.7' + check-latest: false + + - name: Enforce scan JSON and exit-code contracts + run: | + go test ./testinfra/contracts -run 'TestRequiredChecks_EnforceWaveSequence1To2To3To4|TestScanContract_NoJSONOrExitRegressionAcrossWaves' -count=1 + mkdir -p .tmp + go run ./cmd/wrkr scan --path scenarios/wrkr/scan-diff-no-noise/input/local-repos --json --quiet > .tmp/scan-contract.json + test -s .tmp/scan-contract.json + windows-smoke: name: windows-smoke runs-on: windows-latest diff --git a/README.md b/README.md index 93f97b5..6c75c16 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![CodeQL](https://github.com/Clyra-AI/wrkr/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Clyra-AI/wrkr/actions/workflows/github-code-scanning/codeql) [![Nightly](https://github.com/Clyra-AI/wrkr/actions/workflows/nightly.yml/badge.svg?event=schedule)](https://github.com/Clyra-AI/wrkr/actions/workflows/nightly.yml) -Most teams don't know what AI dev tools and agents are active across their repos, what permissions they have, or what changed since last week. Wrkr answers that in minutes. Scan your GitHub org, get ranked findings, and generate audit-ready evidence. Read-only. No integration required. +Most teams don't know what AI dev tools and agents are active across their repos, what permissions they have, or what changed since last week. Wrkr answers that in minutes. Scan your GitHub org, get ranked findings for tools and agents, and generate audit-ready evidence. Read-only. No integration required. -Wrkr is the **See** layer in the Clyra AI governance stack (See -> Prove -> Control -> Build). It discovers AI tooling across repositories and orgs, scores posture, tracks identity lifecycle, and emits signed proof artifacts ready for compliance review or downstream automation. +Wrkr is the **See** layer in the Clyra AI governance stack (See -> Prove -> Control -> Build). It discovers AI tooling and agent declarations across repositories and orgs, scores posture, tracks identity lifecycle, and emits signed proof artifacts ready for compliance review or downstream automation. Docs: [clyra-ai.github.io/wrkr](https://clyra-ai.github.io/wrkr/) | Command contracts: [`docs/commands/`](docs/commands/) | Docs map: [`docs/map.md`](docs/map.md) @@ -79,7 +79,7 @@ make build ./.tmp/wrkr score --json # Generate and verify evidence -./.tmp/wrkr evidence --frameworks eu-ai-act,soc2 --output ./.tmp/evidence --json +./.tmp/wrkr evidence --frameworks eu-ai-act,soc2,pci-dss --output ./.tmp/evidence --json ./.tmp/wrkr verify --chain --json # Baseline and drift gate @@ -92,13 +92,14 @@ Expected JSON keys by command family: - `scan`: `status`, `target`, `findings`, `ranked_findings`, `top_findings`, `attack_paths`, `top_attack_paths`, `inventory`, `privilege_budget`, `agent_privilege_map`, `repo_exposure_summaries`, `profile`, `posture_score` (optional: `detector_errors`, `partial_result`, `source_errors`, `source_degraded`, `policy_warnings`, `report`, `sarif`) - `report`: `status`, `generated_at`, `top_findings`, `attack_paths`, `top_attack_paths`, `total_tools`, `tool_type_breakdown`, `compliance_gap_count`, `privilege_budget`, `summary` (optional: `md_path`, `pdf_path`) - `score`: `score`, `grade`, `breakdown`, `weighted_breakdown`, `weights`, `trend_delta` (optional: `attack_paths`, `top_attack_paths`) -- `evidence`: `status`, `output_dir`, `manifest_path`, `chain_path`, `framework_coverage`, `report_artifacts` +- `evidence`: `status`, `output_dir`, `frameworks`, `manifest_path`, `chain_path`, `framework_coverage`, `report_artifacts` - `verify`: `chain.intact`, `chain.head_hash` - `regress run`: deterministic drift status and reason fields Prompt-channel findings are emitted deterministically with stable reason codes and evidence hashes (no raw secret extraction). When `scan --enrich` is enabled, MCP findings include enrich provenance and quality fields (`source`, `as_of`, `advisory_count`, `registry_status`, `enrich_quality`, schema IDs, and adapter error classes). Evidence bundles include deterministic inventory artifacts at `inventory.json`, `inventory-snapshot.json`, and `inventory.yaml`. +Evidence framework IDs are normalized to upstream `Clyra-AI/proof` IDs in output (`eu-ai-act`, `pci-dss`); underscore aliases such as `eu_ai_act` and `pci_dss` are accepted as input. Canonical local path lifecycle for state, baseline, manifest, and proof chain: [`docs/state_lifecycle.md`](docs/state_lifecycle.md). ## What You Get @@ -117,7 +118,7 @@ Deterministic identities in `wrkr::` format. Lifecycle transitions ### Audit-ready evidence -Signed proof records for `scan_finding`, `risk_assessment`, and lifecycle events. Evidence bundles with compliance framework mappings and offline verification. No calling home required. +Signed proof records for `scan_finding`, `risk_assessment`, and lifecycle events. Agent-aware proof events now carry additive `agent_context` fields for portability, and evidence bundles keep compliance framework mappings verifiable offline. No calling home required. ### CI drift gates diff --git a/core/aggregate/attackpath/graph.go b/core/aggregate/attackpath/graph.go index 8c70527..fcc85da 100644 --- a/core/aggregate/attackpath/graph.go +++ b/core/aggregate/attackpath/graph.go @@ -82,6 +82,27 @@ func buildRepoGraph(org string, repo string, findings []model.Finding) Graph { targetNodes := make([]Node, 0) nodeSet := map[string]Node{} + edgeSet := map[string]Edge{} + addNode := func(node Node) { + if _, exists := nodeSet[node.NodeID]; exists { + return + } + nodeSet[node.NodeID] = node + switch node.Kind { + case "entry": + entryNodes = append(entryNodes, node) + case "pivot": + pivotNodes = append(pivotNodes, node) + case "target": + targetNodes = append(targetNodes, node) + } + } + addEdge := func(edge Edge) { + if _, exists := edgeSet[edge.EdgeID]; exists { + return + } + edgeSet[edge.EdgeID] = edge + } for _, finding := range findings { kind := nodeKind(finding) if kind == "" { @@ -97,17 +118,15 @@ func buildRepoGraph(org string, repo string, findings []model.Finding) Graph { Location: strings.TrimSpace(finding.Location), CanonicalKey: canonicalFindingKey(finding), } - if _, exists := nodeSet[node.NodeID]; exists { - continue - } - nodeSet[node.NodeID] = node - switch kind { - case "entry": - entryNodes = append(entryNodes, node) - case "pivot": - pivotNodes = append(pivotNodes, node) - case "target": - targetNodes = append(targetNodes, node) + addNode(node) + if strings.TrimSpace(finding.FindingType) == "agent_framework" { + syntheticNodes, syntheticEdges := agentRelationshipNodesAndEdges(org, repo, node, finding) + for _, synthetic := range syntheticNodes { + addNode(synthetic) + } + for _, synthetic := range syntheticEdges { + addEdge(synthetic) + } } } @@ -115,25 +134,32 @@ func buildRepoGraph(org string, repo string, findings []model.Finding) Graph { sortNodes(pivotNodes) sortNodes(targetNodes) - edges := make([]Edge, 0) + edges := make([]Edge, 0, len(edgeSet)) + for _, edge := range edgeSet { + edges = append(edges, edge) + } if len(entryNodes) > 0 && len(pivotNodes) > 0 { for _, entry := range entryNodes { for _, pivot := range pivotNodes { - edges = append(edges, newEdge(org, repo, entry, pivot, "entry_to_pivot")) + addEdge(newEdge(org, repo, entry, pivot, "entry_to_pivot")) } } for _, pivot := range pivotNodes { for _, target := range targetNodes { - edges = append(edges, newEdge(org, repo, pivot, target, "pivot_to_target")) + addEdge(newEdge(org, repo, pivot, target, "pivot_to_target")) } } } else { for _, entry := range entryNodes { for _, target := range targetNodes { - edges = append(edges, newEdge(org, repo, entry, target, "entry_to_target")) + addEdge(newEdge(org, repo, entry, target, "entry_to_target")) } } } + edges = edges[:0] + for _, edge := range edgeSet { + edges = append(edges, edge) + } sort.Slice(edges, func(i, j int) bool { if edges[i].FromNodeID != edges[j].FromNodeID { @@ -153,6 +179,8 @@ func buildRepoGraph(org string, repo string, findings []model.Finding) Graph { func nodeKind(finding model.Finding) string { switch strings.TrimSpace(finding.FindingType) { + case "agent_framework": + return "entry" case "a2a_agent_card", "webmcp_declaration", "prompt_channel_hidden_text", "prompt_channel_override", "prompt_channel_untrusted_context": return "entry" case "ci_autonomy", "mcp_server", "compiled_action", "skill", "skill_metrics": @@ -218,6 +246,105 @@ func sortNodes(nodes []Node) { }) } +func agentRelationshipNodesAndEdges(org string, repo string, agentNode Node, finding model.Finding) ([]Node, []Edge) { + pivots := make([]Node, 0) + targets := make([]Node, 0) + edges := make([]Edge, 0) + agentKey := strings.TrimSpace(agentNode.CanonicalKey) + + tools := splitEvidenceList(finding, "bound_tools") + dataSources := splitEvidenceList(finding, "data_sources") + authSurfaces := splitEvidenceList(finding, "auth_surfaces") + deploymentArtifacts := splitEvidenceList(finding, "deployment_artifacts") + + for _, tool := range tools { + pivot := syntheticNode(org, repo, "pivot", "agent_tool_binding", tool, finding.Location, agentKey+"|tool:"+tool) + pivots = append(pivots, pivot) + edges = append(edges, newEdge(org, repo, agentNode, pivot, "agent_to_tool_binding")) + } + for _, dataSource := range dataSources { + target := syntheticNode(org, repo, "target", "agent_data_binding", dataSource, finding.Location, agentKey+"|data:"+dataSource) + targets = append(targets, target) + if len(pivots) == 0 { + edges = append(edges, newEdge(org, repo, agentNode, target, "agent_to_data_binding")) + continue + } + for _, pivot := range pivots { + edges = append(edges, newEdge(org, repo, pivot, target, "tool_to_data_binding")) + } + } + for _, authSurface := range authSurfaces { + target := syntheticNode(org, repo, "target", "agent_auth_surface", authSurface, finding.Location, agentKey+"|auth:"+authSurface) + targets = append(targets, target) + if len(pivots) == 0 { + edges = append(edges, newEdge(org, repo, agentNode, target, "agent_to_auth_surface")) + continue + } + for _, pivot := range pivots { + edges = append(edges, newEdge(org, repo, pivot, target, "tool_to_auth_surface")) + } + } + for _, artifact := range deploymentArtifacts { + target := syntheticNode(org, repo, "target", "agent_deploy_artifact", artifact, finding.Location, agentKey+"|deploy:"+artifact) + targets = append(targets, target) + if len(pivots) == 0 { + edges = append(edges, newEdge(org, repo, agentNode, target, "agent_to_deploy_artifact")) + continue + } + for _, pivot := range pivots { + edges = append(edges, newEdge(org, repo, pivot, target, "tool_to_deploy_artifact")) + } + } + + nodes := append([]Node{}, pivots...) + nodes = append(nodes, targets...) + return nodes, edges +} + +func syntheticNode(org, repo, kind, findingType, toolType, location, canonicalKey string) Node { + node := Node{ + Org: org, + Repo: repo, + Kind: kind, + FindingType: strings.TrimSpace(findingType), + ToolType: strings.TrimSpace(toolType), + Location: strings.TrimSpace(location), + CanonicalKey: strings.TrimSpace(canonicalKey), + } + node.NodeID = nodeID(kind, model.Finding{ + FindingType: node.FindingType, + ToolType: node.ToolType, + Location: node.Location, + }) + return node +} + +func splitEvidenceList(finding model.Finding, key string) []string { + needle := strings.ToLower(strings.TrimSpace(key)) + set := map[string]struct{}{} + for _, item := range finding.Evidence { + if strings.ToLower(strings.TrimSpace(item.Key)) != needle { + continue + } + for _, part := range strings.Split(item.Value, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + set[trimmed] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for item := range set { + out = append(out, item) + } + sort.Strings(out) + return out +} + func fallbackOrg(org string) string { if strings.TrimSpace(org) == "" { return "local" diff --git a/core/aggregate/attackpath/graph_test.go b/core/aggregate/attackpath/graph_test.go index 3d0e569..775b003 100644 --- a/core/aggregate/attackpath/graph_test.go +++ b/core/aggregate/attackpath/graph_test.go @@ -44,3 +44,99 @@ func TestBuildGraphSkipsReposWithoutComposableNodes(t *testing.T) { t.Fatalf("expected zero graphs, got %#v", graphs) } } + +func TestAttackGraph_IncludesAgentToolDataDeployEdges(t *testing.T) { + t.Parallel() + + findings := []model.Finding{ + { + FindingType: "agent_framework", + ToolType: "langchain", + Location: "agents/release.py", + Repo: "repo", + Org: "acme", + Evidence: []model.Evidence{ + {Key: "bound_tools", Value: "deploy.write,search.read"}, + {Key: "data_sources", Value: "warehouse.events"}, + {Key: "auth_surfaces", Value: "token"}, + {Key: "deployment_artifacts", Value: ".github/workflows/release.yml"}, + }, + }, + } + + graphs := Build(findings) + if len(graphs) != 1 { + t.Fatalf("expected one graph, got %d", len(graphs)) + } + + graph := graphs[0] + requiredNodeIDs := []string{ + "entry::agent_framework::langchain::agents/release.py", + "pivot::agent_tool_binding::deploy.write::agents/release.py", + "pivot::agent_tool_binding::search.read::agents/release.py", + "target::agent_auth_surface::token::agents/release.py", + "target::agent_data_binding::warehouse.events::agents/release.py", + "target::agent_deploy_artifact::.github/workflows/release.yml::agents/release.py", + } + for _, want := range requiredNodeIDs { + if !hasNode(graph.Nodes, want) { + t.Fatalf("expected node %s in graph: %#v", want, graph.Nodes) + } + } + requiredRationales := []string{ + "agent_to_tool_binding", + "tool_to_auth_surface", + "tool_to_data_binding", + "tool_to_deploy_artifact", + } + for _, want := range requiredRationales { + if !hasEdgeRationale(graph.Edges, want) { + t.Fatalf("expected rationale %s in graph edges: %#v", want, graph.Edges) + } + } +} + +func TestAttackPathNodeEdgeIDs_AreDeterministic(t *testing.T) { + t.Parallel() + + findings := []model.Finding{ + { + FindingType: "agent_framework", + ToolType: "crewai", + Location: "crews/release.py", + Repo: "repo", + Org: "acme", + Evidence: []model.Evidence{ + {Key: "bound_tools", Value: "deploy.write"}, + {Key: "data_sources", Value: "warehouse.events"}, + {Key: "deployment_artifacts", Value: "Dockerfile"}, + }, + }, + } + + first := Build(findings) + for i := 0; i < 32; i++ { + next := Build(findings) + if !reflect.DeepEqual(first, next) { + t.Fatalf("non-deterministic agent graph output at run %d", i+1) + } + } +} + +func hasNode(nodes []Node, want string) bool { + for _, node := range nodes { + if node.NodeID == want { + return true + } + } + return false +} + +func hasEdgeRationale(edges []Edge, want string) bool { + for _, edge := range edges { + if edge.Rationale == want { + return true + } + } + return false +} diff --git a/core/compliance/compliance.go b/core/compliance/compliance.go index cda85d8..0cb0285 100644 --- a/core/compliance/compliance.go +++ b/core/compliance/compliance.go @@ -30,6 +30,7 @@ type ControlCheck struct { Title string `json:"title"` Status string `json:"status"` MatchedRecords int `json:"matched_records"` + MappedRuleIDs []string `json:"mapped_rule_ids,omitempty"` MissingRecordTypes []string `json:"missing_record_types,omitempty"` MissingFields []string `json:"missing_fields,omitempty"` RequiredRecordTypes []string `json:"required_record_types"` @@ -44,11 +45,12 @@ func Evaluate(in Input) (Result, error) { return Result{}, fmt.Errorf("chain is required") } controls := flatten(in.Framework.Controls) + matchedRuleIDs := collectRuleIDs(in.Chain.Records) checks := make([]ControlCheck, 0, len(controls)) gaps := make([]ControlCheck, 0) covered := 0 for _, control := range controls { - check := evaluateControl(control, in.Chain.Records) + check := evaluateControl(in.Framework.Framework.ID, control, in.Chain.Records, matchedRuleIDs) checks = append(checks, check) if check.Status == "covered" { covered++ @@ -72,7 +74,7 @@ func Evaluate(in Input) (Result, error) { }, nil } -func evaluateControl(control framework.Control, records []proof.Record) ControlCheck { +func evaluateControl(frameworkID string, control framework.Control, records []proof.Record, matchedRuleIDs map[string]struct{}) ControlCheck { requiredTypes := uniqueSortedStrings(control.RequiredRecordTypes) requiredFields := uniqueSortedStrings(control.RequiredFields) missingTypes := make([]string, 0) @@ -99,17 +101,20 @@ func evaluateControl(control framework.Control, records []proof.Record) ControlC if len(missingTypes) > 0 || len(missingFields) > 0 { status = "gap" } + mappedRules := mappedRuleIDs(frameworkID, control.ID, matchedRuleIDs) matchedCount := 0 for _, items := range matchedByType { matchedCount += len(items) } + matchedCount += len(mappedRules) return ControlCheck{ ID: control.ID, Title: control.Title, Status: status, MatchedRecords: matchedCount, + MappedRuleIDs: mappedRules, MissingRecordTypes: missingTypes, MissingFields: missingFields, RequiredRecordTypes: requiredTypes, @@ -201,3 +206,72 @@ func uniqueSortedStrings(values []string) []string { func round2(value float64) float64 { return float64(int(value*100+0.5)) / 100 } + +func collectRuleIDs(records []proof.Record) map[string]struct{} { + out := map[string]struct{}{} + for _, record := range records { + for _, ruleID := range recordRuleIDs(record) { + out[ruleID] = struct{}{} + } + } + return out +} + +func recordRuleIDs(record proof.Record) []string { + set := map[string]struct{}{} + if ruleID := eventRuleID(record.Event); ruleID != "" { + set[ruleID] = struct{}{} + } + if record.Relationship != nil && record.Relationship.PolicyRef != nil { + for _, ruleID := range record.Relationship.PolicyRef.MatchedRuleIDs { + trimmed := strings.TrimSpace(ruleID) + if trimmed == "" { + continue + } + set[trimmed] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for ruleID := range set { + out = append(out, ruleID) + } + sort.Strings(out) + return out +} + +func eventRuleID(event map[string]any) string { + if event == nil { + return "" + } + if ruleID, ok := event["rule_id"].(string); ok && strings.TrimSpace(ruleID) != "" { + return strings.TrimSpace(ruleID) + } + finding, ok := event["finding"].(map[string]any) + if !ok { + return "" + } + ruleID, _ := finding["rule_id"].(string) + return strings.TrimSpace(ruleID) +} + +func mappedRuleIDs(frameworkID, controlID string, matchedRuleIDs map[string]struct{}) []string { + controls := frameworkControlRuleMap[strings.TrimSpace(frameworkID)] + if len(controls) == 0 { + return nil + } + ruleIDs := controls[strings.TrimSpace(controlID)] + if len(ruleIDs) == 0 { + return nil + } + out := make([]string, 0, len(ruleIDs)) + for _, ruleID := range ruleIDs { + if _, ok := matchedRuleIDs[ruleID]; ok { + out = append(out, ruleID) + } + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} diff --git a/core/compliance/compliance_test.go b/core/compliance/compliance_test.go index 14c8e73..0fca23e 100644 --- a/core/compliance/compliance_test.go +++ b/core/compliance/compliance_test.go @@ -72,6 +72,124 @@ func TestEvaluateFrameworkGapWhenMissingRecordType(t *testing.T) { } } +func TestComplianceMapping_WRKRAControlsCovered(t *testing.T) { + t.Parallel() + + frameworkIDs := []string{"eu-ai-act", "soc2", "pci-dss"} + for _, frameworkID := range frameworkIDs { + frameworkDef, err := proof.LoadFramework(frameworkID) + if err != nil { + t.Fatalf("load framework %s: %v", frameworkID, err) + } + chain := proof.NewChain("wrkr-proof") + record, err := proof.NewRecord(proof.RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC), + Source: "wrkr", + SourceProduct: "wrkr", + Type: "risk_assessment", + Event: map[string]any{ + "assessment_type": "finding_risk", + "finding": map[string]any{ + "rule_id": "WRKR-A010", + }, + }, + Relationship: &proof.Relationship{ + PolicyRef: &proof.PolicyRef{ + PolicyID: "wrkr-policy", + MatchedRuleIDs: []string{"WRKR-A001", "WRKR-A010"}, + }, + }, + Controls: proof.Controls{PermissionsEnforced: true}, + }) + if err != nil { + t.Fatalf("new record for %s: %v", frameworkID, err) + } + if err := proof.AppendToChain(chain, record); err != nil { + t.Fatalf("append record for %s: %v", frameworkID, err) + } + + result, err := Evaluate(Input{Framework: frameworkDef, Chain: chain}) + if err != nil { + t.Fatalf("evaluate framework %s: %v", frameworkID, err) + } + coveredByRules := false + for _, control := range result.Controls { + if len(control.MappedRuleIDs) > 0 { + coveredByRules = true + if control.Status == "covered" && (len(control.MissingRecordTypes) > 0 || len(control.MissingFields) > 0) { + t.Fatalf("expected covered mapped control without missing evidence for %s, got %+v", frameworkID, control) + } + } + } + if !coveredByRules { + t.Fatalf("expected mapped WRKR-A rule coverage for %s, got %+v", frameworkID, result.Controls) + } + } +} + +func TestComplianceMapping_DoesNotMaskMissingRecords(t *testing.T) { + t.Parallel() + + frameworkDef := &proof.Framework{} + frameworkDef.Framework.ID = "soc2" + frameworkDef.Framework.Version = "2026" + frameworkDef.Framework.Title = "SOC2" + frameworkDef.Controls = []framework.Control{ + { + ID: "cc7", + Title: "Operations", + RequiredRecordTypes: []string{"incident"}, + RequiredFields: []string{"record_id", "event"}, + MinimumFrequency: "continuous", + }, + } + + chain := proof.NewChain("wrkr-proof") + record, err := proof.NewRecord(proof.RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC), + Source: "wrkr", + SourceProduct: "wrkr", + Type: "risk_assessment", + Event: map[string]any{ + "assessment_type": "finding_risk", + "finding": map[string]any{ + "rule_id": "WRKR-A010", + }, + }, + Relationship: &proof.Relationship{ + PolicyRef: &proof.PolicyRef{ + PolicyID: "wrkr-policy", + MatchedRuleIDs: []string{"WRKR-A010"}, + }, + }, + Controls: proof.Controls{PermissionsEnforced: true}, + }) + if err != nil { + t.Fatalf("new record: %v", err) + } + if err := proof.AppendToChain(chain, record); err != nil { + t.Fatalf("append record: %v", err) + } + + result, err := Evaluate(Input{Framework: frameworkDef, Chain: chain}) + if err != nil { + t.Fatalf("evaluate compliance: %v", err) + } + if len(result.Gaps) != 1 { + t.Fatalf("expected 1 gap, got %d", len(result.Gaps)) + } + gap := result.Gaps[0] + if gap.Status != "gap" { + t.Fatalf("expected gap status, got %s", gap.Status) + } + if len(gap.MappedRuleIDs) == 0 { + t.Fatalf("expected mapped rule IDs to be preserved, got %+v", gap) + } + if len(gap.MissingRecordTypes) != 1 || gap.MissingRecordTypes[0] != "incident" { + t.Fatalf("expected missing incident record type, got %v", gap.MissingRecordTypes) + } +} + func appendRecord(t *testing.T, chain *proof.Chain, recordType string) { t.Helper() record, err := proof.NewRecord(proof.RecordOpts{ diff --git a/core/compliance/rulemap.go b/core/compliance/rulemap.go new file mode 100644 index 0000000..ad5b0ac --- /dev/null +++ b/core/compliance/rulemap.go @@ -0,0 +1,17 @@ +package compliance + +var frameworkControlRuleMap = map[string]map[string][]string{ + "eu-ai-act": { + "article-9": {"WRKR-A001", "WRKR-A002", "WRKR-A005", "WRKR-A006", "WRKR-A009", "WRKR-A010"}, + "article-12": {"WRKR-A001", "WRKR-A003", "WRKR-A004", "WRKR-A008"}, + "article-14": {"WRKR-A001", "WRKR-A002", "WRKR-A007", "WRKR-A009", "WRKR-A010"}, + }, + "soc2": { + "cc6": {"WRKR-A001", "WRKR-A002", "WRKR-A003", "WRKR-A005", "WRKR-A007", "WRKR-A009"}, + "cc7": {"WRKR-A004", "WRKR-A006", "WRKR-A007", "WRKR-A010"}, + "cc8": {"WRKR-A001", "WRKR-A002", "WRKR-A009", "WRKR-A010"}, + }, + "pci-dss": { + "req-10": {"WRKR-A001", "WRKR-A003", "WRKR-A004", "WRKR-A006", "WRKR-A009", "WRKR-A010"}, + }, +} diff --git a/core/evidence/evidence.go b/core/evidence/evidence.go index 699e883..a0677ce 100644 --- a/core/evidence/evidence.go +++ b/core/evidence/evidence.go @@ -316,7 +316,7 @@ func normalizeFrameworks(in []string) []string { set := map[string]struct{}{} for _, value := range in { for _, part := range strings.Split(value, ",") { - trimmed := strings.TrimSpace(part) + trimmed := normalizeFrameworkID(part) if trimmed == "" { continue } @@ -331,6 +331,23 @@ func normalizeFrameworks(in []string) []string { return out } +func normalizeFrameworkID(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + replacer := strings.NewReplacer("_", "-", " ", "-", "eu-ai-act", "eu-ai-act", "pci-dss", "pci-dss") + trimmed = replacer.Replace(trimmed) + switch trimmed { + case "euaiact": + return "eu-ai-act" + case "pcidss": + return "pci-dss" + default: + return trimmed + } +} + func validateSnapshot(snapshot state.Snapshot) error { missing := make([]string, 0, 4) if snapshot.Inventory == nil { diff --git a/core/evidence/evidence_test.go b/core/evidence/evidence_test.go index dd1f227..d21c034 100644 --- a/core/evidence/evidence_test.go +++ b/core/evidence/evidence_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -85,6 +86,118 @@ func TestBuildEvidenceBundle(t *testing.T) { } } +func TestEvidenceBundle_VerifiesWithAgentContextFields(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + statePath := filepath.Join(tmp, "state.json") + findings := []model.Finding{ + { + FindingType: "agent_framework", + Severity: model.SeverityHigh, + ToolType: "langchain", + Location: "agents/release.py", + Repo: "repo", + Org: "acme", + Evidence: []model.Evidence{ + {Key: "symbol", Value: "release_agent"}, + {Key: "bound_tools", Value: "deploy.write"}, + {Key: "deployment_artifacts", Value: ".github/workflows/release.yml"}, + {Key: "deployment_status", Value: "deployed"}, + {Key: "approval_status", Value: "missing"}, + {Key: "kill_switch", Value: "false"}, + }, + }, + } + report := risk.Score(findings, 5, time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)) + profile := profileeval.Result{ProfileName: "standard", CompliancePercent: 88.2, Status: "pass"} + posture := score.Result{Score: 81.0, Grade: "B", Weights: scoremodel.DefaultWeights()} + snapshot := state.Snapshot{ + Version: state.SnapshotVersion, + Target: source.Target{Mode: "repo", Value: "acme/repo"}, + Findings: findings, + Inventory: &agginventory.Inventory{InventoryVersion: "v1", GeneratedAt: "2026-02-20T12:00:00Z"}, + RiskReport: &report, + Profile: &profile, + PostureScore: &posture, + } + if err := state.Save(statePath, snapshot); err != nil { + t.Fatalf("save state: %v", err) + } + if _, err := proofemit.EmitScan(statePath, time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC), findings, report, profile, posture, nil); err != nil { + t.Fatalf("emit scan records: %v", err) + } + + outputDir := filepath.Join(tmp, "wrkr-evidence") + if _, err := Build(BuildInput{StatePath: statePath, Frameworks: []string{"soc2"}, OutputDir: outputDir, GeneratedAt: time.Date(2026, 2, 20, 14, 0, 0, 0, time.UTC)}); err != nil { + t.Fatalf("build evidence bundle: %v", err) + } + + payload, err := os.ReadFile(filepath.Join(outputDir, "proof-records", "scan-findings.jsonl")) + if err != nil { + t.Fatalf("read scan findings jsonl: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(payload)), "\n") + if len(lines) == 0 { + t.Fatal("expected scan findings records in evidence bundle") + } + record := map[string]any{} + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("parse scan finding jsonl: %v", err) + } + event, ok := record["event"].(map[string]any) + if !ok { + t.Fatalf("expected event map in proof record, got %T", record["event"]) + } + if event["agent_id"] == "" { + t.Fatalf("expected additive agent_id in proof record event, got %v", event) + } + if _, ok := event["agent_context"].(map[string]any); !ok { + t.Fatalf("expected additive agent_context map in proof record event, got %T", event["agent_context"]) + } +} + +func TestEvidenceFrameworkCoverage_DeterministicForAgentFindings(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + statePath := createEvidenceStateWithProof(t, tmp) + firstDir := filepath.Join(tmp, "evidence-first") + secondDir := filepath.Join(tmp, "evidence-second") + input := BuildInput{ + StatePath: statePath, + Frameworks: []string{"eu_ai_act", "soc2", "pci_dss"}, + GeneratedAt: time.Date(2026, 2, 20, 14, 0, 0, 0, time.UTC), + } + + first, err := Build(BuildInput{ + StatePath: input.StatePath, + Frameworks: input.Frameworks, + OutputDir: firstDir, + GeneratedAt: input.GeneratedAt, + }) + if err != nil { + t.Fatalf("first evidence build: %v", err) + } + second, err := Build(BuildInput{ + StatePath: input.StatePath, + Frameworks: input.Frameworks, + OutputDir: secondDir, + GeneratedAt: input.GeneratedAt, + }) + if err != nil { + t.Fatalf("second evidence build: %v", err) + } + + if !reflect.DeepEqual(first.FrameworkCoverage, second.FrameworkCoverage) { + t.Fatalf("expected deterministic framework coverage\nfirst=%v\nsecond=%v", first.FrameworkCoverage, second.FrameworkCoverage) + } + expectedFrameworks := []string{"eu-ai-act", "pci-dss", "soc2"} + if !reflect.DeepEqual(first.Frameworks, expectedFrameworks) { + t.Fatalf("expected normalized framework IDs %v, got %v", expectedFrameworks, first.Frameworks) + } +} + func TestBuildEvidenceInventoryYAMLByteStableAcrossRuns(t *testing.T) { t.Parallel() tmp := t.TempDir() diff --git a/core/proofmap/proofmap.go b/core/proofmap/proofmap.go index 0ae1151..db36e68 100644 --- a/core/proofmap/proofmap.go +++ b/core/proofmap/proofmap.go @@ -56,6 +56,10 @@ func MapFindings(findings []model.Finding, profile *profileeval.Result, now time "permissions": append([]string(nil), representative.Permissions...), "evidence": evidenceMap(representative.Evidence), } + if agentContext := agentContextForFinding(representative); len(agentContext) > 0 { + event["agent_id"] = agentIDForFinding(representative) + event["agent_context"] = agentContext + } if representative.RuleID != "" { event["rule_id"] = representative.RuleID } @@ -102,6 +106,14 @@ func MapFindings(findings []model.Finding, profile *profileeval.Result, now time metadata["profile_compliance_percent"] = profile.CompliancePercent } agentID := agentIDForFinding(representative) + if agentContext := agentContextForFinding(representative); len(agentContext) > 0 { + if agentInstanceID, ok := agentContext["agent_instance_id"].(string); ok && strings.TrimSpace(agentInstanceID) != "" { + metadata["agent_instance_id"] = agentInstanceID + } + if framework, ok := agentContext["framework"].(string); ok && strings.TrimSpace(framework) != "" { + metadata["agent_framework"] = framework + } + } records = append(records, MappedRecord{ RecordType: "scan_finding", @@ -139,17 +151,32 @@ func MapRisk(report risk.Report, posture score.Result, profile profileeval.Resul }, "reasons": append([]string(nil), item.Reasons...), } + if agentContext := agentContextForFinding(item.Finding); len(agentContext) > 0 { + event["agent_id"] = agentIDForFinding(item.Finding) + event["agent_context"] = agentContext + findingMap := event["finding"].(map[string]any) + findingMap["agent_id"] = agentIDForFinding(item.Finding) + } agentID := agentIDForFinding(item.Finding) + metadata := map[string]any{ + "rank": idx + 1, + "canonical_finding": item.CanonicalKey, + } + if agentContext := agentContextForFinding(item.Finding); len(agentContext) > 0 { + if agentInstanceID, ok := agentContext["agent_instance_id"].(string); ok && strings.TrimSpace(agentInstanceID) != "" { + metadata["agent_instance_id"] = agentInstanceID + } + if framework, ok := agentContext["framework"].(string); ok && strings.TrimSpace(framework) != "" { + metadata["agent_framework"] = framework + } + } records = append(records, MappedRecord{ RecordType: "risk_assessment", AgentID: agentID, Timestamp: canonicalTime(now), Event: event, Relationship: buildFindingRiskRelationship(item, agentID), - Metadata: map[string]any{ - "rank": idx + 1, - "canonical_finding": item.CanonicalKey, - }, + Metadata: metadata, }) } for idx, path := range report.AttackPaths { @@ -433,6 +460,56 @@ func stringValue(values map[string]any, key string) string { return strings.TrimSpace(typed) } +func agentContextForFinding(finding model.Finding) map[string]any { + agentID := strings.TrimSpace(agentIDForFinding(finding)) + if agentID == "" { + return nil + } + + context := map[string]any{ + "agent_id": agentID, + } + if instanceID := agentInstanceIDForFinding(finding); instanceID != "" { + context["agent_instance_id"] = instanceID + } + if strings.TrimSpace(finding.FindingType) == "agent_framework" { + context["framework"] = strings.TrimSpace(finding.ToolType) + } + if symbol := evidenceStringValue(finding, "symbol"); symbol != "" { + context["name"] = symbol + } + if approvalStatus := evidenceStringValue(finding, "approval_status"); approvalStatus != "" { + context["approval_status"] = approvalStatus + } + if deploymentStatus := evidenceStringValue(finding, "deployment_status"); deploymentStatus != "" { + context["deployment_status"] = deploymentStatus + } + if dataClass := evidenceStringValue(finding, "data_class"); dataClass != "" { + context["data_class"] = dataClass + } + if boundTools := evidenceListValue(finding, "bound_tools"); len(boundTools) > 0 { + context["bound_tools"] = boundTools + } + if boundDataSources := evidenceListValue(finding, "data_sources"); len(boundDataSources) > 0 { + context["bound_data_sources"] = boundDataSources + } + if boundAuthSurfaces := evidenceListValue(finding, "auth_surfaces"); len(boundAuthSurfaces) > 0 { + context["bound_auth_surfaces"] = boundAuthSurfaces + } + if deploymentArtifacts := evidenceListValue(finding, "deployment_artifacts"); len(deploymentArtifacts) > 0 { + context["deployment_artifacts"] = deploymentArtifacts + } + for _, key := range []string{"kill_switch", "dynamic_discovery", "auto_deploy", "human_gate", "delegation"} { + if value := evidenceStringValue(finding, key); value != "" { + context[key] = value + } + } + if len(context) == 1 { + return nil + } + return context +} + func buildFindingRelationship(finding model.Finding, canonicalKey string, ruleIDs []string, agentID string) *proof.Relationship { toolID := identity.ToolID(finding.ToolType, finding.Location) entityRefs := relationshipRefs( @@ -777,6 +854,53 @@ func scopedID(prefix, value string) string { return trimmedPrefix + ":" + trimmedValue } +func agentInstanceIDForFinding(finding model.Finding) string { + symbol := evidenceStringValue(finding, "symbol") + startLine := 0 + endLine := 0 + if finding.LocationRange != nil { + startLine = finding.LocationRange.StartLine + endLine = finding.LocationRange.EndLine + } + return identity.AgentInstanceID(finding.ToolType, finding.Location, symbol, startLine, endLine) +} + +func evidenceStringValue(finding model.Finding, key string) string { + needle := strings.ToLower(strings.TrimSpace(key)) + for _, item := range finding.Evidence { + if strings.ToLower(strings.TrimSpace(item.Key)) == needle { + return strings.TrimSpace(item.Value) + } + } + return "" +} + +func evidenceListValue(finding model.Finding, key string) []string { + needle := strings.ToLower(strings.TrimSpace(key)) + set := map[string]struct{}{} + for _, item := range finding.Evidence { + if strings.ToLower(strings.TrimSpace(item.Key)) != needle { + continue + } + for _, part := range strings.Split(item.Value, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + set[trimmed] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for item := range set { + out = append(out, item) + } + sort.Strings(out) + return out +} + func lifecycleEvidenceID(agentID, state string) string { agent := strings.TrimSpace(agentID) if agent == "" { diff --git a/core/proofmap/proofmap_test.go b/core/proofmap/proofmap_test.go index 3fd3277..af46883 100644 --- a/core/proofmap/proofmap_test.go +++ b/core/proofmap/proofmap_test.go @@ -160,3 +160,48 @@ func TestMapTransitionApprovalIncludesScope(t *testing.T) { t.Fatalf("expected transition relationship entity refs, got %#v", record.Relationship) } } + +func TestProofMap_ScanFindingIncludesAgentContextAdditively(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC) + findings := []model.Finding{ + { + FindingType: "agent_framework", + Severity: model.SeverityHigh, + ToolType: "langchain", + Location: "agents/release.py", + Repo: "repo", + Org: "acme", + Evidence: []model.Evidence{ + {Key: "symbol", Value: "release_agent"}, + {Key: "bound_tools", Value: "deploy.write,search.read"}, + {Key: "data_sources", Value: "warehouse.events"}, + {Key: "auth_surfaces", Value: "token"}, + {Key: "deployment_artifacts", Value: ".github/workflows/release.yml"}, + {Key: "deployment_status", Value: "deployed"}, + {Key: "approval_status", Value: "missing"}, + }, + }, + } + + records := MapFindings(findings, nil, now) + if len(records) != 1 { + t.Fatalf("expected one record, got %d", len(records)) + } + if records[0].Event["agent_id"] == "" { + t.Fatalf("expected additive event.agent_id, got %v", records[0].Event) + } + context, ok := records[0].Event["agent_context"].(map[string]any) + if !ok { + t.Fatalf("expected event.agent_context map, got %T", records[0].Event["agent_context"]) + } + for _, key := range []string{"agent_instance_id", "bound_tools", "deployment_artifacts", "framework", "name"} { + if _, ok := context[key]; !ok { + t.Fatalf("expected agent_context key %s, got %v", key, context) + } + } + if records[0].Metadata["agent_instance_id"] == "" { + t.Fatalf("expected additive metadata.agent_instance_id, got %v", records[0].Metadata) + } +} diff --git a/core/risk/attackpath/score.go b/core/risk/attackpath/score.go index 9971d66..5a333bc 100644 --- a/core/risk/attackpath/score.go +++ b/core/risk/attackpath/score.go @@ -86,7 +86,8 @@ func buildPath(graph aggattack.Graph, entry aggattack.Node, pivot aggattack.Node entryExposure := entryExposure(entry) pivotPrivilege := pivotPrivilege(pivot) targetImpact := targetImpact(target) - score := entryExposure + pivotPrivilege + targetImpact + rationaleBonus, rationaleExplain := edgeRationaleBonus(edges) + score := entryExposure + pivotPrivilege + targetImpact + rationaleBonus if score > 10 { score = 10 } @@ -96,6 +97,9 @@ func buildPath(graph aggattack.Graph, entry aggattack.Node, pivot aggattack.Node fmt.Sprintf("pivot_privilege=%.2f", pivotPrivilege), fmt.Sprintf("target_impact=%.2f", targetImpact), } + if rationaleBonus > 0 { + reasons = append(reasons, rationaleExplain...) + } edgeRationale := make([]string, 0, len(edges)) sourceFindings := []string{entry.CanonicalKey, target.CanonicalKey} if pivot.NodeID != "" { @@ -128,6 +132,8 @@ func buildPath(graph aggattack.Graph, entry aggattack.Node, pivot aggattack.Node func entryExposure(node aggattack.Node) float64 { switch strings.TrimSpace(node.FindingType) { + case "agent_framework": + return 3.2 case "a2a_agent_card", "webmcp_declaration": return 3.4 case "prompt_channel_untrusted_context": @@ -141,6 +147,14 @@ func entryExposure(node aggattack.Node) float64 { func pivotPrivilege(node aggattack.Node) float64 { switch strings.TrimSpace(node.FindingType) { + case "agent_tool_binding": + lower := strings.ToLower(strings.TrimSpace(node.ToolType)) + switch { + case strings.Contains(lower, "deploy"), strings.Contains(lower, "write"), strings.Contains(lower, "exec"): + return 3.1 + default: + return 2.5 + } case "ci_autonomy": return 3.4 case "compiled_action": @@ -159,6 +173,12 @@ func pivotPrivilege(node aggattack.Node) float64 { func targetImpact(node aggattack.Node) float64 { switch strings.TrimSpace(node.FindingType) { + case "agent_auth_surface": + return 3.4 + case "agent_deploy_artifact": + return 3.1 + case "agent_data_binding": + return 2.8 case "secret_presence": return 3.5 case "policy_violation": @@ -168,6 +188,34 @@ func targetImpact(node aggattack.Node) float64 { } } +func edgeRationaleBonus(edges []aggattack.Edge) (float64, []string) { + bonus := 0.0 + reasons := make([]string, 0, len(edges)) + for _, edge := range edges { + switch strings.TrimSpace(edge.Rationale) { + case "agent_to_tool_binding": + bonus += 0.7 + reasons = append(reasons, "edge_rationale=agent_to_tool_binding") + case "tool_to_data_binding": + bonus += 0.6 + reasons = append(reasons, "edge_rationale=tool_to_data_binding") + case "tool_to_auth_surface": + bonus += 0.8 + reasons = append(reasons, "edge_rationale=tool_to_auth_surface") + case "tool_to_deploy_artifact", "agent_to_deploy_artifact": + bonus += 0.9 + reasons = append(reasons, "edge_rationale=tool_to_deploy_artifact") + case "agent_to_data_binding": + bonus += 0.5 + reasons = append(reasons, "edge_rationale=agent_to_data_binding") + case "agent_to_auth_surface": + bonus += 0.7 + reasons = append(reasons, "edge_rationale=agent_to_auth_surface") + } + } + return bonus, reasons +} + func pathID(org, repo, entry, pivot, target string) string { sum := sha256.Sum256([]byte(strings.Join([]string{org, repo, entry, pivot, target}, "|"))) return "ap-" + hex.EncodeToString(sum[:6]) diff --git a/core/risk/attackpath/score_test.go b/core/risk/attackpath/score_test.go index fb5041e..b320bfb 100644 --- a/core/risk/attackpath/score_test.go +++ b/core/risk/attackpath/score_test.go @@ -41,3 +41,40 @@ func TestScoreDeterministicOrdering(t *testing.T) { } } } + +func TestScoreIncludesAgentRelationshipRationales(t *testing.T) { + t.Parallel() + + graphs := []aggattack.Graph{ + { + Org: "acme", + Repo: "repo", + Nodes: []aggattack.Node{ + {NodeID: "entry::agent_framework::langchain::agents/release.py", Kind: "entry", FindingType: "agent_framework", CanonicalKey: "agent"}, + {NodeID: "pivot::agent_tool_binding::deploy.write::agents/release.py", Kind: "pivot", FindingType: "agent_tool_binding", ToolType: "deploy.write", CanonicalKey: "tool"}, + {NodeID: "target::agent_deploy_artifact::.github/workflows/release.yml::agents/release.py", Kind: "target", FindingType: "agent_deploy_artifact", CanonicalKey: "deploy"}, + }, + Edges: []aggattack.Edge{ + {FromNodeID: "entry::agent_framework::langchain::agents/release.py", ToNodeID: "pivot::agent_tool_binding::deploy.write::agents/release.py", Rationale: "agent_to_tool_binding"}, + {FromNodeID: "pivot::agent_tool_binding::deploy.write::agents/release.py", ToNodeID: "target::agent_deploy_artifact::.github/workflows/release.yml::agents/release.py", Rationale: "tool_to_deploy_artifact"}, + }, + }, + } + + paths := Score(graphs) + if len(paths) != 1 { + t.Fatalf("expected one agent scored path, got %d", len(paths)) + } + if paths[0].PathScore <= 0 { + t.Fatalf("expected positive path score, got %.2f", paths[0].PathScore) + } + reasonSet := map[string]bool{} + for _, reason := range paths[0].Explain { + reasonSet[reason] = true + } + for _, reason := range []string{"edge_rationale=agent_to_tool_binding", "edge_rationale=tool_to_deploy_artifact"} { + if !reasonSet[reason] { + t.Fatalf("expected agent edge rationale %s, got %v", reason, paths[0].Explain) + } + } +} diff --git a/core/risk/classify/classify.go b/core/risk/classify/classify.go index 5cc6e50..e2c1fe1 100644 --- a/core/risk/classify/classify.go +++ b/core/risk/classify/classify.go @@ -13,6 +13,16 @@ func EndpointClass(finding model.Finding) string { toolType := strings.ToLower(strings.TrimSpace(finding.ToolType)) switch { + case finding.FindingType == "agent_framework": + deploymentArtifacts := evidenceValue(finding, "deployment_artifacts") + deploymentStatus := evidenceValue(finding, "deployment_status") + autoDeploy := evidenceBool(finding, "auto_deploy") + switch { + case strings.Contains(deploymentArtifacts, ".github/workflows"), strings.Contains(deploymentArtifacts, "jenkinsfile"), deploymentStatus == "deployed" || deploymentStatus == "ambiguous", autoDeploy: + return "ci_pipeline" + default: + return "repo_config" + } case finding.FindingType == "ci_autonomy" || strings.Contains(location, ".github/workflows") || strings.Contains(location, "jenkinsfile"): return "ci_pipeline" case finding.FindingType == "compiled_action" || strings.Contains(location, "agent-plans") || strings.Contains(location, "workflows/"): @@ -35,6 +45,11 @@ func EndpointClass(finding model.Finding) string { func DataClass(finding model.Finding) string { location := strings.ToLower(strings.TrimSpace(finding.Location)) + if finding.FindingType == "agent_framework" { + if dataClass := evidenceValue(finding, "data_class"); dataClass != "" && dataClass != "unknown" && dataClass != "unclassified" { + return dataClass + } + } if finding.FindingType == "secret_presence" { return "credentials" } @@ -60,6 +75,16 @@ func AutonomyLevel(finding model.Finding) string { if strings.TrimSpace(finding.Autonomy) != "" { return strings.TrimSpace(finding.Autonomy) } + if finding.FindingType == "agent_framework" { + autoDeploy := evidenceBool(finding, "auto_deploy") + humanGate := evidenceBoolWithDefault(finding, "human_gate", true) + if autoDeploy { + return autonomy.Classify(autonomy.Signals{Headless: true, HasApprovalGate: humanGate}) + } + if boolEvidenceHint(finding, "dynamic_discovery", "delegation") { + return autonomy.LevelCopilot + } + } if finding.FindingType == "ci_autonomy" { headless := evidenceBool(finding, "headless") hasGate := evidenceBool(finding, "approval_gate") @@ -89,3 +114,24 @@ func evidenceBool(finding model.Finding, key string) bool { } return parsed } + +func evidenceBoolWithDefault(finding model.Finding, key string, fallback bool) bool { + value := evidenceValue(finding, key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} + +func boolEvidenceHint(finding model.Finding, keys ...string) bool { + for _, key := range keys { + if evidenceBool(finding, key) { + return true + } + } + return false +} diff --git a/core/risk/classify/classify_test.go b/core/risk/classify/classify_test.go index 50d8924..ed0b842 100644 --- a/core/risk/classify/classify_test.go +++ b/core/risk/classify/classify_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/Clyra-AI/wrkr/core/model" + "github.com/Clyra-AI/wrkr/core/risk/autonomy" ) func TestEndpointClass(t *testing.T) { @@ -33,3 +34,29 @@ func TestAutonomyLevelDefaults(t *testing.T) { t.Fatalf("unexpected autonomy level: %s", got) } } + +func TestAgentFrameworkClassificationUsesEvidence(t *testing.T) { + t.Parallel() + + finding := model.Finding{ + FindingType: "agent_framework", + ToolType: "langchain", + Location: "agents/release.py", + Evidence: []model.Evidence{ + {Key: "data_class", Value: "pii"}, + {Key: "deployment_status", Value: "deployed"}, + {Key: "auto_deploy", Value: "true"}, + {Key: "human_gate", Value: "false"}, + }, + } + + if got := EndpointClass(finding); got != "ci_pipeline" { + t.Fatalf("unexpected agent endpoint class: %s", got) + } + if got := DataClass(finding); got != "pii" { + t.Fatalf("unexpected agent data class: %s", got) + } + if got := AutonomyLevel(finding); got != autonomy.LevelHeadlessAuto { + t.Fatalf("unexpected agent autonomy level: %s", got) + } +} diff --git a/core/risk/risk.go b/core/risk/risk.go index a579140..baee932 100644 --- a/core/risk/risk.go +++ b/core/risk/risk.go @@ -186,6 +186,11 @@ func scoreFinding(finding model.Finding, cooccurrence promptCooccurrence) Scored reasons = append(reasons, fmt.Sprintf("prompt_channel_correlation_multiplier=%.2f", promptMultiplier)) } } + if agentMultiplier, agentReasons := agentAmplification(finding); agentMultiplier > 1 { + score = score * agentMultiplier + reasons = append(reasons, agentReasons...) + reasons = append(reasons, fmt.Sprintf("agent_context_multiplier=%.2f", agentMultiplier)) + } if score > 10 { score = 10 @@ -505,6 +510,51 @@ func compiledActionFactor(finding model.Finding) float64 { return 1.1 } +func agentAmplification(finding model.Finding) (float64, []string) { + if strings.TrimSpace(finding.FindingType) != "agent_framework" { + return 1, nil + } + + multiplier := 1.0 + reasons := make([]string, 0, 7) + + deploymentStatus := evidenceString(finding, "deployment_status") + switch deploymentStatus { + case "deployed": + multiplier += 0.20 + reasons = append(reasons, "agent_deployment_scope=deployed") + case "ambiguous": + multiplier += 0.10 + reasons = append(reasons, "agent_deployment_scope=ambiguous") + } + + if hasAgentProductionWrite(finding) { + multiplier += 0.25 + reasons = append(reasons, "agent_production_write") + } + if hasAgentDelegation(finding) { + multiplier += 0.15 + reasons = append(reasons, "agent_delegation_enabled") + } + if evidenceBool(finding, "dynamic_discovery") { + multiplier += 0.15 + reasons = append(reasons, "agent_dynamic_tool_discovery") + } + if approval := evidenceString(finding, "approval_status"); approval != "approved" && approval != "valid" { + multiplier += 0.20 + reasons = append(reasons, "agent_approval_missing") + } + if deploymentStatus == "deployed" && !evidenceBool(finding, "kill_switch") { + multiplier += 0.15 + reasons = append(reasons, "agent_kill_switch_missing") + } + + if len(reasons) == 0 { + return 1, nil + } + return multiplier, reasons +} + func autonomyFactor(level string) float64 { switch level { case autonomy.LevelHeadlessAuto: @@ -518,6 +568,17 @@ func autonomyFactor(level string) float64 { } } +func hasAgentProductionWrite(finding model.Finding) bool { + return hasPermission(finding.Permissions, "deploy.write") || + hasPermission(finding.Permissions, "production.write") || + evidenceBool(finding, "auto_deploy") || + evidenceString(finding, "deployment_gate") == "open" +} + +func hasAgentDelegation(finding model.Finding) bool { + return evidenceBool(finding, "delegation") || evidenceString(finding, "delegate_to") != "" +} + func autonomyRank(level string) int { switch level { case autonomy.LevelHeadlessAuto: @@ -592,6 +653,15 @@ func evidenceFloat(finding model.Finding, key string) float64 { return parsed } +func evidenceBool(finding model.Finding, key string) bool { + value := evidenceString(finding, key) + parsed, err := strconv.ParseBool(value) + if err != nil { + return false + } + return parsed +} + func round2(in float64) float64 { parsed, _ := strconv.ParseFloat(fmt.Sprintf("%.2f", in), 64) return parsed diff --git a/core/risk/risk_test.go b/core/risk/risk_test.go index fa8f666..5d4d005 100644 --- a/core/risk/risk_test.go +++ b/core/risk/risk_test.go @@ -252,3 +252,101 @@ func TestMCPEnrichUnavailableDoesNotAlterTrustDeficit(t *testing.T) { t.Fatalf("expected unavailable enrich to not alter trust deficit, base=%.2f unavailable=%.2f", base.Ranked[0].TrustDeficit, unavailable.Ranked[0].TrustDeficit) } } + +func TestRiskScore_AgentAmplificationElevatesHighBlastExposure(t *testing.T) { + t.Parallel() + + findings := []model.Finding{ + { + FindingType: "agent_framework", + Severity: model.SeverityMedium, + ToolType: "langchain", + Location: "agents/base.py", + Repo: "repo", + Org: "acme", + Evidence: []model.Evidence{ + {Key: "approval_status", Value: "approved"}, + {Key: "deployment_status", Value: "unknown"}, + {Key: "kill_switch", Value: "true"}, + }, + }, + { + FindingType: "agent_framework", + Severity: model.SeverityHigh, + ToolType: "langchain", + Location: "agents/release.py", + Repo: "repo", + Org: "acme", + Permissions: []string{"deploy.write", "secret.read"}, + Evidence: []model.Evidence{ + {Key: "deployment_status", Value: "deployed"}, + {Key: "approval_status", Value: "missing"}, + {Key: "kill_switch", Value: "false"}, + {Key: "dynamic_discovery", Value: "true"}, + {Key: "delegation", Value: "true"}, + {Key: "auto_deploy", Value: "true"}, + {Key: "human_gate", Value: "false"}, + }, + }, + } + + report := Score(findings, 5, time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)) + if len(report.Ranked) != 2 { + t.Fatalf("expected 2 ranked findings, got %d", len(report.Ranked)) + } + if report.Ranked[0].Finding.Location != "agents/release.py" { + t.Fatalf("expected amplified agent finding to rank first, got %s", report.Ranked[0].Finding.Location) + } + reasonSet := map[string]bool{} + for _, reason := range report.Ranked[0].Reasons { + reasonSet[reason] = true + } + for _, reason := range []string{ + "agent_deployment_scope=deployed", + "agent_production_write", + "agent_delegation_enabled", + "agent_dynamic_tool_discovery", + "agent_approval_missing", + "agent_kill_switch_missing", + } { + if !reasonSet[reason] { + t.Fatalf("expected amplified agent reason %s, got %v", reason, report.Ranked[0].Reasons) + } + } +} + +func TestRiskReasons_DeterministicOrderingWithAgentFactors(t *testing.T) { + t.Parallel() + + finding := model.Finding{ + FindingType: "agent_framework", + Severity: model.SeverityHigh, + ToolType: "crewai", + Location: "crews/release.py", + Repo: "repo", + Org: "acme", + Permissions: []string{"deploy.write"}, + Evidence: []model.Evidence{ + {Key: "deployment_status", Value: "deployed"}, + {Key: "approval_status", Value: "missing"}, + {Key: "kill_switch", Value: "false"}, + {Key: "dynamic_discovery", Value: "true"}, + {Key: "delegation", Value: "true"}, + {Key: "auto_deploy", Value: "true"}, + {Key: "human_gate", Value: "false"}, + }, + } + + first := Score([]model.Finding{finding}, 5, time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)).Ranked[0].Reasons + for i := 0; i < 32; i++ { + next := Score([]model.Finding{finding}, 5, time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)).Ranked[0].Reasons + if len(next) != len(first) { + t.Fatalf("expected stable reason count, got %d vs %d", len(next), len(first)) + } + for idx := range first { + if first[idx] != next[idx] { + t.Fatalf("non-deterministic reason ordering at run %d\nfirst=%v\nnext=%v", i+1, first, next) + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 81ae046..5892c5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ description: "Deterministic architecture boundaries for Wrkr discovery, risk sco # Wrkr Architecture -Wrkr evaluates your AI dev tool configurations across your GitHub repo/org against policy. Posture-scored, compliance-ready. +Wrkr evaluates AI dev tools and agent declarations across your GitHub repo/org against policy. Posture-scored, compliance-ready. ## System Boundaries @@ -19,6 +19,8 @@ Wrkr preserves deterministic boundaries so the same input yields stable outputs - Proof emission engine - Compliance mapping/evidence output +Wrkr remains the See boundary in See -> Prove -> Control. Discovery, aggregation, scoring, and proof emission live here; downstream compliance packaging is Prove-layer consumption, and runtime enforcement remains out of scope. + ## Pipeline Diagram ```mermaid @@ -26,8 +28,8 @@ flowchart LR A["Source Layer\n(repo|org|path)"] --> B["Detection Engine\nstructured parsers"] B --> C["Aggregation Engine\ninventory + exposure rollups"] C --> D["Identity Engine\nwrkr::"] - D --> E["Risk Engine\nrank + posture"] - E --> F["Proof Emission\nscan_finding, risk_assessment"] + D --> E["Risk Engine\nrank + posture + agent amplification"] + E --> F["Proof Emission\nscan_finding, risk_assessment, agent_context"] F --> G["Evidence Output\nframework mapping + artifacts"] ``` @@ -37,6 +39,7 @@ flowchart LR - WebMCP JavaScript parsing is AST-only (`goja/parser` + `goja/ast`), never runtime eval. - Secret values are never emitted. - Risk ordering uses deterministic tie-breakers. +- Agent-linked attack-path edges and proof context are additive and deterministic. - Exit codes are stable API contracts. ## Command Anchors @@ -45,7 +48,7 @@ flowchart LR wrkr scan --path ./scenarios/wrkr/scan-mixed-org/repos --json wrkr score --json wrkr verify --chain --json -wrkr evidence --frameworks eu-ai-act,soc2 --json +wrkr evidence --frameworks eu-ai-act,soc2,pci-dss --json ``` ## When Not To Use @@ -71,3 +74,7 @@ wrkr verify --chain --json ### Does Wrkr enforce runtime side effects directly? No. Wrkr is a discovery and posture engine. Runtime enforcement belongs to a control-layer system. + +### How is wave sequencing enforced for the tools-plus-agents rollout? + +Wrkr keeps the ordered merge-gate contract in [`docs/trust/wave-gates.md`](./trust/wave-gates.md) and `/.github/wave-gates.json`. CI validates the wave contract and blocks scan JSON or exit-code regressions before merge. diff --git a/docs/commands/scan.md b/docs/commands/scan.md index 37f7ace..d5c2045 100644 --- a/docs/commands/scan.md +++ b/docs/commands/scan.md @@ -66,6 +66,7 @@ Expected JSON keys include `status`, `target`, `findings`, `ranked_findings`, `t `sarif.path` is included when `--sarif` output is requested. `inventory.methodology` emits machine-readable scan metadata (`wrkr_version`, timing, repo/file counts, detector inventory). `inventory.agents` is always present (possibly empty) and is deterministically sorted by org/framework/instance/location; agent entries may include additive `location_range` when parser metadata is available. +`ranked_findings[*]` and `attack_paths[*]` now include deterministic agent-aware amplification and edge rationale when agent declarations expose deployment, delegation, dynamic discovery, or bound tool/data/auth/deploy chains. `inventory.tools[*]` includes deterministic `approval_classification` (`approved|unapproved|unknown`), and `inventory.approval_summary` emits aggregate approval-gap ratios for campaign/report workflows. `inventory.tools[*]` also emits report-ready `tool_category` and deterministic `confidence_score` (`0.00-1.00`) for inventory breakdown tables. `inventory.tools[*]` emits normalized `permission_surface`, `permission_tier`, `risk_tier`, `adoption_pattern`, and per-tool `regulatory_mapping` statuses. @@ -114,5 +115,7 @@ Emerging discovery surfaces are static-only in default deterministic mode: - MCP gateway posture is derived from local config files only. - No live endpoint probing is performed by default. +Wrkr stays in the See boundary: it inventories and scores tools plus agents from files and CI declarations, but it does not enforce runtime side effects or execute agent workflows. + Custom extension detectors are loaded from `.wrkr/detectors/extensions.json` when present in scanned repositories. See [`docs/extensions/detectors.md`](../extensions/detectors.md). Canonical state and artifact lifecycle: [`docs/state_lifecycle.md`](../state_lifecycle.md). diff --git a/docs/trust/detection-coverage-matrix.md b/docs/trust/detection-coverage-matrix.md index 7b6a454..9bb3e93 100644 --- a/docs/trust/detection-coverage-matrix.md +++ b/docs/trust/detection-coverage-matrix.md @@ -8,14 +8,16 @@ description: "What Wrkr detects, what it does not detect, and why under determin ## What Wrkr detects - Repository and org configuration surfaces for Claude, Cursor, Codex, Copilot, MCP, WebMCP, A2A, and CI headless execution patterns. +- First-class agent declarations and bindings from LangChain, CrewAI, OpenAI Agents, AutoGen, LlamaIndex, MCP-client, and conservative custom-agent scaffolding surfaces. - Prompt-channel override/poisoning patterns from static instruction surfaces with deterministic reason codes and evidence hashes. - Static policy/profile posture signals and ranked findings. -- Deterministic inventory and risk outputs. +- Deterministic inventory and risk outputs for both tools and agents, including agent-linked attack-path edges when bindings/deployments are declared in-repo. - Optional enrich-mode MCP metadata (`source`, `as_of`, advisory/registry schema IDs, `enrich_quality`, adapter error classes) when `--enrich` is enabled. ## What Wrkr does not detect - Live runtime network traffic, live endpoint behavior, or post-deploy runtime side effects. +- Live runtime execution of agents or tool side effects beyond what is declared in repository and CI artifacts. - Dynamic SaaS telemetry from external systems unless explicitly integrated in non-default paths. - Guaranteed upstream API/schema stability for external enrich providers. @@ -44,6 +46,8 @@ No. Wrkr is deterministic and file-based by default, so it detects declared conf Run `wrkr scan --json` on representative fixtures and verify the inventory findings include the expected tool/config declarations. +The expected v1 output model is tools plus agents. `inventory.agents`, `agent_privilege_map`, agent-aware `ranked_findings`, and additive agent-linked `attack_paths` are the deterministic proof surfaces for that model. + ### How should I interpret MCP enrich quality fields? Treat `enrich_quality` as explicit confidence metadata for optional network lookups: `ok`, `partial`, `stale`, or `unavailable`. diff --git a/docs/trust/wave-gates.md b/docs/trust/wave-gates.md new file mode 100644 index 0000000..9a60a62 --- /dev/null +++ b/docs/trust/wave-gates.md @@ -0,0 +1,22 @@ +# Four-Wave Delivery Gates + +Wrkr ships the tools-plus-agents program in four ordered merge waves: + +1. `wave-1`: foundation contracts and identity safety. +2. `wave-2`: core detection, relationship, deployment, and policy enforcement. +3. `wave-3`: coverage expansion, benchmarks, and scenario/contract quality packs. +4. `wave-4`: risk hardening, proof/compliance portability, governance, and docs. + +The contract for this model lives in [`/.github/wave-gates.json`](../../.github/wave-gates.json). CI enforces it with: + +- `wave-sequence`: validates the ordered wave contract and required story checks. +- `scan-contract`: blocks scan JSON or exit-code regressions on the stable diff fixture. +- `.github/required-checks.json`: branch-protection status checks must exactly match the Wave gate contract. + +Release hardening also requires these commands to remain auditable and reproducible: + +- `make prepush-full` +- `make test-contracts` +- `make test-scenarios` +- `scripts/run_v1_acceptance.sh --mode=local` +- `go run ./cmd/wrkr scan --path scenarios/wrkr/scan-diff-no-noise/input/local-repos --json --quiet` diff --git a/product/wrkr.md b/product/wrkr.md index c08b8fb..da51007 100644 --- a/product/wrkr.md +++ b/product/wrkr.md @@ -11,7 +11,7 @@ ## Executive Summary -Wrkr is an open-source Go CLI and AI-DSPM scanner that discovers AI development tools across an organization, tracks their lifecycle, and produces compliance-ready proof records of AI posture. It answers four questions no existing tool can: What AI tools are developers using? What can those tools access? What's their trust status? Can we prove it to an auditor? +Wrkr is an open-source Go CLI and AI-DSPM scanner that discovers AI development tools and agent declarations across an organization, tracks their lifecycle, and produces compliance-ready proof records of AI posture. It answers four questions no existing tool can: What AI tools and agents are developers using? What can they access? What's their trust status? Can we prove it to an auditor? **Primary audiences:** The primary user is the **platform engineering lead** responsible for developer tooling standards. The primary buyer is the **CISO or VP of Engineering** who needs to answer "what AI tools are in our environment" for auditors, boards, or regulators. The champion is the **security engineer** who runs the scan and surfaces findings. @@ -51,7 +51,7 @@ Wrkr is an open-source Go CLI and AI-DSPM scanner that discovers AI development ### One-liner: -**Wrkr is an open-source Go CLI that discovers AI development tools across your organization and produces compliance-ready proof records of your AI posture.** +**Wrkr is an open-source Go CLI that discovers AI development tools and agent declarations across your organization and produces compliance-ready proof records of your AI posture.** ### Core loop (the "10-minute time-to-value"): diff --git a/scripts/check_branch_protection_contract.sh b/scripts/check_branch_protection_contract.sh index 4c1d5ea..ddaf10e 100755 --- a/scripts/check_branch_protection_contract.sh +++ b/scripts/check_branch_protection_contract.sh @@ -6,6 +6,11 @@ if [[ ! -f .github/required-checks.json ]]; then exit 3 fi +if [[ ! -f .github/wave-gates.json ]]; then + echo "missing wave gate contract: .github/wave-gates.json" >&2 + exit 3 +fi + if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required for branch protection contract validation" >&2 exit 7 @@ -20,6 +25,7 @@ import sys ROOT = pathlib.Path(".") required_checks_path = ROOT / ".github" / "required-checks.json" +wave_gates_path = ROOT / ".github" / "wave-gates.json" workflows_dir = ROOT / ".github" / "workflows" errors = [] @@ -30,6 +36,12 @@ except Exception as exc: print(f"failed to parse {required_checks_path}: {exc}", file=sys.stderr) sys.exit(3) +try: + wave_payload = json.loads(wave_gates_path.read_text(encoding="utf-8")) +except Exception as exc: + print(f"failed to parse {wave_gates_path}: {exc}", file=sys.stderr) + sys.exit(3) + required_checks = payload.get("required_checks") if not isinstance(required_checks, list) or not all( isinstance(item, str) and item.strip() for item in required_checks @@ -46,6 +58,16 @@ if len(set(required_checks)) != len(required_checks): if required_checks != sorted(required_checks): errors.append("required_checks must be sorted for deterministic diffs") +wave_required_checks = wave_payload.get("merge_gates", {}).get("required_pr_checks") +if not isinstance(wave_required_checks, list) or not all( + isinstance(item, str) and item.strip() for item in wave_required_checks +): + errors.append("wave gate contract merge_gates.required_pr_checks must be a non-empty string array") + wave_required_checks = [] + +if list(required_checks) != [item.strip() for item in wave_required_checks]: + errors.append("required_checks must match wave gate contract merge_gates.required_pr_checks exactly") + workflow_files = sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml")) if not workflow_files: errors.append("no workflow files found under .github/workflows") diff --git a/scripts/check_wave_gates.sh b/scripts/check_wave_gates.sh new file mode 100755 index 0000000..d36059b --- /dev/null +++ b/scripts/check_wave_gates.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f .github/wave-gates.json ]]; then + echo "missing wave gate contract: .github/wave-gates.json" >&2 + exit 3 +fi + +if [[ ! -f .github/required-checks.json ]]; then + echo "missing branch protection contract: .github/required-checks.json" >&2 + exit 3 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required for wave gate validation" >&2 + exit 7 +fi + +python3 - <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(".") +wave_path = root / ".github" / "wave-gates.json" +required_checks_path = root / ".github" / "required-checks.json" +contracts_dir = root / "testinfra" / "contracts" + +errors: list[str] = [] + +try: + wave_payload = json.loads(wave_path.read_text(encoding="utf-8")) +except Exception as exc: + print(f"failed to parse {wave_path}: {exc}", file=sys.stderr) + sys.exit(3) + +try: + required_checks_payload = json.loads(required_checks_path.read_text(encoding="utf-8")) +except Exception as exc: + print(f"failed to parse {required_checks_path}: {exc}", file=sys.stderr) + sys.exit(3) + +required_checks = required_checks_payload.get("required_checks") +if not isinstance(required_checks, list): + errors.append("required_checks must be a string array") + required_checks = [] +required_checks = [item.strip() for item in required_checks if isinstance(item, str) and item.strip()] + +merge_gates = wave_payload.get("merge_gates") +if not isinstance(merge_gates, dict): + errors.append("merge_gates must be an object") + merge_gates = {} + +required_pr_checks = merge_gates.get("required_pr_checks") +if not isinstance(required_pr_checks, list) or not required_pr_checks: + errors.append("merge_gates.required_pr_checks must be a non-empty string array") + required_pr_checks = [] +required_pr_checks = [item.strip() for item in required_pr_checks if isinstance(item, str) and item.strip()] + +required_release_commands = merge_gates.get("required_release_commands") +if not isinstance(required_release_commands, list) or not required_release_commands: + errors.append("merge_gates.required_release_commands must be a non-empty string array") + required_release_commands = [] + +waves = wave_payload.get("waves") +if not isinstance(waves, list) or not waves: + errors.append("waves must be a non-empty array") + waves = [] + +wave_ids: list[str] = [] +wave_by_id: dict[str, dict] = {} +expected_order = 1 +expected_ids = [f"wave-{idx}" for idx in range(1, len(waves) + 1)] + +for index, wave in enumerate(waves): + if not isinstance(wave, dict): + errors.append(f"wave entry {index} must be an object") + continue + wave_id = str(wave.get("id", "")).strip() + label = str(wave.get("label", "")).strip() + order = wave.get("order") + lanes = wave.get("required_lanes") + checks = wave.get("required_story_checks") + requires = str(wave.get("requires", "")).strip() + successor = str(wave.get("successor", "")).strip() + + if not wave_id: + errors.append(f"wave entry {index} missing id") + continue + if wave_id in wave_by_id: + errors.append(f"duplicate wave id {wave_id}") + continue + if not label: + errors.append(f"{wave_id} missing label") + if order != expected_order: + errors.append(f"{wave_id} order must be {expected_order}, got {order}") + if index < len(expected_ids) and wave_id != expected_ids[index]: + errors.append(f"wave ids must be sequential wave-1..wave-N, got {wave_id} at index {index}") + if not isinstance(lanes, list) or sorted(lanes) != ["acceptance", "core", "cross_platform", "fast", "risk"]: + errors.append(f"{wave_id} required_lanes must be exactly fast/core/acceptance/cross_platform/risk") + if not isinstance(checks, list) or not checks: + errors.append(f"{wave_id} required_story_checks must be a non-empty string array") + checks = [] + else: + normalized_checks = [] + for check in checks: + if not isinstance(check, str) or not check.strip(): + errors.append(f"{wave_id} required_story_checks contains an invalid entry") + continue + normalized_checks.append(check.strip()) + checks = normalized_checks + if checks != sorted(checks): + errors.append(f"{wave_id} required_story_checks must be sorted") + for filename in checks: + if not (contracts_dir / filename).is_file(): + errors.append(f"{wave_id} required story contract missing: testinfra/contracts/{filename}") + + if order == 1 and requires: + errors.append(f"{wave_id} must not declare requires") + if order > 1 and requires != f"wave-{order - 1}": + errors.append(f"{wave_id} must require wave-{order - 1}") + if successor and order < len(waves) and successor != f"wave-{order + 1}": + errors.append(f"{wave_id} successor must be wave-{order + 1}") + if order == len(waves) and successor: + errors.append(f"{wave_id} must not declare successor") + + wave_ids.append(wave_id) + wave_by_id[wave_id] = wave + expected_order += 1 + +if required_pr_checks != sorted(required_pr_checks): + errors.append("merge_gates.required_pr_checks must be sorted") +if required_pr_checks != required_checks: + errors.append( + "merge_gates.required_pr_checks must exactly match .github/required-checks.json required_checks" + ) + +for command in required_release_commands: + if not isinstance(command, str) or not command.strip(): + errors.append("merge_gates.required_release_commands contains an invalid command") + +if errors: + for err in errors: + print(err, file=sys.stderr) + sys.exit(3) + +print("wave gate contract: pass") +PY diff --git a/testinfra/contracts/story21_contracts_test.go b/testinfra/contracts/story21_contracts_test.go new file mode 100644 index 0000000..b27183f --- /dev/null +++ b/testinfra/contracts/story21_contracts_test.go @@ -0,0 +1,164 @@ +package contracts + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Clyra-AI/wrkr/core/cli" +) + +func TestRequiredChecks_EnforceWaveSequence1To2To3To4(t *testing.T) { + t.Parallel() + + repoRoot := mustFindRepoRoot(t) + payload, err := os.ReadFile(filepath.Join(repoRoot, ".github", "wave-gates.json")) + if err != nil { + t.Fatalf("read wave gates: %v", err) + } + + var contract struct { + MergeGates struct { + RequiredPRChecks []string `json:"required_pr_checks"` + } `json:"merge_gates"` + Waves []struct { + ID string `json:"id"` + Order int `json:"order"` + Requires string `json:"requires"` + Successor string `json:"successor"` + RequiredLanes []string `json:"required_lanes"` + RequiredStoryChecks []string `json:"required_story_checks"` + } `json:"waves"` + } + if err := json.Unmarshal(payload, &contract); err != nil { + t.Fatalf("parse wave gates: %v", err) + } + + if len(contract.Waves) != 4 { + t.Fatalf("expected four waves, got %d", len(contract.Waves)) + } + requiredLanes := []string{"acceptance", "core", "cross_platform", "fast", "risk"} + for idx, wave := range contract.Waves { + expectedID := "wave-" + string(rune('1'+idx)) + if wave.ID != expectedID { + t.Fatalf("expected %s at index %d, got %s", expectedID, idx, wave.ID) + } + if wave.Order != idx+1 { + t.Fatalf("expected %s order=%d, got %d", wave.ID, idx+1, wave.Order) + } + actualLanes := append([]string(nil), wave.RequiredLanes...) + sort.Strings(actualLanes) + if !reflect.DeepEqual(actualLanes, requiredLanes) { + t.Fatalf("expected %s lanes %v, got %v", wave.ID, requiredLanes, wave.RequiredLanes) + } + if len(wave.RequiredStoryChecks) == 0 { + t.Fatalf("expected required story checks for %s", wave.ID) + } + if idx == 0 { + if wave.Requires != "" { + t.Fatalf("wave-1 must not require a predecessor, got %q", wave.Requires) + } + } else if wave.Requires != contract.Waves[idx-1].ID { + t.Fatalf("expected %s to require %s, got %q", wave.ID, contract.Waves[idx-1].ID, wave.Requires) + } + if idx < len(contract.Waves)-1 { + if wave.Successor != contract.Waves[idx+1].ID { + t.Fatalf("expected %s successor %s, got %q", wave.ID, contract.Waves[idx+1].ID, wave.Successor) + } + } else if wave.Successor != "" { + t.Fatalf("final wave must not define successor, got %q", wave.Successor) + } + } + + requiredChecks := loadRequiredChecks(t, repoRoot) + if !reflect.DeepEqual(contract.MergeGates.RequiredPRChecks, requiredChecks) { + t.Fatalf("required PR checks must match branch protection contract: wave=%v required=%v", contract.MergeGates.RequiredPRChecks, requiredChecks) + } +} + +func TestScanContract_NoJSONOrExitRegressionAcrossWaves(t *testing.T) { + t.Parallel() + + repoRoot := mustFindRepoRoot(t) + inputPath := filepath.Join(repoRoot, "scenarios", "wrkr", "scan-diff-no-noise", "input", "local-repos") + + firstPayload, firstCode := runStory21Scan(t, inputPath, filepath.Join(t.TempDir(), "first-state.json")) + secondPayload, secondCode := runStory21Scan(t, inputPath, filepath.Join(t.TempDir(), "second-state.json")) + if firstCode != 0 || secondCode != 0 { + t.Fatalf("expected exit code 0 across repeated scan runs, got first=%d second=%d", firstCode, secondCode) + } + + for _, payload := range []map[string]any{firstPayload, secondPayload} { + for _, key := range []string{"findings", "inventory", "ranked_findings"} { + if _, ok := payload[key]; !ok { + t.Fatalf("scan payload missing %q: %v", key, payload) + } + } + } + + if !reflect.DeepEqual(normalizeStory21Volatile(firstPayload), normalizeStory21Volatile(secondPayload)) { + t.Fatalf("expected deterministic scan JSON across repeated runs\nfirst=%v\nsecond=%v", normalizeStory21Volatile(firstPayload), normalizeStory21Volatile(secondPayload)) + } +} + +func runStory21Scan(t *testing.T, inputPath, statePath string) (map[string]any, int) { + t.Helper() + + var out bytes.Buffer + var errOut bytes.Buffer + code := cli.Run([]string{"scan", "--path", inputPath, "--state", statePath, "--json", "--quiet"}, &out, &errOut) + if code != 0 { + t.Fatalf("scan failed: code=%d stderr=%s", code, errOut.String()) + } + payload := map[string]any{} + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("parse scan payload: %v", err) + } + return payload, code +} + +func normalizeStory21Volatile(in map[string]any) map[string]any { + out := map[string]any{} + for key, value := range in { + if strings.EqualFold(strings.TrimSpace(key), "generated_at") { + continue + } + out[key] = normalizeStory21Any(value) + } + return out +} + +func normalizeStory21Any(value any) any { + switch typed := value.(type) { + case map[string]any: + out := map[string]any{} + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + lower := strings.ToLower(strings.TrimSpace(key)) + switch lower { + case "generated_at", "scan_started_at", "scan_completed_at", "scan_duration_seconds": + continue + default: + out[key] = normalizeStory21Any(typed[key]) + } + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, normalizeStory21Any(item)) + } + return out + default: + return value + } +}