diff --git a/cmd/coordinator/fuzz_pool.go b/cmd/coordinator/fuzz_pool.go index f99a923f..50808532 100644 --- a/cmd/coordinator/fuzz_pool.go +++ b/cmd/coordinator/fuzz_pool.go @@ -644,6 +644,13 @@ func addFuzzPoolRoutes(mux *http.ServeMux, adminToken, workerToken string, allow payload["harness_content_sha256"] = sha } payload["hunt_detect_leaks"] = work.HuntDetectLeaks + // Mutation scheduling — workers must derive the same exec inputs as replay. + if work.PowerMutCap > 0 { + payload["power_mut_cap"] = work.PowerMutCap + } + if work.HavocDeepV28 { + payload["havoc_deep_v28"] = true + } payload["shard_spec"] = map[string]any{ "iterations_per_shard": work.IterationsPerShard, "check_semantics": work.CheckSemantics, diff --git a/internal/fuzzengine/corpus_snapshot_test.go b/internal/fuzzengine/corpus_snapshot_test.go index 7e63d58f..d243fd53 100644 --- a/internal/fuzzengine/corpus_snapshot_test.go +++ b/internal/fuzzengine/corpus_snapshot_test.go @@ -33,3 +33,38 @@ func TestCorpusSnapshotRoundTrip(t *testing.T) { t.Fatalf("claim maps roundtrip failed") } } + +// Workers receive corpus seeds through the claim maps while the coordinator's +// verification replay loads the frozen snapshot; both channels must yield the +// same seed tuples or the two sides schedule different inputs. Crash seeds and +// empty-InputBytes seeds are the lossy edge cases — they must degrade +// identically on both channels. +func TestClaimMapsMatchSnapshotChannel(t *testing.T) { + seeds := []PoolCorpusSeed{ + {Input: 42, InputBytes: []byte(`{"a":1}`), Energy: 3, Edge: 7, Path: 11}, + {Input: 99, Energy: 1, Edge: 2, Path: 3, Crash: true}, // empty InputBytes + Crash flag + {InputBytes: []byte(`{"c":"x"}`), Energy: 5, Edge: 9}, // zero Input u64 + } + viaClaim, err := CorpusSeedsFromClaimMaps(CorpusSeedsClaimMaps(seeds)) + if err != nil { + t.Fatal(err) + } + snapJSON, _, err := EncodeCorpusSnapshot(seeds) + if err != nil { + t.Fatal(err) + } + viaSnapshot, err := DecodeCorpusSnapshot(snapJSON) + if err != nil { + t.Fatal(err) + } + if len(viaClaim) != len(viaSnapshot) { + t.Fatalf("channel sizes differ: claim %d vs snapshot %d", len(viaClaim), len(viaSnapshot)) + } + for i := range viaSnapshot { + a, b := viaClaim[i], viaSnapshot[i] + if a.Input != b.Input || a.Energy != b.Energy || a.Edge != b.Edge || a.Path != b.Path || + string(a.InputBytes) != string(b.InputBytes) || a.Crash != b.Crash { + t.Fatalf("seed %d differs across channels: claim %+v vs snapshot %+v", i, a, b) + } + } +} diff --git a/internal/poolfuzz/hunt_shard.go b/internal/poolfuzz/hunt_shard.go index 345fd366..258649be 100644 --- a/internal/poolfuzz/hunt_shard.go +++ b/internal/poolfuzz/hunt_shard.go @@ -47,6 +47,11 @@ func (s *Service) buildHuntClaimedWork(ctx context.Context, campaignID string, i return ClaimedWork{}, fmt.Errorf("poolfuzz: hunt harness not ready: %w", err) } iter := huntIterationsPerShard(cfg) + // Snapshot mutation scheduling before guided seeding: a seed merge can touch + // the in-memory cfg, but the claim must mirror the persisted campaign config + // the verification replay will use, so both sides derive identical inputs. + mutCap := fuzzengine.PowerMutCap(cfg) + deepV28 := fuzzengine.DeepHavocV28(cfg) now := time.Now().Unix() var inputB []byte var inputU uint64 @@ -95,6 +100,8 @@ func (s *Service) buildHuntClaimedWork(ctx context.Context, campaignID string, i HarnessContentSHA256: contentSHA, IterationsPerShard: iter, HuntDetectLeaks: hunt.DetectLeaksFromConfig(cfg), + PowerMutCap: mutCap, + HavocDeepV28: deepV28, }, nil } diff --git a/internal/poolfuzz/service.go b/internal/poolfuzz/service.go index f244b567..da089e8d 100644 --- a/internal/poolfuzz/service.go +++ b/internal/poolfuzz/service.go @@ -84,6 +84,11 @@ type ClaimedWork struct { HarnessContentSHA256 string IterationsPerShard int HuntDetectLeaks bool + // PowerMutCap / HavocDeepV28 are the mutation-scheduling values the worker + // must replay with; sent on the claim so worker exec inputs stay identical + // to the coordinator's verification replay. + PowerMutCap int + HavocDeepV28 bool } type SubmitRequest struct { diff --git a/internal/workerfuzzloop/hunt_shard.go b/internal/workerfuzzloop/hunt_shard.go index 3b737f5c..61da952e 100644 --- a/internal/workerfuzzloop/hunt_shard.go +++ b/internal/workerfuzzloop/hunt_shard.go @@ -149,5 +149,16 @@ func huntShardConfigFromClaim(cr ClaimResp, corpusGuided bool) map[string]any { if cr.HuntDetectLeaks { cfg["hunt_detect_leaks"] = true } + // Keep exec input derivation byte-identical to the coordinator's replay: + // PowerScheduleStage and the deep-havoc stack both depend on these keys. + if cr.PowerMutCap > 0 { + cfg["power_mut_cap"] = cr.PowerMutCap + } + if cr.HavocDeepV28 { + cfg["havoc_deep_v28"] = true + } + // The campaign's mutator dict is a pure function of the target id, so it can + // be rebuilt locally instead of shipped on every claim. + hunt.ApplyHuntMutatorDict(cfg, strings.TrimSpace(cr.UpstreamTargetID)) return cfg } diff --git a/internal/workerfuzzloop/hunt_shard_test.go b/internal/workerfuzzloop/hunt_shard_test.go index ab25492e..34d96f79 100644 --- a/internal/workerfuzzloop/hunt_shard_test.go +++ b/internal/workerfuzzloop/hunt_shard_test.go @@ -1,6 +1,12 @@ package workerfuzzloop -import "testing" +import ( + "bytes" + "testing" + + "hackme/internal/fuzzengine" + "hackme/internal/hunt" +) func TestIsHuntClaim(t *testing.T) { if !IsHuntClaim(ClaimResp{TaskClass: "hunt"}) { @@ -34,3 +40,115 @@ func TestHuntShardConfigFromClaim(t *testing.T) { t.Fatalf("cfg=%v", cfg) } } + +func TestHuntShardConfigCarriesMutationScheduling(t *testing.T) { + cfg := huntShardConfigFromClaim(ClaimResp{ + UpstreamTargetID: "jsmn", + MaxInputBytes: 128, + ExecPerUnit: 8, + DepthTier: "oss_cve", + PowerMutCap: 14, + HavocDeepV28: true, + }, true) + if got := fuzzengine.PowerMutCap(cfg); got != 14 { + t.Fatalf("power_mut_cap: want 14 got %d", got) + } + if !fuzzengine.DeepHavocV28(cfg) { + t.Fatal("claim with havoc_deep_v28 must enable the deep stack") + } + + legacy := huntShardConfigFromClaim(ClaimResp{ + UpstreamTargetID: "jsmn", + MaxInputBytes: 128, + ExecPerUnit: 8, + DepthTier: "oss_cve", + }, true) + if fuzzengine.DeepHavocV28(legacy) { + t.Fatal("claims without havoc_deep_v28 must keep legacy replay identity") + } + if got := fuzzengine.PowerMutCap(legacy); got != 12 { + t.Fatalf("legacy power_mut_cap default: want 12 got %d", got) + } +} + +// campaignCfgFixture mirrors what CampaignConfig persists for a JSON catalog +// target running hunt_standard under rc17.2: power schedule, deep-havoc opt-in +// and the target's static mutator dict. +func campaignCfgFixture() map[string]any { + return map[string]any{ + "upstream_target_id": "jsmn", + "max_input_bytes": 128, + "input_mode": "bytes", + "iterations_per_shard": 8, + "depth_tier": "oss_cve", + "hunt_corpus_guided": true, + "guided_scheduling": true, + "coverage_feedback_v1": true, + "corpus_explore_v2": true, + "hunt_segment_mutating": true, + "power_mut_cap": 14, + "havoc_deep_v28": true, + "mutator_dict": hunt.MutatorDictForTarget("jsmn"), + "hunt_mutator_profile": "json", + } +} + +func claimForCampaign() ClaimResp { + return ClaimResp{ + UpstreamTargetID: "jsmn", + MaxInputBytes: 128, + ExecPerUnit: 8, + DepthTier: "oss_cve", + CoverageKind: "hunt_corpus_guided", + PowerMutCap: 14, + HavocDeepV28: true, + } +} + +// The claim-rebuilt config must resolve to the same mutation inputs as the +// persisted campaign config: same power cap, same deep-havoc opt-in, and the +// same effective mutator dict (campaign static dict merged with the same +// corpus autodict tokens). Asserting the dict bytes pins the root cause +// directly - exec-input equality alone can mask a dict difference whenever the +// scheduled stages happen to avoid dict-aware operators. +func TestHuntClaimConfigMatchesCampaignMutator(t *testing.T) { + corpus := [][]byte{ + []byte(`{"a":1}`), + []byte(`{"b":[1,2,3]}`), + []byte(`{"c":"x"}`), + } + claim := huntShardConfigFromClaim(claimForCampaign(), true) + if got := fuzzengine.PowerMutCap(claim); got != 14 { + t.Fatalf("power_mut_cap: want 14 got %d", got) + } + if !fuzzengine.DeepHavocV28(claim) { + t.Fatal("claim config must opt into deep v2.8 like the campaign") + } + want := fuzzengine.EffectiveMutatorDict(campaignCfgFixture(), corpus) + got := fuzzengine.EffectiveMutatorDict(claim, corpus) + if !bytes.Equal(want, got) { + t.Fatalf("effective mutator dict diverged: campaign %d bytes vs claim %d bytes", len(want), len(got)) + } +} + +// The worker derives every exec input from the claim-reconstructed config while +// the coordinator's verification replay uses the persisted campaign config; both +// must produce identical bytes for the same (campaign, inputN, execIdx, seeds). +func TestHuntClaimConfigMatchesCampaignExecInputs(t *testing.T) { + seeds := []fuzzengine.PoolCorpusSeed{ + {InputBytes: []byte(`{"a":1}`), Energy: 2}, + {InputBytes: []byte(`{"b":[1,2,3]}`), Energy: 3}, + {InputBytes: []byte(`{"c":"x"}`), Energy: 1}, + } + campaign := campaignCfgFixture() + claim := huntShardConfigFromClaim(claimForCampaign(), true) + for inputN := uint64(0); inputN < 8; inputN++ { + for exec := uint64(0); exec < 8; exec++ { + want := hunt.ShardSegmentExecInput("camp-x", inputN, exec, campaign, seeds) + got := hunt.ShardSegmentExecInput("camp-x", inputN, exec, claim, seeds) + if string(want) != string(got) { + t.Fatalf("inputN=%d exec=%d: worker and replay inputs diverged (%d vs %d bytes)", inputN, exec, len(want), len(got)) + } + } + } +} diff --git a/internal/workerfuzzloop/loop.go b/internal/workerfuzzloop/loop.go index 838bc464..5effaca6 100644 --- a/internal/workerfuzzloop/loop.go +++ b/internal/workerfuzzloop/loop.go @@ -60,6 +60,10 @@ type ClaimResp struct { HarnessFetchURL string `json:"harness_fetch_url,omitempty"` HarnessContentSHA256 string `json:"harness_content_sha256,omitempty"` HuntDetectLeaks bool `json:"hunt_detect_leaks,omitempty"` + // PowerMutCap / HavocDeepV28 mirror the campaign's mutation scheduling so the + // worker's exec chain derives the same inputs as the coordinator's replay. + PowerMutCap int `json:"power_mut_cap,omitempty"` + HavocDeepV28 bool `json:"havoc_deep_v28,omitempty"` } // Config drives a supervised fuzz dig loop.