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.
@@ -6,6 +6,7 @@ require "json"
6
6
 
7
7
  require_relative "../canonical_json"
8
8
  require_relative "../repository_state"
9
+ require_relative "../verification_environment"
9
10
 
10
11
  module RailVerdict
11
12
  module MCP
@@ -39,23 +40,32 @@ module RailVerdict
39
40
  nil
40
41
  end
41
42
 
42
- # Returns the cached entry only when the CURRENT observable state still
43
- # matches the state that was verified. Never reruns analyzers.
44
- def fresh_entry(current_state)
43
+ # Returns the cached entry only when the CURRENT observable identity
44
+ # (repository + environment) still matches what was verified. Never reruns analyzers,
45
+ # but does perform lightweight version probes for environment observation.
46
+ def fresh_entry(current_state, current_environment = nil)
45
47
  entry = @mutex.synchronize { @entry }
46
48
  return nil if entry.nil? || entry.state_digest.nil?
47
49
  return nil if current_state.nil? || !current_state.available?
48
50
  return nil if current_state.digest != entry.state_digest
49
51
 
52
+ current_env = current_environment || current_environment_for(entry.outcome)
53
+ return nil if current_env.nil? || !current_env.available?
54
+ return nil if current_env.digest != entry.environment_digest
55
+
50
56
  entry
51
57
  end
52
58
 
53
- def verification_state(current_state)
59
+ def verification_state(current_state, current_environment = nil)
54
60
  entry = @mutex.synchronize { @entry }
55
61
  return "verification_required" if entry.nil? || entry.state_digest.nil?
56
62
  return "state_unavailable" if current_state.nil? || !current_state.available?
57
63
  return "stale" if current_state.digest != entry.state_digest
58
64
 
65
+ current_env = current_environment || current_environment_for(entry.outcome)
66
+ return "state_unavailable" if current_env.nil? || !current_env.available?
67
+ return "stale" if current_env.digest != entry.environment_digest
68
+
59
69
  "fresh"
60
70
  end
61
71
 
@@ -78,8 +88,11 @@ module RailVerdict
78
88
  current = current_state_for(entry.outcome)
79
89
  return false if current.nil? || !current.available?
80
90
 
91
+ current_env = current_environment_for(entry.outcome)
92
+ return false if current_env.nil? || !current_env.available?
93
+
81
94
  current.digest == entry.state_digest &&
82
- environment_digest_for(entry.outcome) == entry.environment_digest
95
+ current_env.digest == entry.environment_digest
83
96
  rescue StandardError
84
97
  false
85
98
  end
@@ -102,6 +115,16 @@ module RailVerdict
102
115
  @mutex.synchronize { @last_packets[packet_id] }
103
116
  end
104
117
 
118
+ def store_handoff(handoff)
119
+ @mutex.synchronize do
120
+ @last_handoff = handoff
121
+ end
122
+ end
123
+
124
+ def fetch_handoff
125
+ @mutex.synchronize { @last_handoff }
126
+ end
127
+
105
128
  private
106
129
 
107
130
  def state_digest_for(outcome)
@@ -143,10 +166,31 @@ module RailVerdict
143
166
  }
144
167
  end
145
168
 
169
+ def current_environment_for(outcome)
170
+ root = outcome&.context&.repository_root
171
+ root ||= outcome&.result&.git&.fetch("repository_root", nil) rescue nil
172
+ return nil unless root.is_a?(String) && File.directory?(root)
173
+
174
+ real = begin
175
+ File.realpath(root)
176
+ rescue StandardError
177
+ return nil
178
+ end
179
+ config = outcome&.configuration
180
+ VerificationEnvironment.capture(repository_root: real, configuration: config)
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
146
185
  def environment_digest_for(outcome)
186
+ env = current_environment_for(outcome)
187
+ return env.digest if env&.available? && env.digest
188
+
189
+ # Fallback for cases where current capture fails (e.g., no config)
147
190
  payload = {
148
191
  "railverdict_version" => RailVerdict::VERSION,
149
- "ruby_version" => RUBY_VERSION,
192
+ "ruby_engine" => RUBY_ENGINE.to_s,
193
+ "ruby_version" => RUBY_VERSION.to_s,
150
194
  "analyzer_versions" => sorted_analyzer_versions(outcome)
151
195
  }
152
196
  Digest::SHA256.hexdigest(CanonicalJSON.generate(payload))
@@ -70,6 +70,9 @@ module RailVerdict
70
70
  require_relative "tools/investigate"
71
71
  require_relative "tools/get_verification_receipt"
72
72
  require_relative "tools/get_pr_intelligence"
73
+ require_relative "tools/create_handoff"
74
+ require_relative "tools/inspect_handoff"
75
+ require_relative "tools/verify_handoff"
73
76
 
74
77
  [
75
78
  Tools::Verify,
@@ -80,7 +83,10 @@ module RailVerdict
80
83
  Tools::Explain,
81
84
  Tools::Investigate,
82
85
  Tools::GetVerificationReceipt,
83
- Tools::GetPRIntelligence
86
+ Tools::GetPRIntelligence,
87
+ Tools::CreateHandoff,
88
+ Tools::InspectHandoff,
89
+ Tools::VerifyHandoff
84
90
  ].each do |tool_class|
85
91
  instance = tool_class.new(server: self)
86
92
  mcp_server.define_tool(
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module MCP
5
+ module Tools
6
+ class CreateHandoff
7
+ def initialize(server:)
8
+ @server = server
9
+ end
10
+
11
+ def tool_name
12
+ "create_handoff"
13
+ end
14
+
15
+ def tool_title
16
+ "Create verification handoff"
17
+ end
18
+
19
+ def tool_description
20
+ "Create a deterministic Verification Handoff (bounded transport envelope) from a fresh verification. Runs verification, builds receipt and handoff. Offline, file/process usable, fail-closed. Handoff never self-authorizes reuse."
21
+ end
22
+
23
+ def tool_input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ changed: { type: "boolean" },
28
+ base: { type: "string" },
29
+ config_path: { type: "string" },
30
+ baseline_path: { type: "string" },
31
+ waiver_path: { type: "string" }
32
+ },
33
+ additionalProperties: false
34
+ }
35
+ end
36
+
37
+ def tool_output_schema
38
+ {
39
+ type: "object",
40
+ properties: {
41
+ handoff: { type: "object" },
42
+ receipt: { type: "object" },
43
+ handoff_id: { type: "string" }
44
+ },
45
+ required: %w[handoff]
46
+ }
47
+ end
48
+
49
+ def tool_annotations
50
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
51
+ end
52
+
53
+ def call(changed: nil, base: nil, config_path: nil, baseline_path: nil, waiver_path: nil, **_rest)
54
+ changed = changed == true
55
+ base_validated = Validators.validate_base_revision(base)
56
+ return Serializers.error_response("--base requires --changed", code: "invalid_arguments") if base_validated && !changed
57
+
58
+ config_file = resolve_config_path(config_path)
59
+ outcome = @server.synchronized_verification { run_check(changed: changed, base: base_validated, config_path: config_file, baseline_path: baseline_path, waiver_path: waiver_path) }
60
+ receipt = Receipt.build(outcome: outcome)
61
+ evidence_set = {
62
+ "analyzer_results" => outcome.result.analyzer_results.map do |ar|
63
+ h = { "analyzer" => ar.analyzer, "execution_status" => ar.execution_status, "tool_version" => ar.tool_version }
64
+ findings = outcome.findings.select { |f| f.analyzer == ar.analyzer }.map(&:to_schema_h).sort_by { |ff| ff["fingerprint"] }
65
+ h["findings"] = findings if findings.any?
66
+ h
67
+ end
68
+ }
69
+ provenance = { "analyzer_versions" => outcome.context&.analyzer_versions || {} }
70
+ scope = { "verification_mode" => outcome.result.git ? "changed" : "full", "changed_base" => outcome.result.git && outcome.result.git["base"], "changed_merge_base" => outcome.result.git && outcome.result.git["merge_base"] }
71
+ handoff = Handoff.build(receipt: receipt, evidence_set: evidence_set, evidence_provenance: provenance, source_scope: scope)
72
+ @server.cache.store_handoff(handoff) if @server.cache.respond_to?(:store_handoff)
73
+ Serializers.tool_response({ "handoff" => Serializers.scrub(handoff), "receipt" => Serializers.scrub(receipt), "handoff_id" => handoff["handoff_id"] }, error: false)
74
+ rescue Handoff::BuildError, Receipt::BuildError => e
75
+ Serializers.error_response("#{e.code}: #{e.message}", code: e.code)
76
+ rescue ArgumentError => e
77
+ Serializers.error_response(e.message, code: "invalid_arguments")
78
+ rescue StandardError => e
79
+ Serializers.error_response("create_handoff failed: #{e.message}", code: "internal_error")
80
+ end
81
+
82
+ private
83
+
84
+ def run_check(changed:, base:, config_path:, baseline_path:, waiver_path:)
85
+ root = @server.repository_root
86
+ opts = { repository_root: root, config_path: config_path }
87
+ opts[:changed] = true if changed
88
+ opts[:base] = base if base
89
+ baseline_resolved = resolve_contained_path(baseline_path, "baseline_path") if baseline_path && !baseline_path.to_s.strip.empty?
90
+ waiver_resolved = resolve_contained_path(waiver_path, "waiver_path") if waiver_path && !waiver_path.to_s.strip.empty?
91
+ opts[:baseline_path_override] = baseline_resolved if baseline_resolved
92
+ opts[:waiver_path_override] = waiver_resolved if waiver_resolved
93
+ Check.execute_with_state_guard(**opts)
94
+ end
95
+
96
+ def resolve_contained_path(raw_path, field_name)
97
+ raw = raw_path.to_s.strip
98
+ raise ArgumentError, "#{field_name} contains NUL byte" if raw.include?("\u0000")
99
+ expanded = File.expand_path(raw, @server.repository_root)
100
+ raise ArgumentError, "#{field_name} escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
101
+ expanded
102
+ end
103
+
104
+ def resolve_config_path(config_path)
105
+ return File.join(@server.repository_root, ".railverdict.yml") if config_path.nil? || config_path.to_s.strip.empty?
106
+ raw = config_path.to_s.strip
107
+ raise ArgumentError, "config_path contains NUL byte" if raw.include?("\u0000")
108
+ expanded = File.expand_path(raw, @server.repository_root)
109
+ raise ArgumentError, "config_path escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
110
+ expanded
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -48,10 +48,23 @@ module RailVerdict
48
48
  end
49
49
 
50
50
  def call(**_rest)
51
- current_state = RailVerdict::RepositoryState.capture(repository_root: @server.repository_root)
52
- case @server.cache.verification_state(current_state)
51
+ root = @server.repository_root
52
+ effective_paths = begin
53
+ RailVerdict::Check.effective_input_paths(root: File.realpath(root), config_path: File.join(File.realpath(root), ".railverdict.yml"))
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ current_state = RailVerdict::RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
58
+ current_env = begin
59
+ outcome = @server.cache.fetch_outcome
60
+ config = outcome&.configuration
61
+ RailVerdict::VerificationEnvironment.capture(repository_root: root, configuration: config)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ case @server.cache.verification_state(current_state, current_env)
53
66
  when "fresh"
54
- entry = @server.cache.fresh_entry(current_state)
67
+ entry = @server.cache.fresh_entry(current_state, current_env)
55
68
  document = entry&.pr_intelligence_document
56
69
  if document.is_a?(Hash)
57
70
  payload = { "status" => "fresh", "pr_intelligence" => Serializers.scrub(document) }
@@ -48,10 +48,24 @@ module RailVerdict
48
48
  end
49
49
 
50
50
  def call(**_rest)
51
- current_state = RailVerdict::RepositoryState.capture(repository_root: @server.repository_root)
52
- case @server.cache.verification_state(current_state)
51
+ root = @server.repository_root
52
+ effective_paths = begin
53
+ RailVerdict::Check.effective_input_paths(root: File.realpath(root), config_path: File.join(File.realpath(root), ".railverdict.yml"))
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ current_state = RailVerdict::RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
58
+ current_env = begin
59
+ # Use cached outcome config if available for relevant probing
60
+ outcome = @server.cache.fetch_outcome
61
+ config = outcome&.configuration
62
+ RailVerdict::VerificationEnvironment.capture(repository_root: root, configuration: config)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+ case @server.cache.verification_state(current_state, current_env)
53
67
  when "fresh"
54
- entry = @server.cache.fresh_entry(current_state)
68
+ entry = @server.cache.fresh_entry(current_state, current_env)
55
69
  receipt = entry&.receipt_document
56
70
  if receipt.is_a?(Hash) && receipt["receipt_id"]
57
71
  payload = { "status" => "fresh", "receipt" => Serializers.scrub(receipt) }
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module MCP
5
+ module Tools
6
+ class InspectHandoff
7
+ def initialize(server:)
8
+ @server = server
9
+ end
10
+
11
+ def tool_name
12
+ "inspect_handoff"
13
+ end
14
+
15
+ def tool_title
16
+ "Inspect verification handoff"
17
+ end
18
+
19
+ def tool_description
20
+ "Parse and validate a Verification Handoff. Bounded, deterministic, tamper-detected via handoff_id. Does not execute analyzers."
21
+ end
22
+
23
+ def tool_input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ handoff: { type: "object", description: "Handoff document to inspect" },
28
+ handoff_json: { type: "string", description: "Handoff JSON string (alternative to handoff object)" }
29
+ },
30
+ additionalProperties: false
31
+ }
32
+ end
33
+
34
+ def tool_output_schema
35
+ {
36
+ type: "object",
37
+ properties: {
38
+ valid: { type: "boolean" },
39
+ handoff: { type: ["object", "null"] },
40
+ error: { type: "string" },
41
+ handoff_id: { type: "string" }
42
+ },
43
+ required: %w[valid]
44
+ }
45
+ end
46
+
47
+ def tool_annotations
48
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
49
+ end
50
+
51
+ def call(handoff: nil, handoff_json: nil, **_rest)
52
+ text = if handoff_json && !handoff_json.to_s.strip.empty?
53
+ handoff_json.to_s
54
+ elsif handoff
55
+ JSON.generate(handoff)
56
+ else
57
+ return Serializers.error_response("handoff or handoff_json is required", code: "invalid_arguments")
58
+ end
59
+ return Serializers.error_response("handoff too large", code: "handoff_too_large") if text.bytesize > Handoff::MAX_DOCUMENT_BYTES
60
+ doc, err = Handoff.parse(text)
61
+ if doc
62
+ Serializers.tool_response({ "valid" => true, "handoff" => Serializers.scrub(doc), "handoff_id" => doc["handoff_id"] }, error: false)
63
+ else
64
+ Serializers.tool_response({ "valid" => false, "error" => err.to_s, "handoff" => nil }, error: false)
65
+ end
66
+ rescue StandardError => e
67
+ Serializers.error_response("inspect_handoff failed: #{e.message}", code: "internal_error")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module MCP
5
+ module Tools
6
+ class VerifyHandoff
7
+ def initialize(server:)
8
+ @server = server
9
+ end
10
+
11
+ def tool_name
12
+ "verify_handoff"
13
+ end
14
+
15
+ def tool_title
16
+ "Verify handoff reuse"
17
+ end
18
+
19
+ def tool_description
20
+ "Evaluate whether a Verification Handoff is REUSABLE under current repository state/environment. Re-observes state/environment independently, never trusts Handoff. Returns REUSABLE or VERIFICATION_REQUIRED with reasons. Does not execute analyzers."
21
+ end
22
+
23
+ def tool_input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ handoff: { type: "object", description: "Handoff document" },
28
+ handoff_json: { type: "string", description: "Handoff JSON string" },
29
+ config_path: { type: "string" },
30
+ baseline_path: { type: "string" },
31
+ waiver_path: { type: "string" }
32
+ },
33
+ additionalProperties: false
34
+ }
35
+ end
36
+
37
+ def tool_output_schema
38
+ {
39
+ type: "object",
40
+ properties: {
41
+ handoff_valid: { type: "boolean" },
42
+ receipt_fresh: { type: "boolean" },
43
+ decision: { type: "string", enum: %w[REUSABLE VERIFICATION_REQUIRED INVALID UNAVAILABLE] },
44
+ reasons: { type: "array" },
45
+ handoff_id: { type: "string" },
46
+ receipt_id: { type: "string" }
47
+ },
48
+ required: %w[decision]
49
+ }
50
+ end
51
+
52
+ def tool_annotations
53
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
54
+ end
55
+
56
+ def call(handoff: nil, handoff_json: nil, config_path: nil, baseline_path: nil, waiver_path: nil, **_rest)
57
+ text = if handoff_json && !handoff_json.to_s.strip.empty?
58
+ handoff_json.to_s
59
+ elsif handoff
60
+ JSON.generate(handoff)
61
+ else
62
+ return Serializers.error_response("handoff or handoff_json required", code: "invalid_arguments")
63
+ end
64
+ return Serializers.error_response("handoff too large", code: "handoff_too_large") if text.bytesize > Handoff::MAX_DOCUMENT_BYTES
65
+ doc, err = Handoff.parse(text)
66
+ unless doc
67
+ return Serializers.tool_response({ "handoff_valid" => false, "receipt_fresh" => false, "decision" => Reuse::INVALID, "reasons" => [err.to_s] }, error: false)
68
+ end
69
+
70
+ root = @server.repository_root
71
+ effective_paths = Check.effective_input_paths(root: File.realpath(root), config_path: resolve_config_path(config_path), baseline_path_override: resolve_contained_path(baseline_path, "baseline_path"), waiver_path_override: resolve_contained_path(waiver_path, "waiver_path"))
72
+ before_state = RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
73
+ current_env_obj = VerificationEnvironment.capture(repository_root: root)
74
+ current_env = {
75
+ "ruby_engine" => current_env_obj.ruby_engine,
76
+ "ruby_version" => current_env_obj.ruby_version,
77
+ "railverdict_version" => current_env_obj.railverdict_version,
78
+ "analyzer_versions" => current_env_obj.analyzer_versions
79
+ }
80
+ config = Configuration.load(effective_paths[:config]) rescue nil
81
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
82
+ contract = { "required_analyzers" => required, "verification_mode" => doc.dig("source_scope", "verification_mode"), "changed_base" => doc.dig("source_scope", "changed_base") }
83
+ result = Reuse.evaluate(handoff_document: doc, current_repository_state: before_state, current_environment: current_env, current_contract: contract)
84
+ after_state = RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
85
+ if before_state && after_state && before_state.digest != after_state.digest
86
+ result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: result.reasons + ["reuse_toctou"], handoff_valid: result.handoff_valid, receipt_fresh: result.receipt_fresh)
87
+ end
88
+ Serializers.tool_response({ "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") }, error: false)
89
+ rescue ArgumentError => e
90
+ Serializers.error_response(e.message, code: "invalid_arguments")
91
+ rescue StandardError => e
92
+ Serializers.error_response("verify_handoff failed: #{e.message}", code: "internal_error")
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_contained_path(raw_path, field_name)
98
+ return nil if raw_path.nil? || raw_path.to_s.strip.empty?
99
+ raw = raw_path.to_s.strip
100
+ raise ArgumentError, "#{field_name} contains NUL byte" if raw.include?("\u0000")
101
+ expanded = File.expand_path(raw, @server.repository_root)
102
+ raise ArgumentError, "#{field_name} escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
103
+ expanded
104
+ end
105
+
106
+ def resolve_config_path(config_path)
107
+ return File.join(@server.repository_root, ".railverdict.yml") if config_path.nil? || config_path.to_s.strip.empty?
108
+ raw = config_path.to_s.strip
109
+ raise ArgumentError, "config_path contains NUL byte" if raw.include?("\u0000")
110
+ expanded = File.expand_path(raw, @server.repository_root)
111
+ raise ArgumentError, "config_path escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
112
+ expanded
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end