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
19 changes: 18 additions & 1 deletion core/cli/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,11 @@ func runIdentityTransition(args []string, stdout io.Writer, stderr io.Writer, st
} else if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "identity id is required", exitInvalidInput)
}
return runIdentityManualTransition(stateName, agentID, "", "", strings.TrimSpace(*reason), time.Time{}, *statePathFlag, jsonRequested || *jsonOut, stdout, stderr)
resolvedReason := strings.TrimSpace(*reason)
if resolvedReason == "" {
resolvedReason = defaultTransitionReason(stateName)
}
return runIdentityManualTransition(stateName, agentID, "", "", resolvedReason, time.Time{}, *statePathFlag, jsonRequested || *jsonOut, stdout, stderr)
}

func runIdentityManualTransition(stateName, agentID, approver, scope, reason string, expiresAt time.Time, statePathFlag string, jsonOut bool, stdout io.Writer, stderr io.Writer) int {
Expand Down Expand Up @@ -245,3 +249,16 @@ func runIdentityManualTransition(stateName, agentID, approver, scope, reason str
_, _ = fmt.Fprintf(stdout, "wrkr identity %s -> %s\n", agentID, stateName)
return exitSuccess
}

func defaultTransitionReason(stateName string) string {
switch stateName {
case identity.StateUnderReview:
return "manual_transition_under_review"
case identity.StateDeprecated:
return "manual_transition_deprecated"
case identity.StateRevoked:
return "manual_transition_revoked"
default:
return "manual_transition"
}
}
194 changes: 192 additions & 2 deletions core/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,14 @@ func TestScanUsesConfiguredDefaultTarget(t *testing.T) {
tmp := t.TempDir()
configPath := filepath.Join(tmp, "config.json")
statePath := filepath.Join(tmp, "last-scan.json")
reposPath := filepath.Join(tmp, "repos")
if err := os.MkdirAll(filepath.Join(reposPath, "alpha"), 0o755); err != nil {
t.Fatalf("mkdir repos fixture: %v", err)
}

var initOut bytes.Buffer
var initErr bytes.Buffer
initCode := Run([]string{"init", "--non-interactive", "--repo", "acme/backend", "--config", configPath, "--json"}, &initOut, &initErr)
initCode := Run([]string{"init", "--non-interactive", "--path", reposPath, "--config", configPath, "--json"}, &initOut, &initErr)
if initCode != 0 {
t.Fatalf("init failed: exit %d stderr %s", initCode, initErr.String())
}
Expand All @@ -280,7 +284,7 @@ func TestScanUsesConfiguredDefaultTarget(t *testing.T) {
t.Fatalf("parse json output: %v", err)
}
target := payload["target"].(map[string]any)
if target["mode"] != "repo" || target["value"] != "acme/backend" {
if target["mode"] != "path" || target["value"] != reposPath {
t.Fatalf("unexpected target: %v", target)
}
}
Expand Down Expand Up @@ -367,6 +371,88 @@ func TestScanEnrichRequiresNetworkSource(t *testing.T) {
}
}

func TestScanRepoAndOrgRequireGitHubAPI(t *testing.T) {
t.Parallel()

cases := []struct {
name string
args []string
}{
{name: "repo", args: []string{"scan", "--repo", "acme/backend", "--json"}},
{name: "org", args: []string{"scan", "--org", "acme", "--json"}},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

var out bytes.Buffer
var errOut bytes.Buffer
code := Run(tc.args, &out, &errOut)
if code != 7 {
t.Fatalf("expected exit 7, got %d", code)
}
if out.Len() != 0 {
t.Fatalf("expected no stdout on dependency error, got %q", out.String())
}

var payload map[string]any
if err := json.Unmarshal(errOut.Bytes(), &payload); err != nil {
t.Fatalf("parse error payload: %v", err)
}
errorPayload, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error object, got %v", payload)
}
if errorPayload["code"] != "dependency_missing" {
t.Fatalf("unexpected error code: %v", errorPayload["code"])
}
})
}
}

func TestScanRepoAndOrgWithUnreachableGitHubAPIReturnRuntimeFailure(t *testing.T) {
t.Parallel()

cases := []struct {
name string
args []string
}{
{name: "repo", args: []string{"scan", "--repo", "acme/backend", "--github-api", "http://127.0.0.1:1", "--json"}},
{name: "org", args: []string{"scan", "--org", "acme", "--github-api", "http://127.0.0.1:1", "--json"}},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

var out bytes.Buffer
var errOut bytes.Buffer
code := Run(tc.args, &out, &errOut)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
if out.Len() != 0 {
t.Fatalf("expected no stdout on runtime error, got %q", out.String())
}

var payload map[string]any
if err := json.Unmarshal(errOut.Bytes(), &payload); err != nil {
t.Fatalf("parse error payload: %v", err)
}
errorPayload, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error object, got %v", payload)
}
if errorPayload["code"] != "runtime_failure" {
t.Fatalf("unexpected error code: %v", errorPayload["code"])
}
})
}
}

func TestScanIncludesInventoryProfileAndScore(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -624,6 +710,110 @@ func TestIdentityAndLifecycleCommands(t *testing.T) {
}
}

func TestIdentityNonApprovedTransitionsUseDeterministicDefaultReasonAndRevokeApproval(t *testing.T) {
t.Parallel()

cases := []struct {
name string
subcommand string
expectedState string
expectedReason string
}{
{name: "review", subcommand: "review", expectedState: "under_review", expectedReason: "manual_transition_under_review"},
{name: "deprecate", subcommand: "deprecate", expectedState: "deprecated", expectedReason: "manual_transition_deprecated"},
{name: "revoke", subcommand: "revoke", expectedState: "revoked", expectedReason: "manual_transition_revoked"},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
statePath := filepath.Join(tmp, "state.json")
repoRoot := mustFindRepoRoot(t)
scanPath := filepath.Join(repoRoot, "scenarios", "wrkr", "scan-mixed-org", "repos")

var scanOut bytes.Buffer
var scanErr bytes.Buffer
if code := Run([]string{"scan", "--path", scanPath, "--state", statePath, "--json"}, &scanOut, &scanErr); code != 0 {
t.Fatalf("scan failed: %d %s", code, scanErr.String())
}

var payload map[string]any
if err := json.Unmarshal(scanOut.Bytes(), &payload); err != nil {
t.Fatalf("parse scan payload: %v", err)
}
inventoryPayload, ok := payload["inventory"].(map[string]any)
if !ok {
t.Fatalf("expected inventory payload, got %T", payload["inventory"])
}
tools, ok := inventoryPayload["tools"].([]any)
if !ok || len(tools) == 0 {
t.Fatalf("expected inventory tools, got %v", inventoryPayload["tools"])
}
firstTool, ok := tools[0].(map[string]any)
if !ok {
t.Fatalf("unexpected first tool shape: %T", tools[0])
}
agentID, ok := firstTool["agent_id"].(string)
if !ok || agentID == "" {
t.Fatalf("missing agent_id in first tool: %v", firstTool)
}

var approveOut bytes.Buffer
var approveErr bytes.Buffer
if code := Run([]string{"identity", "approve", agentID, "--approver", "@maria", "--scope", "read-only", "--expires", "90d", "--state", statePath, "--json"}, &approveOut, &approveErr); code != 0 {
t.Fatalf("identity approve failed: %d %s", code, approveErr.String())
}

var transitionOut bytes.Buffer
var transitionErr bytes.Buffer
if code := Run([]string{"identity", tc.subcommand, agentID, "--state", statePath, "--json"}, &transitionOut, &transitionErr); code != 0 {
t.Fatalf("identity %s failed: %d %s", tc.subcommand, code, transitionErr.String())
}
var transitionPayload map[string]any
if err := json.Unmarshal(transitionOut.Bytes(), &transitionPayload); err != nil {
t.Fatalf("parse transition payload: %v", err)
}
transitionObj, ok := transitionPayload["transition"].(map[string]any)
if !ok {
t.Fatalf("expected transition payload object, got %v", transitionPayload)
}
if gotState, _ := transitionObj["new_state"].(string); gotState != tc.expectedState {
t.Fatalf("expected new_state=%s, got %q", tc.expectedState, gotState)
}
diffObj, ok := transitionObj["diff"].(map[string]any)
if !ok {
t.Fatalf("expected transition diff object, got %v", transitionObj["diff"])
}
if gotReason, _ := diffObj["reason"].(string); gotReason != tc.expectedReason {
t.Fatalf("expected reason=%q, got %q", tc.expectedReason, gotReason)
}

var showOut bytes.Buffer
var showErr bytes.Buffer
if code := Run([]string{"identity", "show", agentID, "--state", statePath, "--json"}, &showOut, &showErr); code != 0 {
t.Fatalf("identity show failed: %d %s", code, showErr.String())
}
var showPayload map[string]any
if err := json.Unmarshal(showOut.Bytes(), &showPayload); err != nil {
t.Fatalf("parse identity show payload: %v", err)
}
identityObj, ok := showPayload["identity"].(map[string]any)
if !ok {
t.Fatalf("expected identity payload object, got %T", showPayload["identity"])
}
if gotStatus, _ := identityObj["status"].(string); gotStatus != tc.expectedState {
t.Fatalf("expected identity status %q, got %q", tc.expectedState, gotStatus)
}
if approvalStatus, _ := identityObj["approval_status"].(string); approvalStatus != "revoked" {
t.Fatalf("expected approval_status=revoked, got %q", approvalStatus)
}
})
}
}

func TestVerifyAndEvidenceCommands(t *testing.T) {
t.Parallel()

Expand Down
15 changes: 11 additions & 4 deletions core/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/Clyra-AI/wrkr/core/identity"
"github.com/Clyra-AI/wrkr/core/lifecycle"
"github.com/Clyra-AI/wrkr/core/manifest"
"github.com/Clyra-AI/wrkr/core/model"
"github.com/Clyra-AI/wrkr/core/policy"
policyeval "github.com/Clyra-AI/wrkr/core/policy/eval"
profilemodel "github.com/Clyra-AI/wrkr/core/policy/profile"
Expand Down Expand Up @@ -88,6 +89,15 @@ func runScan(args []string, stdout io.Writer, stderr io.Writer) int {
exitDependencyMissing,
)
}
if (targetMode == config.TargetRepo || targetMode == config.TargetOrg) && strings.TrimSpace(*githubBaseURL) == "" {
return emitError(
stderr,
jsonRequested || *jsonOut,
"dependency_missing",
"--repo and --org scans require --github-api or WRKR_GITHUB_API_BASE",
exitDependencyMissing,
)
}

ctx := context.Background()
manifestOut, findings, err := acquireSources(ctx, targetMode, targetValue, *githubBaseURL, *githubToken)
Expand Down Expand Up @@ -478,10 +488,7 @@ func buildFindingContexts(report risk.Report) map[string]agginventory.ToolContex
func observedTools(findings []source.Finding, contexts map[string]agginventory.ToolContext) []lifecycle.ObservedTool {
byAgent := map[string]lifecycle.ObservedTool{}
for _, finding := range findings {
if strings.TrimSpace(finding.ToolType) == "" {
continue
}
if finding.FindingType == "policy_check" || finding.FindingType == "policy_violation" || finding.FindingType == "parse_error" {
if !model.IsIdentityBearingFinding(finding) {
continue
}
org := strings.TrimSpace(finding.Org)
Expand Down
48 changes: 48 additions & 0 deletions core/cli/scan_observed_tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cli

import (
"testing"

agginventory "github.com/Clyra-AI/wrkr/core/aggregate/inventory"
"github.com/Clyra-AI/wrkr/core/source"
)

func TestObservedToolsExcludesPolicyAndParseFindingTypes(t *testing.T) {
t.Parallel()

findings := []source.Finding{
{
FindingType: "source_discovery",
ToolType: "source_repo",
Location: "acme/backend",
Repo: "acme/backend",
Org: "acme",
},
{
FindingType: "policy_violation",
ToolType: "policy",
Location: ".wrkr/policy.yaml",
Repo: "acme/backend",
Org: "acme",
},
{
FindingType: "parse_error",
ToolType: "yaml",
Location: ".github/workflows/ci.yml",
Repo: "acme/backend",
Org: "acme",
},
}
contexts := map[string]agginventory.ToolContext{}
for _, finding := range findings {
contexts[agginventory.KeyForFinding(finding)] = agginventory.ToolContext{RiskScore: 1.0}
}

observed := observedTools(findings, contexts)
if len(observed) != 1 {
t.Fatalf("expected one identity-bearing observed tool, got %d (%+v)", len(observed), observed)
}
if observed[0].ToolType != "source_repo" {
t.Fatalf("unexpected observed tool: %+v", observed[0])
}
}
4 changes: 1 addition & 3 deletions core/lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,7 @@ func ApplyManualState(m manifest.Manifest, agentID, state, approver, scope, reas
}
record.ApprovalState = "valid"
case identity.StateRevoked, identity.StateDeprecated, identity.StateUnderReview:
if strings.TrimSpace(reason) != "" {
record.ApprovalState = "revoked"
}
record.ApprovalState = "revoked"
Comment on lines 184 to +185

Choose a reason for hiding this comment

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

P1 Badge Clear approval metadata on non-approved transitions

ApplyManualState now forces approval_state to revoked for review/deprecate/revoke, but it leaves the existing Approval fields intact. On the next scan, Reconcile calls applyApprovalState, which recomputes approval from Approval.Expires and can flip that same identity back to approval_state=valid if the old approval is still unexpired (e.g., approve -> revoke -> scan). This reintroduces contradictory lifecycle semantics and can affect regress behavior because isApproved treats approval_status=valid as approved.

Useful? React with 👍 / 👎.

}
record.LastSeen = now.Format(time.RFC3339)
m.Identities[index] = record
Expand Down
45 changes: 45 additions & 0 deletions core/lifecycle/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,48 @@ func TestParseExpiryDefault90Days(t *testing.T) {
t.Fatalf("expected 90d expiry, got %s", expires.Sub(now))
}
}

func TestApplyManualStateNonApprovedStatesAlwaysRevokeApprovalStatus(t *testing.T) {
t.Parallel()

now := time.Date(2026, 2, 21, 12, 0, 0, 0, time.UTC)
baseManifest := manifest.Manifest{
Version: manifest.Version,
Identities: []manifest.IdentityRecord{
{
AgentID: "wrkr:mcp-1:acme",
ToolID: "mcp-1",
Status: identity.StateActive,
ApprovalState: "valid",
Approval: manifest.Approval{
Approver: "@maria",
Scope: "read-only",
Approved: now.Add(-time.Hour).Format(time.RFC3339),
Expires: now.Add(24 * time.Hour).Format(time.RFC3339),
},
Present: true,
},
},
}

for _, stateName := range []string{identity.StateUnderReview, identity.StateDeprecated, identity.StateRevoked} {
stateName := stateName
t.Run(stateName, func(t *testing.T) {
t.Parallel()

next, transition, err := ApplyManualState(baseManifest, "wrkr:mcp-1:acme", stateName, "", "", "", time.Time{}, now)
if err != nil {
t.Fatalf("apply manual state: %v", err)
}
if next.Identities[0].Status != stateName {
t.Fatalf("expected status %s, got %s", stateName, next.Identities[0].Status)
}
if next.Identities[0].ApprovalState != "revoked" {
t.Fatalf("expected approval_state=revoked, got %s", next.Identities[0].ApprovalState)
}
if transition.NewState != stateName {
t.Fatalf("unexpected transition state: %+v", transition)
}
})
}
}
Loading