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
|
@@ -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
|
|
43
|
-
#
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|