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.
- checksums.yaml +4 -4
- data/README.md +151 -18
- data/lib/rail_verdict/analyzers/bundler_audit.rb +2 -1
- data/lib/rail_verdict/analyzers/minitest.rb +47 -34
- data/lib/rail_verdict/analyzers/rspec.rb +120 -42
- data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
- data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
- data/lib/rail_verdict/check.rb +1 -1
- data/lib/rail_verdict/cli.rb +365 -17
- data/lib/rail_verdict/doctor.rb +2 -1
- data/lib/rail_verdict/handoff.rb +122 -0
- data/lib/rail_verdict/mcp/cache.rb +50 -6
- data/lib/rail_verdict/mcp/server.rb +7 -1
- data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
- data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +16 -3
- data/lib/rail_verdict/mcp/tools/get_verification_receipt.rb +17 -3
- data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
- data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
- data/lib/rail_verdict/receipt.rb +163 -22
- data/lib/rail_verdict/reuse.rb +168 -0
- data/lib/rail_verdict/schema_validator.rb +5 -0
- data/lib/rail_verdict/verification_environment.rb +180 -0
- data/lib/rail_verdict/verification_identity.rb +95 -0
- data/lib/rail_verdict/version.rb +1 -1
- data/lib/rail_verdict.rb +4 -0
- data/schemas/receipt-validation-v1.schema.json +6 -0
- data/schemas/verification-handoff-v1.schema.json +69 -0
- data/schemas/verification-receipt-v1.schema.json +1 -0
- metadata +9 -1
data/lib/rail_verdict/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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"
|
data/lib/rail_verdict/doctor.rb
CHANGED
|
@@ -19,7 +19,8 @@ module RailVerdict
|
|
|
19
19
|
adapter = build_adapter(name, rubocop_command_resolver)
|
|
20
20
|
next unless adapter
|
|
21
21
|
|
|
22
|
-
|
|
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
|