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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +27 -2
- data/lib/browser_review_gate/config.rb +22 -4
- data/lib/browser_review_gate/doctor.rb +42 -6
- data/lib/browser_review_gate/evidence.rb +16 -3
- data/lib/browser_review_gate/installer.rb +2 -1
- data/lib/browser_review_gate/report.rb +28 -3
- data/lib/browser_review_gate/revision_header.rb +72 -0
- data/lib/browser_review_gate/status.rb +11 -0
- data/lib/browser_review_gate/templates/config.yml.erb +11 -0
- data/lib/browser_review_gate/templates/playbook.md.erb +3 -1
- data/lib/browser_review_gate/trace_check.rb +119 -0
- data/lib/browser_review_gate/verify.rb +9 -0
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate/zip_reader.rb +103 -0
- data/lib/browser_review_gate.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf07fc0385a222895293b09a8000db74bf153a3b87731528b3e2d51c20cfd4e0
|
|
4
|
+
data.tar.gz: 5866378bb1acbfa17f03d44d98413c6f6222903858b7ec7dd6f71a9b6ba95788
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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"],
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
173
|
-
"
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
data/lib/browser_review_gate.rb
CHANGED
|
@@ -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.
|
|
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:
|