browser_review_gate 0.2.2 → 0.3.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.
@@ -0,0 +1,170 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "uri"
4
+
5
+ module BrowserReviewGate
6
+ # Turns the evidence an agent lists in a submitted report into what is published.
7
+ #
8
+ # Files (screenshots, traces) are read from the directory of the report file, checked, hashed and
9
+ # copied into the git directory; the PR gets their names, sizes and SHA-256 digests, never the
10
+ # files. A digest shows that a file of that type existed when the run was published. It does not
11
+ # show that a browser produced it. Console and network entries are the agent's own account; they are
12
+ # trimmed and scrubbed of anything that looks like a credential.
13
+ class Evidence
14
+ MAX_FILE_BYTES = 20 * 1024 * 1024
15
+ MAX_FILES = 10
16
+ MAX_ENTRIES = 50
17
+ MAX_ENTRY = 500
18
+ KINDS = { "screenshots" => "screenshot", "trace" => "trace" }.freeze
19
+ # Accepted files by extension, each with the bytes its content must start with.
20
+ TYPES = {
21
+ ".png" => [ "image/png", [ "\x89PNG\r\n\x1A\n".b ] ],
22
+ ".jpg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
23
+ ".jpeg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
24
+ ".webp" => [ "image/webp", [ "RIFF".b ] ],
25
+ ".zip" => [ "application/zip", [ "PK\x03\x04".b ] ]
26
+ }.freeze
27
+ TRACE_TYPES = %w[.zip].freeze
28
+ SECRET = /
29
+ (?<key>authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|
30
+ id[_-]?token|token|secret|password|passwd|session(?:[_-]?id)?|csrf[_-]?token|auth)
31
+ (?<sep>["']?\s*[:=]\s*["']?)(?:bearer\s+|basic\s+)?[^\s"',;&]+
32
+ /xi
33
+ BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{8,}/i
34
+ JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/
35
+ LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/
36
+
37
+ # Masks values that look like credentials. Applied to everything an agent writes into a report.
38
+ def self.scrub(text)
39
+ text.to_s.gsub(SECRET) { "#{$~[:key]}#{$~[:sep]}[REDACTED]" }.gsub(BEARER) { "#{$1} [REDACTED]" }
40
+ .gsub(JWT, "[REDACTED]").gsub(LONG_TOKEN, "[REDACTED]")
41
+ end
42
+
43
+ # Origin and path only: query strings and fragments carry tokens too often to publish.
44
+ def self.safe_url(value)
45
+ uri = URI.parse(value.to_s)
46
+ return scrub(value.to_s.split(/[?#]/).first.to_s)[0, 300] unless uri.scheme
47
+
48
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
49
+ scrub("#{uri.scheme}://#{uri.host}#{port}#{uri.path}")[0, 300]
50
+ rescue URI::InvalidURIError
51
+ "[unparseable URL]"
52
+ end
53
+
54
+ attr_reader :files
55
+
56
+ # `base_dir` is where the report file lives; every evidence path is relative to it.
57
+ def initialize(base_dir:, config: Config.new)
58
+ @base_dir = base_dir && File.realpath(base_dir)
59
+ @config = config
60
+ @files = []
61
+ rescue SystemCallError
62
+ raise Error, "The report directory #{base_dir} cannot be read"
63
+ end
64
+
65
+ # Returns the report data with each case's evidence in its published form. Raises Error naming the
66
+ # case and the problem when a path, file or entry is not acceptable.
67
+ def process(data)
68
+ return data unless data.is_a?(Hash) && data["cases"].is_a?(Array)
69
+
70
+ data.merge("cases" => data["cases"].each_with_index.map { |test_case, index| process_case(test_case, index + 1) })
71
+ end
72
+
73
+ # Copies the accepted files under `dir`, by digest, so the run can be looked at later.
74
+ def keep(dir)
75
+ files.each do |file|
76
+ target = File.join(dir, "#{file["sha256"]}#{File.extname(file["name"])}")
77
+ FileUtils.mkdir_p(dir)
78
+ FileUtils.cp(file.fetch("source"), target) unless File.exist?(target)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def process_case(test_case, position)
85
+ return test_case unless test_case.is_a?(Hash) && test_case.key?("evidence")
86
+
87
+ evidence = test_case["evidence"]
88
+ raise Error, "case #{position} evidence must be an object" unless evidence.is_a?(Hash)
89
+
90
+ unknown = evidence.keys - %w[screenshots trace console_errors network_errors links]
91
+ raise Error, "case #{position} evidence has unknown keys: #{unknown.join(", ")}" if unknown.any?
92
+
93
+ published = {}
94
+ listed = Array(evidence["screenshots"]).map { |path| [ "screenshots", path ] }
95
+ listed << [ "trace", evidence["trace"] ] if evidence["trace"]
96
+ raise Error, "case #{position} evidence lists more than #{MAX_FILES} files" if listed.size > MAX_FILES
97
+
98
+ published["files"] = listed.map { |kind, path| file(kind, path, position) } if listed.any?
99
+ published["console_errors"] = entries(evidence["console_errors"], position, "console_errors") { |entry| Evidence.scrub(entry) } if evidence.key?("console_errors")
100
+ published["network_errors"] = entries(evidence["network_errors"], position, "network_errors") { |entry| network(entry) } if evidence.key?("network_errors")
101
+ published["links"] = links(evidence["links"], position) if evidence.key?("links")
102
+ test_case.merge("evidence" => published)
103
+ end
104
+
105
+ def file(kind, path, position)
106
+ raise Error, "case #{position} evidence files need a report file to be relative to" unless @base_dir
107
+ raise Error, "case #{position} #{kind} path must be a relative path inside the report directory" unless relative?(path)
108
+
109
+ full = File.join(@base_dir, path)
110
+ raise Error, "case #{position} #{kind} file #{path} does not exist" unless File.exist?(full)
111
+ raise Error, "case #{position} #{kind} file #{path} must be a regular file, not a link" if File.symlink?(full) || !File.file?(full)
112
+
113
+ real = File.realpath(full)
114
+ raise Error, "case #{position} #{kind} file #{path} is outside the report directory" unless real.start_with?("#{@base_dir}/")
115
+
116
+ extension = File.extname(real).downcase
117
+ allowed = kind == "trace" ? TRACE_TYPES : TYPES.keys - TRACE_TYPES
118
+ raise Error, "case #{position} #{kind} file #{path} must be one of #{allowed.join(", ")}" unless allowed.include?(extension)
119
+
120
+ bytes = File.size(real)
121
+ raise Error, "case #{position} #{kind} file #{path} is larger than #{MAX_FILE_BYTES / 1024 / 1024} MB" if bytes > MAX_FILE_BYTES
122
+
123
+ media_type, signatures = TYPES.fetch(extension)
124
+ head = File.binread(real, 16).to_s
125
+ raise Error, "case #{position} #{kind} file #{path} is not a #{media_type} file" unless signatures.any? { |signature| head.start_with?(signature) }
126
+
127
+ entry = { "kind" => KINDS.fetch(kind), "name" => File.basename(real)[0, 100], "media_type" => media_type,
128
+ "bytes" => bytes, "sha256" => Digest::SHA256.file(real).hexdigest }
129
+ @files << entry.merge("source" => real)
130
+ entry
131
+ end
132
+
133
+ def relative?(path)
134
+ path.is_a?(String) && !path.empty? && path.length <= 300 && !path.start_with?("/", "~") &&
135
+ !path.include?("\0") && !path.split(%r{[/\\]}).include?("..")
136
+ end
137
+
138
+ def entries(value, position, key)
139
+ raise Error, "case #{position} evidence #{key} must be a list" unless value.is_a?(Array)
140
+ raise Error, "case #{position} evidence #{key} has more than #{MAX_ENTRIES} entries" if value.size > MAX_ENTRIES
141
+
142
+ value.map { |entry| yield(entry).to_s[0, MAX_ENTRY] }
143
+ end
144
+
145
+ # A failed request as "METHOD URL -> STATUS", with the URL reduced to origin and path.
146
+ def network(entry)
147
+ return Evidence.safe_url(entry) unless entry.is_a?(Hash)
148
+
149
+ method = entry["method"].to_s.upcase[/\A[A-Z]{1,10}\z/]
150
+ status = entry["status"].to_s[/\A\d{1,3}|\A[a-z_:]{1,40}\z/i]
151
+ [ method, Evidence.safe_url(entry["url"]), status && "-> #{status}" ].compact.join(" ")
152
+ end
153
+
154
+ # Links to evidence kept elsewhere, accepted only on hosts the project listed.
155
+ def links(value, position)
156
+ raise Error, "case #{position} evidence links must be a list" unless value.is_a?(Array)
157
+ raise Error, "case #{position} evidence has more than #{MAX_FILES} links" if value.size > MAX_FILES
158
+
159
+ value.map do |link|
160
+ uri = URI.parse(link.to_s)
161
+ allowed = uri.scheme == "https" && uri.userinfo.nil? && @config.evidence["link_hosts"].include?(uri.host)
162
+ raise Error, "case #{position} evidence link #{link.to_s[0, 100]} must be https on a host listed in evidence.link_hosts" unless allowed
163
+
164
+ link.to_s[0, 300]
165
+ rescue URI::InvalidURIError
166
+ raise Error, "case #{position} evidence link is not a valid URL"
167
+ end
168
+ end
169
+ end
170
+ end
@@ -39,7 +39,7 @@ module BrowserReviewGate
39
39
  return [ :open, "Browser verification waived by #{waived_by}." ] if waived_by
40
40
  return [ :closed, "The browser-testing assessment for this commit is not available." ] unless assessment
41
41
  return [ :open, "Browser verification is not needed." ] unless assessment.required?
42
- return [ :open, "Required browser cases are verified." ] if verified?(pull_request, comments, assessment)
42
+ return [ :open, "Required browser cases are verified." ] if verified?(number, pull_request, comments, assessment)
43
43
 
44
44
  [ :closed, "Browser run needed: #{@config.command} #{number}" ]
45
45
  end
@@ -48,8 +48,11 @@ module BrowserReviewGate
48
48
  pull_request.fetch("labels").any? { |entry| entry["name"] == label }
49
49
  end
50
50
 
51
- def verified?(pull_request, comments, assessment)
52
- labelled?(pull_request, @config.label) && assessment.covered_by?(Report.latest(comments))
51
+ # Only cases tested on a commit of this PR count: a report about other code proves nothing here.
52
+ def verified?(number, pull_request, comments, assessment)
53
+ labelled?(pull_request, @config.label) &&
54
+ assessment.covered_by?(Report.latest(comments, config: @config), commits: @github.pull_request_commits(number),
55
+ reuse_passing: @config.scenarios["reuse_passing"])
53
56
  end
54
57
 
55
58
  # A waiver counts only from someone other than the author, unless the project allows otherwise.
@@ -146,8 +149,8 @@ module BrowserReviewGate
146
149
  def notice(number, assessment, comments)
147
150
  return "Review request paused. The browser-testing assessment for this commit is not available. Request review again to retry it." unless assessment
148
151
 
149
- report = Report.latest_passing(comments)
150
- missing = report && !assessment.covered_by?(report) ? "the published browser run does not cover the latest commits" :
152
+ report = Report.latest(comments, config: @config)
153
+ missing = report&.passed? && !assessment.covered_by?(report, commits: @github.pull_request_commits(number)) ? "the published browser run does not cover the latest commits" :
151
154
  "no successful local browser run has been published yet"
152
155
  "Review request paused. Browser testing is required for this change, and #{missing}. " \
153
156
  "Run `#{@config.command} #{number}` in your AI coding agent from the PR branch."
@@ -51,6 +51,13 @@ module BrowserReviewGate
51
51
  raise Error, "gh returned output that is not a pull request list"
52
52
  end
53
53
 
54
+ # Full SHAs of the PR's commits, oldest first. GitHub lists at most 250.
55
+ def pull_request_commits(number)
56
+ pages("repos/#{repository}/pulls/#{number}/commits").map { |commit| commit.fetch("sha") }
57
+ rescue KeyError
58
+ raise Error, "gh returned commits without a sha for PR #{number}"
59
+ end
60
+
54
61
  def files(number)
55
62
  pages("repos/#{repository}/pulls/#{number}/files")
56
63
  end
@@ -24,6 +24,9 @@ module BrowserReviewGate
24
24
  raise Error, "Set `model` in #{Config::PATH} to assess with #{provider}" unless @model
25
25
  end
26
26
 
27
+ # Token counts of the last call as the provider reported them, or nil. Providers report no cost.
28
+ attr_reader :last_usage
29
+
27
30
  # Returns the model's answer as JSON text. Raises Error when the call fails or gives no answer.
28
31
  def complete(system:, user:, schema:)
29
32
  key = ENV[@key_name].to_s
@@ -32,6 +35,7 @@ module BrowserReviewGate
32
35
  url, headers, body = send("#{@provider}_request", key, system, user, schema)
33
36
  status, text = @transport.call(url, headers, body)
34
37
  response = parse(text)
38
+ @last_usage = usage(response)
35
39
  raise Error, "The model gave no answer: HTTP #{status} #{failure(response, text)}" unless status.to_i == 200
36
40
 
37
41
  answer = parse(send("#{@provider}_answer", response))
@@ -81,6 +85,18 @@ module BrowserReviewGate
81
85
  Array(response.dig("candidates", 0, "content", "parts")).filter_map { |part| part["text"] if part.is_a?(Hash) }.join
82
86
  end
83
87
 
88
+ def usage(response)
89
+ input, output = case @provider
90
+ when "anthropic" then [ response.dig("usage", "input_tokens"), response.dig("usage", "output_tokens") ]
91
+ when "openai" then [ response.dig("usage", "prompt_tokens"), response.dig("usage", "completion_tokens") ]
92
+ else [ response.dig("usageMetadata", "promptTokenCount"), response.dig("usageMetadata", "candidatesTokenCount") ]
93
+ end
94
+ return unless input.is_a?(Integer) || output.is_a?(Integer)
95
+
96
+ { "provider" => @provider, "model" => (response["model"] || response["modelVersion"] || @model).to_s,
97
+ "input_tokens" => input, "output_tokens" => output }.compact
98
+ end
99
+
84
100
  def parse(text)
85
101
  data = JSON.parse(text.to_s)
86
102
  data.is_a?(Hash) ? data : {}
@@ -32,6 +32,9 @@ module BrowserReviewGate
32
32
  @runner = runner || method(:run)
33
33
  end
34
34
 
35
+ # Token counts and cost of the last call as the CLI reported them, or nil.
36
+ attr_reader :last_usage
37
+
35
38
  # Returns the model's answer as JSON text. Raises Error when the call fails or gives no answer.
36
39
  def complete(system:, user:, schema:)
37
40
  argv = @command + [ "--print", "--tools", "", "--setting-sources", "", "--system-prompt", system,
@@ -39,6 +42,7 @@ module BrowserReviewGate
39
42
  argv += [ "--model", @model ] if @model
40
43
  stdout, stderr, success = @runner.call(environment, argv, user)
41
44
  result = parse(stdout)
45
+ @last_usage = usage(result)
42
46
  answer = result["structured_output"] if success && result["is_error"] == false
43
47
  return JSON.generate(answer) if answer.is_a?(Hash)
44
48
 
@@ -47,6 +51,12 @@ module BrowserReviewGate
47
51
 
48
52
  private
49
53
 
54
+ def usage(result)
55
+ tokens = result["usage"].is_a?(Hash) ? result["usage"] : {}
56
+ { "provider" => "claude-cli", "model" => @model, "input_tokens" => tokens["input_tokens"],
57
+ "output_tokens" => tokens["output_tokens"], "cost_usd" => result["total_cost_usd"] }.compact.then { |found| found.size > 1 ? found : nil }
58
+ end
59
+
50
60
  def parse(stdout)
51
61
  result = JSON.parse(stdout.to_s)
52
62
  result.is_a?(Hash) ? result : {}
@@ -30,10 +30,10 @@ module BrowserReviewGate
30
30
  text(RULES)
31
31
  end
32
32
 
33
- def assessment
33
+ def assessment(max_count: 12)
34
34
  parts = [ FRAME_HEAD, "## Decision\n\n#{policy}" ]
35
35
  parts << "## Project rules\n\n#{PROJECT_RULES_NOTE}\n\n#{rules}" if rules
36
- parts << FRAME_TAIL
36
+ parts << format(FRAME_TAIL, max_count: max_count)
37
37
  parts.join("\n\n")
38
38
  end
39
39
 
@@ -58,9 +58,14 @@ module BrowserReviewGate
58
58
 
59
59
  When the decision is `required`, `scenarios` lists what a person must do in the browser to exercise
60
60
  every behavior this pull request changes, including what the project rules require for the areas
61
- it touches. One entry per scenario, at most 12, the fewest that cover the change. `name` says what
62
- to do and what must be seen, in one sentence. `id` is a short label such as `SIGN-IN-1`: letters,
63
- digits and hyphens.
61
+ it touches. One entry per scenario, at most %<max_count>d, the fewest that cover the change. `name` says
62
+ what to do and what must be seen, in one sentence. `id` is a short label such as `SIGN-IN-1`:
63
+ letters, digits and hyphens. Derive the id from the behavior, not from its position, so the same
64
+ behavior gets the same id when the pull request changes. `priority` is `critical` when a failure
65
+ would block people from a core task or expose data, otherwise `normal`. `category` is one short
66
+ lowercase word for the area, such as `forms`, `navigation` or `permissions`. `why` is one sentence
67
+ saying which change makes this scenario necessary. Scenarios the project settings make mandatory
68
+ are added after you answer; do not list them.
64
69
 
65
70
  When `report_cases` is present, those are the cases of an earlier browser run. If one of them
66
71
  already exercises a scenario, give that scenario the `id` of that case, exactly. Give every other
@@ -27,6 +27,7 @@ module BrowserReviewGate
27
27
  saved_report = SavedReport.new(sha: local_sha, shell: @shell)
28
28
  data = load_report(saved_report)
29
29
  ensure_report_sha!(data, local_sha)
30
+ ensure_environment!(data)
30
31
  actor = @github.login
31
32
  data["verified_by"] = "AI browser agent (run by #{actor})"
32
33
 
@@ -37,9 +38,10 @@ module BrowserReviewGate
37
38
  complete = report.passed?
38
39
  labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
39
40
  @github.remove_label(number, @config.label) if labelled && !complete
40
- write_comment(number, comments, actor, body(report, complete))
41
+ write_comment(number, comments, actor, body(report, complete, @github.pull_request_commits(number)))
41
42
  @github.add_label(number, @config.label) if complete
42
43
  saved_report.delete unless @report_path
44
+ SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
43
45
  complete
44
46
  rescue KeyError => error
45
47
  raise Error, "Browser verification data is incomplete: #{error.message}"
@@ -53,11 +55,13 @@ module BrowserReviewGate
53
55
  ensure_clean_worktree!
54
56
  data = read_report_file
55
57
  ensure_report_sha!(data, local_sha)
56
- report = Report.new(data.merge("verified_by" => "pending"))
58
+ ensure_environment!(data)
59
+ report = Report.new(data.merge("verified_by" => "pending"), config: @config)
57
60
  ensure_acceptable!(report)
58
61
 
59
62
  saved_report = SavedReport.new(sha: local_sha, shell: @shell)
60
63
  saved_report.write(data)
64
+ SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
61
65
  saved_report.path
62
66
  rescue JSON::ParserError => error
63
67
  raise Error, "Browser verification data is not valid JSON: #{error.message}"
@@ -89,14 +93,28 @@ module BrowserReviewGate
89
93
  raise Error, "No saved browser verification report for the current commit"
90
94
  end
91
95
 
96
+ # Evidence files are read here, next to the report, and kept in the git directory by digest.
92
97
  def read_report_file
93
98
  raise Error, "A report file is required" unless @report_path
94
99
 
95
- JSON.parse(File.read(@report_path))
100
+ data = JSON.parse(File.read(@report_path))
101
+ evidence = Evidence.new(base_dir: File.dirname(File.expand_path(@report_path)), config: @config)
102
+ processed = evidence.process(data)
103
+ evidence.keep(SavedReport.evidence_dir(shell: @shell))
104
+ processed
96
105
  rescue SystemCallError => error
97
106
  raise Error, "Cannot read report file #{@report_path}: #{error.message}"
98
107
  end
99
108
 
109
+ # A run names the environment it used; with environments configured it must be one of them.
110
+ def ensure_environment!(data)
111
+ name = data["environment"]
112
+ return if @config.environments.empty? ? Config::NAME_PATTERN.match?(name.to_s) : @config.environments.key?(name)
113
+
114
+ known = @config.environments.keys
115
+ raise Error, "Report environment must be #{known.empty? ? "a short lowercase name" : "one of #{known.join(", ")}"}, not #{name.inspect}"
116
+ end
117
+
100
118
  # A report made before the PR existed may arrive before the gate has assessed the commit.
101
119
  def ensure_browser_run_wanted!(number, comments, tested_sha)
102
120
  assessment = Assessment.latest(comments)
@@ -106,11 +124,16 @@ module BrowserReviewGate
106
124
  end
107
125
 
108
126
  def merged_report(data, comments)
109
- submitted = Report.new(data)
127
+ submitted = Report.new(data, config: @config)
110
128
  ensure_acceptable!(submitted)
111
129
 
112
130
  prior = Report.latest_comment(comments)
113
- report = Report.merge(prior ? Report.data_from(prior["body"]) : {}, submitted.to_h)
131
+ prior_data = begin
132
+ prior ? Report.data_from(prior["body"]) : {}
133
+ rescue Error
134
+ {}
135
+ end
136
+ report = Report.merge(prior_data, submitted.to_h, config: @config)
114
137
  raise Error, report.errors.join("; ") unless report.valid?
115
138
 
116
139
  report
@@ -118,15 +141,15 @@ module BrowserReviewGate
118
141
 
119
142
  # A run is taken only with the evidence of each case: the page and what was observed there.
120
143
  def ensure_acceptable!(report)
121
- problems = report.valid? ? report.evidence_errors : report.errors
144
+ problems = report.valid? ? report.submission_errors : report.errors
122
145
  raise Error, problems.join("; ") if problems.any?
123
146
  end
124
147
 
125
- def body(report, complete)
126
- return report.markdown if complete
148
+ def body(report, complete, commits)
149
+ return report.markdown(commits: commits) if complete
127
150
 
128
151
  [ "**Not verified.** Complete every required browser scenario and pass all reported cases before human review.",
129
- "", report.markdown ].join("\n")
152
+ "", report.markdown(commits: commits) ].join("\n")
130
153
  end
131
154
 
132
155
  # Only the author's own earlier report comment can be edited in place.