From bee7309fc4a7272fe83f9e9fe75df7b45073397b Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Tue, 31 Mar 2026 13:31:09 -0400 Subject: [PATCH 1/4] contracts: harden artifact and extension boundaries --- CHANGELOG.md | 7 +- core/cli/campaign.go | 50 +- core/cli/campaign_test.go | 65 ++ core/cli/root_test.go | 4 +- core/cli/scan.go | 55 +- core/cli/scan_extension_boundary_test.go | 93 +++ core/cli/scan_helpers.go | 22 +- core/cli/scan_materialized_root_test.go | 45 +- core/cli/scan_transaction_test.go | 202 ++++++ core/evidence/evidence.go | 14 +- core/evidence/evidence_test.go | 55 ++ core/evidence/stage.go | 34 +- core/model/identity_bearing.go | 10 +- core/model/identity_bearing_test.go | 8 +- core/regress/regress_test.go | 25 + core/source/org/checkpoint.go | 16 +- core/source/org/checkpoint_test.go | 53 ++ docs/commands/campaign.md | 2 + docs/commands/evidence.md | 4 +- docs/commands/scan.md | 11 +- docs/extensions/detectors.md | 3 +- docs/state_lifecycle.md | 7 +- docs/trust/compatibility-and-versioning.md | 6 + docs/trust/contracts-and-schemas.md | 5 + internal/managedmarker/managedmarker.go | 134 ++++ internal/scenarios/epic11_scenario_test.go | 27 + product/PLAN_NEXT.md | 712 +++++++++++---------- 27 files changed, 1275 insertions(+), 394 deletions(-) create mode 100644 core/cli/scan_extension_boundary_test.go create mode 100644 core/cli/scan_transaction_test.go create mode 100644 core/source/org/checkpoint_test.go create mode 100644 internal/managedmarker/managedmarker.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc3901..f788c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Clarified the public `action_paths[*].path_id` contract and aligned docs and contract tests with the shipped deterministic identifier format. - Clarified scan and report wording so Wrkr's customer-facing output stays explicitly scoped to static posture, risky paths, and offline-verifiable proof. - Govern-first summaries now highlight ownership quality and ownerless exposure so unresolved or conflicting ownership is explicit in top action paths. +- Updated scan, evidence, campaign, and extension-detector docs plus regression coverage to match the hardened contract and boundary behavior. ### Deprecated @@ -40,6 +41,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Deduplicated govern-first `action_paths` so each deterministic action path emits one unique `path_id` row per scan. - Priority detectors now surface permission and stat failures consistently in scan output so incomplete visibility is explicit. +- Made scan artifact publication transactional so failed late writes no longer leave mixed state, proof, and manifest generations on disk. +- `wrkr campaign aggregate` now rejects non-scan JSON and incomplete artifacts with stable `invalid_input` errors instead of summarizing them as posture evidence. +- Repo-local extension detectors now stay on additive finding surfaces by default and no longer create implicit tool identities, action paths, or regress state. ## Changelog maintenance process @@ -75,5 +79,4 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - `wrkr regress run` now reconciles legacy `v1` baselines created before instance identities when the current identity is equivalent. ### Security - -- (none yet) +- Hardened managed output and scan-owned directory ownership checks so forged marker files can no longer authorize destructive reuse of caller-selected paths. diff --git a/core/cli/campaign.go b/core/cli/campaign.go index d031d18..0c2acb6 100644 --- a/core/cli/campaign.go +++ b/core/cli/campaign.go @@ -90,11 +90,15 @@ func runCampaignAggregate(args []string, stdout io.Writer, stderr io.Writer) int if readErr != nil { return emitError(stderr, jsonRequested || *jsonOut, "runtime_failure", fmt.Sprintf("read scan artifact %s: %v", scanPath, readErr), exitRuntime) } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("parse scan artifact %s: %v", scanPath, err), exitInvalidInput) + } var parsed campaignScanArtifact if err := json.Unmarshal(payload, &parsed); err != nil { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("parse scan artifact %s: %v", scanPath, err), exitInvalidInput) } - if artifactErr := validateCampaignScanArtifact(scanPath, parsed); artifactErr != nil { + if artifactErr := validateCampaignScanArtifact(scanPath, raw, parsed); artifactErr != nil { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", artifactErr.Error(), exitInvalidInput) } inputs = append(inputs, reportcore.CampaignScanInput{ @@ -158,7 +162,7 @@ func runCampaignAggregate(args []string, stdout io.Writer, stderr io.Writer) int return exitSuccess } -func validateCampaignScanArtifact(scanPath string, parsed campaignScanArtifact) error { +func validateCampaignScanArtifact(scanPath string, raw map[string]any, parsed campaignScanArtifact) error { if strings.TrimSpace(parsed.Status) != "ok" { return fmt.Errorf("scan artifact %s status must be ok", scanPath) } @@ -175,9 +179,51 @@ func validateCampaignScanArtifact(scanPath string, parsed campaignScanArtifact) if len(incompleteReasons) > 0 { return fmt.Errorf("scan artifact %s must be complete; found %s", scanPath, strings.Join(incompleteReasons, ", ")) } + if err := validateCampaignTargetObject(scanPath, "target", raw["target"]); err != nil { + return err + } + sourceManifest, ok := raw["source_manifest"].(map[string]any) + if !ok { + return fmt.Errorf("scan artifact %s missing source_manifest object", scanPath) + } + if err := validateCampaignTargetObject(scanPath, "source_manifest.target", sourceManifest["target"]); err != nil { + return err + } + if repos, ok := sourceManifest["repos"].([]any); !ok || repos == nil { + return fmt.Errorf("scan artifact %s missing source_manifest.repos array", scanPath) + } + if _, ok := raw["inventory"].(map[string]any); !ok { + return fmt.Errorf("scan artifact %s missing inventory object", scanPath) + } + if _, ok := raw["privilege_budget"].(map[string]any); !ok { + return fmt.Errorf("scan artifact %s missing privilege_budget object", scanPath) + } + if _, ok := raw["findings"].([]any); !ok { + return fmt.Errorf("scan artifact %s missing findings array", scanPath) + } return nil } +func validateCampaignTargetObject(scanPath string, label string, value any) error { + target, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("scan artifact %s missing %s object", scanPath, label) + } + mode, _ := target["mode"].(string) + if strings.TrimSpace(mode) == "" { + return fmt.Errorf("scan artifact %s missing %s.mode", scanPath, label) + } + rawValue, _ := target["value"].(string) + if campaignTargetRequiresValue(mode) && strings.TrimSpace(rawValue) == "" { + return fmt.Errorf("scan artifact %s missing %s.value", scanPath, label) + } + return nil +} + +func campaignTargetRequiresValue(mode string) bool { + return strings.TrimSpace(mode) != "my_setup" +} + type campaignSegmentMetadataFile struct { SchemaVersion string `yaml:"schema_version"` Orgs map[string]campaignSegmentOrg `yaml:"orgs"` diff --git a/core/cli/campaign_test.go b/core/cli/campaign_test.go index 420ad44..19cbc0d 100644 --- a/core/cli/campaign_test.go +++ b/core/cli/campaign_test.go @@ -249,6 +249,71 @@ func TestCampaignAggregateRejectsArtifactsWithSourceErrors(t *testing.T) { assertCampaignInvalidInput(t, errOut.Bytes(), "source_errors=1") } +func TestCampaignAggregateRejectsVersionEnvelope(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + inputPath := filepath.Join(tmp, "version.json") + writeCampaignArtifact(t, inputPath, map[string]any{ + "status": "ok", + "version": "devel", + }) + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut) + if code != 6 { + t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertCampaignInvalidInput(t, errOut.Bytes(), "missing target object") +} + +func TestCampaignAggregateRejectsReportEnvelope(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + inputPath := filepath.Join(tmp, "report.json") + writeCampaignArtifact(t, inputPath, map[string]any{ + "status": "ok", + "generated_at": "2026-03-31T00:00:00Z", + "top_findings": []any{}, + "summary": map[string]any{}, + }) + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut) + if code != 6 { + t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertCampaignInvalidInput(t, errOut.Bytes(), "missing target object") +} + +func TestCampaignAggregateRejectsArtifactMissingInventoryContract(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + inputPath := filepath.Join(tmp, "scan.json") + writeCampaignArtifact(t, inputPath, map[string]any{ + "status": "ok", + "target": map[string]any{"mode": "repo", "value": "acme/backend"}, + "source_manifest": map[string]any{ + "target": map[string]any{"mode": "repo", "value": "acme/backend"}, + "repos": []any{}, + }, + "privilege_budget": map[string]any{}, + "findings": []any{}, + }) + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut) + if code != 6 { + t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertCampaignInvalidInput(t, errOut.Bytes(), "missing inventory object") +} + func TestCampaignAggregateSuppressesUnknownToSecurityMetricsWithoutReferenceBasis(t *testing.T) { t.Parallel() diff --git a/core/cli/root_test.go b/core/cli/root_test.go index 683d588..0da9b0f 100644 --- a/core/cli/root_test.go +++ b/core/cli/root_test.go @@ -704,9 +704,11 @@ func TestScanRepoAndOrgWithUnreachableGitHubAPIReturnRuntimeFailure(t *testing.T t.Run(tc.name, func(t *testing.T) { t.Parallel() + tmp := t.TempDir() + args := append(append([]string{}, tc.args...), "--state", filepath.Join(tmp, "state.json")) var out bytes.Buffer var errOut bytes.Buffer - code := Run(tc.args, &out, &errOut) + code := Run(args, &out, &errOut) if code != 1 { t.Fatalf("expected exit 1, got %d", code) } diff --git a/core/cli/scan.go b/core/cli/scan.go index abba635..2039793 100644 --- a/core/cli/scan.go +++ b/core/cli/scan.go @@ -337,35 +337,62 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ Identities: nextManifest.Identities, Transitions: transitions, } + chainPath := lifecycle.ChainPath(statePath) + proofChainPath := proofemit.ChainPath(statePath) + managedSnapshots, snapshotErr := captureManagedArtifacts( + statePath, + manifestPath, + chainPath, + proofChainPath, + proofemit.ChainAttestationPath(proofChainPath), + proofemit.SigningKeyPath(statePath), + artifactPreflight.ReportPath, + artifactPreflight.SARIFPath, + jsonSink.outputPath, + ) + if snapshotErr != nil { + return emitScanFailure(snapshotErr) + } + emitRolledBackScanFailure := func(err error) int { + progress.Flush() + return emitRolledBackRuntimeFailure(stderr, jsonRequested || *jsonOut, err, managedSnapshots) + } + emitRolledBackScanError := func(code, message string, exitCode int) int { + progress.Flush() + if restoreErr := restoreManagedArtifacts(managedSnapshots); restoreErr != nil { + return emitError(stderr, jsonRequested || *jsonOut, "runtime_failure", fmt.Sprintf("%s (rollback restore failed: %v)", message, restoreErr), exitRuntime) + } + return emitError(stderr, jsonRequested || *jsonOut, code, message, exitCode) + } + if err := state.Save(statePath, snapshot); err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } - chainPath := lifecycle.ChainPath(statePath) chain, chainErr := lifecycle.LoadChain(chainPath) if chainErr != nil { - return emitScanFailure(chainErr) + return emitRolledBackScanFailure(chainErr) } for _, transition := range transitions { if err := lifecycle.AppendTransitionRecord(chain, transition, "lifecycle_transition"); err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } } if err := lifecycle.SaveChain(chainPath, chain); err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } if _, err := proofemit.EmitScan(statePath, now, findings, &inventoryOut, riskReport, profileResult, postureScore, transitions); err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } - proofChain, err := proofemit.LoadChain(proofemit.ChainPath(statePath)) + proofChain, err := proofemit.LoadChain(proofChainPath) if err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } complianceSummary, err := compliance.BuildRollupSummary(findings, proofChain) if err != nil { - return emitScanError("policy_schema_violation", err.Error(), exitPolicyViolation) + return emitRolledBackScanError("policy_schema_violation", err.Error(), exitPolicyViolation) } if err := manifest.Save(manifestPath, nextManifest); err != nil { - return emitScanFailure(err) + return emitRolledBackScanFailure(err) } payload := map[string]any{ @@ -436,9 +463,9 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ }) if reportErr != nil { if isArtifactPathError(reportErr) { - return emitScanError("invalid_input", reportErr.Error(), exitInvalidInput) + return emitRolledBackScanError("invalid_input", reportErr.Error(), exitInvalidInput) } - return emitScanFailure(reportErr) + return emitRolledBackScanFailure(reportErr) } scanReportPath = mdOutPath payload["report"] = map[string]any{ @@ -450,7 +477,7 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ if *sarifOut { report := exportsarif.Build(findings, wrkrVersion()) if writeErr := exportsarif.Write(artifactPreflight.SARIFPath, report); writeErr != nil { - return emitScanFailure(writeErr) + return emitRolledBackScanFailure(writeErr) } scanSARIFPath = artifactPreflight.SARIFPath payload["sarif"] = map[string]any{ @@ -460,7 +487,7 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ if jsonSink.enabled() { if err := jsonSink.writePayload(payload); err != nil { - return emitScanError("runtime_failure", err.Error(), exitRuntime) + return emitRolledBackScanError("runtime_failure", err.Error(), exitRuntime) } progress.Flush() if *jsonOut { diff --git a/core/cli/scan_extension_boundary_test.go b/core/cli/scan_extension_boundary_test.go new file mode 100644 index 0000000..324ae83 --- /dev/null +++ b/core/cli/scan_extension_boundary_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestScanExtensionFindingDoesNotEmitAuthoritativeSurfaces(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + repoPath := filepath.Join(tmp, "repo", ".wrkr", "detectors") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir extension detector path: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "extensions.json"), []byte(`{ + "version": "v1", + "detectors": [ + { + "id": "custom-note", + "finding_type": "custom_extension_note", + "tool_type": "custom_detector", + "location": "README.md", + "severity": "low" + } + ] +} +`), 0o600); err != nil { + t.Fatalf("write extension descriptor: %v", err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{"scan", "--path", tmp, "--state", filepath.Join(tmp, "state.json"), "--json"}, &out, &errOut) + if code != exitSuccess { + t.Fatalf("scan failed: code=%d stderr=%s", code, errOut.String()) + } + + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("parse scan payload: %v", err) + } + findings, ok := payload["findings"].([]any) + if !ok { + t.Fatalf("expected findings array, got %T", payload["findings"]) + } + foundCustom := false + for _, item := range findings { + finding, ok := item.(map[string]any) + if !ok { + continue + } + if finding["finding_type"] == "custom_extension_note" && finding["tool_type"] == "custom_detector" { + foundCustom = true + break + } + } + if !foundCustom { + t.Fatalf("expected custom extension finding in raw findings, got %v", findings) + } + + inventory, ok := payload["inventory"].(map[string]any) + if !ok { + t.Fatalf("expected inventory object, got %T", payload["inventory"]) + } + if tools, ok := inventory["tools"].([]any); ok { + for _, item := range tools { + tool, ok := item.(map[string]any) + if ok && tool["tool_type"] == "custom_detector" { + t.Fatalf("extension finding must not become inventory tool, got %v", tool) + } + } + } + if rows, ok := payload["agent_privilege_map"].([]any); ok { + for _, item := range rows { + row, ok := item.(map[string]any) + if ok && row["tool_type"] == "custom_detector" { + t.Fatalf("extension finding must not become agent privilege row, got %v", row) + } + } + } + if paths, ok := payload["action_paths"].([]any); ok { + for _, item := range paths { + path, ok := item.(map[string]any) + if ok && path["tool_type"] == "custom_detector" { + t.Fatalf("extension finding must not become action path, got %v", path) + } + } + } +} diff --git a/core/cli/scan_helpers.go b/core/cli/scan_helpers.go index 532e808..24beeca 100644 --- a/core/cli/scan_helpers.go +++ b/core/cli/scan_helpers.go @@ -27,10 +27,12 @@ import ( "github.com/Clyra-AI/wrkr/core/source/localsetup" "github.com/Clyra-AI/wrkr/core/source/org" "github.com/Clyra-AI/wrkr/core/state" + "github.com/Clyra-AI/wrkr/internal/managedmarker" ) const materializedRootMarkerFile = ".wrkr-materialized-sources-managed" const materializedRootMarkerContent = "managed by wrkr scan materialized sources\n" +const materializedRootMarkerKind = "scan_materialized_root" type materializedRootSafetyError struct { message string @@ -230,7 +232,7 @@ func prepareMaterializedRoot(statePath string) (string, error) { return "", fmt.Errorf("state path is required for materialized source acquisition") } root := filepath.Join(filepath.Dir(cleanState), "materialized-sources") - if err := prepareManagedMaterializedRoot(root, true); err != nil { + if err := prepareManagedMaterializedRoot(root, cleanState, true); err != nil { return "", err } return root, nil @@ -242,20 +244,20 @@ func prepareMaterializedRootForResume(statePath string) (string, error) { return "", fmt.Errorf("state path is required for materialized source acquisition") } root := filepath.Join(filepath.Dir(cleanState), "materialized-sources") - if err := prepareManagedMaterializedRoot(root, false); err != nil { + if err := prepareManagedMaterializedRoot(root, cleanState, false); err != nil { return "", err } return root, nil } -func prepareManagedMaterializedRoot(root string, reset bool) error { +func prepareManagedMaterializedRoot(root string, statePath string, reset bool) error { info, err := os.Lstat(root) if err != nil { if os.IsNotExist(err) { if err := os.MkdirAll(root, 0o750); err != nil { return fmt.Errorf("create materialized source root: %w", err) } - return writeMaterializedRootMarker(root) + return writeMaterializedRootMarker(statePath, root) } return fmt.Errorf("lstat materialized source root: %w", err) } @@ -270,7 +272,7 @@ func prepareManagedMaterializedRoot(root string, reset bool) error { return fmt.Errorf("read materialized source root: %w", err) } if len(entries) == 0 { - return writeMaterializedRootMarker(root) + return writeMaterializedRootMarker(statePath, root) } markerPath := filepath.Join(root, materializedRootMarkerFile) @@ -288,7 +290,7 @@ func prepareManagedMaterializedRoot(root string, reset bool) error { if err != nil { return fmt.Errorf("read materialized source root marker: %w", err) } - if string(markerPayload) != materializedRootMarkerContent { + if err := managedmarker.ValidatePayload(statePath, root, materializedRootMarkerKind, markerPayload); err != nil { return newMaterializedRootSafetyError("materialized source root marker content is invalid: %s", markerPath) } if !reset { @@ -307,9 +309,13 @@ func prepareManagedMaterializedRoot(root string, reset bool) error { return nil } -func writeMaterializedRootMarker(root string) error { +func writeMaterializedRootMarker(statePath string, root string) error { markerPath := filepath.Join(root, materializedRootMarkerFile) - if err := os.WriteFile(markerPath, []byte(materializedRootMarkerContent), 0o600); err != nil { + payload, err := managedmarker.BuildPayload(statePath, root, materializedRootMarkerKind) + if err != nil { + return fmt.Errorf("build materialized source root marker: %w", err) + } + if err := os.WriteFile(markerPath, payload, 0o600); err != nil { return fmt.Errorf("write materialized source root marker: %w", err) } return nil diff --git a/core/cli/scan_materialized_root_test.go b/core/cli/scan_materialized_root_test.go index 29fe557..04ce554 100644 --- a/core/cli/scan_materialized_root_test.go +++ b/core/cli/scan_materialized_root_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/Clyra-AI/wrkr/internal/managedmarker" ) func TestPrepareMaterializedRootRejectsNonManagedNonEmptyDir(t *testing.T) { @@ -43,7 +45,11 @@ func TestPrepareMaterializedRootRejectsMarkerSymlink(t *testing.T) { if err := os.MkdirAll(root, 0o750); err != nil { t.Fatalf("mkdir root: %v", err) } - if err := os.WriteFile(filepath.Join(root, "marker-target.txt"), []byte(materializedRootMarkerContent), 0o600); err != nil { + payload, err := managedmarker.BuildPayload(statePath, root, materializedRootMarkerKind) + if err != nil { + t.Fatalf("build marker target: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "marker-target.txt"), payload, 0o600); err != nil { t.Fatalf("write marker target: %v", err) } if err := os.Symlink("marker-target.txt", filepath.Join(root, materializedRootMarkerFile)); err != nil { @@ -53,7 +59,7 @@ func TestPrepareMaterializedRootRejectsMarkerSymlink(t *testing.T) { t.Fatalf("write stale file: %v", err) } - _, err := prepareMaterializedRoot(statePath) + _, err = prepareMaterializedRoot(statePath) if err == nil { t.Fatal("expected marker symlink to be rejected") } @@ -71,7 +77,11 @@ func TestPrepareMaterializedRootResetsManagedRoot(t *testing.T) { if err := os.MkdirAll(root, 0o750); err != nil { t.Fatalf("mkdir root: %v", err) } - if err := os.WriteFile(filepath.Join(root, materializedRootMarkerFile), []byte(materializedRootMarkerContent), 0o600); err != nil { + payload, err := managedmarker.BuildPayload(statePath, root, materializedRootMarkerKind) + if err != nil { + t.Fatalf("build marker: %v", err) + } + if err := os.WriteFile(filepath.Join(root, materializedRootMarkerFile), payload, 0o600); err != nil { t.Fatalf("write marker: %v", err) } stalePath := filepath.Join(root, "stale.txt") @@ -91,8 +101,33 @@ func TestPrepareMaterializedRootResetsManagedRoot(t *testing.T) { } if markerPayload, readErr := os.ReadFile(filepath.Join(root, materializedRootMarkerFile)); readErr != nil { t.Fatalf("read marker: %v", readErr) - } else if string(markerPayload) != materializedRootMarkerContent { - t.Fatalf("unexpected marker content: %q", string(markerPayload)) + } else if err := managedmarker.ValidatePayload(statePath, root, materializedRootMarkerKind, markerPayload); err != nil { + t.Fatalf("expected signed managed marker, got: %v", err) + } +} + +func TestPrepareMaterializedRootRejectsLegacyMarkerContent(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + statePath := filepath.Join(tmp, ".wrkr", "last-scan.json") + root := filepath.Join(filepath.Dir(statePath), "materialized-sources") + if err := os.MkdirAll(root, 0o750); err != nil { + t.Fatalf("mkdir root: %v", err) + } + if err := os.WriteFile(filepath.Join(root, materializedRootMarkerFile), []byte(materializedRootMarkerContent), 0o600); err != nil { + t.Fatalf("write legacy marker: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "stale.txt"), []byte("stale"), 0o600); err != nil { + t.Fatalf("write stale file: %v", err) + } + + _, err := prepareMaterializedRoot(statePath) + if err == nil { + t.Fatal("expected legacy marker content to be rejected") + } + if !isMaterializedRootSafetyError(err) { + t.Fatalf("expected materialized root safety error, got: %v", err) } } diff --git a/core/cli/scan_transaction_test.go b/core/cli/scan_transaction_test.go new file mode 100644 index 0000000..410d9e9 --- /dev/null +++ b/core/cli/scan_transaction_test.go @@ -0,0 +1,202 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/Clyra-AI/wrkr/core/lifecycle" + "github.com/Clyra-AI/wrkr/core/manifest" + "github.com/Clyra-AI/wrkr/core/proofemit" +) + +func TestScanLateReportWriteFailureRollsBackManagedArtifacts(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reposPath := filepath.Join(tmp, "repos") + repoPath := filepath.Join(reposPath, "alpha", ".codex") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "config.toml"), []byte("approval_policy = \"never\"\n"), 0o600); err != nil { + t.Fatalf("write codex config: %v", err) + } + + statePath := filepath.Join(tmp, ".wrkr", "state.json") + var initialOut bytes.Buffer + var initialErr bytes.Buffer + if code := Run([]string{"scan", "--path", reposPath, "--state", statePath, "--json"}, &initialOut, &initialErr); code != exitSuccess { + t.Fatalf("initial scan failed: %d stdout=%q stderr=%q", code, initialOut.String(), initialErr.String()) + } + + manifestPath := manifest.ResolvePath(statePath) + lifecyclePath := lifecycle.ChainPath(statePath) + proofPath := proofemit.ChainPath(statePath) + attestationPath := proofemit.ChainAttestationPath(proofPath) + signingKeyPath := proofemit.SigningKeyPath(statePath) + + manifestBefore := readOptionalTestFile(t, manifestPath) + lifecycleBefore := readOptionalTestFile(t, lifecyclePath) + proofBefore := readOptionalTestFile(t, proofPath) + attestationBefore := readOptionalTestFile(t, attestationPath) + signingKeyBefore := readOptionalTestFile(t, signingKeyPath) + + lockedDir := filepath.Join(tmp, "locked-report") + if err := os.MkdirAll(lockedDir, 0o700); err != nil { + t.Fatalf("mkdir locked dir: %v", err) + } + if err := os.Chmod(lockedDir, 0o500); err != nil { + t.Skipf("chmod unsupported in current environment: %v", err) + } + defer func() { + _ = os.Chmod(lockedDir, 0o700) + }() + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{ + "scan", + "--path", reposPath, + "--state", statePath, + "--report-md", + "--report-md-path", filepath.Join(lockedDir, "scan.md"), + "--json", + }, &out, &errOut) + if code != exitRuntime { + t.Fatalf("expected runtime failure, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertErrorEnvelopeCode(t, errOut.Bytes(), "runtime_failure", exitRuntime) + + assertOptionalTestFileEquals(t, manifestPath, manifestBefore) + assertOptionalTestFileEquals(t, lifecyclePath, lifecycleBefore) + assertOptionalTestFileEquals(t, proofPath, proofBefore) + assertOptionalTestFileEquals(t, attestationPath, attestationBefore) + assertOptionalTestFileEquals(t, signingKeyPath, signingKeyBefore) +} + +func TestScanLateSARIFWriteFailureRollsBackManagedArtifacts(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reposPath := filepath.Join(tmp, "repos") + repoPath := filepath.Join(reposPath, "alpha", ".codex") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "config.toml"), []byte("approval_policy = \"never\"\n"), 0o600); err != nil { + t.Fatalf("write codex config: %v", err) + } + + statePath := filepath.Join(tmp, ".wrkr", "state.json") + var initialOut bytes.Buffer + var initialErr bytes.Buffer + if code := Run([]string{"scan", "--path", reposPath, "--state", statePath, "--json"}, &initialOut, &initialErr); code != exitSuccess { + t.Fatalf("initial scan failed: %d stdout=%q stderr=%q", code, initialOut.String(), initialErr.String()) + } + + manifestPath := manifest.ResolvePath(statePath) + lifecyclePath := lifecycle.ChainPath(statePath) + proofPath := proofemit.ChainPath(statePath) + attestationPath := proofemit.ChainAttestationPath(proofPath) + signingKeyPath := proofemit.SigningKeyPath(statePath) + + manifestBefore := readOptionalTestFile(t, manifestPath) + lifecycleBefore := readOptionalTestFile(t, lifecyclePath) + proofBefore := readOptionalTestFile(t, proofPath) + attestationBefore := readOptionalTestFile(t, attestationPath) + signingKeyBefore := readOptionalTestFile(t, signingKeyPath) + + lockedDir := filepath.Join(tmp, "locked-sarif") + if err := os.MkdirAll(lockedDir, 0o700); err != nil { + t.Fatalf("mkdir locked dir: %v", err) + } + if err := os.Chmod(lockedDir, 0o500); err != nil { + t.Skipf("chmod unsupported in current environment: %v", err) + } + defer func() { + _ = os.Chmod(lockedDir, 0o700) + }() + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{ + "scan", + "--path", reposPath, + "--state", statePath, + "--sarif", + "--sarif-path", filepath.Join(lockedDir, "wrkr.sarif"), + "--json", + }, &out, &errOut) + if code != exitRuntime { + t.Fatalf("expected runtime failure, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertErrorEnvelopeCode(t, errOut.Bytes(), "runtime_failure", exitRuntime) + + assertOptionalTestFileEquals(t, manifestPath, manifestBefore) + assertOptionalTestFileEquals(t, lifecyclePath, lifecycleBefore) + assertOptionalTestFileEquals(t, proofPath, proofBefore) + assertOptionalTestFileEquals(t, attestationPath, attestationBefore) + assertOptionalTestFileEquals(t, signingKeyPath, signingKeyBefore) +} + +func TestScanLateJSONPathWriteFailureRollsBackManagedArtifacts(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reposPath := filepath.Join(tmp, "repos") + if err := os.MkdirAll(filepath.Join(reposPath, "alpha"), 0o755); err != nil { + t.Fatalf("mkdir repo fixture: %v", err) + } + + statePath := filepath.Join(tmp, "state.json") + var initialOut bytes.Buffer + var initialErr bytes.Buffer + if code := Run([]string{"scan", "--path", reposPath, "--state", statePath, "--json"}, &initialOut, &initialErr); code != exitSuccess { + t.Fatalf("initial scan failed: %d stdout=%q stderr=%q", code, initialOut.String(), initialErr.String()) + } + + manifestPath := manifest.ResolvePath(statePath) + lifecyclePath := lifecycle.ChainPath(statePath) + proofPath := proofemit.ChainPath(statePath) + attestationPath := proofemit.ChainAttestationPath(proofPath) + signingKeyPath := proofemit.SigningKeyPath(statePath) + + manifestBefore := readOptionalTestFile(t, manifestPath) + lifecycleBefore := readOptionalTestFile(t, lifecyclePath) + proofBefore := readOptionalTestFile(t, proofPath) + attestationBefore := readOptionalTestFile(t, attestationPath) + signingKeyBefore := readOptionalTestFile(t, signingKeyPath) + + lockedDir := filepath.Join(tmp, "locked-json") + if err := os.MkdirAll(lockedDir, 0o700); err != nil { + t.Fatalf("mkdir locked dir: %v", err) + } + if err := os.Chmod(lockedDir, 0o500); err != nil { + t.Skipf("chmod unsupported in current environment: %v", err) + } + defer func() { + _ = os.Chmod(lockedDir, 0o700) + }() + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{ + "scan", + "--path", reposPath, + "--state", statePath, + "--json", + "--json-path", filepath.Join(lockedDir, "scan.json"), + }, &out, &errOut) + if code != exitRuntime { + t.Fatalf("expected runtime failure, got %d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + assertErrorEnvelopeCode(t, errOut.Bytes(), "runtime_failure", exitRuntime) + + assertOptionalTestFileEquals(t, manifestPath, manifestBefore) + assertOptionalTestFileEquals(t, lifecyclePath, lifecycleBefore) + assertOptionalTestFileEquals(t, proofPath, proofBefore) + assertOptionalTestFileEquals(t, attestationPath, attestationBefore) + assertOptionalTestFileEquals(t, signingKeyPath, signingKeyBefore) +} diff --git a/core/evidence/evidence.go b/core/evidence/evidence.go index 93d808a..01a8c80 100644 --- a/core/evidence/evidence.go +++ b/core/evidence/evidence.go @@ -20,6 +20,7 @@ import ( reportcore "github.com/Clyra-AI/wrkr/core/report" "github.com/Clyra-AI/wrkr/core/state" verifycore "github.com/Clyra-AI/wrkr/core/verify" + "github.com/Clyra-AI/wrkr/internal/managedmarker" "gopkg.in/yaml.v3" ) @@ -41,6 +42,7 @@ type BuildResult struct { const outputDirMarkerFile = ".wrkr-evidence-managed" const outputDirMarkerContent = "managed by wrkr evidence build\n" +const outputDirMarkerKind = "evidence_output" type ErrorClass string @@ -177,13 +179,13 @@ func Build(in BuildInput) (BuildResult, error) { outputDir = "wrkr-evidence" } targetOutputDir := outputDir - if err := validateOutputDirTarget(targetOutputDir); err != nil { + if err := validateOutputDirTargetWithState(targetOutputDir, resolvedStatePath); err != nil { if isOutputDirSafetyError(err) { return BuildResult{}, classifyError(ErrorClassUnsafeOperationBlocked, err) } return BuildResult{}, classifyError(ErrorClassRuntimeFailure, err) } - stageDir, err := createOutputStageDir(targetOutputDir) + stageDir, err := createOutputStageDir(targetOutputDir, resolvedStatePath) if err != nil { if isOutputDirSafetyError(err) { return BuildResult{}, classifyError(ErrorClassUnsafeOperationBlocked, err) @@ -507,9 +509,13 @@ func writeJSONL(path string, records []proof.Record) error { return nil } -func writeOutputDirMarker(path string) error { +func writeOutputDirMarker(statePath string, path string, targetPath string) error { markerPath := filepath.Join(path, outputDirMarkerFile) - if err := os.WriteFile(markerPath, []byte(outputDirMarkerContent), 0o600); err != nil { + payload, err := managedmarker.BuildPayload(statePath, targetPath, outputDirMarkerKind) + if err != nil { + return fmt.Errorf("build output dir marker: %w", err) + } + if err := os.WriteFile(markerPath, payload, 0o600); err != nil { return fmt.Errorf("write output dir marker: %w", err) } return nil diff --git a/core/evidence/evidence_test.go b/core/evidence/evidence_test.go index 581eb4c..96fafdf 100644 --- a/core/evidence/evidence_test.go +++ b/core/evidence/evidence_test.go @@ -22,6 +22,7 @@ import ( "github.com/Clyra-AI/wrkr/core/source" "github.com/Clyra-AI/wrkr/core/state" verifycore "github.com/Clyra-AI/wrkr/core/verify" + "github.com/Clyra-AI/wrkr/internal/managedmarker" ) func TestBuildEvidenceBundle(t *testing.T) { @@ -948,6 +949,60 @@ func TestBuildEvidenceRejectsMarkerWithInvalidContent(t *testing.T) { } } +func TestBuildEvidenceRejectsForgedLegacyMarker(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + statePath := createEvidenceStateWithProof(t, tmp) + + outputDir := filepath.Join(tmp, "wrkr-evidence") + if err := os.MkdirAll(outputDir, 0o750); err != nil { + t.Fatalf("mkdir output dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outputDir, outputDirMarkerFile), []byte(outputDirMarkerContent), 0o600); err != nil { + t.Fatalf("write forged legacy marker: %v", err) + } + if err := os.WriteFile(filepath.Join(outputDir, "unrelated.txt"), []byte("do-not-delete"), 0o600); err != nil { + t.Fatalf("write unrelated file: %v", err) + } + + _, err := Build(BuildInput{StatePath: statePath, Frameworks: []string{"soc2"}, OutputDir: outputDir, GeneratedAt: time.Date(2026, 2, 20, 14, 0, 0, 0, time.UTC)}) + if err == nil { + t.Fatal("expected forged legacy marker to be rejected") + } + if !strings.Contains(err.Error(), "marker content is invalid") { + t.Fatalf("expected marker content error, got: %v", err) + } + if _, statErr := os.Stat(filepath.Join(outputDir, "unrelated.txt")); statErr != nil { + t.Fatalf("expected unrelated file to remain, got: %v", statErr) + } +} + +func TestBuildEvidenceMigratesLegacyManagedBundle(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + statePath := createEvidenceStateWithProof(t, tmp) + + 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("initial build evidence bundle: %v", err) + } + if err := os.WriteFile(filepath.Join(outputDir, outputDirMarkerFile), []byte(outputDirMarkerContent), 0o600); err != nil { + t.Fatalf("rewrite legacy marker: %v", err) + } + + if _, err := Build(BuildInput{StatePath: statePath, Frameworks: []string{"soc2"}, OutputDir: outputDir, GeneratedAt: time.Date(2026, 2, 20, 15, 0, 0, 0, time.UTC)}); err != nil { + t.Fatalf("migrate legacy managed bundle: %v", err) + } + + payload, err := os.ReadFile(filepath.Join(outputDir, outputDirMarkerFile)) + if err != nil { + t.Fatalf("read migrated marker: %v", err) + } + if err := managedmarker.ValidatePayload(statePath, outputDir, outputDirMarkerKind, payload); err != nil { + t.Fatalf("expected migrated signed marker, got: %v", err) + } +} + func TestBuildEvidenceRejectsSymlinkOutputDir(t *testing.T) { t.Parallel() tmp := t.TempDir() diff --git a/core/evidence/stage.go b/core/evidence/stage.go index 366e427..1c70318 100644 --- a/core/evidence/stage.go +++ b/core/evidence/stage.go @@ -4,8 +4,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" + + proof "github.com/Clyra-AI/proof" + "github.com/Clyra-AI/wrkr/internal/managedmarker" ) var ( @@ -17,6 +21,10 @@ var ( ) func validateOutputDirTarget(path string) error { + return validateOutputDirTargetWithState(path, "") +} + +func validateOutputDirTargetWithState(path string, statePath string) error { cleanPath := filepath.Clean(path) info, err := os.Lstat(cleanPath) if err != nil { @@ -38,10 +46,10 @@ func validateOutputDirTarget(path string) error { if len(entries) == 0 { return nil } - return validateManagedOutputDir(cleanPath) + return validateManagedOutputDir(cleanPath, statePath) } -func validateManagedOutputDir(path string) error { +func validateManagedOutputDir(path string, statePath string) error { markerPath := filepath.Join(path, outputDirMarkerFile) markerInfo, err := os.Lstat(markerPath) if err != nil { @@ -57,13 +65,27 @@ func validateManagedOutputDir(path string) error { if err != nil { return fmt.Errorf("read output dir marker: %w", err) } - if string(markerPayload) != outputDirMarkerContent { - return newOutputDirSafetyError("output dir marker content is invalid: %s", markerPath) + if strings.TrimSpace(statePath) != "" { + if validateErr := managedmarker.ValidatePayload(statePath, path, outputDirMarkerKind, markerPayload); validateErr == nil { + return nil + } + } + if string(markerPayload) == outputDirMarkerContent { + if legacyErr := validateLegacyManagedOutputDir(path); legacyErr == nil { + return nil + } + } + return newOutputDirSafetyError("output dir marker content is invalid: %s", markerPath) +} + +func validateLegacyManagedOutputDir(path string) error { + if _, err := proof.VerifyBundle(path, proof.BundleVerifyOpts{}); err != nil { + return fmt.Errorf("verify legacy managed output dir: %w", err) } return nil } -func createOutputStageDir(targetDir string) (string, error) { +func createOutputStageDir(targetDir string, statePath string) (string, error) { cleanTarget := filepath.Clean(targetDir) parentDir := filepath.Dir(cleanTarget) if err := os.MkdirAll(parentDir, 0o750); err != nil { @@ -73,7 +95,7 @@ func createOutputStageDir(targetDir string) (string, error) { if err != nil { return "", fmt.Errorf("create output stage dir: %w", err) } - if err := writeOutputDirMarker(stageDir); err != nil { + if err := writeOutputDirMarker(statePath, stageDir, cleanTarget); err != nil { _ = removeAll(stageDir) return "", err } diff --git a/core/model/identity_bearing.go b/core/model/identity_bearing.go index 9e49c8d..ff39ed8 100644 --- a/core/model/identity_bearing.go +++ b/core/model/identity_bearing.go @@ -92,14 +92,8 @@ func isBearingFinding(f Finding, allowlist map[string]struct{}) bool { if normalizedToolType == "" { return false } - if _, allowed := allowlist[normalizeIdentityScopeToken(f.FindingType)]; allowed { - return true - } - if normalizeIdentityScopeToken(f.Detector) != "extension" { - return false - } - _, excluded := legacyNonToolArtifactTypes[normalizedToolType] - return !excluded + _, allowed := allowlist[normalizeIdentityScopeToken(f.FindingType)] + return allowed } func normalizeIdentityScopeToken(value string) string { diff --git a/core/model/identity_bearing_test.go b/core/model/identity_bearing_test.go index f19e043..60ea400 100644 --- a/core/model/identity_bearing_test.go +++ b/core/model/identity_bearing_test.go @@ -87,13 +87,13 @@ func TestIsIdentityBearingFinding(t *testing.T) { want: false, }, { - name: "extension finding with real tool type allowed", + name: "extension finding with real tool type excluded by default", in: Finding{ FindingType: "custom_extension_finding", ToolType: "custom_detector", Detector: "extension", }, - want: true, + want: false, }, { name: "extension finding with non-tool type excluded", @@ -161,8 +161,8 @@ func TestIsInventoryBearingFinding_UsesExplicitAllowlist(t *testing.T) { if IsInventoryBearingFinding(Finding{FindingType: "secret_presence", ToolType: "secret"}) { t.Fatal("expected secret_presence to be excluded from inventory-bearing classification") } - if !IsInventoryBearingFinding(Finding{FindingType: "custom_extension_finding", ToolType: "custom_detector", Detector: "extension"}) { - t.Fatal("expected extension finding with real tool type to be inventory-bearing") + if IsInventoryBearingFinding(Finding{FindingType: "custom_extension_finding", ToolType: "custom_detector", Detector: "extension"}) { + t.Fatal("expected extension finding with real tool type to stay off authoritative inventory surfaces by default") } if IsInventoryBearingFinding(Finding{FindingType: "custom_extension_finding", ToolType: "secret", Detector: "extension"}) { t.Fatal("expected extension finding with non-tool type to stay excluded from inventory-bearing classification") diff --git a/core/regress/regress_test.go b/core/regress/regress_test.go index 590eca9..5bca386 100644 --- a/core/regress/regress_test.go +++ b/core/regress/regress_test.go @@ -247,6 +247,31 @@ func TestCompareFlagsNewUnapprovedTool(t *testing.T) { } } +func TestCompareIgnoresExtensionFindingsByDefault(t *testing.T) { + t.Parallel() + + current := state.Snapshot{ + Findings: []model.Finding{ + { + FindingType: "custom_extension_finding", + ToolType: "custom_detector", + Detector: "extension", + Location: "README.md", + Org: "acme", + Repo: "repo", + }, + }, + } + + if tools := SnapshotTools(current); len(tools) != 0 { + t.Fatalf("expected extension finding to stay out of baseline tools, got %+v", tools) + } + result := Compare(Baseline{Version: BaselineVersion, Tools: []ToolState{}}, current) + if result.Drift { + t.Fatalf("expected no drift for extension-only finding, got %+v", result) + } +} + func TestCompareFlagsRevokedToolReappearance(t *testing.T) { t.Parallel() diff --git a/core/source/org/checkpoint.go b/core/source/org/checkpoint.go index 0efdccd..d06c32a 100644 --- a/core/source/org/checkpoint.go +++ b/core/source/org/checkpoint.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/Clyra-AI/wrkr/internal/atomicwrite" + "github.com/Clyra-AI/wrkr/internal/managedmarker" ) const ( @@ -18,6 +19,7 @@ const ( checkpointRootName = "org-checkpoints" checkpointMarkerFile = ".wrkr-org-checkpoints-managed" checkpointMarkerContent = "managed by wrkr org checkpoints\n" + checkpointMarkerKind = "org_checkpoint_root" ) type checkpointInputError struct { @@ -95,7 +97,7 @@ func prepareCheckpointRoot(statePath string) (string, error) { if err := os.MkdirAll(root, 0o750); err != nil { return "", fmt.Errorf("create org checkpoint root: %w", err) } - if err := writeCheckpointMarker(root); err != nil { + if err := writeCheckpointMarker(cleanState, root); err != nil { return "", err } return root, nil @@ -114,7 +116,7 @@ func prepareCheckpointRoot(statePath string) (string, error) { return "", fmt.Errorf("read org checkpoint root: %w", err) } if len(entries) == 0 { - if err := writeCheckpointMarker(root); err != nil { + if err := writeCheckpointMarker(cleanState, root); err != nil { return "", err } return root, nil @@ -135,15 +137,19 @@ func prepareCheckpointRoot(statePath string) (string, error) { if err != nil { return "", fmt.Errorf("read org checkpoint root marker: %w", err) } - if string(payload) != checkpointMarkerContent { + if err := managedmarker.ValidatePayload(cleanState, root, checkpointMarkerKind, payload); err != nil { return "", newCheckpointSafetyError("org checkpoint root marker content is invalid: %s", markerPath) } return root, nil } -func writeCheckpointMarker(root string) error { +func writeCheckpointMarker(statePath string, root string) error { markerPath := filepath.Join(root, checkpointMarkerFile) - if err := os.WriteFile(markerPath, []byte(checkpointMarkerContent), 0o600); err != nil { + payload, err := managedmarker.BuildPayload(statePath, root, checkpointMarkerKind) + if err != nil { + return fmt.Errorf("build org checkpoint root marker: %w", err) + } + if err := os.WriteFile(markerPath, payload, 0o600); err != nil { return fmt.Errorf("write org checkpoint root marker: %w", err) } return nil diff --git a/core/source/org/checkpoint_test.go b/core/source/org/checkpoint_test.go new file mode 100644 index 0000000..bd2e10e --- /dev/null +++ b/core/source/org/checkpoint_test.go @@ -0,0 +1,53 @@ +package org + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Clyra-AI/wrkr/internal/managedmarker" +) + +func TestCheckpointPathCreatesSignedManagedRoot(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + statePath := filepath.Join(tmp, "state.json") + path, err := checkpointPath(statePath, "acme") + if err != nil { + t.Fatalf("checkpoint path: %v", err) + } + root := filepath.Dir(path) + payload, err := os.ReadFile(filepath.Join(root, checkpointMarkerFile)) + if err != nil { + t.Fatalf("read checkpoint marker: %v", err) + } + if err := managedmarker.ValidatePayload(statePath, root, checkpointMarkerKind, payload); err != nil { + t.Fatalf("expected signed checkpoint marker, got: %v", err) + } +} + +func TestCheckpointPathRejectsLegacyMarkerContent(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + statePath := filepath.Join(tmp, "state.json") + root := filepath.Join(filepath.Dir(statePath), checkpointRootName) + if err := os.MkdirAll(root, 0o750); err != nil { + t.Fatalf("mkdir checkpoint root: %v", err) + } + if err := os.WriteFile(filepath.Join(root, checkpointMarkerFile), []byte(checkpointMarkerContent), 0o600); err != nil { + t.Fatalf("write legacy checkpoint marker: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "stale.txt"), []byte("stale"), 0o600); err != nil { + t.Fatalf("write stale file: %v", err) + } + + _, err := checkpointPath(statePath, "acme") + if err == nil { + t.Fatal("expected legacy checkpoint marker to fail") + } + if !IsCheckpointSafetyError(err) { + t.Fatalf("expected checkpoint safety error, got %v", err) + } +} diff --git a/docs/commands/campaign.md b/docs/commands/campaign.md index e19f7e3..72612d3 100644 --- a/docs/commands/campaign.md +++ b/docs/commands/campaign.md @@ -10,6 +10,7 @@ wrkr campaign aggregate --input-glob '' [--output ] [--md] [--md-pat Aggregate multiple `wrkr scan --json` artifacts into one deterministic campaign summary for report headline metrics and methodology metadata. Campaign aggregation accepts complete scan artifacts only. Artifacts with `partial_result=true`, `source_degraded=true`, or non-empty `source_errors` are rejected as `invalid_input` instead of being summarized. +Artifacts must also carry the expected scan contract fields (`target`, `source_manifest`, `inventory`, `privilege_budget`, and `findings`); generic `status=ok` JSON from other commands is rejected as `invalid_input`. ## Flags @@ -52,6 +53,7 @@ wrkr campaign aggregate --input-glob './.tmp/campaign/*.json' --segment-metadata - Input file paths are sorted before aggregation. - Partial or degraded scan artifacts fail closed before aggregation. +- Non-scan JSON envelopes fail closed before aggregation. - Detector inventory and per-scan outputs are sorted and stable for fixed artifacts. - Production-write totals are emitted only when all contributing scans have configured production-target policy. - When production targets are not configured, public markdown stays at `write-capable` wording and reports production-target status rather than a production-write count. diff --git a/docs/commands/evidence.md b/docs/commands/evidence.md index bdd2a2c..2424e40 100644 --- a/docs/commands/evidence.md +++ b/docs/commands/evidence.md @@ -19,9 +19,9 @@ Evidence output directories are fail-closed: - Wrkr verifies the saved proof chain before any staged bundle write or publish step. - Malformed or tampered proof chains fail closed before a new bundle is staged or published. -- Wrkr writes ownership marker `.wrkr-evidence-managed` in managed directories. +- Wrkr writes ownership marker `.wrkr-evidence-managed` in managed directories using state-bound marker provenance rather than a static marker body alone. - A non-empty, non-managed output directory is blocked. -- Marker path must be a regular file; symlink or directory markers are blocked. +- Marker path must be a regular file with valid marker provenance; symlink, directory, forged legacy-static, or otherwise invalid markers are blocked. - Wrkr builds bundles in a same-parent staged directory and publishes to `--output` only after manifest generation, signing, and bundle verification succeed. - If a build fails, Wrkr leaves the prior managed bundle intact or leaves the target path absent; it does not expose a partial new bundle at the final target path. - Unsafe output directory usage returns exit code `8` with error code `unsafe_operation_blocked`. diff --git a/docs/commands/scan.md b/docs/commands/scan.md index a65cfa3..614333d 100644 --- a/docs/commands/scan.md +++ b/docs/commands/scan.md @@ -18,15 +18,16 @@ Acquisition behavior is fail-closed by target: - `--github-org` is an additive alias for `--org`. - `--repo` and `--org` materialize repository contents into a deterministic local workspace under the scan state directory before detectors run. - Materialized workspace root (`materialized-sources/`) is ownership-gated: - - Wrkr-managed roots include marker `.wrkr-materialized-sources-managed`. + - Wrkr-managed roots include marker `.wrkr-materialized-sources-managed` with state-bound provenance, not just a static marker body. - Non-empty roots without a valid marker are blocked (no recursive cleanup). - - Marker must be a regular file with expected content; symlink/directory/invalid marker content is blocked. + - Marker must be a regular file with valid state-bound marker payload; symlink/directory/legacy-static/invalid marker content is blocked. - On `--resume`, previously materialized repo directories and checkpoint files must also be regular in-root artifacts; symlink-swapped repo roots or checkpoint files are blocked. - Ownership violations return `unsafe_operation_blocked` (exit `8`). - When GitHub acquisition is unavailable, `scan` returns `dependency_missing` with exit code `7` (no synthetic repos are emitted). - `--state` defaults to `.wrkr/last-scan.json`, with manifest/proof artifacts written alongside it. -- The state snapshot is the authoritative commit point; auxiliary manifest/chain artifacts are emitted only after snapshot persistence succeeds. -- Invalid scan-owned artifact paths such as `--report-md-path` and `--sarif-path` are preflight-validated before the authoritative commit point; `invalid_input` on those paths must leave managed state and proof artifacts untouched. +- Scan-owned managed artifacts are published transactionally: state snapshot, lifecycle chain, proof chain/attestation, manifest, and any requested `--json-path`, `--report-md-path`, or `--sarif-path` sidecars commit as one generation. +- Invalid scan-owned artifact paths such as `--report-md-path` and `--sarif-path` are preflight-validated before any managed artifact mutation. +- Late write failures after preflight still fail closed and roll managed artifacts back to the previous committed generation instead of leaving mixed state/proof/manifest outputs behind. - For `--path` scans, detector file reads stay bounded to the selected repo root. Root-escaping symlinked config, env, workflow, and MCP files are rejected with deterministic `parse_error.kind=unsafe_path` diagnostics instead of being read. ## Flags @@ -204,5 +205,5 @@ Wrkr stays in the See boundary: it inventories and scores tools plus agents from Wrkr also does not assess package or MCP-server vulnerabilities in this path; use dedicated scanners such as Snyk for that class of assessment. Gait is optional interoperability for control-layer decisions, not a prerequisite for `scan`. -Custom extension detectors are loaded from `.wrkr/detectors/extensions.json` when present in scanned repositories. See [`docs/extensions/detectors.md`](../extensions/detectors.md). +Custom extension detectors are loaded from `.wrkr/detectors/extensions.json` when present in scanned repositories. Their findings remain on additive finding and risk surfaces only by default; they do not create authoritative inventory, lifecycle, regress, or action-path state unless a future explicit contract says so. See [`docs/extensions/detectors.md`](../extensions/detectors.md). Canonical state and artifact lifecycle: [`docs/state_lifecycle.md`](../state_lifecycle.md). diff --git a/docs/extensions/detectors.md b/docs/extensions/detectors.md index 7ebd560..ff9aabc 100644 --- a/docs/extensions/detectors.md +++ b/docs/extensions/detectors.md @@ -38,4 +38,5 @@ Wrkr supports deterministic file-based detector extensions via repository-local - Descriptors are loaded and validated with strict typed parsing. - Descriptor IDs are deterministically ordered before emission. - Invalid descriptors fail closed as detector errors with stable code/class (`invalid_extension_descriptor`, `extension`). -- Extension findings are additive and do not bypass built-in detector/risk/proof boundaries. +- Extension findings are additive and remain on raw finding and risk-report surfaces by default. +- Extension descriptors do not create inventory tools, lifecycle identities, regress tools, `agent_privilege_map` rows, or `action_paths` unless a future explicit contract introduces that capability. diff --git a/docs/state_lifecycle.md b/docs/state_lifecycle.md index 207c03e..2a83b9d 100644 --- a/docs/state_lifecycle.md +++ b/docs/state_lifecycle.md @@ -8,7 +8,8 @@ Wrkr uses two path classes: - Managed contract artifacts under `.wrkr/` (state, baseline, manifest, proof chain). - Operator-selected output paths (for reports/evidence exports), commonly under `.tmp/` or `wrkr-evidence/`. -- Scan-owned additive artifact paths (`--report-md-path`, `--sarif-path`) are preflight-validated before managed `.wrkr/` commit paths are mutated. +- Scan-owned additive artifact paths (`--report-md-path`, `--sarif-path`, `--json-path`) are preflight-validated before managed `.wrkr/` commit paths are mutated. +- After preflight, scan-owned managed artifacts publish transactionally; late sidecar write failures roll the managed generation back instead of leaving mixed state/proof/manifest outputs on disk. ## Canonical artifact locations @@ -18,7 +19,7 @@ Wrkr uses two path classes: | Regress baseline | `.wrkr/wrkr-regress-baseline.json` | `wrkr regress init` (default output) | Defaults to the same directory as state. | | Identity manifest | `.wrkr/wrkr-manifest.yaml` | `wrkr scan`, `wrkr manifest generate` | Lifecycle/approval baseline contract for real tool identities only. | | Proof chain | `.wrkr/proof-chain.json` | `wrkr scan` / `wrkr evidence` | Verifiable signed record chain. | -| Evidence bundle | `wrkr-evidence/` | `wrkr evidence` | User-supplied `--output` is allowed; unsafe non-managed non-empty paths fail closed. Wrkr verifies the saved proof chain first, then stages bundle writes in a same-parent temporary directory and only publishes after manifest/sign/verify success. | +| Evidence bundle | `wrkr-evidence/` | `wrkr evidence` | User-supplied `--output` is allowed; unsafe non-managed non-empty paths fail closed. Managed reruns are authorized by state-bound marker provenance, not static marker content alone. Wrkr verifies the saved proof chain first, then stages bundle writes in a same-parent temporary directory and only publishes after manifest/sign/verify success. | | Human report artifacts | user-selected (`.tmp/*.md`, `.tmp/*.pdf`) | `wrkr report`, `wrkr regress run --summary-md`, `wrkr lifecycle --summary-md` | Keep separate from managed `.wrkr/` contract artifacts. | ## Identity scope @@ -29,7 +30,7 @@ Wrkr uses two path classes: ## Lifecycle flow -1. `wrkr scan` writes/refreshes `.wrkr/last-scan.json`, `.wrkr/wrkr-manifest.yaml`, `.wrkr/proof-chain.json`. +1. `wrkr scan` writes/refreshes `.wrkr/last-scan.json`, `.wrkr/wrkr-manifest.yaml`, `.wrkr/proof-chain.json`, and requested scan-owned sidecars as one managed generation. 2. `wrkr regress init` snapshots current state into `.wrkr/wrkr-regress-baseline.json` (unless `--output` overrides). 3. `wrkr regress run` compares current state vs baseline and returns deterministic drift reasons. 4. `wrkr evidence` consumes state only after the saved proof chain passes the same local integrity prerequisite used by Wrkr's verification runtime, then emits evidence bundle outputs while preserving chain continuity and only publishing a complete verified bundle to the requested output path. diff --git a/docs/trust/compatibility-and-versioning.md b/docs/trust/compatibility-and-versioning.md index 0266f82..7a7fe6c 100644 --- a/docs/trust/compatibility-and-versioning.md +++ b/docs/trust/compatibility-and-versioning.md @@ -16,6 +16,8 @@ description: "How Wrkr maintains command, schema, and exit-code compatibility ac - Schema evolution is managed under `schemas/v1/`. - Manifest spec versioning is defined in `docs/specs/wrkr-manifest.md`. - `regress` baseline compatibility remains in `v1` for legacy baselines created before instance identities. Equivalent current identities reconcile automatically; additive JSON fields remain the preferred evolution path. +- Stricter rejection of invalid inputs that never matched the documented command contract, such as non-scan JSON passed to `wrkr campaign aggregate`, is treated as a compatibility-preserving bug fix inside the current major line. +- Repo-local extension detector findings remain additive by default; their prior implicit promotion into authoritative inventory, lifecycle, and regress state is not a stable compatibility guarantee. ## Command anchors @@ -42,3 +44,7 @@ Wrkr reconciles legacy `v1` baseline agent IDs against equivalent current instan ### How should agents handle unknown fields in Wrkr JSON? Ignore unknown optional fields and fail only when required contract fields are missing or invalid. + +### Does rejecting non-scan JSON in `wrkr campaign aggregate` require a new version line? + +No. Campaign aggregation is documented to consume complete `wrkr scan --json` artifacts only, so rejecting other `status=ok` envelopes is a current-line contract fix rather than a versioned breaking change. diff --git a/docs/trust/contracts-and-schemas.md b/docs/trust/contracts-and-schemas.md index b094784..c2c0662 100644 --- a/docs/trust/contracts-and-schemas.md +++ b/docs/trust/contracts-and-schemas.md @@ -23,6 +23,7 @@ wrkr verify --chain --json ## Compatibility posture Within the same major contract line, additive fields are expected to remain backward compatible for consumers that ignore unknown optional fields. +Command-specific validators may still reject inputs that never matched the documented contract, for example non-scan JSON passed to `wrkr campaign aggregate`. ## Q&A @@ -37,3 +38,7 @@ Schemas live in `schemas/v1/`, while command and flag contracts are documented u ### How should I design consumers to remain compatible over time? Treat additive optional fields as non-breaking, validate required fields strictly, and pin expected schema/manifest versions in CI checks. + +### Which JSON artifacts are valid inputs to `wrkr campaign aggregate`? + +Only complete `wrkr scan --json` artifacts. Other `status=ok` envelopes from commands such as `wrkr version` or `wrkr report` are not valid campaign inputs. diff --git a/internal/managedmarker/managedmarker.go b/internal/managedmarker/managedmarker.go new file mode 100644 index 0000000..db99b7b --- /dev/null +++ b/internal/managedmarker/managedmarker.go @@ -0,0 +1,134 @@ +package managedmarker + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/Clyra-AI/wrkr/internal/atomicwrite" + "os" +) + +const version = "v2" +const tokenFileName = ".wrkr-managed-token" + +type payload struct { + Version string `json:"version"` + Kind string `json:"kind"` + TargetPath string `json:"target_path"` + MAC string `json:"hmac_sha256"` +} + +func BuildPayload(statePath, targetPath, kind string) ([]byte, error) { + token, err := loadOrCreateToken(statePath) + if err != nil { + return nil, err + } + canonicalTarget, err := canonicalTargetPath(targetPath) + if err != nil { + return nil, err + } + encoded, err := json.MarshalIndent(payload{ + Version: version, + Kind: strings.TrimSpace(kind), + TargetPath: canonicalTarget, + MAC: sign(token, strings.TrimSpace(kind), canonicalTarget), + }, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal managed marker: %w", err) + } + return append(encoded, '\n'), nil +} + +func ValidatePayload(statePath, targetPath, kind string, raw []byte) error { + canonicalTarget, err := canonicalTargetPath(targetPath) + if err != nil { + return err + } + var parsed payload + if err := json.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("parse managed marker: %w", err) + } + if strings.TrimSpace(parsed.Version) != version { + return fmt.Errorf("managed marker version mismatch: have %q want %q", parsed.Version, version) + } + if strings.TrimSpace(parsed.Kind) != strings.TrimSpace(kind) { + return fmt.Errorf("managed marker kind mismatch: have %q want %q", parsed.Kind, kind) + } + if strings.TrimSpace(parsed.TargetPath) != canonicalTarget { + return fmt.Errorf("managed marker target mismatch: have %q want %q", parsed.TargetPath, canonicalTarget) + } + token, err := loadToken(statePath) + if err != nil { + return err + } + expected := sign(token, strings.TrimSpace(kind), canonicalTarget) + if !hmac.Equal([]byte(strings.TrimSpace(parsed.MAC)), []byte(expected)) { + return fmt.Errorf("managed marker signature is invalid") + } + return nil +} + +func canonicalTargetPath(targetPath string) (string, error) { + clean := filepath.Clean(strings.TrimSpace(targetPath)) + if clean == "" || clean == "." { + return "", fmt.Errorf("managed marker target path is required") + } + absolute, err := filepath.Abs(clean) + if err != nil { + return "", fmt.Errorf("resolve managed marker target path: %w", err) + } + return filepath.Clean(absolute), nil +} + +func loadOrCreateToken(statePath string) ([]byte, error) { + if token, err := loadToken(statePath); err == nil { + return token, nil + } else if !os.IsNotExist(err) { + return nil, err + } + + encoded := make([]byte, 64) + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return nil, fmt.Errorf("generate managed marker token: %w", err) + } + hex.Encode(encoded, random) + encoded = append(encoded, '\n') + if err := atomicwrite.WriteFile(tokenPath(statePath), encoded, 0o600); err != nil { + return nil, fmt.Errorf("write managed marker token: %w", err) + } + return random, nil +} + +func loadToken(statePath string) ([]byte, error) { + raw, err := os.ReadFile(tokenPath(statePath)) // #nosec G304 -- token path is derived from explicit state-path configuration. + if err != nil { + return nil, err + } + decoded, err := hex.DecodeString(strings.TrimSpace(string(raw))) + if err != nil { + return nil, fmt.Errorf("parse managed marker token: %w", err) + } + if len(decoded) == 0 { + return nil, fmt.Errorf("managed marker token is empty") + } + return decoded, nil +} + +func tokenPath(statePath string) string { + return filepath.Join(filepath.Dir(filepath.Clean(strings.TrimSpace(statePath))), tokenFileName) +} + +func sign(token []byte, kind, targetPath string) string { + mac := hmac.New(sha256.New, token) + _, _ = mac.Write([]byte(strings.TrimSpace(kind))) + _, _ = mac.Write([]byte{'\n'}) + _, _ = mac.Write([]byte(strings.TrimSpace(targetPath))) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/scenarios/epic11_scenario_test.go b/internal/scenarios/epic11_scenario_test.go index c88a83c..f741f4c 100644 --- a/internal/scenarios/epic11_scenario_test.go +++ b/internal/scenarios/epic11_scenario_test.go @@ -43,4 +43,31 @@ func TestScenarioExtensionDetectorExecution(t *testing.T) { if !foundCustom { t.Fatalf("expected custom_extension_finding in scenario output, got %v", findings) } + + if inventoryValue, ok := payload["inventory"].(map[string]any); ok { + if tools, ok := inventoryValue["tools"].([]any); ok { + for _, item := range tools { + tool, castOK := item.(map[string]any) + if castOK && tool["tool_type"] == "custom_detector" { + t.Fatalf("expected custom extension finding to stay off inventory tool surfaces, got %v", tool) + } + } + } + } + if rows, ok := payload["agent_privilege_map"].([]any); ok { + for _, item := range rows { + row, castOK := item.(map[string]any) + if castOK && row["tool_type"] == "custom_detector" { + t.Fatalf("expected custom extension finding to stay off agent privilege map, got %v", row) + } + } + } + if paths, ok := payload["action_paths"].([]any); ok { + for _, item := range paths { + path, castOK := item.(map[string]any) + if castOK && path["tool_type"] == "custom_detector" { + t.Fatalf("expected custom extension finding to stay off action paths, got %v", path) + } + } + } } diff --git a/product/PLAN_NEXT.md b/product/PLAN_NEXT.md index a79ecdd..21b1f22 100644 --- a/product/PLAN_NEXT.md +++ b/product/PLAN_NEXT.md @@ -1,229 +1,230 @@ -# PLAN WRKR_FO_GAP_CLOSURE: Close Remaining First-Offer Fidelity Gaps - -Date: 2026-03-30 -Source of truth: -- user-provided gap findings from 2026-03-30 analysis of `product/PLAN_NEXT.md` -- `AGENTS.md` -- `product/dev_guides.md` -- `product/architecture_guides.md` -- `README.md` -- `docs/commands/scan.md` -- `docs/commands/report.md` -- `core/risk/action_paths.go` -- `core/risk/action_paths_test.go` -- `core/report/build.go` -- `core/report/report_test.go` -- `core/cli/report_contract_test.go` -- `core/cli/root_test.go` -- `core/policy/profile/profile_test.go` -- `internal/scenarios/contracts_test.go` -- `internal/scenarios/first_offer_regression_scenario_test.go` -- `internal/scenarios/coverage_map.json` -- `testinfra/contracts/story1_contracts_test.go` -- `testinfra/contracts/story24_contracts_test.go` -Scope: Wrkr repository only. Planning artifact only. Close the remaining plan-to-repo fidelity gaps after the first-offer implementation landed: ratify the public `path_id` contract, harden FO-14 and FO-15 regression evidence with checked-in deterministic goldens and enforced coverage mappings, and add the direct package-level tests the plan promised for assessment behavior. +# PLAN NEXT: Contract Hardening and Boundary Safety + +Date: 2026-03-31 +Source of truth: user-provided 2026-03-31 full-repo code-review findings, `AGENTS.md`, `product/dev_guides.md`, `product/architecture_guides.md`, `docs/commands/scan.md`, `docs/commands/evidence.md`, `docs/commands/campaign.md`, `docs/extensions/detectors.md` +Scope: Wrkr repository only. Planning artifact only. Convert the four reproduced P1 findings into an execution-ready backlog plan that preserves Wrkr's deterministic, offline-first, fail-closed contract line. + +This plan is execution-first: every story includes concrete repo paths, commands, tests, lane wiring, acceptance criteria, changelog intent, and architecture constraints. + +--- ## Global Decisions (Locked) - This file is planning-only. No implementation work is in scope for this artifact. -- Treat the shipped first-offer capability set as present. This plan closes only the remaining fidelity gaps between the intended plan, public contract wording, and the current test harness. -- Preserve Wrkr's deterministic, offline-first, fail-closed posture. No story in this plan may introduce live-network-dependent regression fixtures, schema drift, or exit-code changes. -- Treat `action_paths[*].path_id` as an opaque public identifier. Unless implementation discovers a proven downstream need for a wire-format migration, keep the shipped `apc-` form and align docs/tests/contracts to that opaque contract rather than changing runtime behavior for documentation-only drift. -- First-offer regression evidence must move from broad behavioral assertions alone to checked-in, deterministic CLI/report goldens for the dedicated FO scenario packs. -- Coverage-map enforcement must fail if first-offer scenario mappings drift or disappear; FO-14 and FO-15 keys are part of the scenario contract once this plan lands. -- Direct package-level tests promised by the first-offer plan are required in addition to existing CLI contract tests; CLI contract coverage is not a substitute for report/profile package tests. -- Thin orchestration remains in `core/cli/*`; contract logic stays in `core/risk/*`, report shaping in `core/report/*`, and scenario/contract enforcement in `internal/scenarios/*` and `testinfra/contracts/*`. -- Stories that touch architecture boundaries, report contracts, risk logic, CLI help/usage, or validation gates must run `make prepush-full`. -- Docs/help/contract wording changes must ship with the corresponding docs and changelog updates in the same PR. +- The four reproduced P1 findings are minimum-now release blockers. No lower-priority polish may displace them. +- Preserve Wrkr's v1 stable surfaces: offline-first defaults, deterministic `--json` outputs, exit codes `0..8`, proof-chain contracts, and schema stability unless a story explicitly proves a version bump is unavoidable. +- Marker-name-only trust is not acceptable on destructive filesystem paths. Managed-directory reuse must be bound to stronger provenance than a predictable marker filename and static contents. +- The authoritative scan commit point must be all-or-nothing across snapshot, lifecycle chain, proof chain/attestation, manifest, and explicitly requested sidecar artifacts. A failed scan must not advance only part of that set. +- `wrkr campaign aggregate` accepts only complete `wrkr scan --json` artifacts. Accepting arbitrary `status=ok` JSON is treated as a contract bug, not a supported compatibility surface. +- Repo-local extension detectors remain additive finding sources only unless an explicit, documented contract promotes them into authoritative tool/identity surfaces. Undocumented implicit promotion is blocked. +- Thin orchestration stays in `core/cli/*`; proof/state/persistence logic stays in focused packages. Shared safety helpers may be introduced, but they must not collapse the source, detection, identity, risk, proof, and evidence boundaries. +- Stories touching architecture/risk/adapter/failure semantics must include `make prepush-full`. +- Reliability and fault-tolerance stories must include `make test-hardening` and `make test-chaos`. +- Docs, trust pages, and changelog updates ship in the same PR as the contract/runtime change they describe. + +--- ## Current Baseline (Observed) - Preconditions validated: - - `product/dev_guides.md` exists and is readable - - `product/architecture_guides.md` exists and is readable - - output path resolves inside `/Users/tr/wrkr` -- Standards guides already enforce the key planning constraints needed for this follow-up: + - `product/dev_guides.md` exists and is readable. + - `product/architecture_guides.md` exists and is readable. + - `/Users/tr/wrkr/product/PLAN_NEXT.md` resolves inside the repository and is writable. +- Standards guides contain the enforceable rules required by this skill: - testing and CI gating via `make prepush`, `make prepush-full`, `make test-risk-lane`, `scripts/validate_scenarios.sh`, and `scripts/run_v1_acceptance.sh --mode=local` - - determinism and contract stability via `testinfra/contracts`, `testinfra/hygiene`, `internal/scenarios`, and docs parity/storyline gates - - architecture/TDD/chaos/frugal governance via `product/architecture_guides.md` -- The major first-offer product surfaces are already implemented: - - additive `assessment` profile - - additive `assessment_summary` - - additive `ownerless_exposure`, `identity_exposure_summary`, `identity_to_review_first`, and `identity_to_revoke_first` - - additive `business_state_surface` and `exposure_groups` - - live org-scan stderr progress - - partial-visibility surfacing -- Verified green during the gap analysis: - - `go test ./core/risk ./core/report ./core/cli -count=1` - - `go test ./internal/scenarios -count=1 -tags=scenario` - - `go test ./testinfra/contracts -count=1` - - `make test-docs-consistency` - - `make test-docs-storyline` - - `make test-risk-lane` -- Remaining fidelity gaps are now narrow and explicit: - - `product/PLAN_NEXT.md` previously described `path_id` as lowercase hex-only while runtime/tests use `apc-` - - first-offer scenario tests assert broad behavior but do not yet check committed CLI/report goldens for the new FO packs - - `coverage_map.json` includes FO keys, but `TestScenarioContracts` currently enforces only the older `FR*` and `AC*` mappings - - `core/report/report_test.go` does not yet directly cover `assessment_summary` and AI-path-first summary selection, `core/policy/profile/profile_test.go` omits `assessment`, and scan help tests do not assert the `assessment` profile text directly -- Current worktree contains unrelated untracked `scripts/__pycache__/`. Planning can proceed, but implementation follow-up should scope or clean that path before code work. + - determinism and contract stability via `testinfra/contracts`, `testinfra/hygiene`, scenario fixtures, and docs parity/storyline checks + - architecture, TDD, frugal governance, and chaos requirements via `product/architecture_guides.md` +- Repository baseline is otherwise healthy: + - `git status --short` is clean before writing this plan + - `go test ./...` passed during the code review + - command anchors exercised successfully in temp workspaces: `wrkr scan --json`, `wrkr verify --chain --json`, `wrkr regress init --json`, `wrkr regress run --json` +- Reproduced release-blocking gaps: + - evidence output ownership can be spoofed by a forged `.wrkr-evidence-managed` marker, causing unrelated files in the selected output directory to be deleted + - `scan` can exit `1` after a late artifact failure while still leaving `state.json`, `proof-chain.json`, and `wrkr-manifest.yaml` updated + - `campaign aggregate` accepts non-scan JSON such as `wrkr version --json` and emits a bogus campaign artifact + - repo-local extension descriptors can synthesize authoritative tool/identity surfaces (`agent_privilege_map`, `action_paths`, and downstream regress/proof state) +- Current documentation already promises stronger behavior than the runtime enforces: + - `docs/commands/evidence.md` describes fail-closed managed output ownership + - `docs/commands/campaign.md` says campaign aggregation accepts complete scan artifacts only + - `docs/extensions/detectors.md` says extension findings do not bypass built-in detector/risk/proof boundaries + +--- ## Exit Criteria -1. The canonical public `action_paths[*].path_id` contract is explicit and aligned across runtime code, docs, and contract tests. No remaining source claims hex-only format if runtime keeps `apc-`. -2. First-offer scenario packs have checked-in deterministic scan/report goldens for the intended standard-versus-assessment and report-usefulness cases. -3. Scenario contract validation fails when FO-14 or FO-15 coverage-map keys drift, disappear, or reference unknown scenario tests. -4. Direct package-level tests exist for: - - `core/report` assessment summary and AI-path-first selection - - `core/policy/profile` builtin `assessment` loading - - `core/cli` scan help/profile surface -5. All added tests preserve offline deterministic fixtures and keep runtime JSON, exit codes, and proof behavior unchanged. -6. README/docs/help/changelog surfaces touched by this work remain inside Wrkr's static-posture and offline-proof claim boundary. +1. Destructive managed-directory reuse is provenance-gated, not marker-name-only, across the touched ownership surfaces. Spoofed markers, symlink markers, directory markers, and unrelated non-empty directories fail closed. +2. `wrkr scan` publishes snapshot, lifecycle, proof, manifest, and requested sidecar artifacts as a transactional unit; a late failure leaves the prior generation intact and does not expose mixed artifacts. +3. `wrkr campaign aggregate` rejects non-scan JSON and malformed scan artifacts with stable `invalid_input` behavior while continuing to accept complete scan artifacts. +4. Extension findings no longer create authoritative tool, identity, action-path, or regress state unless a future explicit contract says so. Raw findings and risk ranking remain available. +5. Docs, trust pages, changelog guidance, and regression tests are aligned with the corrected semantics in the same change set. +6. All required fast, core, acceptance, cross-platform, and risk lanes declared below are green. + +--- ## Public API and Contract Map Stable/public surfaces touched by this plan: -- `wrkr scan --profile assessment --json` -- `wrkr report --json` -- `action_paths[*].path_id` -- `action_path_to_control_first` -- additive `assessment_summary` -- additive `ownerless_exposure` -- additive `identity_exposure_summary` -- additive `identity_to_review_first` -- additive `identity_to_revoke_first` -- `docs/commands/scan.md` -- `docs/commands/report.md` +- `wrkr evidence --frameworks ... --output --json` +- `wrkr scan ... --state --report-md --sarif --json --json-path` +- scan-owned sidecar artifact behavior documented around state, proof, and manifest adjacency +- `wrkr campaign aggregate --input-glob --json` +- exit-code and error-envelope behavior for `invalid_input`, `runtime_failure`, and `unsafe_operation_blocked` +- repository-local extension detector contract at `.wrkr/detectors/extensions.json` +- command/trust docs: + - `docs/commands/scan.md` + - `docs/commands/evidence.md` + - `docs/commands/campaign.md` + - `docs/extensions/detectors.md` + - `docs/state_lifecycle.md` + - relevant `docs/trust/*` pages Internal surfaces expected to change: -- `core/risk/action_paths.go` -- `core/risk/action_paths_test.go` -- `core/report/report_test.go` -- `core/cli/root_test.go` -- `core/policy/profile/profile_test.go` -- `internal/scenarios/contracts_test.go` -- `internal/scenarios/first_offer_regression_scenario_test.go` -- `internal/scenarios/coverage_map.json` -- `scenarios/wrkr/first-offer-*` -- `testinfra/contracts/story1_contracts_test.go` -- `testinfra/contracts/story24_contracts_test.go` +- `core/evidence/stage.go` +- `core/evidence/evidence.go` +- `core/cli/scan.go` +- `core/cli/managed_artifacts.go` +- `core/cli/jsonmode.go` +- `core/cli/report_artifacts.go` +- `core/cli/campaign.go` +- `core/model/identity_bearing.go` +- supporting tests under `core/*_test.go`, `internal/e2e/*`, `internal/scenarios/*`, and `testinfra/contracts/*` Shim and deprecation path: -- Preferred path: keep `path_id` opaque and stable with the shipped `apc-` form; align wording and tests to that contract. -- If implementation instead changes runtime `path_id` formatting, treat it as a public contract change even if the field name stays the same; update all exact-string fixtures and add migration notes for downstream consumers that pinned exact values. -- Existing CLI contract tests remain in place; new direct package tests are additive rather than a replacement. -- Existing broad first-offer scenario assertions may remain as coarse behavioral guards, but goldens become the authoritative regression lock for FO-14 and FO-15. +- No CLI flags are removed in this plan. +- No schema version bump is assumed by default. +- Campaign input validation becomes stricter within the current contract line. Previously accepted non-scan JSON is treated as invalid input from this point forward. +- Extension findings remain in `findings`, `ranked_findings`, and raw scan evidence. Their undocumented promotion into authoritative state is removed. If future users need authoritative extension promotion, introduce it as an explicit descriptor contract with migration notes, not as an implicit default. +- Managed-directory provenance changes must include a safe compatibility path for already legitimate Wrkr-managed directories. Migration may be one-time and internal, but it must not silently authorize unrelated directories that only mimic marker contents. -Schema and versioning policy: +Schema/versioning policy: -- No schema fields are added or removed in this plan. -- No exit-code changes are allowed. -- Default execution path assumes no runtime `path_id` wire-format migration and therefore no schema version bump. -- If runtime `path_id` formatting changes, update exact-value fixtures and document downstream migration expectations in the same PR even if a schema bump is not taken. +- Preserve current scan/report/evidence/campaign output keys and exit codes. +- Prefer additive internal metadata and stricter validators over user-visible schema changes. +- If an explicit future extension-promotion field is introduced, it must be additive, documented, schema-validated, and default to non-authoritative behavior. Machine-readable error expectations: -- Runtime JSON envelopes remain unchanged. -- Scenario and contract drift must fail CI deterministically through test failures, not through best-effort warnings. -- Goldens must come from repo-local fixtures only; no CI step may depend on live external repo state. +- Ownership/provenance violations continue to return `unsafe_operation_blocked` with exit `8`. +- Invalid campaign inputs continue to return `invalid_input` with exit `6`. +- Transactional scan failures continue to return `runtime_failure` or `invalid_input` according to the failing step, but must leave managed artifacts untouched on failure. +- No story may convert these failure classes into warnings or partial successes. + +--- ## Docs and OSS Readiness Baseline README first-screen contract: -- Lead with bounded AI-connected software-delivery paths, risky ones first, and offline-verifiable proof. -- Do not imply runtime provenance, live observation, or control-layer enforcement. -- Keep evaluator-safe and scenario-first commands explicit before widening to org scans. +- Wrkr remains an open-source deterministic scanner for AI tooling posture and proof artifacts. +- Do not imply runtime observation, live enforcement, or control-plane behavior. +- Quickstart remains integration-first: scan, report, evidence, verify. Integration-first docs flow: -- `wrkr scan --path ./scenarios/wrkr/scan-mixed-org/repos --profile assessment --json` +- `wrkr scan --path ./scenarios/wrkr/scan-mixed-org/repos --json` - `wrkr report --state ./.wrkr/last-scan.json --json` - `wrkr evidence --frameworks eu-ai-act,soc2 --state ./.wrkr/last-scan.json --json` - `wrkr verify --chain --state ./.wrkr/last-scan.json --json` Lifecycle path model: -- discovery -- path/risk correlation -- bounded assessment prioritization -- saved-state report rendering -- offline proof generation and verification - -No story in this plan may turn Wrkr into a live observation or enforcement surface. +- scan and source acquisition +- deterministic findings, inventory, identity, and risk shaping +- authoritative state/proof/manifest publish +- report, campaign, evidence, and verify read from committed state only Docs source-of-truth mapping: -- product promise and claim boundary: `README.md`, `product/wrkr.md` -- scan contract: `docs/commands/scan.md` -- report contract: `docs/commands/report.md` -- operator examples: `docs/examples/security-team.md`, `docs/examples/operator-playbooks.md` -- docs source-of-truth coordination: `docs/map.md` +- command contracts: `docs/commands/*.md` +- lifecycle and artifact semantics: `docs/state_lifecycle.md` +- extension contract: `docs/extensions/detectors.md` +- trust posture and compatibility language: `docs/trust/*.md` +- public overview and install parity: `README.md`, `docs/install/minimal-dependencies.md` OSS trust baseline: -- `CHANGELOG.md` must be updated for any story marked `Changelog impact: required` -- `CONTRIBUTING.md` remains the contributor-facing public contract wording policy -- `SECURITY.md` remains aligned when public trust or support expectations shift -- no story may weaken deterministic or offline-first trust posture to improve test convenience +- `CHANGELOG.md` must be updated for every story marked `Changelog impact: required` +- `SECURITY.md` remains aligned when public safety posture or operator expectations change +- `CONTRIBUTING.md` remains the contributor-facing policy source if workflow expectations shift +- issue and PR templates are unaffected unless implementation changes maintainer expectations; otherwise leave unchanged + +--- ## Recommendation Traceability -| # | Recommendation | Strategic direction / benefit | Story IDs | -|---|---|---|---| -| 1 | Resolve the `path_id` public contract drift | Restore trust in the public govern-first contract without unnecessary runtime churn | `GAP-01` | -| 2 | Add checked-in FO-14/FO-15 CLI/report goldens | Make first-offer regressions fail on concrete output drift, not only broad heuristics | `GAP-02` | -| 3 | Enforce FO coverage-map keys | Keep first-offer scenario intent part of the executable scenario contract | `GAP-03` | -| 4 | Add direct `core/report` assessment tests | Match the original plan's promised package-level evidence for AI-path-first behavior | `GAP-04` | -| 5 | Add direct profile/help tests for `assessment` | Lock the additive profile into builtin loading and help surfaces without relying only on CLI integration tests | `GAP-05` | +| Recommendation | Why now | Strategic direction | Expected moat / benefit | Story IDs | +|---|---|---|---|---| +| Replace forgeable marker-file trust on destructive output paths | Prevent local data destruction and meet fail-closed ownership rules | Hard safety boundary for managed artifacts | Safer OSS adoption and higher operator trust in local execution | `SAFE-01`, `DOCS-01` | +| Make scan publication transactional across state, proof, manifest, and late artifacts | Eliminate mixed-generation authoritative state after failed scans | Deterministic state commit semantics | Stronger CI/operator reliability and proof integrity confidence | `SAFE-02`, `DOCS-01` | +| Reject non-scan JSON in campaign aggregation | Restore campaign input contract and prevent bogus summaries | Contract-first machine-readable validation | Safer automation and cleaner downstream posture reporting | `CONTRACT-01`, `DOCS-01` | +| Stop extension findings from entering authoritative surfaces by default | Block false identities, proof records, and regress drift | Preserve core-authority boundaries | Lower noise, more trustworthy proof/regress outputs, less fork pressure | `BOUNDARY-01`, `DOCS-01` | + +--- ## Test Matrix Wiring -| Lane | Purpose | Commands / Evidence | -|---|---|---| -| Fast lane | Quick author feedback for targeted contract/test work | `make lint-fast`; targeted `go test ./core/risk ./core/report ./core/cli ./core/policy/profile -count=1` | -| Core CI lane | Full architecture, contract, CLI, and docs/help gate | `make prepush`; `make prepush-full` | -| Acceptance lane | Scenario and contract behavior from outside-in fixtures | `scripts/validate_scenarios.sh`; `go test ./internal/scenarios -count=1 -tags=scenario`; `go test ./testinfra/contracts -count=1`; `scripts/run_v1_acceptance.sh --mode=local` | -| Cross-platform lane | Windows-safe CLI/help/test behavior | required `windows-smoke` workflow plus only cross-platform-safe test fixtures | -| Risk lane | Regression lock for report/risk/assessment output | `make test-risk-lane` | +Lane definitions: + +- Fast lane: `make lint-fast`, targeted `go test` for touched packages, and narrow docs parity checks when public wording moves. +- Core CI lane: `make prepush` plus `make prepush-full` for architecture/risk/failure stories. +- Acceptance lane: `make test-contracts`, `make test-scenarios`, and targeted e2e coverage for the changed command surfaces. +- Cross-platform lane: `windows-smoke` plus any touched Go tests that are expected to remain platform-safe. +- Risk lane: `make test-risk-lane`; for reliability/failure stories, explicitly include `make test-hardening` and `make test-chaos`. + +Story-to-lane map: + +| Story | Fast | Core CI | Acceptance | Cross-platform | Risk | +|---|---|---|---|---|---| +| `SAFE-01` | Yes | Yes | Yes | Yes | Yes | +| `SAFE-02` | Yes | Yes | Yes | Yes | Yes | +| `CONTRACT-01` | Yes | Yes | Yes | Yes | No | +| `BOUNDARY-01` | Yes | Yes | Yes | Yes | Yes | +| `DOCS-01` | Yes | Yes | Yes | No | No | -Merge and release gating rule: +Merge/release gating rule: -- Required PR checks remain `fast-lane` and `windows-smoke`. -- Stories marked `Core CI lane: required` must not merge unless `make prepush-full` passes locally and the equivalent CI lanes are green. -- Stories marked `Risk lane: required` must also pass `make test-risk-lane`. -- Stories touching scenario contracts or docs/help parity must keep `scripts/validate_scenarios.sh`, `make test-docs-consistency`, and `make test-docs-storyline` green where applicable. +- Any story with `Core CI lane: required` must not merge unless `make prepush-full` passes locally and the equivalent CI lanes are green. +- Any story with `Risk lane: required` must also keep `make test-risk-lane` green. +- Release tags remain blocked on the existing release workflow, but these stories must keep release-path docs, contracts, and binary validation consistent with the current pinned toolchain and scanner regime. -## Epic WRKR-GAP-EPIC-1: Contract Alignment and First-Offer Regression Lock +--- -Objective: close the remaining public-contract and regression-harness gaps so the already-shipped first-offer behavior is documented, validated, and locked against drift. +## Epic WRKR-HARDEN-1: Managed Artifact Safety and Transactional Publishing -### Story GAP-01: Ratify and align the public `path_id` contract +Objective: remove destructive marker spoofing and mixed-generation scan state by hardening ownership validation and atomic publication semantics before any later contract fixes land. + +### Story SAFE-01: Replace marker-name-only trust with provenance-gated managed artifact ownership Priority: P0 Tasks: -- Decide and document the canonical public `path_id` contract, defaulting to the shipped `apc-` opaque identifier unless a real downstream migration need is found. -- Align scan/report docs and contract tests so they no longer describe `path_id` as lowercase hex-only when runtime keeps the prefix. -- Add or tighten tests that assert opacity, uniqueness, determinism, and `action_path_to_control_first` alignment without encouraging downstream parsing of `path_id`. -- Update any exact-value fixtures or comments that still carry the old wording. +- Design a provenance model for Wrkr-managed directories that is stronger than marker filename plus static contents and is compatible with the current state-directory layout. +- Implement the new ownership gate first on evidence output directories, then apply the same safety rule to the equivalent scan-owned managed roots touched by this review if they share the same destructive trust pattern. +- Preserve current fail-closed rejections for symlink markers, directory markers, non-empty unmanaged directories, and root-escaping path tricks. +- Add compatibility handling for already legitimate Wrkr-managed directories so users can rerun commands without manual cleanup, while forged legacy markers remain blocked. +- Add deterministic tests for spoofed markers, symlink markers, directory markers, mismatched provenance, valid managed reruns, and publish rollback after failed stage promotion. Repo paths: -- `core/risk/action_paths.go` -- `core/risk/action_paths_test.go` -- `core/cli/report_contract_test.go` -- `testinfra/contracts/story1_contracts_test.go` -- `docs/commands/scan.md` -- `docs/commands/report.md` -- `README.md` +- `core/evidence/stage.go` +- `core/evidence/evidence.go` +- `core/cli/scan_helpers.go` +- `core/source/org/checkpoint.go` +- `core/evidence/evidence_test.go` +- `internal/scenarios/permission_failure_surfacing_scenario_test.go` +- `docs/commands/evidence.md` +- `docs/state_lifecycle.md` Run commands: -- `go test ./core/risk ./core/cli ./testinfra/contracts -count=1` -- `make test-docs-consistency` +- `go test ./core/evidence ./core/source/org ./core/cli -count=1` +- `make test-contracts` - `make prepush-full` +- `make test-hardening` +- `make test-chaos` Test requirements: -- repeat-run stability tests for `path_id` -- uniqueness tests on deduped `action_paths` -- contract assertions that `action_path_to_control_first.path.path_id` references an emitted row -- docs/help parity checks for any touched public contract wording +- gate/policy/fail-closed fixtures for `non-empty + non-managed => fail` +- marker trust tests proving marker must be a regular file and that symlink/directory markers fail +- crash-safe publish tests for failed stage swap and rollback restore +- repeat-run determinism checks for valid managed reruns +- scenario coverage for unsafe local path handling Matrix wiring: - Fast lane: required - Core CI lane: required @@ -231,51 +232,59 @@ Matrix wiring: - Cross-platform lane: required - Risk lane: required Acceptance criteria: -- no remaining public surface claims hex-only `path_id` if runtime keeps `apc-` -- `path_id` remains opaque, deterministic, and repeat-run stable -- `action_path_to_control_first` remains aligned with emitted `action_paths` -- if implementation chooses a runtime format change instead, all exact-value fixtures and migration notes are updated in the same change +- Evidence output reuse is denied for directories that only mimic the old marker contents. +- Legitimate Wrkr-managed output directories continue to rerun safely under the migrated provenance scheme. +- No destructive command path authorizes deletion based only on marker filename and static content. +- Public docs describe the corrected ownership semantics without weakening fail-closed guarantees. Changelog impact: required -Changelog section: Changed -Draft changelog entry: Clarified the public `action_paths[*].path_id` contract and aligned docs and contract tests with the shipped deterministic identifier format. +Changelog section: Security +Draft changelog entry: Hardened managed output and scan-owned directory ownership checks so forged marker files can no longer authorize destructive reuse of caller-selected paths. Semver marker override: none -Contract/API impact: public contract wording and exact-value test fixtures align to the canonical `path_id` format; field name and opacity guarantee remain unchanged. -Versioning/migration impact: no schema migration if runtime format stays unchanged; a runtime format change requires coordinated fixture updates and downstream migration notes in the same PR. +Contract/API impact: Public flags and exit codes stay the same; `unsafe_operation_blocked` remains the failure class for unsafe ownership reuse, but the acceptance rule becomes stricter and correct. +Versioning/migration impact: No schema bump planned. Implementation must include an explicit compatibility path for legitimate legacy managed directories and block forged legacy markers. Architecture constraints: -- keep `path_id` generation authoritative in `core/risk` -- preserve opacity; downstream consumers must not infer semantics from the string format -- keep determinism and uniqueness explicit in tests, not implied by comments -ADR required: no +- Keep ownership/provenance logic in a focused helper rather than duplicating ad hoc marker checks. +- Preserve explicit side-effect semantics in API names for validate, stage, publish, and cleanup steps. +- Do not let CLI orchestration own provenance policy; core packages remain authoritative. +- Keep extension points available for other managed-root callers without widening trust by default. +ADR required: yes TDD first failing test(s): -- `core/risk/action_paths_test.go`: explicit `path_id` opacity/stability invariant -- `testinfra/contracts/story1_contracts_test.go`: contract wording and emitted `path_id` alignment +- `go test ./core/evidence -run 'TestBuildEvidenceRejectsForgedLegacyMarker$|TestBuildEvidenceAcceptsMigratedManagedOutput$|TestBuildEvidenceRejectsMarkerSymlink$' -count=1` +- `go test ./core/source/org -run 'TestPrepareCheckpointRootRejectsForgedManagedRoot$' -count=1` Cost/perf impact: low -Chaos/failure hypothesis: If downstream automation starts relying on parseable `path_id` structure, Wrkr must still keep the identifier opaque and stable rather than letting accidental formatting drift become a hidden contract. +Chaos/failure hypothesis: If a caller selects a pre-populated path that only impersonates a Wrkr-managed directory, Wrkr must abort without deleting or replacing any unrelated files. -### Story GAP-02: Add deterministic first-offer CLI and report goldens +### Story SAFE-02: Make scan publication transactional across snapshot, proof, manifest, and requested sidecars Priority: P0 +Dependencies: `SAFE-01` if the provenance helper is shared; otherwise independent Tasks: -- Add committed scan/report goldens for `first-offer-noise-pack`, `first-offer-mixed-governance`, and the duplicate-path fixture path where exact output structure matters. -- Capture both the standard-versus-assessment comparison and the AI-path-first report usefulness shape in checked-in expected artifacts. -- Make scenario tests compare structured outputs against those goldens rather than only checking coarse conditions like count reduction or first finding type. -- Keep goldens small, deterministic, and derived only from repo-local fixtures. +- Introduce a scan-owned managed-artifact transaction helper that can snapshot, stage, and roll back the full authoritative artifact set. +- Convert `wrkr scan` publication order so snapshot, lifecycle chain, proof chain/attestation, manifest, `--json-path`, `--report-md`, and `--sarif` are committed as one managed generation. +- Ensure late failures in report, SARIF, or JSON-path publication leave the previous generation untouched and do not expose mixed outputs. +- Align scan-side transaction handling with the rollback discipline already used by manual identity transitions. +- Add deterministic tests for late artifact failure, prior-generation preservation, repeat-run byte stability, and no duplicate lifecycle/proof side effects after retry. Repo paths: -- `scenarios/wrkr/first-offer-noise-pack` -- `scenarios/wrkr/first-offer-mixed-governance` -- `scenarios/wrkr/first-offer-duplicate-paths` -- `internal/scenarios/first_offer_regression_scenario_test.go` -- `testinfra/contracts/story24_contracts_test.go` +- `core/cli/scan.go` +- `core/cli/managed_artifacts.go` +- `core/cli/jsonmode.go` +- `core/cli/report_artifacts.go` +- `core/cli/scan_*_test.go` +- `internal/e2e/cli_contract/cli_contract_e2e_test.go` +- `docs/commands/scan.md` +- `docs/state_lifecycle.md` Run commands: -- `scripts/validate_scenarios.sh` -- `go test ./internal/scenarios -count=1 -tags=scenario` -- `go test ./testinfra/contracts -count=1` -- `scripts/run_v1_acceptance.sh --mode=local` +- `go test ./core/cli ./internal/e2e/cli_contract ./internal/e2e/verify -count=1` +- `make test-contracts` - `make prepush-full` +- `make test-hardening` +- `make test-chaos` Test requirements: -- committed expected scan/report artifacts for the dedicated FO packs -- structured golden comparisons for standard versus assessment outputs -- deterministic golden checks for AI-path-first `top_risks` and `action_path_to_control_first` -- no-network scenario validation +- CLI help/usage and `--json` stability tests +- machine-readable error-envelope checks for late artifact failures +- lifecycle tests proving prior generation survives a failed late write +- crash-safe and atomic-write tests for transactional publish +- deterministic repeat-run tests for state/proof/manifest bundles +- contract tests proving invalid artifact paths do not mutate managed state Matrix wiring: - Fast lane: required - Core CI lane: required @@ -283,200 +292,255 @@ Matrix wiring: - Cross-platform lane: required - Risk lane: required Acceptance criteria: -- first-offer packs have checked-in deterministic scan/report goldens -- FO regression tests fail on substantive output drift, not only on coarse count checks -- standard-versus-assessment behavior is visible in committed expected artifacts -- no new golden depends on live external state -Changelog impact: not required -Changelog section: none +- A failed late artifact write leaves the prior snapshot, proof chain, lifecycle chain, and manifest unchanged. +- Successful scans publish a single coherent generation across all requested managed artifacts. +- Retrying after a failed late write does not duplicate lifecycle or proof records. +- Public scan docs no longer claim semantics the runtime does not enforce. +Changelog impact: required +Changelog section: Fixed +Draft changelog entry: Made scan artifact publication transactional so failed late writes no longer leave mixed state, proof, and manifest generations on disk. Semver marker override: none +Contract/API impact: No flag or schema changes are planned; the contract change is stricter state-safety under existing exit-code and error-envelope behavior. +Versioning/migration impact: No version bump planned. Existing artifact locations remain stable; only commit ordering and rollback behavior change. Architecture constraints: -- scenario goldens remain outside-in evidence, not implementation-detail fixtures -- expected artifacts must stay deterministic and portable -- goldens should validate structured facts first and prose second where possible -ADR required: no +- Keep transaction orchestration thin in `core/cli/scan.go`; snapshot/rollback mechanics belong in focused helpers. +- Preserve explicit `validate`, `stage`, `publish`, and `rollback` semantics rather than hidden side effects. +- Maintain cancellation and timeout propagation through staged publish paths. +- Do not special-case optional sidecars in ways that weaken the authoritative commit rule. +ADR required: yes TDD first failing test(s): -- `internal/scenarios/first_offer_regression_scenario_test.go`: golden mismatch for FO noise-pack scan output -- `testinfra/contracts/story24_contracts_test.go`: golden mismatch for AI-path-first report usefulness output -Cost/perf impact: low -Chaos/failure hypothesis: If a later change quietly reintroduces noisy or secret-first first-offer output, the committed goldens must fail before the regression reaches a release. -Dependencies: -- `GAP-01` +- `go test ./core/cli -run 'TestScanLateReportFailureRollsBackManagedArtifacts$|TestScanLateSARIFFailureRollsBackManagedArtifacts$|TestScanJSONPathFailureLeavesPreviousGenerationUntouched$' -count=1` +- `go test ./internal/e2e/cli_contract -run 'TestE2EScanTransactionalPublish$' -count=1` +Cost/perf impact: medium +Chaos/failure hypothesis: If scan succeeds through snapshot/proof generation but fails while writing a requested sidecar artifact, Wrkr must exit non-zero and leave the previous managed generation intact. -### Story GAP-03: Enforce FO coverage-map keys in scenario contract validation -Priority: P1 +--- + +## Epic WRKR-HARDEN-2: Contract Input and Authoritative Boundary Enforcement + +Objective: close the machine-readable ingestion and extension-boundary leaks so only real scan artifacts and real authoritative tool surfaces can influence campaign, lifecycle, proof, and regress behavior. + +### Story CONTRACT-01: Enforce complete scan-artifact validation in `campaign aggregate` +Priority: P0 Tasks: -- Extend `TestScenarioContracts` so FO-14 and FO-15 mappings are required alongside the legacy `FR*` and `AC*` keys. -- Ensure FO mapping values are validated against real scenario test symbols the same way existing coverage-map entries are. -- Keep the coverage-map gate deterministic and centralized rather than adding one-off checks in individual scenario tests. -- Update the validation script only if needed to keep the scenario contract entrypoint consistent. +- Define the minimum scan-artifact contract required by campaign aggregation and validate it before summarization. +- Reject non-scan JSON, malformed scan JSON, and incomplete scan JSON with stable `invalid_input` behavior. +- Keep acceptance of complete scan artifacts unchanged. +- Add contract and e2e tests that explicitly use `wrkr version --json`, `wrkr report --json`, and degraded scan artifacts as negative inputs. +- Update campaign docs to align with the enforced validator rather than best-effort assumptions. Repo paths: -- `internal/scenarios/contracts_test.go` -- `internal/scenarios/coverage_map.json` -- `scripts/validate_scenarios.sh` +- `core/cli/campaign.go` +- `core/cli/campaign_test.go` +- `internal/e2e/campaign/campaign_e2e_test.go` +- `testinfra/contracts/story24_contracts_test.go` +- `docs/commands/campaign.md` Run commands: -- `scripts/validate_scenarios.sh` -- `go test ./internal/scenarios -run '^TestScenarioContracts$' -count=1 -tags=scenario` +- `go test ./core/cli ./internal/e2e/campaign ./testinfra/contracts -count=1` - `make prepush-full` +- `make test-contracts` +- `make test-scenarios` Test requirements: -- failing contract test when `FO14-*` or `FO15-*` keys are missing -- failing contract test when FO mappings reference unknown scenario symbols -- deterministic coverage-map parsing with no live repo assumptions +- CLI `--json` and exit-code contract tests +- machine-readable error-envelope checks for invalid non-scan inputs +- compatibility tests that complete scan artifacts still aggregate successfully +- fixture/golden updates for rejected malformed inputs where applicable Matrix wiring: - Fast lane: required - Core CI lane: required - Acceptance lane: required - Cross-platform lane: required -- Risk lane: required +- Risk lane: not required Acceptance criteria: -- FO-14 and FO-15 mappings are required by scenario contract validation -- missing or stale FO mappings fail CI deterministically -- coverage-map enforcement stays centralized in the scenario contract gate -- no existing legacy coverage-map checks regress -Changelog impact: not required -Changelog section: none +- `wrkr campaign aggregate --input-glob ` exits `6` with `invalid_input`. +- Complete scan artifacts continue to aggregate successfully with deterministic ordering. +- Incomplete or degraded scan artifacts remain rejected. +- Campaign docs now describe enforced validation rather than best-effort interpretation. +Changelog impact: required +Changelog section: Fixed +Draft changelog entry: `wrkr campaign aggregate` now rejects non-scan JSON and incomplete artifacts with stable `invalid_input` errors instead of summarizing them as posture evidence. Semver marker override: none +Contract/API impact: Tightens the existing public input contract for campaign aggregation without changing flags or success-envelope shape. +Versioning/migration impact: No version bump planned. Consumers relying on accidental acceptance of non-scan JSON must migrate to passing real `wrkr scan --json` artifacts. Architecture constraints: -- keep scenario contract enforcement in `internal/scenarios/contracts_test.go` -- avoid duplicating mapping logic across scripts and tests unless required for a single entrypoint -- preserve deterministic symbol discovery and failure messaging +- Keep the validator as a focused contract-check helper rather than mixing validation and aggregation. +- Preserve symmetric semantics: parse and validate before summarize. +- Do not add network or external dependency lookups to infer missing fields. ADR required: no TDD first failing test(s): -- `internal/scenarios/contracts_test.go`: missing `FO14-*` and `FO15-*` keys fail the scenario contract gate +- `go test ./core/cli -run 'TestCampaignAggregateRejectsVersionEnvelope$|TestCampaignAggregateRejectsReportEnvelope$|TestCampaignAggregateRejectsMalformedScanArtifact$' -count=1` +- `go test ./internal/e2e/campaign -run 'TestCampaignAggregateRequiresRealScanArtifacts$' -count=1` Cost/perf impact: low -Chaos/failure hypothesis: If a future refactor drops a first-offer scenario or renames a test, the coverage-map gate must fail immediately instead of leaving the regression harness partially disconnected. -Dependencies: -- `GAP-02` - -## Epic WRKR-GAP-EPIC-2: Direct Package-Level Coverage Closure - -Objective: add the direct package-level test evidence the first-offer plan promised so report/profile/help behavior is locked closer to the implementation boundary and not only through higher-level CLI contracts. +Chaos/failure hypothesis: If the input glob accidentally matches a non-scan JSON file, campaign aggregation must fail deterministically before any summary artifact is emitted. -### Story GAP-04: Add direct `core/report` coverage for assessment summaries and AI-path-first output -Priority: P1 +### Story BOUNDARY-01: Keep extension findings out of authoritative tool, identity, and regress surfaces by default +Priority: P0 Tasks: -- Add unit tests in `core/report/report_test.go` that construct minimal deterministic report inputs with govern-first `action_paths`. -- Assert that `assessment_summary` is present, additive, and aligned with `action_path_to_control_first`. -- Assert that AI-path-present summaries lead with `finding_type=action_path` in direct report-building tests rather than only through CLI contract fixtures. -- Keep tests small and inline where practical so they validate report behavior without depending on the full scenario harness. +- Remove the implicit rule that any extension finding with a non-excluded `tool_type` is inventory-bearing and identity-bearing. +- Preserve extension findings in raw `findings` and risk outputs unless a future explicit contract promotes them. +- Add regression tests showing extension-only repositories do not emit tool records, manifest identities, `agent_privilege_map`, `action_paths`, or regress drift by default. +- Decide and document the future extension-promotion path as an explicit follow-up contract, not a hidden default. +- Update extension and scan docs to match the corrected behavior. Repo paths: -- `core/report/report_test.go` -- `core/report/build.go` -- `core/cli/report_contract_test.go` +- `core/model/identity_bearing.go` +- `core/model/identity_bearing_test.go` +- `core/aggregate/inventory/inventory_test.go` +- `core/regress/regress_test.go` +- `core/cli/scan_observed_tools_test.go` +- `core/cli/scan_agent_context_test.go` +- `docs/extensions/detectors.md` +- `docs/commands/scan.md` Run commands: -- `go test ./core/report ./core/cli -count=1` +- `go test ./core/model ./core/aggregate/inventory ./core/regress ./core/cli -count=1` - `make prepush-full` +- `make test-hardening` +- `make test-chaos` +- `make test-scenarios` Test requirements: -- deterministic report-building fixtures with `action_paths` -- direct assertions on additive `assessment_summary` -- direct assertions on top-risk ordering when AI action paths are present -- stable alignment checks between summary facts and `action_path_to_control_first` +- deterministic classifier fixtures for identity-bearing vs non-identity-bearing findings +- regress drift tests proving extension-only repos do not create false tool state +- CLI and scan contract tests showing extension findings stay visible in `findings` but absent from authoritative surfaces +- docs parity checks for corrected extension semantics Matrix wiring: - Fast lane: required - Core CI lane: required -- Acceptance lane: not required +- Acceptance lane: required - Cross-platform lane: required - Risk lane: required Acceptance criteria: -- `core/report/report_test.go` directly covers additive `assessment_summary` -- direct report tests prove AI-path-first summary ordering when action paths exist -- `action_path_to_control_first` remains aligned with report summary facts -- existing CLI contract tests remain additive and green -Changelog impact: not required -Changelog section: none +- Extension findings remain visible in scan findings and risk ranking. +- Extension-only repos no longer create tool identities, privilege-map rows, action paths, or regress baseline entries by default. +- Docs stop claiming extension findings are additive while runtime still promotes them into authoritative state. +- Future authoritative extension support is explicitly deferred or separately versioned. +Changelog impact: required +Changelog section: Fixed +Draft changelog entry: Repo-local extension detectors now stay on additive finding surfaces by default and no longer create implicit tool identities, action paths, or regress state. Semver marker override: none +Contract/API impact: Narrows authoritative-surface behavior to match documented boundaries while preserving raw extension findings. +Versioning/migration impact: No schema bump planned. Consumers that depended on undocumented extension-generated identities must migrate to raw findings until a future explicit promotion contract exists. Architecture constraints: -- keep report shaping logic tested at the `core/report` boundary -- do not move ranking logic out of `core/risk` just to satisfy tests -- prefer compact deterministic fixtures over scenario-scale setup for package tests -ADR required: no +- Keep authoritative-surface classification centralized and explicit. +- Do not let detector-local logic decide lifecycle or regress authority by itself. +- Preserve room for a future explicit extension point without reintroducing silent promotion. +- Avoid boundary leakage from detection straight into identity or regress. +ADR required: yes TDD first failing test(s): -- `core/report/report_test.go`: `assessment_summary` present and path-centric -- `core/report/report_test.go`: top summary risk is `action_path` when govern-first paths exist +- `go test ./core/model -run 'TestIsIdentityBearingFindingExtensionDefaultsToFalse$|TestIsInventoryBearingFindingExtensionDefaultsToFalse$' -count=1` +- `go test ./core/cli -run 'TestScanExtensionFindingDoesNotEmitAuthoritativeSurfaces$' -count=1` +- `go test ./core/regress -run 'TestExtensionOnlyFindingDoesNotCreateDriftReason$' -count=1` Cost/perf impact: low -Chaos/failure hypothesis: If later report refactors accidentally fall back to generic finding-first output, direct report package tests must fail even before the broader CLI contract suite runs. +Chaos/failure hypothesis: If a repository adds a custom extension descriptor with an arbitrary `tool_type`, Wrkr must not let that finding create approval gaps, tool identities, or regress drift without an explicit future promotion contract. + +--- + +## Epic WRKR-HARDEN-3: Docs, Changelog, and Acceptance Lock-In -### Story GAP-05: Add direct builtin and help-surface coverage for `assessment` -Priority: P2 +Objective: codify the corrected semantics in user-facing docs, trust guidance, changelog language, and durable regression suites after the runtime behavior is fixed. + +### Story DOCS-01: Align docs, changelog, and executable regression coverage with the hardened runtime +Priority: P1 +Dependencies: `SAFE-01`, `SAFE-02`, `CONTRACT-01`, `BOUNDARY-01` Tasks: -- Extend `core/policy/profile/profile_test.go` so builtin profile loading explicitly includes `assessment`. -- Add direct help-surface assertions that the scan profile help text includes `assessment` and stays aligned with the CLI flag contract. -- Keep these tests cross-platform-safe and independent of scenario fixtures. -- Update docs/help parity tests only if a real mismatch is found while adding the direct assertions. +- Update command docs, trust pages, and lifecycle docs so ownership gating, transactional scan publication, campaign input validation, and extension-boundary semantics all match runtime behavior. +- Add or update scenario, e2e, and contract tests that lock in the four reproduced regressions from the review. +- Update `CHANGELOG.md` `## [Unreleased]` with operator-facing entries that reflect the corrected safety and contract behavior. +- Review README/install/trust wording to ensure no page promises weaker or stronger semantics than the implemented runtime. +- Verify docs-site parity for any touched command/trust pages. Repo paths: -- `core/policy/profile/profile_test.go` -- `core/cli/root_test.go` -- `core/cli/scan.go` - `docs/commands/scan.md` +- `docs/commands/evidence.md` +- `docs/commands/campaign.md` +- `docs/extensions/detectors.md` +- `docs/state_lifecycle.md` +- `docs/trust/compatibility-and-versioning.md` +- `docs/trust/contracts-and-schemas.md` +- `CHANGELOG.md` +- `internal/scenarios/*` +- `testinfra/contracts/*` +- `docs-site/src/lib/docs.ts` +- `docs-site/src/lib/markdown.ts` Run commands: -- `go test ./core/policy/profile ./core/cli -count=1` - `make test-docs-consistency` -- `make prepush-full` +- `make test-docs-storyline` +- `make docs-site-build` +- `make docs-site-check` +- `make test-contracts` +- `make test-scenarios` +- `scripts/run_v1_acceptance.sh --mode=local` Test requirements: -- explicit builtin load test for `assessment` -- help/usage assertion for `posture profile [baseline|standard|strict|assessment]` -- docs/help parity checks if help text or docs are touched -- deterministic invalid-input or help-output coverage only if new assertions require it +- docs consistency, storyline, and smoke checks +- README first-screen and integration-first flow checks for touched flows +- scenario and contract updates for the four reproduced regressions +- changelog and OSS trust surface verification where touched Matrix wiring: - Fast lane: required - Core CI lane: required -- Acceptance lane: not required -- Cross-platform lane: required +- Acceptance lane: required +- Cross-platform lane: not required - Risk lane: not required Acceptance criteria: -- builtin profile tests explicitly include `assessment` -- scan help tests directly assert the `assessment` profile surface -- docs and help remain aligned if any wording changes are needed -- no scenario-only dependency is required to prove the additive profile is present -Changelog impact: not required -Changelog section: none +- Public docs and trust pages no longer contradict runtime semantics for the four blocker areas. +- `CHANGELOG.md` contains operator-facing entries for the user-visible behavior changes. +- Scenario and contract tests fail if any of the four corrected regressions reappear. +- Docs-site and command docs remain in sync. +Changelog impact: required +Changelog section: Changed +Draft changelog entry: Updated scan, evidence, campaign, and extension-detector docs plus regression coverage to match the hardened contract and boundary behavior. Semver marker override: none +Contract/API impact: No new runtime contract is introduced here; this story aligns the public documentation and regression suite with the implemented fixes. +Versioning/migration impact: No version bump planned. This story documents migration expectations already declared in the runtime stories. Architecture constraints: -- keep builtin profile coverage in `core/policy/profile` -- keep help-surface coverage close to the CLI entrypoint -- avoid introducing docs or help text drift while adding direct tests +- Keep docs as executable contract companions to the runtime. +- Do not move normative behavior into docs without matching enforcement in code and tests. +- Preserve README first-screen positioning inside Wrkr's static posture boundary. ADR required: no TDD first failing test(s): -- `core/policy/profile/profile_test.go`: builtin `assessment` load expectation -- `core/cli/root_test.go`: scan help includes `assessment` +- `go test ./testinfra/contracts -run 'TestCampaignContractRejectsNonScanInputs|TestScanContractLateArtifactFailureDoesNotMutateManagedState' -count=1` +- `go test ./internal/scenarios -run 'TestExtensionFindingStaysNonAuthoritative|TestEvidenceRejectsForgedManagedOutput' -count=1 -tags=scenario` +- `make test-docs-consistency` Cost/perf impact: low +Chaos/failure hypothesis: If a future change reintroduces one of these boundary or ownership regressions, docs parity and executable regression suites must fail before release. + +--- ## Minimum-Now Sequence -Wave 1: contract alignment and regression-gate hardening +Wave 1: + +- `SAFE-01` to close destructive ownership spoofing and establish the shared provenance rule. +- `SAFE-02` to make scan publication atomic once managed-artifact safety primitives are in place. -- `GAP-01` Ratify and align the public `path_id` contract -- `GAP-02` Add deterministic first-offer CLI and report goldens -- `GAP-03` Enforce FO coverage-map keys in scenario contract validation +Wave 2: -Wave 2: direct package-level coverage closure +- `CONTRACT-01` to enforce real scan-artifact validation for campaign aggregation. +- `BOUNDARY-01` to remove extension findings from authoritative state by default. -- `GAP-04` Add direct `core/report` coverage for assessment summaries and AI-path-first output -- `GAP-05` Add direct builtin and help-surface coverage for `assessment` +Wave 3: -Minimum-now gap-closure point: +- `DOCS-01` after Waves 1 and 2 settle the runtime semantics, so docs, changelog, and executable regressions lock the final behavior rather than intermediate drafts. -- After Wave 1 is green, the remaining public-contract and regression-harness drift is closed. -- After Wave 2 is green, the original plan's promised package-level test evidence is fully present. +Parallelization notes: + +- `CONTRACT-01` and `BOUNDARY-01` can run in parallel after Wave 1 because their write scopes are largely disjoint. +- `DOCS-01` must follow the runtime stories so wording and golden expectations are anchored to shipped behavior. + +--- ## Explicit Non-Goals -- Reopening first-offer feature scope that is already implemented and green -- Adding new govern-first fields, report sections, or profile behavior beyond the identified gaps -- Runtime provenance, live observation, selective gating, or enforcement claims -- Live-network regression fixtures or CI dependencies -- Changing exit codes, proof record types, or raw findings behavior -- Re-ranking govern-first logic for product reasons unrelated to the identified gaps -- General repo cleanup unrelated to these stories, including `scripts/__pycache__/` +- No new dashboard, web control plane, or hosted runtime scope. +- No expansion of detector coverage beyond what is needed to fix the extension-boundary leak. +- No new proof-record types, schema version bumps, or exit-code renumbering unless a later implementation proves them unavoidable and ships a migration plan. +- No unrelated CI workflow renames, packaging changes, or toolchain pin updates unless directly required by these fixes. +- No runtime observation or enforcement features; Wrkr remains in the See boundary. + +--- ## Definition of Done -- Every gap recommendation maps to at least one implemented story and a green test signal. -- `path_id` contract wording no longer conflicts with shipped behavior. -- FO-14 and FO-15 scenario packs have deterministic committed goldens and enforced coverage-map mappings. -- Direct `core/report`, `core/policy/profile`, and CLI help tests exist for the promised assessment behavior. -- `make prepush-full` is green for stories that touch contract/report/CLI/gate surfaces. -- `make test-risk-lane` is green for the regression-harness and report-risk stories. -- `make test-docs-consistency` and `make test-docs-storyline` remain green for any touched docs/help wording. -- No story widens Wrkr beyond its static-posture and offline-proof boundary. -- Implementation follow-up explicitly scopes or cleans unrelated dirty files before code changes. +- Every recommendation above maps to at least one completed story and all required lanes for those stories are green. +- Acceptance criteria are proven by deterministic tests or docs/build gates, not by manual narrative alone. +- Public docs, trust pages, and changelog entries match the implemented semantics in the same PR. +- `make prepush-full` is green for every architecture/failure-semantics story. +- Reliability stories also keep `make test-hardening` and `make test-chaos` green. +- No story weakens offline-first defaults, fail-closed behavior, proof integrity, schema stability, or exit-code stability. +- Follow-on implementation can start from this file without guessing story order, test scope, or changelog intent. From 6e5de45aa92e82cf488c63c8e5a677a69426fa47 Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Tue, 31 Mar 2026 13:46:39 -0400 Subject: [PATCH 2/4] fix: address actionable CI failures --- core/cli/scan_transaction_test.go | 5 +++++ internal/managedmarker/managedmarker.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/cli/scan_transaction_test.go b/core/cli/scan_transaction_test.go index 410d9e9..830b521 100644 --- a/core/cli/scan_transaction_test.go +++ b/core/cli/scan_transaction_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" "github.com/Clyra-AI/wrkr/core/lifecycle" @@ -144,6 +145,10 @@ func TestScanLateSARIFWriteFailureRollsBackManagedArtifacts(t *testing.T) { func TestScanLateJSONPathWriteFailureRollsBackManagedArtifacts(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("chmod-based write failure fixture is not portable on windows") + } + tmp := t.TempDir() reposPath := filepath.Join(tmp, "repos") if err := os.MkdirAll(filepath.Join(reposPath, "alpha"), 0o755); err != nil { diff --git a/internal/managedmarker/managedmarker.go b/internal/managedmarker/managedmarker.go index db99b7b..21828c2 100644 --- a/internal/managedmarker/managedmarker.go +++ b/internal/managedmarker/managedmarker.go @@ -15,7 +15,7 @@ import ( ) const version = "v2" -const tokenFileName = ".wrkr-managed-token" +const tokenFileName = ".wrkr-managed-token" // #nosec G101 -- filename constant for local token storage, not credential material. type payload struct { Version string `json:"version"` From 2a4d735d79932dd230d2a9723d747726750f3524 Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Tue, 31 Mar 2026 13:59:45 -0400 Subject: [PATCH 3/4] fix: address actionable CI failures (loop 2) --- core/cli/scan_transaction_test.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/core/cli/scan_transaction_test.go b/core/cli/scan_transaction_test.go index 830b521..dcbaf08 100644 --- a/core/cli/scan_transaction_test.go +++ b/core/cli/scan_transaction_test.go @@ -12,8 +12,17 @@ import ( "github.com/Clyra-AI/wrkr/core/proofemit" ) +func skipNonPortableChmodWriteFailureFixture(t *testing.T) { + t.Helper() + + if runtime.GOOS == "windows" { + t.Skip("chmod-based write failure fixture is not portable on windows") + } +} + func TestScanLateReportWriteFailureRollsBackManagedArtifacts(t *testing.T) { t.Parallel() + skipNonPortableChmodWriteFailureFixture(t) tmp := t.TempDir() reposPath := filepath.Join(tmp, "repos") @@ -79,6 +88,7 @@ func TestScanLateReportWriteFailureRollsBackManagedArtifacts(t *testing.T) { func TestScanLateSARIFWriteFailureRollsBackManagedArtifacts(t *testing.T) { t.Parallel() + skipNonPortableChmodWriteFailureFixture(t) tmp := t.TempDir() reposPath := filepath.Join(tmp, "repos") @@ -144,10 +154,7 @@ func TestScanLateSARIFWriteFailureRollsBackManagedArtifacts(t *testing.T) { func TestScanLateJSONPathWriteFailureRollsBackManagedArtifacts(t *testing.T) { t.Parallel() - - if runtime.GOOS == "windows" { - t.Skip("chmod-based write failure fixture is not portable on windows") - } + skipNonPortableChmodWriteFailureFixture(t) tmp := t.TempDir() reposPath := filepath.Join(tmp, "repos") From 5216bcd772aea9a0f937b6a9dafdbfdcff322ff8 Mon Sep 17 00:00:00 2001 From: Talgat Ryshmanov Date: Tue, 31 Mar 2026 14:16:07 -0400 Subject: [PATCH 4/4] fix: address actionable CI failures (loop 3) --- core/evidence/stage.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/evidence/stage.go b/core/evidence/stage.go index 1c70318..dc9d21b 100644 --- a/core/evidence/stage.go +++ b/core/evidence/stage.go @@ -20,10 +20,6 @@ var ( removeAllHook func(path string) error ) -func validateOutputDirTarget(path string) error { - return validateOutputDirTargetWithState(path, "") -} - func validateOutputDirTargetWithState(path string, statePath string) error { cleanPath := filepath.Clean(path) info, err := os.Lstat(cleanPath)