diff --git a/core/cli/identity.go b/core/cli/identity.go index 9818ff5..3cecfec 100644 --- a/core/cli/identity.go +++ b/core/cli/identity.go @@ -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 { @@ -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" + } +} diff --git a/core/cli/root_test.go b/core/cli/root_test.go index 0f8c6f3..8c130c6 100644 --- a/core/cli/root_test.go +++ b/core/cli/root_test.go @@ -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()) } @@ -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) } } @@ -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() @@ -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() diff --git a/core/cli/scan.go b/core/cli/scan.go index 95ad1c4..950ba7c 100644 --- a/core/cli/scan.go +++ b/core/cli/scan.go @@ -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" @@ -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) @@ -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) diff --git a/core/cli/scan_observed_tools_test.go b/core/cli/scan_observed_tools_test.go new file mode 100644 index 0000000..1b23b51 --- /dev/null +++ b/core/cli/scan_observed_tools_test.go @@ -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]) + } +} diff --git a/core/lifecycle/lifecycle.go b/core/lifecycle/lifecycle.go index 36cc80d..0ce3526 100644 --- a/core/lifecycle/lifecycle.go +++ b/core/lifecycle/lifecycle.go @@ -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" } record.LastSeen = now.Format(time.RFC3339) m.Identities[index] = record diff --git a/core/lifecycle/lifecycle_test.go b/core/lifecycle/lifecycle_test.go index ad75757..fc0cec4 100644 --- a/core/lifecycle/lifecycle_test.go +++ b/core/lifecycle/lifecycle_test.go @@ -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) + } + }) + } +} diff --git a/core/model/identity_bearing.go b/core/model/identity_bearing.go new file mode 100644 index 0000000..1fafaad --- /dev/null +++ b/core/model/identity_bearing.go @@ -0,0 +1,16 @@ +package model + +import "strings" + +// IsIdentityBearingFinding returns whether a finding participates in lifecycle/regress identity state. +func IsIdentityBearingFinding(f Finding) bool { + if strings.TrimSpace(f.ToolType) == "" { + return false + } + switch strings.ToLower(strings.TrimSpace(f.FindingType)) { + case "policy_check", "policy_violation", "parse_error": + return false + default: + return true + } +} diff --git a/core/model/identity_bearing_test.go b/core/model/identity_bearing_test.go new file mode 100644 index 0000000..f1445cd --- /dev/null +++ b/core/model/identity_bearing_test.go @@ -0,0 +1,63 @@ +package model + +import "testing" + +func TestIsIdentityBearingFinding(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in Finding + want bool + }{ + { + name: "regular finding with tool type", + in: Finding{ + FindingType: "source_discovery", + ToolType: "source_repo", + }, + want: true, + }, + { + name: "policy check excluded", + in: Finding{ + FindingType: "policy_check", + ToolType: "policy", + }, + want: false, + }, + { + name: "policy violation excluded", + in: Finding{ + FindingType: "policy_violation", + ToolType: "policy", + }, + want: false, + }, + { + name: "parse error excluded", + in: Finding{ + FindingType: "parse_error", + ToolType: "yaml", + }, + want: false, + }, + { + name: "missing tool type excluded", + in: Finding{ + FindingType: "source_discovery", + }, + want: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := IsIdentityBearingFinding(tc.in); got != tc.want { + t.Fatalf("unexpected classifier result: got=%v want=%v finding=%+v", got, tc.want, tc.in) + } + }) + } +} diff --git a/core/regress/regress.go b/core/regress/regress.go index 9aa9ba8..ab406d8 100644 --- a/core/regress/regress.go +++ b/core/regress/regress.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Clyra-AI/wrkr/core/identity" + "github.com/Clyra-AI/wrkr/core/model" "github.com/Clyra-AI/wrkr/core/state" ) @@ -90,6 +91,9 @@ func SnapshotTools(snapshot state.Snapshot) []ToolState { } for _, finding := range snapshot.Findings { + if !model.IsIdentityBearingFinding(finding) { + continue + } org := fallback(finding.Org, "local") toolID := identity.ToolID(finding.ToolType, finding.Location) agentID := identity.AgentID(toolID, org) diff --git a/core/regress/regress_test.go b/core/regress/regress_test.go index 11e0631..6b8972e 100644 --- a/core/regress/regress_test.go +++ b/core/regress/regress_test.go @@ -273,3 +273,78 @@ func TestCompareDeterministicForSameInput(t *testing.T) { t.Fatalf("compare must be deterministic\nfirst=%+v\nsecond=%+v", first, second) } } + +func TestSnapshotToolsExcludesPolicyAndParseFindingTypes(t *testing.T) { + t.Parallel() + + snapshot := state.Snapshot{ + Findings: []model.Finding{ + { + FindingType: "source_discovery", + ToolType: "source_repo", + Location: "acme/backend", + Org: "acme", + Permissions: []string{"repo.contents.read"}, + }, + { + FindingType: "policy_check", + ToolType: "policy", + Location: ".wrkr/policy.yaml", + Org: "acme", + }, + { + FindingType: "parse_error", + ToolType: "yaml", + Location: ".github/workflows/ci.yml", + Org: "acme", + }, + }, + } + + tools := SnapshotTools(snapshot) + if len(tools) != 1 { + t.Fatalf("expected one tool after filtering policy/meta findings, got %d (%+v)", len(tools), tools) + } + if tools[0].ToolID != identity.ToolID("source_repo", "acme/backend") { + t.Fatalf("unexpected remaining tool: %+v", tools[0]) + } +} + +func TestCompareIgnoresPolicyOnlyBaselineDelta(t *testing.T) { + t.Parallel() + + baselineSnapshot := state.Snapshot{ + Findings: []model.Finding{ + { + FindingType: "source_discovery", + ToolType: "source_repo", + Location: "acme/backend", + Org: "acme", + Permissions: []string{"repo.contents.read"}, + }, + { + FindingType: "policy_violation", + ToolType: "policy", + Location: "WRKR-001", + Org: "acme", + }, + }, + } + currentSnapshot := state.Snapshot{ + Findings: []model.Finding{ + { + FindingType: "source_discovery", + ToolType: "source_repo", + Location: "acme/backend", + Org: "acme", + Permissions: []string{"repo.contents.read"}, + }, + }, + } + + baseline := BuildBaseline(baselineSnapshot, time.Date(2026, 2, 21, 12, 0, 0, 0, time.UTC)) + result := Compare(baseline, currentSnapshot) + if result.Drift { + t.Fatalf("expected no drift for policy-only baseline delta, got %v", result.Reasons) + } +} diff --git a/core/source/github/connector.go b/core/source/github/connector.go index c5f7392..d6418b6 100644 --- a/core/source/github/connector.go +++ b/core/source/github/connector.go @@ -46,7 +46,7 @@ func (c *Connector) AcquireRepo(ctx context.Context, repo string) (source.RepoMa return source.RepoManifest{}, err } if c.BaseURL == "" { - return source.RepoManifest{Repo: repo, Location: repo, Source: "github_repo"}, nil + return source.RepoManifest{}, errors.New("github api base url is required for repository acquisition") } endpoint := c.BaseURL + "/repos/" + repo @@ -73,7 +73,7 @@ func (c *Connector) ListOrgRepos(ctx context.Context, org string) ([]string, err return nil, errors.New("org is required") } if c.BaseURL == "" { - return []string{org + "/default"}, nil + return nil, errors.New("github api base url is required for organization acquisition") } u, err := url.Parse(c.BaseURL) @@ -109,9 +109,6 @@ func (c *Connector) ListOrgRepos(ctx context.Context, org string) ([]string, err } repos = append(repos, repo) } - if len(repos) == 0 { - repos = append(repos, org+"/default") - } return repos, nil } diff --git a/core/source/github/connector_test.go b/core/source/github/connector_test.go index 2441315..12079e6 100644 --- a/core/source/github/connector_test.go +++ b/core/source/github/connector_test.go @@ -9,16 +9,12 @@ import ( "testing" ) -func TestAcquireRepoOfflineMode(t *testing.T) { +func TestAcquireRepoRequiresBaseURL(t *testing.T) { t.Parallel() connector := NewConnector("", "", nil) - manifest, err := connector.AcquireRepo(context.Background(), "acme/backend") - if err != nil { - t.Fatalf("acquire repo: %v", err) - } - if manifest.Repo != "acme/backend" { - t.Fatalf("unexpected repo: %+v", manifest) + if _, err := connector.AcquireRepo(context.Background(), "acme/backend"); err == nil { + t.Fatal("expected missing base URL to fail") } } @@ -79,3 +75,30 @@ func TestAcquireRepoFailsOnInvalidRepo(t *testing.T) { t.Fatal("expected invalid repo input to fail") } } + +func TestListOrgReposRequiresBaseURL(t *testing.T) { + t.Parallel() + + connector := NewConnector("", "", nil) + if _, err := connector.ListOrgRepos(context.Background(), "acme"); err == nil { + t.Fatal("expected missing base URL to fail") + } +} + +func TestListOrgReposAllowsEmptyResultWithoutSyntheticFallback(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, `[]`) + })) + defer server.Close() + + connector := NewConnector(server.URL, "", server.Client()) + repos, err := connector.ListOrgRepos(context.Background(), "acme") + if err != nil { + t.Fatalf("list org repos: %v", err) + } + if len(repos) != 0 { + t.Fatalf("expected no repos, got %v", repos) + } +} diff --git a/docs/commands/identity.md b/docs/commands/identity.md index da069d9..31cd686 100644 --- a/docs/commands/identity.md +++ b/docs/commands/identity.md @@ -26,6 +26,14 @@ - `--reason` +`--reason` is optional. When omitted, Wrkr records a deterministic default reason: + +- `review` -> `manual_transition_under_review` +- `deprecate` -> `manual_transition_deprecated` +- `revoke` -> `manual_transition_revoked` + +Manual transitions to `under_review`, `deprecated`, or `revoked` always normalize `approval_status` away from `valid` (`approval_status=revoked`). + ## Examples ```bash @@ -33,6 +41,7 @@ wrkr identity list --json wrkr identity show wrkr:cursor-abc:local --json wrkr identity approve wrkr:cursor-abc:local --approver @maria --scope read-only --expires 90d --json wrkr identity review wrkr:cursor-abc:local --reason "manual review" --json +wrkr identity revoke wrkr:cursor-abc:local --json ``` Expected JSON keys vary by subcommand and include `status`, `identities`, `identity`, `history`, or `transition`. diff --git a/docs/commands/index.md b/docs/commands/index.md index 16839d5..93502b8 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -24,6 +24,11 @@ Wrkr CLI surfaces are deterministic and file-based by default. - `wrkr evidence` - `wrkr fix` +## Notable scan contract + +- `wrkr scan --path` is local/offline. +- `wrkr scan --repo` and `wrkr scan --org` require `--github-api` or `WRKR_GITHUB_API_BASE` and fail closed with exit `7` when unavailable. + ## Exit codes Global process exit codes are documented in `docs/commands/root.md` and apply consistently across command families. diff --git a/docs/commands/scan.md b/docs/commands/scan.md index 3d89b4e..97921e9 100644 --- a/docs/commands/scan.md +++ b/docs/commands/scan.md @@ -8,6 +8,12 @@ wrkr scan [--repo | --org | --path ] [--diff] [--enrich] Exactly one target source is required: `--repo`, `--org`, or `--path`. +Acquisition behavior is fail-closed by target: + +- `--path` runs fully local/offline. +- `--repo` and `--org` require real GitHub acquisition via `--github-api` or `WRKR_GITHUB_API_BASE`. +- When GitHub acquisition is unavailable, `scan` returns `dependency_missing` with exit code `7` (no synthetic repos are emitted). + ## Flags - `--json` @@ -37,4 +43,8 @@ Exactly one target source is required: `--repo`, `--org`, or `--path`. wrkr scan --path ./scenarios/wrkr/scan-mixed-org/repos --profile standard --report-md --report-md-path ./.tmp/scan-summary.md --report-template operator --json ``` +```bash +wrkr scan --org acme --github-api https://api.github.com --json +``` + Expected JSON keys include `status`, `target`, `findings`, `ranked_findings`, `inventory`, `repo_exposure_summaries`, `profile`, `posture_score`, and optional `report` when summary output is requested. diff --git a/docs/examples/quickstart.md b/docs/examples/quickstart.md index cf5f2a4..ac3c1c1 100644 --- a/docs/examples/quickstart.md +++ b/docs/examples/quickstart.md @@ -10,6 +10,8 @@ Wrkr is the AI-DSPM discovery layer in the See -> Prove -> Control sequence: Wrkr is useful standalone and interoperates with Axym/Gait through shared proof contracts. +For hosted source modes, `scan --repo` and `scan --org` require `--github-api` (or `WRKR_GITHUB_API_BASE`) and fail closed when acquisition is unavailable. + ## Deterministic local scan ```bash diff --git a/internal/acceptance/v1_acceptance_test.go b/internal/acceptance/v1_acceptance_test.go index 38bcd7d..03856f9 100644 --- a/internal/acceptance/v1_acceptance_test.go +++ b/internal/acceptance/v1_acceptance_test.go @@ -5,6 +5,8 @@ import ( "bytes" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" @@ -35,13 +37,14 @@ func TestV1AcceptanceMatrix(t *testing.T) { tmp := t.TempDir() configPath := filepath.Join(tmp, "wrkr-config.yaml") statePath := filepath.Join(tmp, "state.json") + githubAPI := newAcceptanceGitHubAPIServer(t) initPayload := runJSONOK(t, "init", "--non-interactive", "--org", "acme", "--config", configPath, "--json") if initPayload["status"] != "ok" { t.Fatalf("unexpected init payload: %v", initPayload) } - scanPayload := runJSONOK(t, "scan", "--org", "acme", "--state", statePath, "--json") + scanPayload := runJSONOK(t, "scan", "--org", "acme", "--github-api", githubAPI, "--state", statePath, "--json") requireKey(t, scanPayload, "inventory") topFindings, ok := scanPayload["top_findings"].([]any) if !ok || len(topFindings) == 0 { @@ -473,6 +476,23 @@ func loadAcceptancePaths(t *testing.T) acceptancePaths { } } +func newAcceptanceGitHubAPIServer(t *testing.T) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/orgs/acme/repos": + _, _ = fmt.Fprint(w, `[{"full_name":"acme/backend"}]`) + case "/repos/acme/backend": + _, _ = fmt.Fprint(w, `{"full_name":"acme/backend"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server.URL +} + func scanScenarioState(t *testing.T, scanPath, profile string) string { t.Helper() statePath := filepath.Join(t.TempDir(), "state.json") diff --git a/internal/e2e/cli_contract/cli_contract_e2e_test.go b/internal/e2e/cli_contract/cli_contract_e2e_test.go index 45cbc54..3fb57ac 100644 --- a/internal/e2e/cli_contract/cli_contract_e2e_test.go +++ b/internal/e2e/cli_contract/cli_contract_e2e_test.go @@ -105,3 +105,67 @@ func TestE2EHelpContractMatrixReturnsExit0(t *testing.T) { }) } } + +func TestE2EIdentityTransitionWithoutReasonUsesDeterministicDefaultReason(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reposPath := filepath.Join(tmp, "repos") + statePath := filepath.Join(tmp, "state.json") + if err := os.MkdirAll(filepath.Join(reposPath, "alpha"), 0o755); err != nil { + t.Fatalf("mkdir repo fixture: %v", err) + } + + var scanOut bytes.Buffer + var scanErr bytes.Buffer + if code := cli.Run([]string{"scan", "--path", reposPath, "--state", statePath, "--json"}, &scanOut, &scanErr); code != 0 { + t.Fatalf("scan failed: %d (%s)", code, scanErr.String()) + } + var scanPayload map[string]any + if err := json.Unmarshal(scanOut.Bytes(), &scanPayload); err != nil { + t.Fatalf("parse scan payload: %v", err) + } + inventoryObj, ok := scanPayload["inventory"].(map[string]any) + if !ok { + t.Fatalf("missing inventory payload: %v", scanPayload) + } + tools, ok := inventoryObj["tools"].([]any) + if !ok || len(tools) == 0 { + t.Fatalf("missing inventory tools payload: %v", inventoryObj) + } + firstTool, ok := tools[0].(map[string]any) + if !ok { + t.Fatalf("unexpected tool payload type: %T", tools[0]) + } + agentID, _ := firstTool["agent_id"].(string) + if agentID == "" { + t.Fatalf("missing agent_id in tool payload: %v", firstTool) + } + + var approveOut bytes.Buffer + var approveErr bytes.Buffer + if code := cli.Run([]string{"identity", "approve", agentID, "--approver", "@qa", "--scope", "contract", "--expires", "90d", "--state", statePath, "--json"}, &approveOut, &approveErr); code != 0 { + t.Fatalf("approve failed: %d (%s)", code, approveErr.String()) + } + + var out bytes.Buffer + var errOut bytes.Buffer + if code := cli.Run([]string{"identity", "revoke", agentID, "--state", statePath, "--json"}, &out, &errOut); code != 0 { + t.Fatalf("revoke failed: %d (%s)", code, errOut.String()) + } + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("parse revoke payload: %v", err) + } + transition, ok := payload["transition"].(map[string]any) + if !ok { + t.Fatalf("missing transition payload: %v", payload) + } + diffObj, ok := transition["diff"].(map[string]any) + if !ok { + t.Fatalf("missing transition diff: %v", transition) + } + if diffObj["reason"] != "manual_transition_revoked" { + t.Fatalf("unexpected default reason: %v", diffObj["reason"]) + } +} diff --git a/internal/e2e/init/init_e2e_test.go b/internal/e2e/init/init_e2e_test.go index 3c4bb6c..b4bd6d9 100644 --- a/internal/e2e/init/init_e2e_test.go +++ b/internal/e2e/init/init_e2e_test.go @@ -3,6 +3,9 @@ package inite2e import ( "bytes" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "path/filepath" "testing" @@ -10,11 +13,18 @@ import ( ) func TestE2EInitThenScanWithRepoTarget(t *testing.T) { - t.Parallel() - tmp := t.TempDir() configPath := filepath.Join(tmp, "config.json") statePath := filepath.Join(tmp, "state.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/acme/backend" { + _, _ = fmt.Fprint(w, `{"full_name":"acme/backend"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + t.Setenv("WRKR_GITHUB_API_BASE", server.URL) var initOut bytes.Buffer var initErr bytes.Buffer diff --git a/internal/e2e/regress/regress_e2e_test.go b/internal/e2e/regress/regress_e2e_test.go index cc9c747..d24999e 100644 --- a/internal/e2e/regress/regress_e2e_test.go +++ b/internal/e2e/regress/regress_e2e_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Clyra-AI/wrkr/core/cli" + "github.com/Clyra-AI/wrkr/core/state" ) func TestE2ERegressInitAndRunDetectsDrift(t *testing.T) { @@ -70,3 +71,80 @@ func TestE2ERegressInitAndRunDetectsDrift(t *testing.T) { t.Fatalf("expected drift detected payload, got %v", runPayload) } } + +func TestE2ERegressRunIgnoresPolicyOnlyStateDelta(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + repoRoot := mustFindRepoRoot(t) + statePath := filepath.Join(tmp, "state.json") + baselinePath := filepath.Join(tmp, "baseline.json") + reposPath := filepath.Join(repoRoot, "scenarios", "wrkr", "scan-mixed-org", "repos") + + var scanOut bytes.Buffer + var scanErr bytes.Buffer + if code := cli.Run([]string{"scan", "--path", reposPath, "--state", statePath, "--json"}, &scanOut, &scanErr); code != 0 { + t.Fatalf("initial scan failed: %d (%s)", code, scanErr.String()) + } + + var initOut bytes.Buffer + var initErr bytes.Buffer + if code := cli.Run([]string{"regress", "init", "--baseline", statePath, "--output", baselinePath, "--json"}, &initOut, &initErr); code != 0 { + t.Fatalf("regress init failed: %d (%s)", code, initErr.String()) + } + + snapshot, loadErr := state.Load(statePath) + if loadErr != nil { + t.Fatalf("load state: %v", loadErr) + } + removeIndex := -1 + for i, finding := range snapshot.Findings { + if finding.FindingType == "policy_check" || finding.FindingType == "policy_violation" { + removeIndex = i + break + } + } + if removeIndex < 0 { + t.Fatal("expected policy finding in fixture state") + } + snapshot.Findings = append(snapshot.Findings[:removeIndex], snapshot.Findings[removeIndex+1:]...) + if saveErr := state.Save(statePath, snapshot); saveErr != nil { + t.Fatalf("save mutated state: %v", saveErr) + } + + var runOut bytes.Buffer + var runErr bytes.Buffer + if code := cli.Run([]string{"regress", "run", "--baseline", baselinePath, "--state", statePath, "--json"}, &runOut, &runErr); code != 0 { + t.Fatalf("expected no drift for policy-only delta, got %d (%s)", code, runErr.String()) + } + var payload map[string]any + if err := json.Unmarshal(runOut.Bytes(), &payload); err != nil { + t.Fatalf("parse regress run payload: %v", err) + } + if payload["drift_detected"] != false { + t.Fatalf("expected no drift for policy-only delta, got %v", payload) + } +} + +func mustFindRepoRoot(t *testing.T) string { + t.Helper() + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + current := wd + for i := 0; i < 8; i++ { + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { + return current + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + t.Fatalf("could not locate repository root from %s", wd) + return "" +} diff --git a/internal/e2e/source/source_e2e_test.go b/internal/e2e/source/source_e2e_test.go index 1a62069..2be47bb 100644 --- a/internal/e2e/source/source_e2e_test.go +++ b/internal/e2e/source/source_e2e_test.go @@ -3,6 +3,9 @@ package sourcee2e import ( "bytes" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -15,16 +18,27 @@ func TestE2EScanModesRepoOrgPath(t *testing.T) { tmp := t.TempDir() state := filepath.Join(tmp, "state.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/acme/backend": + _, _ = fmt.Fprint(w, `{"full_name":"acme/backend"}`) + case "/orgs/acme/repos": + _, _ = fmt.Fprint(w, `[{"full_name":"acme/backend"}]`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() var repoOut bytes.Buffer var repoErr bytes.Buffer - if code := cli.Run([]string{"scan", "--repo", "acme/backend", "--state", state, "--json"}, &repoOut, &repoErr); code != 0 { + if code := cli.Run([]string{"scan", "--repo", "acme/backend", "--github-api", server.URL, "--state", state, "--json"}, &repoOut, &repoErr); code != 0 { t.Fatalf("repo scan failed: %d (%s)", code, repoErr.String()) } var orgOut bytes.Buffer var orgErr bytes.Buffer - if code := cli.Run([]string{"scan", "--org", "acme", "--state", state, "--json"}, &orgOut, &orgErr); code != 0 { + if code := cli.Run([]string{"scan", "--org", "acme", "--github-api", server.URL, "--state", state, "--json"}, &orgOut, &orgErr); code != 0 { t.Fatalf("org scan failed: %d (%s)", code, orgErr.String()) } diff --git a/testinfra/contracts/story1_contracts_test.go b/testinfra/contracts/story1_contracts_test.go index cd3b684..d2aa5ba 100644 --- a/testinfra/contracts/story1_contracts_test.go +++ b/testinfra/contracts/story1_contracts_test.go @@ -3,6 +3,9 @@ package contracts import ( "bytes" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" @@ -27,10 +30,18 @@ func TestScanJSONContractStableKeys(t *testing.T) { tmp := t.TempDir() statePath := filepath.Join(tmp, "state.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/acme/backend" { + _, _ = fmt.Fprint(w, `{"full_name":"acme/backend"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() var out bytes.Buffer var errOut bytes.Buffer - code := cli.Run([]string{"scan", "--repo", "acme/backend", "--state", statePath, "--json"}, &out, &errOut) + code := cli.Run([]string{"scan", "--repo", "acme/backend", "--github-api", server.URL, "--state", statePath, "--json"}, &out, &errOut) if code != 0 { t.Fatalf("scan failed: %d (%s)", code, errOut.String()) }