browser_review_gate 0.3.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2e4e7dfaa70718cc4eaf691aa3f60023ae10968b34127b0b363ff15c42971c6
4
- data.tar.gz: e29d1f6ba94e5d07ebba865d6faa838efe9222eba7f5ece6e286fc3d88895bbf
3
+ metadata.gz: bf07fc0385a222895293b09a8000db74bf153a3b87731528b3e2d51c20cfd4e0
4
+ data.tar.gz: 5866378bb1acbfa17f03d44d98413c6f6222903858b7ec7dd6f71a9b6ba95788
5
5
  SHA512:
6
- metadata.gz: d9859bc00fe366be2568ee088c18cdd19431e80cf0f468fd8370ec9465ce7a2111cd761ea96b1c44693c5188c1c1c2073daec2de928b05265942c9babdd99cd1
7
- data.tar.gz: c5a80a4802e8d9053d3f0c2fcf2f197152e6b8b9ac9d8e172c24c3fcdc57ce153dd15802afaaab8fe31c8908d26a25161caa09a922252f3313b0f2d7333e45a4
6
+ metadata.gz: de467b5092e7d8d3e9871e789b2c844712808f6af4916173d3ec903fb84407eac61218c5e6ee54e4c20e91e10ccdd3cb4d06010a355e2ddae869118b496c4791
7
+ data.tar.gz: 4ca8044e0cc223d3e269542ff1edb0ab635bdd710c549d017e48802c834eeef9b00fb2ff7ed71f9c9be37bd683185ea2d5450e95256574da7b12cf82723b7979
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - `provenance.trace` (off by default): the gem opens each case's Playwright trace and checks that a
6
+ response carried the tested commit in `revision_header`, that the case's page was requested, and
7
+ that requests fall within the run's time window. `warn` shows the result in the PR; `require`
8
+ counts a passing case only with a verified trace. Traces are read in memory with only zlib, with
9
+ caps on entries, sizes and compression ratio.
10
+ - A HAR network log (`.har`) is accepted as a case's trace too, so the check works with any browser
11
+ tool that saves HAR, not only Playwright.
12
+ - `doctor` warns when `provenance.trace` is on and no Playwright is configured in the project; `verify`
13
+ tells the agent a trace or HAR is needed per case.
14
+ - `BrowserReviewGate::RevisionHeader`: Rack middleware for development that sends `X-App-Revision`
15
+ with the checked-out commit and follows new commits without a restart.
16
+ - `doctor` checks that the app sends the revision header and that it matches the checkout.
17
+ - The installer's next steps name the 0.3 settings.
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  Breaking: the report format and the settings change, and nothing from 0.2 is read. Move `url`,
data/README.md CHANGED
@@ -95,6 +95,12 @@ scenarios:
95
95
  gate:
96
96
  require_evidence: false # enforced: passing cases need the evidence asked for above
97
97
  block_on_failure: true # enforced: any failing case keeps review on hold
98
+
99
+ provenance:
100
+ trace: "off" # enforced: off | warn | require (see "Checking that a run happened")
101
+ revision_header: "X-App-Revision"
102
+ check_pages: true
103
+ clock_skew_seconds: 300
98
104
  ```
99
105
 
100
106
  Other keys: `provider` and `model` (who assesses, see below), `workflow`, `label`, `waiver_label`, `waiver_by_author`, `status_context`, `command`, `skip_authors`, `skip_branch_prefixes`, `ignore_paths`.
@@ -105,11 +111,30 @@ Other keys: `provider` and `model` (who assesses, see below), `workflow`, `label
105
111
 
106
112
  **Not configurable on purpose.** Run timeouts and retries are not settings: the browser run happens in the agent, and the gem cannot enforce them. Tell the agent in `rules.md` if you have expectations.
107
113
 
114
+ ## Checking that a run happened
115
+
116
+ Off by default. A report is the agent's account; `provenance.trace` lets the gem check it against a Playwright trace instead of taking it on trust.
117
+
118
+ 1. Make the app name the commit it serves, in development only:
119
+
120
+ ```ruby
121
+ # config/environments/development.rb
122
+ config.middleware.use BrowserReviewGate::RevisionHeader
123
+ ```
124
+
125
+ It adds `X-App-Revision: <HEAD SHA>` to every response and follows new commits without a restart. Any other way of sending the header works too; name it in `provenance.revision_header`.
126
+ 2. Set `provenance.trace` to `warn` (results shown in the PR) or `require` (a passing case counts only with a verified trace). `doctor` checks that the app sends the header and that it matches the checkout.
127
+ 3. The agent records a Playwright trace (`.zip`) or a HAR network log (`.har`) per case and lists it as `evidence.trace`. HAR is a standard format that Playwright, Chrome DevTools ("Save all as HAR") and many other tools write, so the check does not need Playwright itself. A browser tool that can save neither cannot satisfy `require`; `doctor` and `verify` warn about it before a run.
128
+
129
+ When the run is published, the gem opens each trace (a zip, or HAR JSON; read in memory with size, entry and compression-ratio caps, no extra dependency) and checks its network log: a response carried the revision header with the tested commit, the case's page was requested, and the request times fall within `started_at`..`finished_at` (± `clock_skew_seconds`). The PR shows "trace verified" or what was wrong.
130
+
131
+ What it catches: an agent that reports a run it did not do, ran against another commit or a stale server, or ran a different page. What it does not catch: someone who builds a trace by hand. It is a check on agents, not an attestation.
132
+
108
133
  ## Reports
109
134
 
110
135
  A report is JSON in the shape the playbook describes (`schema_version: 2`): the tested commit (full SHA), the environment, the browser tool and its capabilities, start and finish times, and one case per scenario with `result` (`pass`, `fail`, `blocked`, `skipped`), the page, what was observed, and evidence.
111
136
 
112
- - Screenshots (`.png`, `.jpg`, `.webp`) and traces (`.zip`) are read from the report's directory. Paths that leave it, links, wrong file types (checked by content, not just name) and files over 20 MB are refused. Accepted files are hashed and kept in the git directory; the PR shows their names, sizes and SHA-256 digests, not the files.
137
+ - Screenshots (`.png`, `.jpg`, `.webp`) and traces (`.zip` Playwright traces or `.har` network logs) are read from the report's directory. Paths that leave it, links, wrong file types (checked by content, not just name) and files over 20 MB are refused. Accepted files are hashed and kept in the git directory; the PR shows their names, sizes and SHA-256 digests, not the files.
113
138
  - Console errors and failed requests are trimmed, query strings are dropped, and values that look like credentials are masked.
114
139
  - Evidence links are accepted only on hosts listed in `evidence.link_hosts`; use them to point reviewers at screenshots you uploaded to your own artifact store.
115
140
  - A case counts when it passes, breaks no enforced policy, and was tested on a commit of the PR.
@@ -169,7 +194,7 @@ Set `provider` to `claude-cli`, `anthropic`, `openai` or `gemini` to choose expl
169
194
 
170
195
  Residual risks, stated plainly:
171
196
 
172
- - **A report is the agent's account, not an attestation.** Someone with push access, or an agent that misreports, can publish a passing report for a run that did not happen. The gem raises the cost (exact scenario ids, pages, observations, evidence digests, commit checks) but cannot prove a browser did anything. Human review remains the control.
197
+ - **A report is the agent's account, not an attestation.** Someone with push access can publish a passing report for a run that did not happen. With `provenance.trace: require` an agent cannot do so by accident or by guessing, but a hand-built trace still passes. Human review remains the control.
173
198
  - **Evidence files stay local.** Reviewers see digests, not screenshots, unless you upload them and link them through `evidence.link_hosts`.
174
199
  - **Scrubbing is pattern-based.** It catches common credential shapes, not everything. Agents are told not to record secrets; screenshots and traces are never inspected.
175
200
  - **A taken-back review request may still notify the reviewer**, because GitHub has no way to refuse a request.
@@ -30,6 +30,7 @@ module BrowserReviewGate
30
30
  "evidence" => nil,
31
31
  "scenarios" => nil,
32
32
  "gate" => nil,
33
+ "provenance" => nil,
33
34
  "skip_authors" => [ "dependabot[bot]", "github-actions[bot]" ],
34
35
  "skip_branch_prefixes" => [],
35
36
  # A PR that only touches these paths cannot change browser behavior; no model call is made.
@@ -41,8 +42,12 @@ module BrowserReviewGate
41
42
  "evidence" => { "screenshots" => "never", "trace" => "never", "console_logs" => false, "network_logs" => false,
42
43
  "retention_days" => 14, "link_hosts" => [] },
43
44
  "scenarios" => { "max_count" => 12, "reuse_passing" => true, "mandatory" => [], "matrix" => {} },
44
- "gate" => { "require_evidence" => false, "block_on_failure" => true }
45
+ "gate" => { "require_evidence" => false, "block_on_failure" => true },
46
+ # Checks a case's Playwright trace against the tested commit: off, warn (shown in the PR) or
47
+ # require (a passing case counts only with a verified trace).
48
+ "provenance" => { "trace" => "off", "revision_header" => "X-App-Revision", "check_pages" => true, "clock_skew_seconds" => 300 }
45
49
  }.freeze
50
+ TRACE_MODES = %w[off warn require].freeze
46
51
 
47
52
  ENVIRONMENT_KEYS = %w[url healthcheck start_command sign_in].freeze
48
53
  MANDATORY_KEYS = %w[id name why paths matrix].freeze
@@ -81,7 +86,7 @@ module BrowserReviewGate
81
86
  validate
82
87
  end
83
88
 
84
- (DEFAULTS.keys - %w[environments verification evidence scenarios gate]).each do |key|
89
+ (DEFAULTS.keys - %w[environments verification evidence scenarios gate provenance]).each do |key|
85
90
  define_method(key) { @data[key] }
86
91
  end
87
92
 
@@ -91,6 +96,7 @@ module BrowserReviewGate
91
96
  def evidence = @sections["evidence"]
92
97
  def scenarios = @sections["scenarios"]
93
98
  def gate = @sections["gate"]
99
+ def provenance = @sections["provenance"]
94
100
 
95
101
  # The environment a run uses: the one named, the configured default, or the only one there is.
96
102
  def environment(name = nil)
@@ -132,10 +138,12 @@ module BrowserReviewGate
132
138
  "require_evidence" => gate["require_evidence"],
133
139
  "block_on_failure" => gate["block_on_failure"],
134
140
  "reuse_passing" => scenarios["reuse_passing"],
135
- "max_scenarios" => scenarios["max_count"]
141
+ "max_scenarios" => scenarios["max_count"],
142
+ "trace_check" => provenance["trace"]
136
143
  },
137
144
  "agent" => {
138
- "screenshots" => evidence["screenshots"], "trace" => evidence["trace"],
145
+ "screenshots" => evidence["screenshots"],
146
+ "trace" => provenance["trace"] == "off" ? evidence["trace"] : "always",
139
147
  "console_logs" => evidence["console_logs"], "network_logs" => evidence["network_logs"]
140
148
  }
141
149
  }
@@ -182,6 +190,7 @@ module BrowserReviewGate
182
190
  environment # the configured default must exist
183
191
  validate_flags
184
192
  validate_evidence
193
+ validate_provenance
185
194
  validate_scenarios
186
195
  end
187
196
 
@@ -209,6 +218,15 @@ module BrowserReviewGate
209
218
  raise Error, "#{PATH}: evidence.link_hosts must be a list of host names"
210
219
  end
211
220
 
221
+ def validate_provenance
222
+ raise Error, "#{PATH}: provenance.trace must be one of #{TRACE_MODES.join(", ")}" unless TRACE_MODES.include?(provenance["trace"])
223
+ raise Error, "#{PATH}: provenance.revision_header must be an HTTP header name" unless provenance["revision_header"].to_s.match?(/\A[A-Za-z0-9-]{1,64}\z/)
224
+ raise Error, "#{PATH}: provenance.check_pages must be true or false" unless [ true, false ].include?(provenance["check_pages"])
225
+
226
+ skew = provenance["clock_skew_seconds"]
227
+ raise Error, "#{PATH}: provenance.clock_skew_seconds must be a whole number from 0 to 3600" unless skew.is_a?(Integer) && skew.between?(0, 3600)
228
+ end
229
+
212
230
  def validate_scenarios
213
231
  count = scenarios["max_count"]
214
232
  raise Error, "#{PATH}: scenarios.max_count must be a whole number from 1 to #{MAX_SCENARIOS}" unless count.is_a?(Integer) && count.between?(1, MAX_SCENARIOS)
@@ -19,7 +19,9 @@ module BrowserReviewGate
19
19
  }.freeze
20
20
 
21
21
  # `probe` takes a URL and says whether something answers there.
22
- def initialize(root:, shell: Shell.new, github: nil, probe: nil, config_loader: nil)
22
+ # `header_probe` takes a URL and a header name and returns the header's value there, or nil.
23
+ def initialize(root:, shell: Shell.new, github: nil, probe: nil, config_loader: nil, header_probe: nil)
24
+ @header_probe = header_probe || Status.method(:header_at)
23
25
  @root = root
24
26
  @shell = shell
25
27
  @github = github
@@ -37,7 +39,7 @@ module BrowserReviewGate
37
39
  repository_access if gh_ready
38
40
  environments(config) if config
39
41
  agents(config)
40
- browser_tools
42
+ browser_tools(config)
41
43
  workflow(config) if config
42
44
  required_check(config) if config && gh_ready
43
45
  provider_secrets(config) if config && gh_ready
@@ -134,6 +136,7 @@ module BrowserReviewGate
134
136
  url = Status.healthcheck_url(settings.merge("name" => name))
135
137
  if @probe.call(url)
136
138
  add("environment #{name}", "PASS", "answers at #{url}")
139
+ revision_header(config, name, settings["url"])
137
140
  else
138
141
  add("environment #{name}", "WARN", "does not answer at #{url}",
139
142
  settings["start_command"] ? "Start it with #{settings["start_command"]} before a run" : "Make it reachable from where the agent runs")
@@ -141,6 +144,23 @@ module BrowserReviewGate
141
144
  end
142
145
  end
143
146
 
147
+ # With trace checks on, a run can only count when the app names the commit it serves.
148
+ def revision_header(config, name, url)
149
+ mode = config.provenance["trace"]
150
+ return if mode == "off"
151
+
152
+ header = config.provenance["revision_header"]
153
+ served = @header_probe.call(url, header).to_s.strip.downcase
154
+ head = run("git", "-C", @root, "rev-parse", "HEAD").to_s.strip
155
+ label = "revision header #{name}"
156
+ fix = "Add `config.middleware.use BrowserReviewGate::RevisionHeader` to config/environments/development.rb"
157
+ return add(label, mode == "require" ? "FAIL" : "WARN", "#{url} sends no #{header} header; traces cannot be checked", fix) if served.empty?
158
+ return add(label, "PASS", "#{header} matches the checked-out commit") if !head.empty? && head.start_with?(served)
159
+
160
+ add(label, "WARN", "#{header} is #{served[0, 12]}, the checkout is at #{head[0, 12]}",
161
+ "Serve the checked-out code (restart the server, or run it from this worktree)")
162
+ end
163
+
144
164
  def agents(config)
145
165
  installer = Installer.new(root: @root, log: StringIO.new)
146
166
  installer.detect_agents.each do |agent|
@@ -163,14 +183,30 @@ module BrowserReviewGate
163
183
  end
164
184
 
165
185
  # Only what the project's own files show. A tool configured elsewhere cannot be seen from here.
166
- def browser_tools
186
+ def browser_tools(config)
167
187
  files = [ ".mcp.json", ".cursor/mcp.json", ".codex/config.toml", ".claude/settings.json", "package.json" ]
168
188
  text = files.map { |path| read(path) }.join("\n").downcase
169
189
  found = BROWSER_TOOLS.select { |key, _label| text.include?(key) }.values
170
- return add("browser tool", "PASS", "configured in the project: #{found.join(", ")}") if found.any?
190
+ if found.any?
191
+ add("browser tool", "PASS", "configured in the project: #{found.join(", ")}")
192
+ else
193
+ add("browser tool", "SKIP", "none found in the project's files; one may be configured per user",
194
+ "Give the agent a browser tool, e.g. Playwright MCP or Chrome DevTools MCP")
195
+ end
196
+ trace_tool(config, text) if config
197
+ end
198
+
199
+ # With trace checks on, every case needs a Playwright trace or a HAR file. Only Playwright is known
200
+ # to write both; other tools may write HAR, but that cannot be told from here.
201
+ def trace_tool(config, text)
202
+ mode = config.provenance["trace"]
203
+ return if mode == "off"
204
+ return add("trace recording", "PASS", "Playwright is configured in the project; it records traces and HAR files") if text.include?("playwright")
171
205
 
172
- add("browser tool", "SKIP", "none found in the project's files; one may be configured per user",
173
- "Give the agent a browser tool, e.g. Playwright MCP or Chrome DevTools MCP")
206
+ add("trace recording", mode == "require" ? "WARN" : "SKIP",
207
+ "provenance.trace is #{mode}, and no Playwright is configured in the project's files. Each case needs a Playwright " \
208
+ "trace (.zip) or a HAR file (.har); a browser tool that cannot save either #{mode == "require" ? "gets every passing case refused" : "gets no check"}",
209
+ "Give the agent Playwright (e.g. Playwright MCP), or a tool that saves HAR files, or set provenance.trace to warn or off")
174
210
  end
175
211
 
176
212
  def workflow(config)
@@ -22,9 +22,11 @@ module BrowserReviewGate
22
22
  ".jpg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
23
23
  ".jpeg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
24
24
  ".webp" => [ "image/webp", [ "RIFF".b ] ],
25
- ".zip" => [ "application/zip", [ "PK\x03\x04".b ] ]
25
+ ".zip" => [ "application/zip", [ "PK\x03\x04".b ] ],
26
+ # A HAR network log: JSON, so it starts with "{" after optional whitespace or a byte order mark.
27
+ ".har" => [ "application/json", [ "{".b ] ]
26
28
  }.freeze
27
- TRACE_TYPES = %w[.zip].freeze
29
+ TRACE_TYPES = %w[.zip .har].freeze
28
30
  SECRET = /
29
31
  (?<key>authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|
30
32
  id[_-]?token|token|secret|password|passwd|session(?:[_-]?id)?|csrf[_-]?token|auth)
@@ -67,6 +69,7 @@ module BrowserReviewGate
67
69
  def process(data)
68
70
  return data unless data.is_a?(Hash) && data["cases"].is_a?(Array)
69
71
 
72
+ @run = data
70
73
  data.merge("cases" => data["cases"].each_with_index.map { |test_case, index| process_case(test_case, index + 1) })
71
74
  end
72
75
 
@@ -96,6 +99,8 @@ module BrowserReviewGate
96
99
  raise Error, "case #{position} evidence lists more than #{MAX_FILES} files" if listed.size > MAX_FILES
97
100
 
98
101
  published["files"] = listed.map { |kind, path| file(kind, path, position) } if listed.any?
102
+ trace = @files.reverse.find { |file| file["kind"] == "trace" && published.fetch("files", []).any? { |entry| entry["sha256"] == file["sha256"] } }
103
+ published["trace_check"] = check_trace(trace["source"], test_case) if trace && @config.provenance["trace"] != "off"
99
104
  published["console_errors"] = entries(evidence["console_errors"], position, "console_errors") { |entry| Evidence.scrub(entry) } if evidence.key?("console_errors")
100
105
  published["network_errors"] = entries(evidence["network_errors"], position, "network_errors") { |entry| network(entry) } if evidence.key?("network_errors")
101
106
  published["links"] = links(evidence["links"], position) if evidence.key?("links")
@@ -121,7 +126,7 @@ module BrowserReviewGate
121
126
  raise Error, "case #{position} #{kind} file #{path} is larger than #{MAX_FILE_BYTES / 1024 / 1024} MB" if bytes > MAX_FILE_BYTES
122
127
 
123
128
  media_type, signatures = TYPES.fetch(extension)
124
- head = File.binread(real, 16).to_s
129
+ head = File.binread(real, 64).to_s.delete_prefix("\xEF\xBB\xBF".b).lstrip
125
130
  raise Error, "case #{position} #{kind} file #{path} is not a #{media_type} file" unless signatures.any? { |signature| head.start_with?(signature) }
126
131
 
127
132
  entry = { "kind" => KINDS.fetch(kind), "name" => File.basename(real)[0, 100], "media_type" => media_type,
@@ -130,6 +135,14 @@ module BrowserReviewGate
130
135
  entry
131
136
  end
132
137
 
138
+ # A trace that cannot be read is a failed check, not an error: the PR shows why.
139
+ def check_trace(path, test_case)
140
+ TraceCheck.new(@config).call(path, tested_sha: @run["tested_sha"].to_s, url: test_case["url"],
141
+ started_at: @run["started_at"], finished_at: @run["finished_at"])
142
+ rescue Error => error
143
+ { "status" => "failed", "requests" => 0, "findings" => [ error.message[0, 300] ] }
144
+ end
145
+
133
146
  def relative?(path)
134
147
  path.is_a?(String) && !path.empty? && path.length <= 300 && !path.start_with?("/", "~") &&
135
148
  !path.include?("\0") && !path.split(%r{[/\\]}).include?("..")
@@ -193,9 +193,10 @@ module BrowserReviewGate
193
193
  <<~TEXT
194
194
  Next:
195
195
  1. Add one secret to the repository: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY.
196
- 2. Fill start_command, url and sign_in in #{Config::PATH}.
196
+ 2. Describe your environments (url, healthcheck, start_command, sign_in) in #{Config::PATH}.
197
197
  3. Describe what this application always checks in #{Prompts::RULES}.
198
198
  4. Commit and merge to the default branch: the workflow runs from there.
199
+ 5. Run browser-review-gate doctor to check the setup.
199
200
  TEXT
200
201
  end
201
202
  end
@@ -19,7 +19,7 @@ module BrowserReviewGate
19
19
  TOP_KEYS = %w[schema_version tested_sha verified_by environment tool started_at finished_at coverage_complete
20
20
  cases excluded runs failed_runs].freeze
21
21
  CASE_KEYS = %w[id name result url details reason evidence tested_sha verified_by environment tool].freeze
22
- EVIDENCE_KEYS = %w[files console_errors network_errors links].freeze
22
+ EVIDENCE_KEYS = %w[files console_errors network_errors links trace_check].freeze
23
23
  FILE_KEYS = %w[kind name media_type bytes sha256].freeze
24
24
 
25
25
  attr_reader :errors
@@ -112,7 +112,7 @@ module BrowserReviewGate
112
112
  return "failed network requests were reported"
113
113
  end
114
114
 
115
- missing_evidence(test_case, evidence)
115
+ missing_evidence(test_case, evidence) || trace_problem(evidence)
116
116
  end
117
117
 
118
118
  def failed? = cases.any? { |test_case| status_of(test_case["id"]) == "failed" }
@@ -139,7 +139,8 @@ module BrowserReviewGate
139
139
  found << "case #{position} is #{test_case["result"]} and needs a reason" unless text?(test_case["reason"], 300)
140
140
  end
141
141
  found << "case #{position} details must say what was observed" unless text?(test_case["details"], 500)
142
- problem = test_case["result"] == "pass" && @config.gate["require_evidence"] ? missing_evidence(test_case, test_case["evidence"] || {}) : nil
142
+ evidence = test_case["evidence"].is_a?(Hash) ? test_case["evidence"] : {}
143
+ problem = test_case["result"] == "pass" ? (missing_evidence(test_case, evidence) || trace_problem(evidence)) : nil
143
144
  found << "case #{position} #{problem}" if problem
144
145
  found
145
146
  end
@@ -193,6 +194,17 @@ module BrowserReviewGate
193
194
  nil
194
195
  end
195
196
 
197
+ # With provenance.trace require, a passing case counts only when its trace showed the run.
198
+ def trace_problem(evidence)
199
+ return unless @config.provenance["trace"] == "require"
200
+
201
+ check = evidence["trace_check"]
202
+ return "has no Playwright trace, which provenance.trace requires" unless check.is_a?(Hash)
203
+ return if check["status"] == "verified"
204
+
205
+ "trace does not show the run: #{Array(check["findings"]).first || "the check failed"}"
206
+ end
207
+
196
208
  # A carried-over case keeps the tool of the run that produced it.
197
209
  def tool_of(test_case) = test_case["tool"].is_a?(Hash) ? test_case["tool"] : tool
198
210
 
@@ -305,6 +317,7 @@ module BrowserReviewGate
305
317
  unless files.nil? || (files.is_a?(Array) && files.size <= Evidence::MAX_FILES && files.all? { |file| file?(file) })
306
318
  errors << "case #{position} evidence files are malformed"
307
319
  end
320
+ validate_trace_check(evidence["trace_check"], position) unless evidence["trace_check"].nil?
308
321
  links = Array(evidence["links"])
309
322
  errors << "case #{position} evidence links must be https URLs" unless links.all? { |link| link.to_s.match?(%r{\Ahttps://[^\s()<>]+\z}) }
310
323
  %w[console_errors network_errors links].each do |key|
@@ -315,6 +328,14 @@ module BrowserReviewGate
315
328
  end
316
329
  end
317
330
 
331
+ def validate_trace_check(check, position)
332
+ valid = check.is_a?(Hash) && (check.keys - %w[status revision requests findings]).empty? &&
333
+ %w[verified failed].include?(check["status"]) && (check["revision"].nil? || check["revision"].to_s.match?(/\A\h{7,12}\z/)) &&
334
+ check["requests"].is_a?(Integer) && check["findings"].is_a?(Array) && check["findings"].size <= 5 &&
335
+ check["findings"].all? { |finding| finding.is_a?(String) && finding.length <= 300 }
336
+ errors << "case #{position} evidence trace_check is malformed" unless valid
337
+ end
338
+
318
339
  def file?(file)
319
340
  file.is_a?(Hash) && (file.keys - FILE_KEYS).empty? && Evidence::KINDS.value?(file["kind"]) && text?(file["name"], 100) &&
320
341
  file["bytes"].is_a?(Integer) && file["sha256"].to_s.match?(/\A\h{64}\z/) && Evidence::TYPES.values.map(&:first).include?(file["media_type"])
@@ -353,6 +374,10 @@ module BrowserReviewGate
353
374
  parts << "#{evidence["console_errors"].size} console errors" if evidence["console_errors"]
354
375
  parts << "#{evidence["network_errors"].size} failed requests" if evidence["network_errors"]
355
376
  parts.concat(Array(evidence["links"]).each_with_index.map { |link, index| "[link #{index + 1}](#{link})" })
377
+ if (check = evidence["trace_check"])
378
+ parts << (check["status"] == "verified" ? "trace verified: revision `#{check["revision"]}`, #{check["requests"]} requests" :
379
+ "trace check failed: #{Markdown.escape(check["findings"].first)}")
380
+ end
356
381
  parts.empty? ? "none" : parts.join("<br>")
357
382
  end
358
383
 
@@ -0,0 +1,72 @@
1
+ module BrowserReviewGate
2
+ # Rack middleware that names the commit the app is serving in a response header, so a Playwright
3
+ # trace shows which code a browser run exercised (`provenance.trace`). Meant for development and
4
+ # test environments only: it reads the checkout's HEAD, and a production app has no business telling
5
+ # visitors its commit.
6
+ #
7
+ # # config/environments/development.rb
8
+ # config.middleware.use BrowserReviewGate::RevisionHeader
9
+ #
10
+ # HEAD is read again whenever .git/HEAD or the branch it points to changes, so a new commit shows up
11
+ # without a restart. A worktree with uncommitted changes is still reported as HEAD: publishing a run
12
+ # requires a clean worktree anyway.
13
+ class RevisionHeader
14
+ def initialize(app, header: "X-App-Revision", root: Dir.pwd)
15
+ @app = app
16
+ @header = header.downcase # Rack 3 wants lowercase header names
17
+ @git_dir = git_dir(root)
18
+ @cached = nil
19
+ end
20
+
21
+ def call(env)
22
+ status, headers, body = @app.call(env)
23
+ revision = current_revision
24
+ headers[@header] = revision if revision
25
+ [ status, headers, body ]
26
+ end
27
+
28
+ private
29
+
30
+ def current_revision
31
+ return unless @git_dir
32
+
33
+ head_path = File.join(@git_dir, "HEAD")
34
+ head = File.read(head_path).strip
35
+ ref_path = head.start_with?("ref: ") ? File.join(common_dir, head.delete_prefix("ref: ")) : nil
36
+ stamp = [ head, File.mtime(head_path), ref_path && File.exist?(ref_path) ? File.mtime(ref_path) : nil ]
37
+ return @cached.last if @cached&.first == stamp
38
+
39
+ @cached = [ stamp, resolve(head, ref_path) ]
40
+ @cached.last
41
+ rescue SystemCallError
42
+ nil
43
+ end
44
+
45
+ def resolve(head, ref_path)
46
+ return head[/\A\h{40}\z/] unless ref_path
47
+ return File.read(ref_path).strip[/\A\h{40}\z/] if File.exist?(ref_path)
48
+
49
+ # A ref that was packed: look it up in packed-refs.
50
+ packed = File.join(common_dir, "packed-refs")
51
+ ref = head.delete_prefix("ref: ")
52
+ File.exist?(packed) ? File.foreach(packed).find { |line| line.strip.end_with?(" #{ref}") }.to_s[/\A\h{40}/] : nil
53
+ end
54
+
55
+ # A worktree's .git is a file pointing at its git directory; branches live in the common one.
56
+ def git_dir(root)
57
+ dot_git = File.join(root, ".git")
58
+ return dot_git if File.directory?(dot_git)
59
+ return unless File.file?(dot_git)
60
+
61
+ path = File.read(dot_git)[/\Agitdir: (.+)$/, 1]
62
+ path && File.expand_path(path.strip, root)
63
+ end
64
+
65
+ def common_dir
66
+ @common_dir ||= begin
67
+ pointer = File.join(@git_dir, "commondir")
68
+ File.exist?(pointer) ? File.expand_path(File.read(pointer).strip, @git_dir) : @git_dir
69
+ end
70
+ end
71
+ end
72
+ end
@@ -41,6 +41,17 @@ module BrowserReviewGate
41
41
  false
42
42
  end
43
43
 
44
+ # The value of `header` in the answer at `url`, or nil. Same limits as answers?.
45
+ def self.header_at(url, header)
46
+ uri = URI(url)
47
+ return unless %w[http https].include?(uri.scheme) && uri.host
48
+
49
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
50
+ open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri)[header] }
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
44
55
  # The URL a health check asks: an absolute `healthcheck`, or its path on the environment URL.
45
56
  def self.healthcheck_url(environment)
46
57
  check = environment["healthcheck"]
@@ -64,6 +64,17 @@ gate:
64
64
  # [enforced] Any failing case keeps review on hold, not only failing required scenarios.
65
65
  block_on_failure: true
66
66
 
67
+ provenance:
68
+ # [enforced] Check each case's Playwright trace against the tested commit. off: no check. warn: the
69
+ # result is shown in the PR. require: a passing case counts only when its trace shows a response
70
+ # from the tested commit (via revision_header), a request to the case's page, and request times
71
+ # inside the run. The app must send the header: add BrowserReviewGate::RevisionHeader to the
72
+ # development middleware. With warn or require the agent is asked for a trace of every case.
73
+ trace: "off"
74
+ revision_header: "X-App-Revision"
75
+ check_pages: true
76
+ clock_skew_seconds: 300
77
+
67
78
  # Who assesses a PR in CI. Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
68
79
  # ANTHROPIC_API_KEY secret; with only an OPENAI_API_KEY or GEMINI_API_KEY secret, that provider's API.
69
80
  # Set `provider` to claude-cli, anthropic, openai or gemini to choose; openai and gemini need `model`.
@@ -25,6 +25,7 @@ This file is the playbook for the AI coding agent that does the run. The person
25
25
  4. Run every scenario end to end. For each case record:
26
26
  - `url`: the page it was exercised on; `details`: what you observed;
27
27
  - evidence, as `policy.agent` in the `verify` output asks: screenshots (`never`, `on_failure`, `always`), a trace, and the console errors and failed requests you saw (`[]` when there were none). Save files in a directory outside the repository, next to the report.
28
+ - When `policy.enforced.trace_check` is `warn` or `require`, record a Playwright trace (`.zip`) or a HAR network log (`.har`, which Playwright and Chrome DevTools can save) for every case, one per case, and list it as `evidence.trace`. If your browser tool can save neither, stop and tell the person: with `require`, every passing case would be refused. The gem opens it and checks that the app answered with the tested commit, that the case's page was requested, and that the requests fall between `started_at` and `finished_at`. With `require`, a passing case without such a trace does not count. Record the trace while running the case; a trace from another run will not match.
28
29
  - A scenario you could not run is `blocked` (something prevented it) or `skipped` (deliberately not run), with a `reason`. Neither is a pass.
29
30
  5. Write the report as JSON next to the evidence (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran.
30
31
  6. Publish, from the checkout that was tested, with everything committed and pushed: `browser-review-gate verify --report PATH` (add `--pr NUMBER` when the branch has several PRs). With a PR it is published; without one it is saved and published when the PR for this commit is opened.
@@ -37,6 +38,7 @@ This file is the playbook for the AI coding agent that does the run. The person
37
38
  - With `gate.require_evidence`, a passing case without the screenshots, trace or logs the project asks for does not count, unless your tool lacks that capability (say so in `tool.capabilities`).
38
39
  - With `verification.fail_on_console_errors` / `fail_on_network_errors`, a passing case that lists such errors counts as failed.
39
40
  - Only cases tested on a commit of this PR count.
41
+ - With `provenance.trace`, it reads each case's Playwright trace and checks it against the tested commit and the case's page.
40
42
  - It cannot tell whether a browser really did what you wrote. File digests show that files existed when the run was published, not who made them.
41
43
 
42
44
  ## When a scenario cannot be run
@@ -84,7 +86,7 @@ Some scenarios cannot be exercised locally, for example a sign-in through an ext
84
86
 
85
87
  - `result`: `pass`, `fail`, `blocked` or `skipped`. `url` is an http(s) URL or a path starting with `/`; required for `pass` and `fail`. `reason` is required for `blocked` and `skipped`.
86
88
  - `tool.capabilities`: what your browser tool could record, from `screenshots`, `trace`, `console`, `network`. Claim only what it really can.
87
- - `evidence` paths are relative to the report file's directory. Accepted: `.png`, `.jpg`, `.jpeg`, `.webp` screenshots and a `.zip` trace, at most 10 files of 20 MB each. `network_errors` entries may be strings or `{ "method", "url", "status" }`; query strings are dropped. `links` may point to evidence kept elsewhere, on hosts listed in `evidence.link_hosts`.
89
+ - `evidence` paths are relative to the report file's directory. Accepted: `.png`, `.jpg`, `.jpeg`, `.webp` screenshots and a `.zip` Playwright trace or `.har` network log, at most 10 files of 20 MB each. `network_errors` entries may be strings or `{ "method", "url", "status" }`; query strings are dropped. `links` may point to evidence kept elsewhere, on hosts listed in `evidence.link_hosts`.
88
90
  - Everything you write is scrubbed of values that look like credentials, but do not rely on it: never put cookies, tokens, passwords or customer data into a report, a screenshot or a trace. Keep customer and real company names out of the report.
89
91
 
90
92
  ## Rules
@@ -0,0 +1,119 @@
1
+ require "json"
2
+ require "time"
3
+ require "uri"
4
+
5
+ module BrowserReviewGate
6
+ # Reads a Playwright trace (trace.zip) or a HAR network log (.har, which Playwright, Chrome DevTools
7
+ # and other tools write) and checks that it shows the case really ran against the
8
+ # tested code: a response carried the app's revision header with the tested commit, the case's page
9
+ # was requested, and the requests fall inside the run's time window.
10
+ #
11
+ # This catches an agent that reports a run it did not do. It does not stop someone who builds a
12
+ # trace by hand; a trace is a file like any other.
13
+ class TraceCheck
14
+ NETWORK_ENTRY = /(?:\A|\/)(?:\d+-)?trace\.network\z/
15
+
16
+ def initialize(config)
17
+ @settings = config.provenance
18
+ end
19
+
20
+ # Returns { "status" => "verified" | "failed", "revision" => ..., "requests" => n, "findings" => [...] }.
21
+ def call(path, tested_sha:, url:, started_at:, finished_at:)
22
+ requests = read(path)
23
+ findings = []
24
+ revisions = requests.filter_map { |request| request[:revision] }.uniq
25
+ matched = revisions.find { |revision| tested_sha.start_with?(revision) }
26
+ if revisions.empty?
27
+ findings << "no response carried the #{@settings["revision_header"]} header"
28
+ elsif matched.nil?
29
+ findings << "the app answered with revision #{revisions.first[0, 12]}, not the tested commit"
30
+ end
31
+ findings << "the case's page #{url} was not requested" if @settings["check_pages"] && url && !page_requested?(requests, url)
32
+ findings.concat(time_findings(requests, started_at, finished_at))
33
+ findings << "the trace has no network requests" if requests.empty?
34
+
35
+ { "status" => findings.empty? ? "verified" : "failed", "revision" => matched && tested_sha[0, 12],
36
+ "requests" => requests.size, "findings" => findings.first(5) }.compact
37
+ end
38
+
39
+ private
40
+
41
+ def read(path)
42
+ return read_har(path) if File.extname(path).casecmp?(".har")
43
+
44
+ requests = []
45
+ ZipReader.new(path).each(->(name) { NETWORK_ENTRY.match?(name) }) do |_name, content|
46
+ content.each_line do |line|
47
+ request = parse(line)
48
+ requests << request if request
49
+ end
50
+ end
51
+ requests
52
+ end
53
+
54
+ def read_har(path)
55
+ text = File.binread(path, ZipReader::MAX_ENTRY_BYTES + 1).to_s.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
56
+ log = JSON.parse(text)
57
+ entries = log.is_a?(Hash) ? log.dig("log", "entries") : nil
58
+ raise Error, "the HAR file has no log.entries" unless entries.is_a?(Array)
59
+
60
+ entries.filter_map { |entry| request(entry) }
61
+ rescue JSON::ParserError
62
+ raise Error, "the HAR file is not valid JSON"
63
+ end
64
+
65
+ # One HAR-like resource snapshot per line; anything else is skipped.
66
+ def parse(line)
67
+ event = JSON.parse(line)
68
+ event.is_a?(Hash) && event["type"] == "resource-snapshot" ? request(event["snapshot"]) : nil
69
+ rescue JSON::ParserError
70
+ nil
71
+ end
72
+
73
+ # A HAR entry, which is also what a Playwright resource snapshot is.
74
+ def request(snapshot)
75
+ return unless snapshot.is_a?(Hash) && snapshot["request"].is_a?(Hash)
76
+
77
+ headers = snapshot.dig("response", "headers")
78
+ header = Array(headers).find { |entry| entry.is_a?(Hash) && entry["name"].to_s.casecmp?(@settings["revision_header"]) }
79
+ { url: snapshot["request"]["url"].to_s, time: time(snapshot["startedDateTime"]),
80
+ revision: header && header["value"].to_s.strip.downcase[/\A\h{7,40}\z/] }
81
+ end
82
+
83
+ def time(value)
84
+ Time.iso8601(value.to_s)
85
+ rescue ArgumentError
86
+ nil
87
+ end
88
+
89
+ # The case's page counts as requested when a request has the same path, and the same host when
90
+ # the case names a full URL.
91
+ def page_requested?(requests, url)
92
+ wanted = URI.parse(url)
93
+ requests.any? do |request|
94
+ seen = URI.parse(request[:url])
95
+ seen.path.to_s.chomp("/") == wanted.path.to_s.chomp("/") && (wanted.host.nil? || wanted.host == seen.host)
96
+ rescue URI::InvalidURIError
97
+ false
98
+ end
99
+ rescue URI::InvalidURIError
100
+ false
101
+ end
102
+
103
+ def time_findings(requests, started_at, finished_at)
104
+ return [] unless started_at && finished_at
105
+
106
+ skew = @settings["clock_skew_seconds"]
107
+ from = Time.iso8601(started_at) - skew
108
+ to = Time.iso8601(finished_at) + skew
109
+ times = requests.filter_map { |request| request[:time] }
110
+ return [ "the trace has no request times" ] if times.empty? && requests.any?
111
+ return [] if times.empty?
112
+
113
+ outside = times.count { |seen| seen < from || seen > to }
114
+ outside.positive? ? [ "#{outside} of #{times.size} requests fall outside the run's started_at..finished_at" ] : []
115
+ rescue ArgumentError
116
+ []
117
+ end
118
+ end
119
+ end
@@ -69,9 +69,18 @@ module BrowserReviewGate
69
69
  [ "A browser run is needed#{status["pull_request"] ? " for PR ##{status["pull_request"]}" : ""}.",
70
70
  "Environment: #{environment["name"] || "not configured"}#{environment["url"] ? " at #{environment["url"]}" : ""}.",
71
71
  "Scenarios to run in your browser tool, one case per id:", *list,
72
+ *trace_note(status),
72
73
  "Then: browser-review-gate verify --report PATH (publishes, or saves before the PR exists)." ].join("\n")
73
74
  end
74
75
 
76
+ def self.trace_note(status)
77
+ mode = status.dig("policy", "enforced", "trace_check")
78
+ return [] if mode.nil? || mode == "off"
79
+
80
+ [ "Record a Playwright trace (.zip) or a HAR file (.har) for every case and list it as evidence.trace." \
81
+ "#{mode == "require" ? " Without one, a passing case is refused. If your browser tool can save neither, stop and tell the person." : ""}" ]
82
+ end
83
+
75
84
  private
76
85
 
77
86
  def submit(status, report)
@@ -1,3 +1,3 @@
1
1
  module BrowserReviewGate
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -0,0 +1,103 @@
1
+ require "zlib"
2
+
3
+ module BrowserReviewGate
4
+ # Reads selected entries of a zip archive into memory, using only zlib. Made for Playwright traces,
5
+ # which come from the agent and are untrusted: entry count, sizes and compression ratio are capped,
6
+ # nothing is written to disk, and entry names are only compared, never used as paths.
7
+ class ZipReader
8
+ MAX_ENTRIES = 10_000
9
+ MAX_ENTRY_BYTES = 50 * 1024 * 1024
10
+ MAX_TOTAL_BYTES = 200 * 1024 * 1024
11
+ MAX_RATIO = 200
12
+ END_OF_DIRECTORY = "PK\x05\x06".b
13
+ DIRECTORY_ENTRY = "PK\x01\x02".b
14
+ LOCAL_ENTRY = "PK\x03\x04".b
15
+
16
+ def initialize(path)
17
+ @path = path
18
+ end
19
+
20
+ # Yields the name and content of every entry whose name the block's filter accepts.
21
+ def each(filter)
22
+ total = 0
23
+ File.open(@path, "rb") do |file|
24
+ directory(file).each do |entry|
25
+ next unless filter.call(entry[:name])
26
+
27
+ content = read(file, entry)
28
+ total += content.bytesize
29
+ raise Error, "trace is larger than #{MAX_TOTAL_BYTES / 1024 / 1024} MB uncompressed" if total > MAX_TOTAL_BYTES
30
+
31
+ yield entry[:name], content
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def directory(file)
39
+ size = file.size
40
+ tail_size = [ size, 65_557 ].min
41
+ file.seek(size - tail_size)
42
+ tail = file.read(tail_size)
43
+ at = tail.rindex(END_OF_DIRECTORY) or raise Error, "trace is not a zip archive"
44
+ count, _directory_size, offset = tail[at + 10, 10].unpack("vVV")
45
+ raise Error, "trace has too many entries" if count > MAX_ENTRIES
46
+
47
+ file.seek(offset)
48
+ Array.new(count) { directory_entry(file) }
49
+ end
50
+
51
+ def directory_entry(file)
52
+ header = file.read(46)
53
+ raise Error, "trace zip directory is damaged" unless header&.start_with?(DIRECTORY_ENTRY)
54
+
55
+ method, compressed, uncompressed, name_length, extra_length, comment_length, offset =
56
+ header.unpack("@10v@20VV@28vvv@42V")
57
+ name = file.read(name_length).to_s.force_encoding(Encoding::UTF_8)
58
+ file.seek(extra_length + comment_length, IO::SEEK_CUR)
59
+ { name: name, method: method, compressed: compressed, uncompressed: uncompressed, offset: offset }
60
+ end
61
+
62
+ def read(file, entry)
63
+ raise Error, "trace entry #{entry[:name][0, 80]} is too large" if entry[:uncompressed] > MAX_ENTRY_BYTES
64
+ if entry[:compressed].positive? && entry[:uncompressed] / entry[:compressed] > MAX_RATIO
65
+ raise Error, "trace entry #{entry[:name][0, 80]} is compressed suspiciously well"
66
+ end
67
+
68
+ position = file.pos
69
+ file.seek(entry[:offset])
70
+ local = file.read(30)
71
+ raise Error, "trace zip entry is damaged" unless local&.start_with?(LOCAL_ENTRY)
72
+
73
+ name_length, extra_length = local.unpack("@26vv")
74
+ file.seek(name_length + extra_length, IO::SEEK_CUR)
75
+ data = file.read(entry[:compressed]).to_s
76
+ file.seek(position)
77
+ content = case entry[:method]
78
+ when 0 then data
79
+ when 8 then inflate(data)
80
+ else raise Error, "trace entry #{entry[:name][0, 80]} uses an unsupported compression method"
81
+ end
82
+ raise Error, "trace entry #{entry[:name][0, 80]} is not the size its header says" unless content.bytesize == entry[:uncompressed]
83
+
84
+ content
85
+ end
86
+
87
+ # Stops as soon as the output passes the entry cap, so a lying header cannot exhaust memory.
88
+ def inflate(data)
89
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
90
+ out = +""
91
+ (0...data.bytesize).step(4096) do |offset|
92
+ out << inflater.inflate(data.byteslice(offset, 4096))
93
+ raise Error, "trace entry is larger than it says" if out.bytesize > MAX_ENTRY_BYTES
94
+ end
95
+ out << inflater.finish
96
+ out
97
+ rescue Zlib::Error
98
+ raise Error, "trace entry is not valid deflate data"
99
+ ensure
100
+ inflater&.close
101
+ end
102
+ end
103
+ end
@@ -25,6 +25,9 @@ module BrowserReviewGate
25
25
  autoload :CLI, File.join(__dir__, "browser_review_gate/cli")
26
26
  autoload :Doctor, File.join(__dir__, "browser_review_gate/doctor")
27
27
  autoload :Evidence, File.join(__dir__, "browser_review_gate/evidence")
28
+ autoload :RevisionHeader, File.join(__dir__, "browser_review_gate/revision_header")
29
+ autoload :TraceCheck, File.join(__dir__, "browser_review_gate/trace_check")
30
+ autoload :ZipReader, File.join(__dir__, "browser_review_gate/zip_reader")
28
31
  autoload :Verify, File.join(__dir__, "browser_review_gate/verify")
29
32
  autoload :Config, File.join(__dir__, "browser_review_gate/config")
30
33
  autoload :Gate, File.join(__dir__, "browser_review_gate/gate")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browser_review_gate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Islam Gagiev
@@ -55,6 +55,7 @@ files:
55
55
  - lib/browser_review_gate/prompts.rb
56
56
  - lib/browser_review_gate/publisher.rb
57
57
  - lib/browser_review_gate/report.rb
58
+ - lib/browser_review_gate/revision_header.rb
58
59
  - lib/browser_review_gate/saved_report.rb
59
60
  - lib/browser_review_gate/shell.rb
60
61
  - lib/browser_review_gate/stats.rb
@@ -67,8 +68,10 @@ files:
67
68
  - lib/browser_review_gate/templates/policy.md
68
69
  - lib/browser_review_gate/templates/rules.md.erb
69
70
  - lib/browser_review_gate/templates/workflow.yml.erb
71
+ - lib/browser_review_gate/trace_check.rb
70
72
  - lib/browser_review_gate/verify.rb
71
73
  - lib/browser_review_gate/version.rb
74
+ - lib/browser_review_gate/zip_reader.rb
72
75
  - lib/generators/browser_review_gate/install/install_generator.rb
73
76
  homepage: https://github.com/Halvanhelv/browser_review_gate
74
77
  licenses: