Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions core/aggregate/privilegebudget/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve deployment artifact path casing during context merge

mergeAgentContext now routes deployment artifacts through dedupeSorted, which lowercases every token before returning it. For repos that use case-sensitive artifact names (for example Deploy.yml), this changes the recorded evidence path and can make the emitted deployment_artifacts/deployment_evidence_keys no longer match real files, reducing auditability of the privilege map output.

Useful? React with 👍 / 👎.

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 {
Expand Down
44 changes: 44 additions & 0 deletions core/aggregate/privilegebudget/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
16 changes: 16 additions & 0 deletions core/detect/agentframework/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
92 changes: 92 additions & 0 deletions core/detect/agentframework/detector_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion core/policy/eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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++
}
Expand Down
63 changes: 63 additions & 0 deletions core/policy/eval/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down