rail_verdict 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ module RailVerdict
29
29
  investigate Investigate top findings with optional AI
30
30
  repair Build a deterministic repair packet for a finding
31
31
  receipt Create or verify a Verification Receipt
32
+ handoff Create or verify a Verification Handoff (evidence reuse)
32
33
  mcp MCP adapter (serve)
33
34
 
34
35
  Global options:
@@ -69,8 +70,10 @@ module RailVerdict
69
70
  command_investigate(argv.drop(1))
70
71
  when "repair"
71
72
  command_repair(argv.drop(1))
72
- when "receipt"
73
+ when "receipt"
73
74
  command_receipt(argv.drop(1))
75
+ when "handoff"
76
+ command_handoff(argv.drop(1))
74
77
  when "mcp"
75
78
  command_mcp(argv.drop(1))
76
79
  else
@@ -130,15 +133,16 @@ module RailVerdict
130
133
  end
131
134
 
132
135
  def command_check(argv)
133
- options = { config: DEFAULT_CONFIG_PATH, format: "console", changed: false, base: nil, baseline: nil, waiver: nil }
136
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", changed: false, base: nil, baseline: nil, waiver: nil, handoff: nil }
134
137
  parser = OptionParser.new do |opts|
135
- opts.banner = "Usage: railverdict check [--config PATH] [--format console|json|sarif] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
138
+ opts.banner = "Usage: railverdict check [--config PATH] [--format console|json|sarif] [--changed] [--base REV] [--baseline PATH] [--waiver PATH] [--handoff PATH]"
136
139
  opts.on("--config PATH", String) { |value| options[:config] = value }
137
140
  opts.on("--format FORMAT", String) { |value| options[:format] = value }
138
141
  opts.on("--changed") { options[:changed] = true }
139
142
  opts.on("--base REV", String) { |value| options[:base] = value }
140
143
  opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
141
144
  opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
145
+ opts.on("--handoff PATH", String) { |value| options[:handoff] = value }
142
146
  end
143
147
  parse!(parser, argv)
144
148
  validate_format!(options[:format])
@@ -146,12 +150,145 @@ module RailVerdict
146
150
  raise RailVerdict::UsageError, "--base requires --changed"
147
151
  end
148
152
 
153
+ if options[:handoff]
154
+ return command_check_with_handoff(options)
155
+ end
156
+
149
157
  outcome, interrupted = execute_check(options)
150
158
  return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
151
159
 
152
160
  exit_code_for(outcome.result, interrupted: interrupted)
153
161
  end
154
162
 
163
+ def command_check_with_handoff(options)
164
+ handoff_path = options[:handoff]
165
+ begin
166
+ text = File.binread(handoff_path)
167
+ rescue StandardError => e
168
+ @stderr.puts "railverdict check --handoff: cannot read handoff: #{e.message}"
169
+ return EXIT_NO_GATE
170
+ end
171
+ handoff_doc, err = Handoff.parse(text)
172
+ unless handoff_doc
173
+ @stderr.puts "railverdict check --handoff: handoff invalid (#{err}); running full verification"
174
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff })
175
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
176
+ return exit_code_for(outcome.result, interrupted: interrupted)
177
+ end
178
+
179
+ # Independent observation
180
+ effective_paths = Check.effective_input_paths(root: @working_directory, config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)), baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil), waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil))
181
+ before_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
182
+ current_state = before_state
183
+ current_env_obj = VerificationEnvironment.capture(repository_root: @working_directory)
184
+ current_env = {
185
+ "ruby_engine" => current_env_obj.ruby_engine,
186
+ "ruby_version" => current_env_obj.ruby_version,
187
+ "railverdict_version" => current_env_obj.railverdict_version,
188
+ "analyzer_versions" => current_env_obj.analyzer_versions
189
+ }
190
+ config = Configuration.load(effective_paths[:config]) rescue nil
191
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
192
+ contract = { "required_analyzers" => required, "verification_mode" => handoff_doc.dig("source_scope", "verification_mode"), "changed_base" => handoff_doc.dig("source_scope", "changed_base") }
193
+ reuse_result = Reuse.evaluate(handoff_document: handoff_doc, current_repository_state: current_state, current_environment: current_env, current_contract: contract)
194
+ after_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
195
+ if before_state && after_state && before_state.digest != after_state.digest
196
+ reuse_result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: reuse_result.reasons + ["reuse_toctou"], handoff_valid: reuse_result.handoff_valid, receipt_fresh: reuse_result.receipt_fresh)
197
+ end
198
+
199
+ if reuse_result.decision == Reuse::REUSABLE
200
+ # Whole-set reuse: re-derive GateResult from reused normalized evidence through current baseline/waivers/policy.
201
+ # This satisfies invariants 6-9: old GateResult never becomes second authority.
202
+ begin
203
+ # Reconstruct findings as observed (state observed) for reclassification
204
+ reused_finding_hashes = handoff_doc.dig("evidence_set", "analyzer_results")&.flat_map { |ar| ar["findings"] || [] } || []
205
+ reused_findings = reused_finding_hashes.map do |fh|
206
+ # fh is a Finding schema hash; convert to observed Finding
207
+ begin
208
+ Finding.new(
209
+ fingerprint: fh["fingerprint"],
210
+ origin: fh["origin"] || "deterministic",
211
+ analyzer: fh["analyzer"],
212
+ rule_id: fh["rule_id"],
213
+ category: fh["category"] || "lint",
214
+ severity: fh["severity"] || "medium",
215
+ confidence: fh["confidence"] || "high",
216
+ state: "observed",
217
+ evidence_ref: fh["evidence_ref"] || "native:#{fh['analyzer']}:#{fh['fingerprint'][0,8]}",
218
+ location: fh["location"] || { "path" => fh["path"] || "unknown.rb" },
219
+ message: fh["message"] || "reused"
220
+ )
221
+ rescue StandardError
222
+ nil
223
+ end
224
+ end.compact
225
+
226
+ # Reconstruct analyzer_results for policy (finding_ids derived from findings)
227
+ handoff_ars = handoff_doc.dig("evidence_set", "analyzer_results") || []
228
+ reused_analyzer_results = handoff_ars.map do |ar|
229
+ fid_map = reused_findings.select { |f| f.analyzer == ar["analyzer"] }.map(&:id)
230
+ # Also include fingerprint mapping for policy compatibility (GateResult uses finding_ids)
231
+ # Use fingerprint-based ids as in original
232
+ AnalyzerResult.new(
233
+ analyzer: ar["analyzer"],
234
+ invocation: { "executable" => ar["analyzer"], "argv" => [] },
235
+ execution_status: ar["execution_status"] == "succeeded" ? "succeeded" : "succeeded",
236
+ finding_ids: fid_map,
237
+ tool_version: ar["tool_version"]
238
+ )
239
+ rescue StandardError
240
+ nil
241
+ end.compact
242
+
243
+ # Load current baseline/waivers (respect effective paths)
244
+ cfg = Configuration.load(effective_paths[:config]) rescue nil
245
+ baseline_path = effective_paths[:baseline]
246
+ waiver_path = effective_paths[:waivers]
247
+ baseline = File.file?(baseline_path) ? (Baseline.read(baseline_path) rescue nil) : nil
248
+ waivers = WaiverStore.read_optional(waiver_path) rescue []
249
+ # Comparison
250
+ comparison = nil
251
+ classified = reused_findings
252
+ if baseline || waivers.any?
253
+ cmp = Comparison.classify(findings: reused_findings, baseline: baseline, waivers: waivers.map(&:to_h), clock: Time.now.utc)
254
+ comparison = { "counts" => cmp.counts, "introduced" => cmp.introduced.map(&:fingerprint).sort, "existing" => cmp.existing.map(&:fingerprint).sort, "resolved" => cmp.resolved.map { |e| e.fetch("fingerprint") }.sort, "changed" => cmp.changed.map(&:fingerprint).sort, "moved" => cmp.moved.map(&:fingerprint).sort, "waived" => cmp.counts.fetch("waived", 0) == 0 ? [] : cmp.classified_findings.select { |it| it.state == "waived" }.map(&:fingerprint).sort, "orphaned_waivers" => cmp.orphaned_waivers.map { |w| w["fingerprint"] || w[:fingerprint] }.compact.sort }
255
+ classified = cmp.classified_findings
256
+ end
257
+
258
+ # Policy evaluation with current config
259
+ policy_result = Verification::Policy.evaluate(configuration: cfg, analyzer_results: reused_analyzer_results, findings: classified, comparison: comparison, baseline_meta: baseline ? { "loaded" => true, "path" => baseline_path } : nil, git_context: nil)
260
+ # Build final GateResult (reuse policy_result but attach handoff metadata)
261
+ gate_result = policy_result
262
+ # Render with handoff_reuse envelope for machine output
263
+ if options[:format] == "json"
264
+ payload = gate_result.to_schema_h
265
+ payload["handoff_reuse"] = { "decision" => Reuse::REUSABLE, "reasons" => reuse_result.reasons, "handoff_id" => handoff_doc["handoff_id"], "receipt_id" => handoff_doc.dig("receipt", "receipt_id"), "receipt_fresh" => true }
266
+ @stdout.write(JSON.generate(payload) + "\n")
267
+ else
268
+ @stdout.puts "Handoff REUSABLE: #{handoff_doc['handoff_id']} (receipt #{handoff_doc.dig('receipt','receipt_id')})"
269
+ @stdout.puts "Gate: #{gate_result.gate} (reused evidence, policy re-evaluated, no analyzer execution)"
270
+ end
271
+ return gate_result.gate == "FAIL" ? EXIT_POLICY_FAIL : EXIT_OK
272
+ rescue StandardError => e
273
+ @stderr.puts "handoff reuse re-evaluation failed (#{e.class}: #{e.message}); falling back to full verification"
274
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff })
275
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
276
+ return exit_code_for(outcome.result, interrupted: interrupted)
277
+ end
278
+ else
279
+ @stderr.puts "Handoff #{reuse_result.decision}: #{reuse_result.reasons.join(', ')}; running full verification" if options[:format] != "json"
280
+ # Fallback to full verification
281
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff })
282
+ # Annotate result with reuse info when json
283
+ if options[:format] == "json" && outcome.result.respond_to?(:to_schema_h)
284
+ # We cannot mutate GateResult; we just render normally but also log reuse decision to stderr
285
+ @stderr.puts JSON.generate({ "handoff_reuse" => { "decision" => reuse_result.decision, "reasons" => reuse_result.reasons } })
286
+ end
287
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
288
+ return exit_code_for(outcome.result, interrupted: interrupted)
289
+ end
290
+ end
291
+
155
292
  def command_pr(argv)
156
293
  options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil }
157
294
  parser = OptionParser.new do |opts|
@@ -654,23 +791,24 @@ module RailVerdict
654
791
  return EXIT_NO_GATE
655
792
  end
656
793
 
794
+ effective_paths = begin
795
+ effective = Check.effective_input_paths(
796
+ root: @working_directory,
797
+ config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)),
798
+ baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil),
799
+ waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil)
800
+ )
801
+ {
802
+ config: effective.fetch(:config),
803
+ baseline: effective.fetch(:baseline),
804
+ waivers: effective.fetch(:waivers)
805
+ }
806
+ end
657
807
  current_state = RepositoryState.capture(
658
808
  repository_root: @working_directory,
659
- configuration_paths: begin
660
- effective = Check.effective_input_paths(
661
- root: @working_directory,
662
- config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)),
663
- baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil),
664
- waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil)
665
- )
666
- {
667
- config: effective.fetch(:config),
668
- baseline: effective.fetch(:baseline),
669
- waivers: effective.fetch(:waivers)
670
- }
671
- end
809
+ configuration_paths: effective_paths
672
810
  )
673
- document, _receipt = Receipt.evaluate(text, current_state: current_state)
811
+ document, _receipt = Receipt.evaluate(text, current_state: current_state, repository_root: @working_directory, configuration_paths: effective_paths)
674
812
  render_receipt_validation(document, options[:format])
675
813
  exit_code_for_validation(document)
676
814
  rescue RailVerdict::UsageError => error
@@ -710,6 +848,216 @@ module RailVerdict
710
848
  end
711
849
  end
712
850
 
851
+ def command_handoff(argv)
852
+ sub = argv.first
853
+ raise RailVerdict::UsageError, "handoff requires a subcommand: create, inspect, or verify" unless %w[create inspect verify].include?(sub)
854
+
855
+ case sub
856
+ when "create" then command_handoff_create(argv.drop(1))
857
+ when "inspect" then command_handoff_inspect(argv.drop(1))
858
+ when "verify" then command_handoff_verify(argv.drop(1))
859
+ end
860
+ rescue RailVerdict::UsageError => e
861
+ @stderr.puts "railverdict handoff: #{e.message}"
862
+ @stderr.puts handoff_usage
863
+ EXIT_NO_GATE
864
+ end
865
+
866
+ def handoff_usage
867
+ <<~USAGE
868
+ Usage: railverdict handoff create [options]
869
+ railverdict handoff inspect PATH [options]
870
+ railverdict handoff verify PATH [options]
871
+
872
+ Create options:
873
+ --config PATH, --format console|json, --output PATH
874
+ --changed --base REV --baseline PATH --waiver PATH
875
+
876
+ Inspect options:
877
+ --format console|json
878
+
879
+ Verify options:
880
+ --format console|json
881
+ --config PATH, --baseline PATH, --waiver PATH
882
+ USAGE
883
+ end
884
+
885
+ def command_handoff_create(argv)
886
+ options = { config: DEFAULT_CONFIG_PATH, format: "json", output: nil, changed: false, base: nil, baseline: nil, waiver: nil }
887
+ parser = OptionParser.new do |opts|
888
+ opts.banner = "Usage: railverdict handoff create [--config PATH] [--format console|json] [--output PATH] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
889
+ opts.on("--config PATH", String) { |v| options[:config] = v }
890
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
891
+ opts.on("--output PATH", String) { |v| options[:output] = v }
892
+ opts.on("--changed") { options[:changed] = true }
893
+ opts.on("--base REV", String) { |v| options[:base] = v }
894
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
895
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
896
+ end
897
+ parse!(parser, argv)
898
+ raise RailVerdict::UsageError, "--base requires --changed" if options[:base] && !options[:changed]
899
+ unless %w[console json].include?(options[:format])
900
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
901
+ end
902
+ options[:config] = resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH))
903
+ options[:baseline] = resolved_override_path(options[:baseline], nil) if options[:baseline]
904
+ options[:waiver] = resolved_override_path(options[:waiver], nil) if options[:waiver]
905
+
906
+ outcome, interrupted = execute_check_with_guard(options)
907
+ return EXIT_INTERRUPTED if interrupted || outcome.result.completion_status == "interrupted"
908
+
909
+ receipt = Receipt.build(outcome: outcome, pr_intelligence_document: pr_intelligence_document_for(outcome))
910
+ evidence_set = {
911
+ "analyzer_results" => outcome.result.analyzer_results.map do |ar|
912
+ h = { "analyzer" => ar.analyzer, "execution_status" => ar.execution_status, "tool_version" => ar.tool_version }
913
+ # Include normalized findings for reuse (bounded) — use canonical Finding hashes
914
+ findings = outcome.findings.select { |f| f.analyzer == ar.analyzer }.map(&:to_schema_h).sort_by { |ff| ff["fingerprint"] }
915
+ h["findings"] = findings if findings.any?
916
+ h
917
+ end
918
+ }
919
+ provenance = { "analyzer_versions" => outcome.context&.analyzer_versions || {} }
920
+ scope = {
921
+ "verification_mode" => outcome.result.git ? "changed" : "full",
922
+ "changed_base" => outcome.result.git && outcome.result.git["base"],
923
+ "changed_merge_base" => outcome.result.git && outcome.result.git["merge_base"]
924
+ }
925
+ handoff = Handoff.build(receipt: receipt, evidence_set: evidence_set, evidence_provenance: provenance, source_scope: scope)
926
+ write_handoff_file(handoff, options[:output]) if options[:output]
927
+ render_handoff(handoff, options[:format], output_path: options[:output])
928
+ exit_code_for(outcome.result, interrupted: interrupted)
929
+ rescue Handoff::BuildError, Receipt::BuildError => e
930
+ payload = { "status" => "unavailable", "reasons" => ["#{e.code}: #{e.message}"] }
931
+ render_handoff_unavailable(payload, options && options[:format])
932
+ EXIT_NO_GATE
933
+ end
934
+
935
+ def command_handoff_inspect(argv)
936
+ options = { format: "json" }
937
+ parser = OptionParser.new do |opts|
938
+ opts.banner = "Usage: railverdict handoff inspect PATH [--format console|json]"
939
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
940
+ end
941
+ rest = begin parser.parse(argv.dup) rescue raise RailVerdict::UsageError, $!.message end
942
+ path = rest.first
943
+ raise RailVerdict::UsageError, "inspect requires the handoff file path" if path.nil?
944
+ unless %w[console json].include?(options[:format])
945
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}"
946
+ end
947
+ text = begin File.binread(path) rescue (@stderr.puts "railverdict handoff inspect: cannot read handoff: #{$!.message}"; return EXIT_NO_GATE) end
948
+ doc, err = Handoff.parse(text)
949
+ if doc
950
+ render_handoff(doc, options[:format])
951
+ EXIT_OK
952
+ else
953
+ payload = { "status" => "invalid", "reasons" => [err.to_s] }
954
+ render_handoff_unavailable(payload, options[:format])
955
+ EXIT_NO_GATE
956
+ end
957
+ end
958
+
959
+ def command_handoff_verify(argv)
960
+ options = { format: "json", config: DEFAULT_CONFIG_PATH, baseline: nil, waiver: nil }
961
+ parser = OptionParser.new do |opts|
962
+ opts.banner = "Usage: railverdict handoff verify PATH [--format console|json] [--config PATH] [--baseline PATH] [--waiver PATH]"
963
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
964
+ opts.on("--config PATH", String) { |v| options[:config] = v }
965
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
966
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
967
+ end
968
+ rest = begin parser.parse(argv.dup) rescue raise RailVerdict::UsageError, $!.message end
969
+ path = rest.first
970
+ raise RailVerdict::UsageError, "verify requires the handoff file path" if path.nil?
971
+ unless %w[console json].include?(options[:format])
972
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}"
973
+ end
974
+ text = begin File.binread(path) rescue (@stderr.puts "railverdict handoff verify: cannot read handoff: #{$!.message}"; return EXIT_NO_GATE) end
975
+ doc, err = Handoff.parse(text)
976
+ unless doc
977
+ payload = { "handoff_valid" => false, "receipt_fresh" => false, "decision" => Reuse::INVALID, "reasons" => [err.to_s] }
978
+ render_handoff_verify(payload, options[:format])
979
+ return EXIT_NO_GATE
980
+ end
981
+ effective_paths = Check.effective_input_paths(root: @working_directory, config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)), baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil), waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil))
982
+ before_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
983
+ current_state = before_state
984
+ current_env_obj = VerificationEnvironment.capture(repository_root: @working_directory)
985
+ current_env = {
986
+ "ruby_engine" => current_env_obj.ruby_engine,
987
+ "ruby_version" => current_env_obj.ruby_version,
988
+ "railverdict_version" => current_env_obj.railverdict_version,
989
+ "analyzer_versions" => current_env_obj.analyzer_versions
990
+ }
991
+ # Evaluate via canonical Reuse
992
+ config = Configuration.load(effective_paths[:config]) rescue nil
993
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
994
+ contract = { "required_analyzers" => required, "verification_mode" => doc.dig("source_scope", "verification_mode"), "changed_base" => doc.dig("source_scope", "changed_base") }
995
+ result = Reuse.evaluate(handoff_document: doc, current_repository_state: current_state, current_environment: current_env, current_contract: contract)
996
+ # TOCTOU guard: re-observe repository state
997
+ after_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
998
+ if before_state && after_state && before_state.digest != after_state.digest
999
+ result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: result.reasons + ["reuse_toctou"], handoff_valid: result.handoff_valid, receipt_fresh: result.receipt_fresh)
1000
+ end
1001
+ payload = { "handoff_valid" => result.handoff_valid, "receipt_fresh" => result.receipt_fresh, "decision" => result.decision, "reasons" => result.reasons, "handoff_id" => doc["handoff_id"], "receipt_id" => doc.dig("receipt", "receipt_id") }
1002
+ render_handoff_verify(payload, options[:format])
1003
+ result.decision == Reuse::REUSABLE ? EXIT_OK : EXIT_NO_GATE
1004
+ end
1005
+
1006
+ def capture_current_env
1007
+ # Minimal env capture for reuse freshness — reuse VerificationEnvironment where possible
1008
+ begin
1009
+ VerificationEnvironment.capture(repository_root: @working_directory)
1010
+ rescue StandardError
1011
+ { "ruby_engine" => RUBY_ENGINE, "ruby_version" => RUBY_VERSION, "railverdict_version" => RailVerdict::VERSION, "analyzer_versions" => {} }
1012
+ end
1013
+ end
1014
+
1015
+ def write_handoff_file(document, path)
1016
+ dir = File.dirname(File.expand_path(path))
1017
+ FileUtils.mkdir_p(dir)
1018
+ tmp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
1019
+ begin
1020
+ File.open(tmp, "wb", 0o600) do |file|
1021
+ file.write(JSON.generate(document) + "\n")
1022
+ file.flush
1023
+ file.fsync
1024
+ end
1025
+ File.rename(tmp, path)
1026
+ ensure
1027
+ File.unlink(tmp) if File.exist?(tmp)
1028
+ end
1029
+ end
1030
+
1031
+ def render_handoff(document, format, output_path: nil)
1032
+ if format == "json"
1033
+ @stdout.write(JSON.generate(document) + "\n")
1034
+ else
1035
+ @stdout.puts "Handoff: #{document['handoff_id']}"
1036
+ @stdout.puts "Receipt: #{document.dig('receipt', 'receipt_id')}"
1037
+ @stdout.puts "Evidence analyzers: #{Array(document.dig('evidence_set', 'analyzer_results')).map { |ar| ar['analyzer'] }.join(', ')}"
1038
+ @stdout.puts "Written to #{output_path}" if output_path
1039
+ end
1040
+ end
1041
+
1042
+ def render_handoff_unavailable(payload, format)
1043
+ if format == "json"
1044
+ @stdout.write(JSON.generate(payload) + "\n")
1045
+ else
1046
+ @stdout.puts "Handoff unavailable: #{payload['reasons'].join('; ')}"
1047
+ end
1048
+ end
1049
+
1050
+ def render_handoff_verify(payload, format)
1051
+ if format == "json"
1052
+ @stdout.write(JSON.generate(payload) + "\n")
1053
+ else
1054
+ @stdout.puts "Handoff: #{payload['handoff_id']} valid=#{payload['handoff_valid']}"
1055
+ @stdout.puts "Receipt fresh: #{payload['receipt_fresh']}"
1056
+ @stdout.puts "Decision: #{payload['decision']}"
1057
+ @stdout.puts "Reasons: #{payload['reasons'].join(', ')}" unless payload["reasons"].empty?
1058
+ end
1059
+ end
1060
+
713
1061
  def command_mcp(argv)
714
1062
  sub = argv.first
715
1063
  raise RailVerdict::UsageError, "mcp requires subcommand: serve" unless sub == "serve"
@@ -19,7 +19,8 @@ module RailVerdict
19
19
  adapter = build_adapter(name, rubocop_command_resolver)
20
20
  next unless adapter
21
21
 
22
- probes[name] = adapter.probe(root, runner: runner, timeout_seconds: configuration.analyzer_timeout_seconds(name))
22
+ probe_timeout = [[(configuration.analyzer_timeout_seconds(name) || 5.0).to_f, 5.0].min, 1.0].max
23
+ probes[name] = adapter.probe(root, runner: runner, timeout_seconds: probe_timeout)
23
24
  end
24
25
  analyzer_versions = probes.transform_values(&:version)
25
26
  context = RunContext.build(
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "canonical_json"
7
+ require_relative "schema_validator"
8
+
9
+ module RailVerdict
10
+ class Handoff
11
+ SCHEMA_VERSION = "1.0"
12
+ ID_PATTERN = /\Asha256:[0-9a-f]{64}\z/
13
+ MAX_DOCUMENT_BYTES = 256 * 1024
14
+
15
+ class BuildError < RailVerdict::Error
16
+ attr_reader :code
17
+ def initialize(code, message)
18
+ @code = code.to_s
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ attr_reader :document
24
+
25
+ def self.build(receipt:, evidence_set:, railverdict_version: RailVerdict::VERSION, evidence_provenance: nil, source_scope: nil)
26
+ raise BuildError.new(:handoff_unavailable, "receipt is required") if receipt.nil? || !receipt.is_a?(Hash)
27
+ raise BuildError.new(:handoff_unavailable, "evidence_set is required") if evidence_set.nil? || !evidence_set.is_a?(Hash)
28
+
29
+ # Validate receipt structurally (reuse Receipt validation without re-persisting)
30
+ errors = SchemaValidator.validate_receipt(receipt)
31
+ raise BuildError.new(:receipt_schema_invalid, errors.join("; ")) unless errors.empty?
32
+
33
+ payload = {
34
+ "schema_version" => SCHEMA_VERSION,
35
+ "railverdict_version" => railverdict_version.to_s,
36
+ "receipt" => receipt,
37
+ "evidence_set" => normalize_evidence_set(evidence_set)
38
+ }
39
+ payload["evidence_provenance"] = normalize_provenance(evidence_provenance) if evidence_provenance
40
+ payload["source_scope"] = normalize_scope(source_scope) if source_scope
41
+
42
+ document = payload.merge("handoff_id" => id_for(payload))
43
+ errors = SchemaValidator.validate_handoff(document)
44
+ raise BuildError.new(:handoff_schema_violation, errors.join("; ")) unless errors.empty?
45
+ raise BuildError.new(:handoff_too_large, "handoff exceeds #{MAX_DOCUMENT_BYTES} bytes") if JSON.generate(document).bytesize > MAX_DOCUMENT_BYTES
46
+
47
+ deep_freeze(document)
48
+ end
49
+
50
+ def self.id_for(payload)
51
+ "sha256:#{Digest::SHA256.hexdigest(CanonicalJSON.generate(payload))}"
52
+ end
53
+
54
+ def self.normalize_evidence_set(set)
55
+ # Ensure deterministic ordering: sorted by analyzer, findings sorted by fingerprint
56
+ results = Array(set["analyzer_results"] || set[:analyzer_results]).map do |ar|
57
+ h = {
58
+ "analyzer" => ar["analyzer"] || ar[:analyzer],
59
+ "execution_status" => ar["execution_status"] || ar[:execution_status]
60
+ }
61
+ tv = ar["tool_version"] || ar[:tool_version]
62
+ h["tool_version"] = tv.nil? ? nil : tv.to_s
63
+ findings = Array(ar["findings"] || ar[:findings]).map { |f| f.is_a?(Hash) ? f : {} }.sort_by { |f| f["fingerprint"] || "" }
64
+ h["findings"] = findings if findings.any?
65
+ h
66
+ end.sort_by { |ar| ar["analyzer"] }
67
+ { "analyzer_results" => results }
68
+ end
69
+ private_class_method :normalize_evidence_set
70
+
71
+ def self.normalize_provenance(prov)
72
+ return nil unless prov.is_a?(Hash)
73
+ out = {}
74
+ av = prov["analyzer_versions"] || prov[:analyzer_versions]
75
+ out["analyzer_versions"] = av.sort.to_h if av.is_a?(Hash) && av.any?
76
+ db = prov["advisory_db_revision"] || prov[:advisory_db_revision]
77
+ out["advisory_db_revision"] = db.to_s if db
78
+ out
79
+ end
80
+ private_class_method :normalize_provenance
81
+
82
+ def self.normalize_scope(scope)
83
+ return nil unless scope.is_a?(Hash)
84
+ out = {}
85
+ vm = scope["verification_mode"] || scope[:verification_mode]
86
+ out["verification_mode"] = vm.to_s if vm
87
+ cb = scope["changed_base"] || scope[:changed_base]
88
+ out["changed_base"] = cb.to_s if cb
89
+ mb = scope["changed_merge_base"] || scope[:changed_merge_base]
90
+ out["changed_merge_base"] = mb.to_s if mb
91
+ out
92
+ end
93
+ private_class_method :normalize_scope
94
+
95
+ def self.deep_freeze(value)
96
+ case value
97
+ when Hash then value.each { |k, v| k.freeze if k.is_a?(String); deep_freeze(v) }; value.freeze
98
+ when Array then value.each { |c| deep_freeze(c) }; value.freeze
99
+ when String then value.freeze
100
+ else value
101
+ end
102
+ end
103
+
104
+ def self.parse(text)
105
+ return [nil, :handoff_too_large] if text.bytesize > MAX_DOCUMENT_BYTES
106
+ document = begin JSON.parse(text) rescue return [nil, :handoff_malformed] end
107
+ parse_document(document)
108
+ end
109
+
110
+ def self.parse_document(document)
111
+ return [nil, :handoff_malformed] unless document.is_a?(Hash)
112
+ return [nil, :incompatible_handoff_version] unless document["schema_version"] == SCHEMA_VERSION
113
+ errors = SchemaValidator.validate_handoff(document)
114
+ return [nil, :handoff_schema_invalid] unless errors.empty?
115
+ stored = document["handoff_id"]
116
+ payload = document.reject { |k, _| k == "handoff_id" }
117
+ expected = id_for(payload)
118
+ return [nil, :handoff_integrity_failed] unless stored == expected
119
+ [deep_freeze(document), nil]
120
+ end
121
+ end
122
+ end