diff --git a/core/aggregate/privilegebudget/budget.go b/core/aggregate/privilegebudget/budget.go index 1fd3168..b4199fb 100644 --- a/core/aggregate/privilegebudget/budget.go +++ b/core/aggregate/privilegebudget/budget.go @@ -123,15 +123,86 @@ func Build( func mapAgentsByID(agents []agginventory.Agent) map[string]agginventory.Agent { out := map[string]agginventory.Agent{} for _, agent := range agents { - agentID := strings.TrimSpace(agent.AgentID) - if agentID == "" { + keys := agentLookupKeys(agent) + if len(keys) == 0 { continue } - out[agentID] = agent + for _, key := range keys { + current := out[key] + out[key] = mergeAgentContext(current, agent, key) + } } return out } +func agentLookupKeys(agent agginventory.Agent) []string { + keys := map[string]struct{}{} + if agentID := strings.TrimSpace(agent.AgentID); agentID != "" { + keys[agentID] = struct{}{} + } + framework := strings.TrimSpace(agent.Framework) + location := strings.TrimSpace(agent.Location) + if framework != "" && location != "" { + toolScoped := identity.AgentID(identity.ToolID(framework, location), fallbackOrg(agent.Org)) + keys[toolScoped] = struct{}{} + } + out := make([]string, 0, len(keys)) + for key := range keys { + out = append(out, key) + } + sort.Strings(out) + return out +} + +func mergeAgentContext(current, incoming agginventory.Agent, key string) agginventory.Agent { + merged := current + if strings.TrimSpace(merged.AgentID) == "" { + merged.AgentID = strings.TrimSpace(key) + } + if strings.TrimSpace(merged.AgentInstanceID) == "" { + merged.AgentInstanceID = strings.TrimSpace(incoming.AgentInstanceID) + } + if strings.TrimSpace(merged.Framework) == "" { + merged.Framework = strings.TrimSpace(incoming.Framework) + } + merged.BoundTools = dedupeSorted(append(append([]string(nil), merged.BoundTools...), incoming.BoundTools...)) + merged.BoundDataSources = dedupeSorted(append(append([]string(nil), merged.BoundDataSources...), incoming.BoundDataSources...)) + merged.BoundAuthSurfaces = dedupeSorted(append(append([]string(nil), merged.BoundAuthSurfaces...), incoming.BoundAuthSurfaces...)) + merged.BindingEvidenceKeys = dedupeSorted(append(append([]string(nil), merged.BindingEvidenceKeys...), incoming.BindingEvidenceKeys...)) + merged.MissingBindings = dedupeSorted(append(append([]string(nil), merged.MissingBindings...), incoming.MissingBindings...)) + merged.DeploymentStatus = mergeDeploymentStatus(merged.DeploymentStatus, incoming.DeploymentStatus) + merged.DeploymentArtifacts = dedupeSorted(append(append([]string(nil), merged.DeploymentArtifacts...), incoming.DeploymentArtifacts...)) + merged.DeploymentEvidenceKeys = dedupeSorted(append(append([]string(nil), merged.DeploymentEvidenceKeys...), incoming.DeploymentEvidenceKeys...)) + return merged +} + +func mergeDeploymentStatus(current, incoming string) string { + currentNormalized := normalizeToken(current) + incomingNormalized := normalizeToken(incoming) + switch { + case currentNormalized == "deployed" || incomingNormalized == "deployed": + return "deployed" + case currentNormalized == "" || currentNormalized == "unknown": + if incomingNormalized != "" { + return incomingNormalized + } + case incomingNormalized == "" || incomingNormalized == "unknown": + if currentNormalized != "" { + return currentNormalized + } + } + if currentNormalized == "" { + return incomingNormalized + } + if incomingNormalized == "" { + return currentNormalized + } + if currentNormalized <= incomingNormalized { + return currentNormalized + } + return incomingNormalized +} + func buildSignalsByAgent(findings []model.Finding) map[string]findingSignals { out := map[string]findingSignals{} for _, finding := range findings { diff --git a/core/aggregate/privilegebudget/budget_test.go b/core/aggregate/privilegebudget/budget_test.go index c10b0e3..48c7ef1 100644 --- a/core/aggregate/privilegebudget/budget_test.go +++ b/core/aggregate/privilegebudget/budget_test.go @@ -249,3 +249,47 @@ func TestBuildIncludesAgentLayerContextDeterministically(t *testing.T) { t.Fatalf("unexpected approval classification: %q", entry.ApprovalClassification) } } + +func TestBuildResolvesInstanceScopedAgentContextForToolEntries(t *testing.T) { + t.Parallel() + + toolID := identity.ToolID("langchain", "agents/main.py") + toolAgentID := identity.AgentID(toolID, "acme") + instanceID := identity.AgentInstanceID("langchain", "agents/main.py", "release_agent", 12, 64) + + tools := []agginventory.Tool{{ + ToolID: toolID, + AgentID: toolAgentID, + ToolType: "langchain", + Org: "acme", + Repos: []string{"acme/backend"}, + Permissions: []string{"deploy.write"}, + }} + agents := []agginventory.Agent{{ + AgentID: identity.AgentID(instanceID, "acme"), + AgentInstanceID: instanceID, + Framework: "langchain", + Org: "acme", + Location: "agents/main.py", + BoundDataSources: []string{"warehouse.events"}, + BindingEvidenceKeys: []string{"data:warehouse.events"}, + DeploymentStatus: "deployed", + DeploymentArtifacts: []string{".github/workflows/release.yml"}, + DeploymentEvidenceKeys: []string{"deployment:.github/workflows/release.yml"}, + }} + + _, entries := Build(tools, agents, nil, nil) + if len(entries) != 1 { + t.Fatalf("expected one privilege map entry, got %d", len(entries)) + } + entry := entries[0] + if entry.DeploymentStatus != "deployed" { + t.Fatalf("expected deployment_status=deployed, got %q", entry.DeploymentStatus) + } + if !reflect.DeepEqual(entry.BoundDataSources, []string{"warehouse.events"}) { + t.Fatalf("unexpected bound_data_sources: %+v", entry.BoundDataSources) + } + if !reflect.DeepEqual(entry.DeploymentEvidenceKeys, []string{"deployment:.github/workflows/release.yml"}) { + t.Fatalf("unexpected deployment_evidence_keys: %+v", entry.DeploymentEvidenceKeys) + } +} diff --git a/core/detect/agentframework/detector.go b/core/detect/agentframework/detector.go index c8df217..d8661ac 100644 --- a/core/detect/agentframework/detector.go +++ b/core/detect/agentframework/detector.go @@ -25,6 +25,7 @@ type AgentSpec struct { KillSwitch bool `json:"kill_switch" yaml:"kill_switch"` AutoDeploy bool `json:"auto_deploy" yaml:"auto_deploy"` HumanGate bool `json:"human_gate" yaml:"human_gate"` + DeploymentGate string `json:"deployment_gate" yaml:"deployment_gate"` } type declaration struct { @@ -120,6 +121,7 @@ func frameworkFinding(scope detect.Scope, cfg DetectorConfig, agent AgentSpec) m {Key: "kill_switch", Value: fmt.Sprintf("%t", agent.KillSwitch)}, {Key: "auto_deploy", Value: fmt.Sprintf("%t", agent.AutoDeploy)}, {Key: "human_gate", Value: fmt.Sprintf("%t", agent.HumanGate)}, + {Key: "deployment_gate", Value: deriveDeploymentGate(agent)}, } severity := model.SeverityLow @@ -233,3 +235,17 @@ func fallbackOrg(org string) string { } return org } + +func deriveDeploymentGate(agent AgentSpec) string { + explicit := strings.ToLower(strings.TrimSpace(agent.DeploymentGate)) + if explicit != "" { + return explicit + } + if !agent.AutoDeploy { + return "" + } + if agent.HumanGate { + return "enforced" + } + return "missing" +} diff --git a/core/detect/agentframework/detector_test.go b/core/detect/agentframework/detector_test.go new file mode 100644 index 0000000..2644d46 --- /dev/null +++ b/core/detect/agentframework/detector_test.go @@ -0,0 +1,92 @@ +package agentframework + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clyra-AI/wrkr/core/detect" + "github.com/Clyra-AI/wrkr/core/model" +) + +func TestDetect_DefaultsDeploymentGateFromHumanGate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + configPath := ".wrkr/agents/langchain.yaml" + writeFile(t, root, configPath, `agents: + - name: release_agent + file: agents/release.py + auto_deploy: true + human_gate: true +`) + + findings, err := Detect(context.Background(), detect.Scope{Org: "acme", Repo: "payments", Root: root}, DetectorConfig{ + DetectorID: "agentframework_langchain", + Framework: "langchain", + ConfigPath: configPath, + Format: "yaml", + }) + if err != nil { + t.Fatalf("detect: %v", err) + } + if len(findings) != 1 { + t.Fatalf("expected one finding, got %d", len(findings)) + } + if value := evidenceValue(findings[0], "deployment_gate"); value != "enforced" { + t.Fatalf("expected deployment_gate=enforced, got %q", value) + } +} + +func TestDetect_UsesExplicitDeploymentGate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + configPath := ".wrkr/agents/openai.yaml" + writeFile(t, root, configPath, `agents: + - name: release_agent + file: agents/release.py + auto_deploy: true + human_gate: false + deployment_gate: approved +`) + + findings, err := Detect(context.Background(), detect.Scope{Org: "acme", Repo: "payments", Root: root}, DetectorConfig{ + DetectorID: "agentframework_openai", + Framework: "openai_agents", + ConfigPath: configPath, + Format: "yaml", + }) + if err != nil { + t.Fatalf("detect: %v", err) + } + if len(findings) != 1 { + t.Fatalf("expected one finding, got %d", len(findings)) + } + if value := evidenceValue(findings[0], "deployment_gate"); value != "approved" { + t.Fatalf("expected deployment_gate=approved, got %q", value) + } +} + +func evidenceValue(finding model.Finding, key string) string { + target := strings.ToLower(strings.TrimSpace(key)) + for _, evidence := range finding.Evidence { + if strings.ToLower(strings.TrimSpace(evidence.Key)) == target { + return strings.TrimSpace(evidence.Value) + } + } + return "" +} + +func writeFile(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} diff --git a/core/policy/eval/eval.go b/core/policy/eval/eval.go index 55bcef0..c7b0177 100644 --- a/core/policy/eval/eval.go +++ b/core/policy/eval/eval.go @@ -90,7 +90,8 @@ func applyRule(rule policy.Rule, findings []model.Finding) (bool, string) { violations++ } } - return violations == 0, fmt.Sprintf("prod_write_without_human_gate=%d", violations) + secretCount := countType(findings, "secret_presence") + return violations == 0 && secretCount == 0, fmt.Sprintf("prod_write_without_human_gate=%d,secret_presence=%d", violations, secretCount) case "agent_secret_controls": agents := agentFindings(findings) if len(agents) == 0 { @@ -184,6 +185,9 @@ func applyRule(rule policy.Rule, findings []model.Finding) (bool, string) { continue } gate := strings.ToLower(strings.TrimSpace(evidenceValue(finding, "deployment_gate"))) + if gate == "" && boolEvidenceWithDefault(finding, "human_gate", false) { + gate = "enforced" + } if gate != "approved" && gate != "enforced" { violations++ } diff --git a/core/policy/eval/eval_test.go b/core/policy/eval/eval_test.go index 2b4e6f6..51f33a9 100644 --- a/core/policy/eval/eval_test.go +++ b/core/policy/eval/eval_test.go @@ -140,6 +140,69 @@ func TestPolicyEval_WRKRA010_AutoDeployWithoutHumanGateFails(t *testing.T) { } } +func TestPolicyEval_WRKR002_AgentProdWriteAlsoChecksSecretPresence(t *testing.T) { + t.Parallel() + + rules := []policy.Rule{{ + ID: "WRKR-002", + Title: "production write agents require human gate", + Severity: "high", + Kind: "agent_prod_write_human_gate", + Remediation: "set human gate and remove inline secrets", + Version: 1, + }} + findings := []model.Finding{ + { + FindingType: "agent_framework", + ToolType: "langchain", + Location: "agents/release.py", + Permissions: []string{"deploy.write"}, + Evidence: []model.Evidence{ + {Key: "symbol", Value: "release_agent"}, + {Key: "deployment_status", Value: "deployed"}, + {Key: "human_gate", Value: "true"}, + }, + }, + { + FindingType: "secret_presence", + ToolType: "codex", + Location: ".codex/config.toml", + }, + } + out := Evaluate("repo", "org", findings, rules) + if !hasViolation(out, "WRKR-002") { + t.Fatalf("expected WRKR-002 violation when secret_presence exists, got %+v", out) + } +} + +func TestPolicyEval_WRKRA009_UsesHumanGateFallbackWithoutDeploymentGate(t *testing.T) { + t.Parallel() + + rules := []policy.Rule{{ + ID: "WRKR-A009", + Title: "auto deploy requires deployment gate", + Severity: "high", + Kind: "agent_auto_deploy_gate", + Remediation: "declare deployment gate", + Version: 1, + }} + findings := []model.Finding{{ + FindingType: "agent_framework", + ToolType: "openai_agents", + Location: "agents/release.py", + Evidence: []model.Evidence{ + {Key: "symbol", Value: "release_agent"}, + {Key: "auto_deploy", Value: "true"}, + {Key: "human_gate", Value: "true"}, + }, + }} + + out := Evaluate("repo", "org", findings, rules) + if hasViolation(out, "WRKR-A009") { + t.Fatalf("expected WRKR-A009 to pass with human_gate fallback, got %+v", out) + } +} + func TestPolicyEval_AgentRuleKindsDeterministicPassFail(t *testing.T) { t.Parallel()