playwright-write-guard 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -9,9 +9,18 @@ Pointing an automated browser at a real app is risky when the run should only lo
9
9
  This package puts a read-only guard in front of the browser:
10
10
 
11
11
  - **Requests.** Any request whose method is not GET, HEAD or OPTIONS is blocked, unless a signature in your `allow` list says it is really a read (a read-only GraphQL endpoint, for example). Requests that are really writes but look like reads can be named in `writes`.
12
- - **Clicks.** A click, form submit, or Enter or Space keypress on a control whose label is a destructive verb (Save, Delete, Publish, Send and so on) is stopped inside the page before the app's own handlers run. Native confirmation dialogs are declined.
12
+ - **Clicks.** A click, form submit, or Enter or Space keypress on a control whose label is a destructive verb (Save, Delete, Publish, Send, Archive, Pay, Purchase, Log out and so on) is stopped inside the page before the app's own handlers run, and the page's console gets a `write-guard refused "<label>": <reason>` warning, so an agent that reads the console learns why nothing happened. Native confirmation dialogs are declined.
13
13
  - **Log.** Every write it blocked, or only observed under the `observe` policy, is recorded as one JSON line.
14
14
 
15
+ What it guarantees, and what it does not:
16
+
17
+ - **Enforced:** every HTTP request the pages of a guarded context send is judged at the network layer, whatever triggered it. This is the main layer: a "Continue" button that submits a payment still sends a request, and that request is judged. WebSockets are the exception: by default a socket opens and each open is recorded as a `websocket` event, but its messages, writes included, are not judged or logged (`websockets: "block"` refuses the sockets the guard routes, and apps that need a socket then do not load).
18
+ - **Heuristic:** which requests count as writes, and which controls count as destructive.
19
+ - Requests are judged by method, unless your `allow` and `writes` signatures say otherwise. Apps that read over POST (GraphQL, search APIs, server actions, long polling) stop working until those endpoints are allow-listed. A run under `observe` lists them (method, URL and content type; set `logBodies: true` to add the first 500 characters of each body, which can hold credentials, so it is off by default). GETs with side effects (logout, unsubscribe, `?action=delete`) pass with no event unless a `writes` signature names them.
20
+ - Controls are judged by label, and only real controls: buttons, links with an `href`, elements with a button, menuitem, link or tab role, and submit, button or image inputs. The click check is a second layer that stops the app before it starts a write. Its built-in verb list is short (save, delete, remove, publish, send, archive, pay, purchase, log out, sign out, logout): it still refuses some harmless controls such as "Remove filter" or "Save search", and misses writing controls such as "Place order" or "Add to cart". If it gets in the way, set `defaultControls: false` and name your app's own verbs in `controls.text`.
21
+ - **Not covered:** anything outside the guarded context, such as other contexts, request clients the guard cannot reach, and code that removes the guard. See [What it cannot see](#what-it-cannot-see). The boundary is a restricted account: give the run credentials that cannot write, and use the guard to catch mistakes before they reach it.
22
+ - **Cost:** Playwright turns off the HTTP cache while a route is installed, so every load fetches every asset again. Service workers are blocked, so an app that waits for `navigator.serviceWorker.ready` hangs.
23
+
15
24
  See it work end to end, with a local page and a local server, in the walkthrough at [demo/README.md](demo/README.md) (`node demo/run.mjs`).
16
25
 
17
26
  ## Install
@@ -25,7 +34,7 @@ It needs Node 20 or newer and Playwright 1.59 or newer: `@playwright/test` for t
25
34
 
26
35
  ## Use with Playwright Test
27
36
 
28
- Swap one import. Everything else in your tests stays the same:
37
+ Swap one import:
29
38
 
30
39
  ```js
31
40
  // before
@@ -38,18 +47,32 @@ test('lists items without changing them', async ({ page }) => {
38
47
  });
39
48
  ```
40
49
 
41
- The fixture guards each test's context, created with `serviceWorkers: 'block'`. By default it reads `write-guard.config.json` from the directory you run Playwright in. Every key is optional; a small example that lets a read-only GraphQL endpoint through and blocks everything else:
50
+ Tests that only read through the page keep working. Under read-only, these change:
51
+
52
+ - The `request` fixture fails the test at setup, and `page.request` and `context.request` refuse every send, GET included.
53
+ - While a test runs, `browser.newContext()`, `browser.newPage()` and the global `fetch` in the test process throw, so tests with several users, or that post results from inside a test, need another approach.
54
+ - A test can mock a write: a `page.route` handler that `fulfill`s or `abort`s a POST runs, and the request never leaves the browser. A handler that passes the request on with `continue()` or `route.fetch()` is judged at that point, overrides included, so the write is still blocked. A mock is not the server: count writes on a real local server, not through a route that fulfills them.
55
+ - Blocked requests other than page navigations are aborted, which the page's console reports as `net::ERR_BLOCKED_BY_CLIENT` errors, so a test that asserts a clean console needs to filter those out.
56
+ - Blocked writes do not fail a test. Check the `blocked` count in the `write-guard.json` attachment if you want them to.
57
+
58
+ The fixture guards each test's context, created with `serviceWorkers: 'block'`. By default it reads `write-guard.config.json` from the directory you run Playwright in. Every key is optional; a small example that lets two named GraphQL queries through and blocks everything else, mutations included:
42
59
 
43
60
  ```json
44
61
  {
45
62
  "policy": "read-only",
46
63
  "allow": [
47
- { "method": "POST", "url": "^https://app[.]example[.]test/graphql$" }
64
+ {
65
+ "method": "POST",
66
+ "url": "^https://app[.]example[.]test/graphql$",
67
+ "body": "\"operationName\":\"(ListItems|GetItem)\""
68
+ }
48
69
  ],
49
70
  "controls": { "icons": ["icon-trash"] }
50
71
  }
51
72
  ```
52
73
 
74
+ Without the `body` pattern, that signature would allow every request to the URL, mutations included. The pattern trusts your app's operation names, so list only names that read. Reads that differ from writes only in a header, such as Next.js server actions, can be allow-listed with a `headers` entry, for example `{ "method": "POST", "url": "^https://app[.]example[.]test/items$", "headers": { "next-action": "^[0-9a-f]+$" } }`. Header names compare case-insensitively and each value is a regex tested against that header on the request.
75
+
53
76
  To set the config in the test file instead, use `test.use`:
54
77
 
55
78
  ```js
@@ -66,7 +89,9 @@ WRITE_GUARD_CONFIG=/path/to/write-guard.config.json npx playwright test -c confi
66
89
 
67
90
  ## Use with AI agents
68
91
 
69
- Pick one mode for a given browser. Using both at once is refused: the second guard throws `write-guard: this context is already guarded by another process; use one mode, not both`.
92
+ Pick one mode for a given browser. Using both at once is refused: the second guard throws `write-guard: this context is already guarded by another process; use one mode, not both`, and Playwright MCP then fails every tool call with that message.
93
+
94
+ Config mode depends on denying the agent its code-running and routing tools; the example below is for Claude Code. If your agent cannot deny tools that way, use launcher mode. Both modes were checked with Playwright MCP 0.0.83 driving the demo app: the agent's Delete click was refused in the page, its `fetch` DELETE was blocked at the network, and the server received no writes.
70
95
 
71
96
  ### Config mode: the `initPage` entry
72
97
 
@@ -115,20 +140,55 @@ It prints `endpoint: <ws endpoint>`. Attach the agent to it:
115
140
  - Playwright MCP: start it with `--endpoint <ws endpoint>` and without `--isolated`. `--isolated` makes a new, unguarded context.
116
141
  - Playwright CLI: `playwright-cli attach --endpoint=<ws endpoint>`.
117
142
 
118
- The guard runs in the launcher's process, not the agent's. The agent's own `unroute` and `unrouteAll` cannot remove it, and a `page.route` handler that calls `continue()` without overrides is still judged, because page handlers run before the owner's context route. Playwright MCP's own route tool only sets the headers and status of mocked responses, so this mode survives the MCP route tools the config mode has to deny. It does not survive arbitrary client code: see "What it cannot see" below. Tabs the agent opens in that context are guarded too. Stop it with Ctrl+C.
143
+ The guard runs in the launcher's process, not the agent's. The agent's own `unroute` and `unrouteAll` cannot remove it, and a `page.route` handler that calls `continue()`, with or without overrides, is still judged, because page handlers run before the owner's context route and the request reaches the guard with the overrides applied. Playwright MCP's own route tool only sets the headers and status of mocked responses, so this mode survives the MCP route tools the config mode has to deny. It does not survive arbitrary client code: see "What it cannot see" below. Tabs the agent opens in that context are guarded too. Stop it with Ctrl+C.
144
+
145
+ ## Logging in
146
+
147
+ Logging in is a write, so under read-only a login form is blocked: the page shows `write-guard blocked POST .../login`. Token refreshes that POST are blocked too. Ways in:
148
+
149
+ - **Playwright Test:** log in from a setup project that imports `@playwright/test`, not this package, save the session with `storageState`, and give it to the guarded project. The fixture wraps Playwright's own context, so `storageState` works as usual.
150
+
151
+ ```js
152
+ // playwright.config.mjs
153
+ import { defineConfig } from '@playwright/test';
154
+ export default defineConfig({
155
+ projects: [
156
+ { name: 'login', testMatch: /login\.setup\.mjs/ },
157
+ { name: 'read-only', dependencies: ['login'], use: { storageState: 'state.json' } },
158
+ ],
159
+ });
160
+ ```
161
+
162
+ ```js
163
+ // login.setup.mjs: imports @playwright/test, so it is not guarded
164
+ import { test } from '@playwright/test';
165
+ test('log in', async ({ page }) => {
166
+ await page.goto('https://app.example.test/login');
167
+ // fill in the form with the read-only test account's credentials
168
+ await page.getByRole('button', { name: 'Log in' }).click();
169
+ await page.waitForURL('**/home');
170
+ await page.context().storageState({ path: 'state.json' });
171
+ });
172
+ ```
173
+
174
+ `state.json` holds a live session: keep it out of version control.
175
+ - **Allow-list the login endpoints**, in any mode: `{ "method": "POST", "url": "^https://app[.]example[.]test/login$" }`, plus the token refresh endpoint if a session must last. Keep these narrow: everything they match is let through.
176
+ - **Playwright MCP in config mode:** start it with `--isolated --storage-state <file>` to begin already logged in (Playwright MCP reads a storage state only for isolated sessions).
177
+ - **Launcher mode** has no storage-state option yet: allow-list the login endpoints, or use config mode.
119
178
 
120
179
  ## The audit log
121
180
 
122
- When `log` is a path (default `write-guard.jsonl`, relative to the process's working directory), each blocked or observed write is appended as one JSON object per line, as it happens. The file is created when missing and never truncated. Allowed requests and controls are not logged. Set `"log": null` to turn it off.
181
+ When `log` is a path (default `write-guard.jsonl`, relative to the process's working directory), each blocked or observed write is appended as one JSON object per line, as it happens, and so is each WebSocket opened under the default `websockets: "allow"`. The file is created when missing and never truncated. Allowed requests and controls are not logged. Set `"log": null` to turn it off.
123
182
 
124
- A blocked request and a refused control look like this (one line each in the file):
183
+ A blocked request, a refused control and a WebSocket open look like this (one line each in the file):
125
184
 
126
185
  ```json
127
- {"kind":"request","action":"block","reason":"POST is not a read method; policy read-only","method":"POST","url":"https://app.example.test/items","at":"2026-01-15T09:30:00.000Z"}
186
+ {"kind":"request","action":"block","reason":"POST is not a read method; policy read-only","method":"POST","url":"https://app.example.test/items","contentType":"application/json","at":"2026-01-15T09:30:00.000Z"}
128
187
  {"kind":"control","action":"block","reason":"\"Delete item\" matches a destructive label; policy read-only","label":"Delete item","at":"2026-01-15T09:30:02.000Z"}
188
+ {"kind":"websocket","action":"open","url":"wss://app.example.test/live","at":"2026-01-15T09:30:03.000Z"}
129
189
  ```
130
190
 
131
- A request event carries `method` and `url`; a control event carries `label` and neither. `reason` is a human-readable note whose wording may change. Under the `observe` policy the same events are recorded with `"action":"observe"` and the write goes through, which is a way to find out what a run would have blocked. The full event shape is in the API below.
191
+ A request event carries `method`, `url` and, when the request has a `content-type` header, `contentType`. With `logBodies: true` it also carries `body`, the first 500 characters of the request body; that is off by default because bodies can hold credentials and personal data, so treat a log that has them as sensitive. A control event carries `label` and none of the request fields. A websocket event carries only its `url`. `reason` is a human-readable note whose wording may change. Under the `observe` policy the same events are recorded with `"action":"observe"` and the write goes through, which is a way to find out what a run would have blocked. The full event shape is in the API below.
132
192
 
133
193
  ## What it cannot see
134
194
 
@@ -137,23 +197,35 @@ The guard judges requests the browser sends through its context and clicks the p
137
197
  - Request clients it does not own: an attached client's own `page.request` and `context.request` in launcher mode, and `playwright.request.newContext()` called from an agent's run-code tool.
138
198
  - An agent's run-code tool, and any browser the agent launches itself. In config mode, deny those tools as shown above.
139
199
  - A Playwright Test run with `-c` from another directory without `WRITE_GUARD_CONFIG`, which falls back to the defaults.
140
- - WebSockets, unless `websockets` is `"block"`. A caller's own `routeWebSocket` registered after the guard can take precedence over `websockets: "block"`.
200
+ - WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, but its messages (writes included, as in LiveView, Socket.IO or GraphQL subscriptions) are not judged or logged. `"block"` refuses every socket the guard routes, and apps that need a socket then do not load.
201
+ - WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
202
+ - A caller's own `routeWebSocket` registered after the guard in the same process takes precedence: no open is recorded, and `websockets: "block"` does not refuse that socket. In launcher mode an attached client cannot register its own `routeWebSocket`: Playwright throws `Another client is already routing WebSockets`.
203
+ - The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation, which an app that inspects or subclasses it can notice.
141
204
  - WebRTC and WebTransport.
142
205
  - Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
143
206
  - Drag-and-drop, and pointer or mouse handlers that act before `click`.
144
207
  - Client-side-only destructive actions that no click or submit reaches.
145
208
  - Mistakes in the config: requests are classified only as well as the config, so a write sent as GET, or a read sent as POST, needs a `writes` or `allow` signature.
209
+ - GET requests with side effects (logout, unsubscribe or confirm links, `?action=delete`) pass with no event unless a `writes` signature names them, and `observe` does not list GETs.
210
+ - Reads that send no query text (persisted GraphQL queries without an operation name): when nothing in the method, URL, headers or body tells such a read from a write, a signature that allows the endpoint allows its mutations too.
211
+ - Request bodies in the log unless `logBodies` is `true`, and request headers other than the content type: events record the method, URL and content type, so an `observe` run shows which endpoints were written to, not with what. `logBodies: true` adds the first 500 characters of each body, and is off by default because bodies can hold credentials and personal data.
212
+ - In launcher mode, mocking a blocked write from a client's `context.route`: that handler runs after the guard's catch-all, so it never receives a blocked write. A client's `page.route` can mock one.
213
+ - The HTTP cache: Playwright turns it off while a route is installed, so every load fetches every asset again.
214
+ - Service workers: they are blocked, so `register()` resolves but `navigator.serviceWorker.ready` never settles, and an app that waits for it hangs.
146
215
  - In launcher mode, any context other than `contexts()[0]`: `--isolated`, or `browser.newContext()` from an agent's run-code tool, makes a new, unguarded context.
147
216
  - In launcher mode, a client's `context.route` overrides are not judged again: that handler runs after the guard, in the client's process, so a `continue()` or `fallback()` that rewrites the method, URL or body (a GET rewritten to DELETE) reaches the server.
148
217
  - In launcher mode, a client's `route.fetch()` from any route handler (`page.route` or `context.route`) is not seen: it is sent through the client's own `APIRequestContext`, which the guard cannot reach.
149
218
  - Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, or `Object.getPrototypeOf(context.request).fetch`, which goes around the read-only lock on `APIRequestContext`) or removes the guard's own `page` or `dialog` listeners (`context.removeAllListeners('page')` leaves new pages' routes unwrapped; `context.removeAllListeners('dialog')` lets a caller accept a dialog) goes around the guard's wrappers: the route it registers is not wrapped (its `continue()` can send a POST), `unrouteAll` removes the guard, and a `continue` with overrides skips the override judging.
150
219
  - Routes and dialog listeners registered before `install`: a `page.route` whose `continue()` sends a write, a `context.route` whose overrides are not judged, and a page `dialog` listener that accepts all run outside the guard's wrappers.
151
220
  - Under `observe`, sends through `context.request`, `page.request` and the `request` fixture are neither blocked nor recorded (only read-only locks them), so an observe run does not list them.
152
- - Controls whose label is rendered inside a shadow root: the page sees the shadow host, whose text does not include the shadow content, so the click is not refused in the page. The network layer still judges any request it sends.
221
+ - Controls the page does not judge: anchors without `href`, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), controls inside closed shadow roots, and plain text outside any control. The network layer still judges any request they send. Open shadow roots are judged through the event's composed path, using the shadow host's label when the control has none. An image input is never found by the submit fallback, so a `requestSubmit()` with no submitter on a form whose only submit control is an image input is not judged in the page (the network layer still judges the request).
153
222
  - A document loaded with `page.setContent()`: the guard's in-page listeners do not carry over into it, so a destructive click there is not refused in the page. The network layer still judges any request it sends.
154
223
  - Node's own `http`, `https` and `net` modules in the test process.
155
224
 
156
- It also refuses too much in two places, which is the safe direction. Under read-only, pressing Enter or Space in a text field whose value contains a built-in verb is refused (the label rule uses the focused element's value), and a click on plain text containing a verb, outside any control, can be refused.
225
+ It also refuses too much, which is the safe direction but can stop a run:
226
+
227
+ - The built-in verbs match harmless controls too, because a label matches as a whole word anywhere in it ("Remove filter", "Save search", "Send feedback", "Pay attention").
228
+ - A real control whose label contains a verb is refused even when it only reads. Text fields are never refused (a typed space or Enter goes through; Enter still submits a form implicitly, and that submit is judged by its submit button).
157
229
 
158
230
  It is a safety net, not a security boundary. A restricted test account is the boundary: give the run credentials that cannot write, and use the guard to catch the mistakes that slip past.
159
231
 
@@ -183,7 +255,8 @@ Every key is optional, and unknown keys are ignored:
183
255
  "controls": { "text": [], "icons": [] },
184
256
  "defaultControls": true,
185
257
  "websockets": "allow",
186
- "log": "write-guard.jsonl"
258
+ "log": "write-guard.jsonl",
259
+ "logBodies": false
187
260
  }
188
261
  ```
189
262
 
@@ -194,16 +267,18 @@ Every key is optional, and unknown keys are ignored:
194
267
  | `writes` | list of Sig | `[]` | requests that are writes even when their method is GET, HEAD or OPTIONS |
195
268
  | `controls.text` | list of regex sources | `[]` | extra label patterns that make a control destructive |
196
269
  | `controls.icons` | list of class name prefixes | `[]` | a control with a class starting with one of these is destructive |
197
- | `defaultControls` | boolean | `true` | include the built-in verbs in the label pattern |
198
- | `websockets` | `"allow"` or `"block"` | `"allow"` | `block` refuses every WebSocket |
270
+ | `defaultControls` | boolean | `true` | include the 11 built-in verbs (listed under Writes and controls) in the label pattern; `false` drops them |
271
+ | `websockets` | `"allow"` or `"block"` | `"allow"` | `allow` records each WebSocket open and lets it connect; `block` refuses every WebSocket |
199
272
  | `log` | path or `null` | `"write-guard.jsonl"` | the JSONL audit log, relative to the process's working directory; `null` turns it off |
273
+ | `logBodies` | boolean | `false` | add `body`, the first 500 characters of the request body, to request events (in `record()` and the log); off by default because bodies can hold credentials and personal data |
200
274
 
201
- A **Sig** (request signature) is `{ "method": M, "url": U, "body": B }`:
275
+ A **Sig** (request signature) is `{ "method": M, "url": U, "body": B, "headers": H }`:
202
276
 
203
- - `method` is one of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` or `*`. `*` matches any method; any other value matches that method exactly, compared with the request's method in upper case.
277
+ - `method` is `"*"` or a method name: a string matching `^[A-Za-z][A-Za-z0-9-]*$` (for example `POST`, `delete`, `REPORT` or `M-SEARCH`), compiled to upper case. `*` matches any method; a method name matches that method exactly, compared with the request's method in upper case.
204
278
  - `url` is a regex source, compiled with no flags (case-sensitive) and tested, unanchored, against the full request URL. Use `^` and `$` to anchor it, and escape dots: `"^https://api[.]example[.]com/graphql$"`.
205
279
  - `body` is optional (absent or `null` means the Sig names no body): a regex source, compiled the same way and tested against the request body. A signature with a `body` never matches a request that has no body (a body that is `null` or absent).
206
- - A request matches a Sig when its method, its URL and (when the Sig has one) its body all match.
280
+ - `headers` is optional (absent or `null` means the Sig names no headers): an object mapping a header name to a regex source, for example `{ "Next-Action": "^[0-9a-f]+$" }`. Header names compare case-insensitively (they are compiled to lower case). Each value is compiled with no flags and tested, unanchored, against the value of that header on the request. A Sig with `headers` matches a request only when every header it names is present on the request and its value matches.
281
+ - A request matches a Sig when its method, its URL, (when the Sig has one) its body and (when the Sig names any) every named header all match.
207
282
 
208
283
  ### Writes and controls
209
284
 
@@ -211,11 +286,19 @@ A **Sig** (request signature) is `{ "method": M, "url": U, "body": B }`:
211
286
 
212
287
  **A control is destructive** when its label matches the label pattern, or it has a class starting with one of the `controls.icons` prefixes. The label pattern is the built-in verbs (unless `defaultControls` is `false`) plus every `controls.text` source, joined into one case-insensitive regex. The built-in verbs match as whole words, case-insensitive (`Save` and `Delete item` match, `Saved` does not):
213
288
 
214
- `save`, `delete`, `remove`, `publish`, `share`, `submit`, `send`, `archive`, `upload`, `import`, `rename`, `duplicate`, `update`, `confirm`, `pay`, `purchase`, `invite`, `reset`, `restore`, `approve`, `reject`, `log out`, `sign out`
289
+ `save`, `delete`, `remove`, `publish`, `send`, `archive`, `pay`, `purchase`, `log out`, `sign out`, `logout`
215
290
 
216
291
  There are no built-in icons: icon libraries differ per app, so name yours in `controls.icons` (for example `"icon-trash"`). An empty label never matches the label pattern; with `defaultControls: false` and no `controls.text`, only icons can make a control destructive.
217
292
 
218
- In the page, the **control** for a click is the nearest ancestor of the click target (or the target itself) matching `button, a, [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image], summary, [onclick], [tabindex]`, or the target when none does. For a form submit it is the submitter, or the form when there is none; for an Enter or Space keydown it is the focused element. Its **label** is the first non-empty, trimmed value of its `aria-label` attribute, its `innerText`, its `title` attribute and its `value`. Its **icons** are every class name on the control and on each of its descendants.
293
+ In the page, a **control** is an element matching the control selector `button, a[href], [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image]`. The page judges three events, and only when they have a control:
294
+
295
+ - **Click:** the control is the first element of `event.composedPath()` that matches the control selector. The path runs from the click target outward and crosses open shadow roots. When no element on it matches, the click is not judged.
296
+ - **Form submit:** the control is `event.submitter`. When there is none (as with `form.requestSubmit()` called without a submitter), it is the first element of the form's `form.elements` matching `button:not([type]), button[type=submit], input[type=submit]`. When there is none of those either, the submit is not judged.
297
+ - **Enter or Space keydown:** judged only when `document.activeElement` itself matches the control selector, and that element is the control. A keydown in a text field, or with focus on any other element, is never judged.
298
+
299
+ A control's **label** is the first non-empty, trimmed value of its `aria-label` attribute, its `innerText`, its `title` attribute and its `value`. When that is empty and the control is inside a shadow root, the label is the same rule applied to that shadow root's host (so `<x-btn>Delete</x-btn>`, whose shadow root holds a `<button>` with a `<slot>`, is labelled `Delete`). Its **icons** are every class name on the control and on each of its descendants.
300
+
301
+ Not judged in the page, by design: anchors without `href`, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), and controls inside closed shadow roots (the path stops at the shadow host). The network layer still judges every request any of them sends.
219
302
 
220
303
  ### The decision core: `playwright-write-guard`
221
304
 
@@ -229,22 +312,51 @@ decideControl({ label: 'Delete item', icons: [] }, config);
229
312
  // { action: 'block', reason: '...' }
230
313
  ```
231
314
 
232
- - `compileConfig(raw)` validates a raw config (`null` and `undefined` mean `{}`), applies every default and compiles every pattern. On anything malformed it throws an `Error` whose message is `write-guard: <path>: <message>`, where `<path>` names the offending key the way the config spells it (`policy`, `allow[0].method`, `controls.text[2]`), or `config` for the whole value. Malformed means: a config that is not an object; a `policy`, `websockets` or Sig `method` outside its allowed values; `allow`, `writes`, `controls.text` or `controls.icons` that is not a list; a Sig that is not an object; a `url`, a `body` other than `null`, or a `controls.text` entry that is not a non-empty string or does not compile; a `controls.icons` entry that is not a non-empty string; `controls` that is not an object; `defaultControls` that is not a boolean; `log` that is neither a non-empty string nor `null`. The result is `{ policy, allow, writes, text, icons, websockets, log }`: `allow` and `writes` hold `{ method, url: RegExp, body: RegExp | null }`, `text` is the label pattern (`null` when there is none), and `icons` is `controls.icons`.
233
- - `decideRequest({ method, url, body }, compiled)` returns `{ action, reason }`. For a write, `action` is `"block"` under `read-only` and `"observe"` under `observe`; for anything else it is `"allow"`.
315
+ - `compileConfig(raw)` validates a raw config (`null` and `undefined` mean `{}`), applies every default and compiles every pattern. On anything malformed it throws an `Error` whose message is `write-guard: <path>: <message>`, where `<path>` names the offending key the way the config spells it (`policy`, `allow[0].method`, `controls.text[2]`, `allow[0].headers.Next-Action`), or `config` for the whole value. Malformed means:
316
+ - a config that is not an object;
317
+ - a `policy` or `websockets` outside its allowed values;
318
+ - a Sig `method` that is neither `"*"` nor a string matching `^[A-Za-z][A-Za-z0-9-]*$`, which throws `write-guard: <path>.method: must be "*" or a method name` (for example `write-guard: allow[0].method: must be "*" or a method name`);
319
+ - `allow`, `writes`, `controls.text` or `controls.icons` that is not a list;
320
+ - a Sig that is not an object;
321
+ - a `url`, a `body` other than `null`, or a `controls.text` entry that is not a non-empty string or does not compile;
322
+ - a Sig `headers` other than `null` that is not an object, which throws `write-guard: <path>.headers: must be an object` (for example `write-guard: allow[0].headers: must be an object`);
323
+ - a `headers` value that is not a non-empty string or does not compile, which throws the same messages as `url` (`must be a non-empty regex string`, `does not compile: <message>`), with the header name as written in the config as the path (for example `write-guard: allow[0].headers.Next-Action: must be a non-empty regex string`);
324
+ - a `controls.icons` entry that is not a non-empty string;
325
+ - `controls` that is not an object;
326
+ - `defaultControls` that is not a boolean;
327
+ - `logBodies` that is not a boolean, which throws `write-guard: logBodies: must be a boolean`;
328
+ - `log` that is neither a non-empty string nor `null`.
329
+
330
+ The result is `{ policy, allow, writes, text, icons, websockets, log, logBodies }`: `allow` and `writes` hold `{ method, url: RegExp, body: RegExp | null, headers: Array<{ name: string, value: RegExp }> }`, where `method` is `"*"` or the method name in upper case and `headers` holds one entry per named header, its `name` in lower case (empty when the Sig names none); `text` is the label pattern (`null` when there is none); `icons` is `controls.icons`; `logBodies` is `false` unless the config sets it to `true`.
331
+ - `decideRequest({ method, url, body, headers }, compiled)` returns `{ action, reason }`. `body` is `null` or absent when the request has none. `headers` is an object of header name to value, names in any case (`decideRequest` lower-cases them itself), absent when the request has none, in which case no Sig that names headers matches. For a write, `action` is `"block"` under `read-only` and `"observe"` under `observe`; for anything else it is `"allow"`.
234
332
  - `decideControl({ label, icons }, compiled)` returns `{ action, reason }`. For a destructive control, `action` is `"block"` under `read-only` and `"observe"` under `observe`; for anything else it is `"allow"`.
235
333
  - `reason` is a short human-readable explanation; its wording is not part of the API.
236
334
 
237
- These three functions reference nothing outside their own arguments, because the installer injects their source text into every page.
335
+ `compileConfig` and `decideControl` reference nothing outside their own arguments, because the installer injects their source text into every page.
238
336
 
239
337
  ### The installer: `playwright-write-guard/install`
240
338
 
241
339
  `install(context, config?)` guards one Playwright `BrowserContext` and returns `Promise<GuardHandle>`. It compiles `config` (default `{}`) with `compileConfig`, so a malformed config throws before anything is installed. A second call on the same context returns the first call's handle and ignores its own `config`. It:
242
340
 
243
- 1. Registers one catch-all `context.route` before anything else, and judges each request exactly once with `decideRequest`, however many route handlers see it. An allowed or observed request falls back to the next handler and then to the network.
244
- 2. Wraps `context.route` and the `route` of every page in the context, present and future, so the guard judges each request before any handler registered later, and a later handler that calls `continue()` cannot let a blocked request past. The wrapped `unroute` and `unrouteAll` remove only the caller's own handlers, never the guard's. The route a later handler receives also judges its overrides again: its `continue(options)`, `fallback(options)` and `fetch(options)` are judged as the request they would make when `options` changes the method, URL or body (calls with no options, or headers only, are not judged again, because the original was already judged). Under read-only, a write override makes `continue` and `fallback` abort the request with `'blockedbyclient'` (not the 403 page, even for a document) and makes `fetch` reject with `write-guard: route.fetch of <METHOD> <URL> is refused under read-only`. Under observe the override proceeds. Either way it is recorded once as a `request` event with the effective method and URL, and not counted in `routed_requests`. This applies in the fixture, the `initPage` entry and a direct `install`; in launcher mode it covers only the launcher's own process (see below).
245
- 3. On `block`: a request whose resource type is `document` is fulfilled with status 403, content type `text/html; charset=utf-8`, and a short HTML page reading `write-guard blocked <METHOD> <URL>` (HTML-escaped), so a blocked navigation shows why instead of a browser error page; any other request is aborted with `'blockedbyclient'`, which the page sees as a network error.
246
- 4. Under `websockets: "block"`, refuses every WebSocket under either policy with `context.routeWebSocket`: the socket is closed without connecting to the server and recorded as a `request` event with `action: "block"` and the socket's `url` (no `method`). It is not counted in `routed_requests`.
247
- 5. Adds an init script that runs before any page script, with capture-phase `click`, `submit` and `keydown` (Enter or Space) listeners on `window` that judge the control with `decideControl`. Under `read-only` a destructive control's event is stopped (`preventDefault` and `stopImmediatePropagation`), so the app never sees it; under `observe` it goes through. Either way the page reports the event through the binding `__writeGuardReport`, which the script captures before any page script can replace it.
341
+ 1. Registers one catch-all `context.route`, matching every URL, before anything else. Page handlers run before context handlers, and context handlers run in reverse order of registration, so in the guard's process the catch-all is the last of the handlers registered after `install`.
342
+ 2. Wraps `context.route` and the `route` of every page in the context, present and future. A handler registered through them receives every request, blocked writes included, and gets a route on which:
343
+ - `continue(options)` and `fetch(options)` are egress points, judged as in step 3;
344
+ - `fallback(options)` is not judged: it passes the request to the next handler with its overrides applied, so the next handler's `route.request()` reflects them, and they are judged at the egress where the request finally leaves;
345
+ - `fulfill` and `abort` are not judged and record nothing, so a test can mock a write by fulfilling it.
346
+
347
+ The wrapped `unroute` and `unrouteAll` remove only the caller's own handlers, never the guard's. This applies in the fixture, the `initPage` entry and a direct `install`; in launcher mode it covers only the launcher's own process (see below).
348
+ 3. Judges each request at **egress**, the point where it would leave the browser. There are three egress points: (a) the guard's catch-all, reached when every handler before it fell back; (b) a caller's `route.continue(options)`; (c) a caller's `route.fetch(options)`. Each egress judges the effective request with `decideRequest`:
349
+ - For each of method, URL and body, the effective value is the one in `options` when given, else the request's as it reached that point (`route.request()`, which reflects earlier `fallback` overrides). A `postData` Buffer is read as UTF-8 and any other non-string `postData` as its JSON encoding; a body that cannot be decoded is judged as none. The effective method is upper-cased.
350
+ - Headers are a whole set, never merged: the effective headers are `options.headers` when given (Playwright replaces the full set), else `route.request().headers()`, with names lower-cased. The event's `contentType` is the effective `content-type` header.
351
+ - On `block`: (a) and (b) fulfill a request whose resource type is `document` with status 403, content type `text/html; charset=utf-8`, and a short HTML page reading `write-guard blocked <METHOD> <URL>` (the effective method and URL, HTML-escaped), so a blocked navigation shows why instead of a browser error page, and abort any other request with `'blockedbyclient'`, which the page sees as a network error; (c) rejects with `write-guard: route.fetch of <METHOD> <URL> is refused under read-only`, with the effective method and URL.
352
+ - On `allow` or `observe` (`observe` records the event first): (a) proceeds with `route.fallback()`, never `continue()`, so handlers registered behind it (a launcher client's `context.route`) still run; (b) and (c) call Playwright's own `continue` or `fetch` with the same options.
353
+ - Each egress attempt records at most one event (none when the request is allowed). `routed_requests` counts each request once, at its first egress; a request a caller fulfills or aborts without reaching an egress is not counted.
354
+ 4. Registers one `context.routeWebSocket`, matching every URL, under both `websockets` settings and either policy:
355
+ - Under `"allow"`, each socket is recorded as `{ kind: "websocket", action: "open", url, at }` and connected with `ws.connectToServer()`. Its messages are not judged or logged.
356
+ - Under `"block"`, each socket is closed with code 1008 without connecting to the server, and recorded as a `request` event with `action: "block"` and the socket's `url` (no `method`).
357
+
358
+ No socket counts in `routed_requests`, and a `websocket` event counts in neither `blocked` nor `observed`. Because the guard always routes sockets: under both settings Playwright replaces the page's `WebSocket` with its routed implementation; in launcher mode an attached client cannot register its own `routeWebSocket` (Playwright throws `Another client is already routing WebSockets`); in the guard's process a caller's own `routeWebSocket` registered later takes precedence, and then no open is recorded and `"block"` does not refuse that socket; sockets opened by dedicated or shared workers are not routed, so they are neither recorded nor blocked.
359
+ 5. Adds an init script that runs before any page script, with capture-phase `click`, `submit` and `keydown` (Enter or Space) listeners on `window` that find the control as described under Writes and controls and judge it with `decideControl`. Under `read-only` a destructive control's event is stopped (`preventDefault` and `stopImmediatePropagation`), so the app never sees it, and the page calls its original `console.warn`, which the script captures before any page script runs, with exactly `write-guard refused "<label>": <reason>`, where `<label>` is the control's label cut to 120 characters and `<reason>` is the reason `decideControl` returned. Under `observe` the event goes through and nothing is written to the console. Either way the page reports the event through the binding `__writeGuardReport`, which the script captures before any page script can replace it.
248
360
  6. Under `read-only`, dismisses every dialog (`alert`, `confirm`, `prompt`, `beforeunload`) on every page, so a native confirmation before a delete is declined.
249
361
  7. Under `read-only`, replaces `context.request` and the `request` of every page, present and future, with a proxy whose send methods (`fetch`, `get`, `post`, `put`, `patch`, `delete`, `head`) throw `write-guard: <method> through APIRequestContext is refused under read-only` (for example `write-guard: post through APIRequestContext is refused under read-only`). The other properties stay readable. This lock exists only in the process that called `install`.
250
362
 
@@ -252,18 +364,23 @@ If the binding `__writeGuardReport` is already registered on the context, anothe
252
364
 
253
365
  `install` cannot change how the context was created: create it with `serviceWorkers: 'block'`, or a service worker can send requests the guard never sees. The fixture, the launcher and the `initPage` config below all do.
254
366
 
255
- **Events.** Every blocked or observed write is one event:
367
+ **Events.** Every blocked or observed write is one event, and so is every WebSocket opened under `websockets: "allow"`:
256
368
 
257
369
  ```ts
258
- { kind: 'request' | 'control', action: 'block' | 'observe', reason: string,
259
- method?: string, url?: string, label?: string, at: string }
370
+ { kind: 'request', action: 'block' | 'observe', reason: string,
371
+ method?: string, url: string, contentType?: string, body?: string, at: string }
372
+ { kind: 'control', action: 'block' | 'observe', reason: string, label: string, at: string }
373
+ { kind: 'websocket', action: 'open', url: string, at: string }
260
374
  ```
261
375
 
262
- A request event carries `method` and `url`; a control event carries `label` (trimmed, at most 120 characters) and neither `method` nor `url`. `at` is an ISO 8601 timestamp. Allowed requests and controls produce no event.
376
+ - A request event carries the effective `method` (upper case) and `url`; a socket refused under `websockets: "block"` carries its `url` and no `method`. It carries `contentType` when the effective request has a `content-type` header, and, only with `logBodies: true`, `body`: the first 500 characters of the effective request body, when the request has one.
377
+ - A control event carries `label` (trimmed, at most 120 characters) and none of the request fields.
378
+ - A websocket event carries `url` and no `reason`.
379
+ - `at` is an ISO 8601 timestamp. Allowed requests and controls produce no event.
263
380
 
264
381
  **The handle.**
265
382
 
266
- - `record()` returns `{ installed: true, policy, routed_requests, blocked, observed, events }`: `routed_requests` counts the requests the guard judged, each once; `blocked` and `observed` count request and control events alike.
383
+ - `record()` returns `{ installed: true, policy, routed_requests, blocked, observed, events }`: `routed_requests` counts the requests the guard judged at egress, each once, at its first egress, and no WebSockets; `blocked` and `observed` count request and control events by their `action`, and `websocket` events count in neither.
267
384
  - `close()` resolves after the last pending log append and returns the final `record()`. The guard stays active until the context closes.
268
385
 
269
386
  **The audit log.** When `config.log` is a path, each event is appended to that file as one JSON line, as it happens. The path is relative to the process's working directory; the file is created when missing and never truncated.
@@ -327,7 +444,7 @@ Without those entries the agent can remove the guard.
327
444
 
328
445
  `startGuardedBrowser({ config, headless = true, host = '127.0.0.1', port = 0, title = 'write-guard' })` returns `Promise<{ wsEndpoint, browser, context, guard, close() }>`. It launches Chromium with `playwright-core`, creates one context with `serviceWorkers: 'block'`, guards it with `install(context, config)` (`config` defaults to `loadConfig()`), and calls `browser.bind(title, { host, port })`; `wsEndpoint` is the `endpoint` that returns, `ws://<host>:<port>/<id>` (`port: 0` lets the OS pick a free port). `guard` is the install handle. `close()` waits for `guard.close()`, then closes the context and the browser.
329
446
 
330
- A client attaches with `chromium.connect(wsEndpoint)` and sees the guarded context as `contexts()[0]`. The guard runs in the launcher's process, so the client's own `unroute` and `unrouteAll()` cannot remove it, and a `page.route` handler that calls `continue()` without overrides is still judged (page handlers run before the owner's context route). New tabs the client opens in that context are guarded. A client's `context.route` handler runs after the guard, in the client's process, so its `continue()` or `fallback()` overrides are not judged again, and its `route.fetch()` is not seen; see "What it cannot see" below.
447
+ A client attaches with `chromium.connect(wsEndpoint)` and sees the guarded context as `contexts()[0]`. The guard runs in the launcher's process, so the client's own `unroute` and `unrouteAll()` cannot remove it, and a `page.route` handler that calls `continue()`, with or without overrides, is still judged (page handlers run before the owner's context route, and the request reaches the guard's catch-all with the overrides applied). New tabs the client opens in that context are guarded. A client's `page.route` handler can `fulfill` or `abort` a write, so it can mock one. A client's `context.route` handler runs after the guard's catch-all, in the client's process: it receives only requests the catch-all already judged and let through, so it cannot mock a blocked write, its `continue()` or `fallback()` overrides are not judged again, and its `route.fetch()` is not seen; see "What it cannot see" below. The client cannot register its own `routeWebSocket`, because the guard already routes every WebSocket (Playwright throws `Another client is already routing WebSockets`).
331
448
 
332
449
  The command does the same from a shell:
333
450
 
@@ -342,20 +459,28 @@ npx playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--
342
459
  - `APIRequestContext` objects the guard cannot reach: an attached client's own `page.request` and `context.request` in launcher mode, and `playwright.request.newContext()` called from an agent's run-code tool.
343
460
  - An agent's run-code tool, and any browser the agent launches itself.
344
461
  - A Playwright Test run with `-c` from another directory without `WRITE_GUARD_CONFIG`: the fixture finds no config file and falls back to the defaults.
345
- - WebSockets, unless `websockets` is `"block"`. A caller's own `routeWebSocket` registered after the guard can take precedence over `websockets: "block"`.
462
+ - WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, but its messages (writes included) are not judged or logged. `"block"` refuses every socket the guard routes.
463
+ - WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
464
+ - A caller's own `routeWebSocket` registered after the guard in the same process takes precedence: no open is recorded, and `websockets: "block"` does not refuse that socket. In launcher mode an attached client cannot register its own `routeWebSocket`: Playwright throws `Another client is already routing WebSockets`.
465
+ - The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation.
346
466
  - WebRTC and WebTransport.
347
467
  - Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
348
468
  - Drag-and-drop, and pointer or mouse handlers that act before `click`.
349
469
  - Client-side-only destructive actions that no click or submit reaches.
350
470
  - Requests are classified only as well as the config: a write sent as GET, or a read sent as POST, needs a `writes` or `allow` signature.
471
+ - GET requests with side effects pass with no event unless a `writes` signature names them, and `observe` does not list GETs.
472
+ - Reads that send no query text (persisted GraphQL queries without an operation name): when nothing in the method, URL, headers or body tells such a read from a write, a signature that allows the endpoint allows its mutations too.
473
+ - Request bodies unless `logBodies` is `true`, and request headers other than the content type: events record the method, URL and content type. `logBodies` is off by default because bodies can hold credentials and personal data.
474
+ - In launcher mode, mocking a blocked write from a client's `context.route`: that handler runs after the guard's catch-all, so it never receives a blocked write. A client's `page.route` can mock one.
475
+ - The HTTP cache: Playwright turns it off while a route is installed.
476
+ - Service workers: `register()` resolves but `navigator.serviceWorker.ready` never settles.
351
477
  - Any context other than `contexts()[0]` in launcher mode: `--isolated`, or `browser.newContext()` from an agent's run-code tool, makes a new, unguarded context.
352
478
  - A client's `context.route` overrides in launcher mode are not judged again: that handler runs after the guard, in the client's process, so a `continue()` or `fallback()` that rewrites the method, URL or body (a GET rewritten to DELETE) reaches the server.
353
479
  - A client's `route.fetch()` from any route handler (`page.route` or `context.route`) in launcher mode is not seen: it is sent through the client's own `APIRequestContext`, which the guard cannot reach.
354
480
  - Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, or `Object.getPrototypeOf(context.request).fetch`, which goes around the read-only lock on `APIRequestContext`) or removes the guard's own `page` or `dialog` listeners (`context.removeAllListeners('page')` leaves new pages' routes unwrapped; `context.removeAllListeners('dialog')` lets a caller accept a dialog) goes around the guard's wrappers: the route it registers is not wrapped (its `continue()` can send a POST), `unrouteAll` removes the guard, and a `continue` with overrides skips the override judging.
355
481
  - Routes and dialog listeners registered before `install`: a `page.route` whose `continue()` sends a write, a `context.route` whose overrides are not judged, and a page `dialog` listener that accepts all run outside the guard's wrappers.
356
482
  - Under `observe`, sends through `context.request`, `page.request` and the `request` fixture are neither blocked nor recorded (only read-only locks them), so an observe run does not list them.
357
- - Under read-only, pressing Enter or Space in a text field whose value contains a built-in verb is refused (the label rule uses the focused element's value), and a click on plain text containing a verb, outside any control, can be refused.
358
- - Controls whose label is rendered inside a shadow root: the page sees the shadow host, whose text does not include the shadow content, so the click is not refused in the page. The network layer still judges any request it sends.
483
+ - Controls the page does not judge: anchors without `href`, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), controls inside closed shadow roots, and plain text outside any control. The network layer still judges any request they send. Open shadow roots are judged through the event's composed path, using the shadow host's label when the control has none. An image input is never found by the submit fallback, so a `requestSubmit()` with no submitter on a form whose only submit control is an image input is not judged in the page (the network layer still judges the request).
359
484
  - A document loaded with `page.setContent()`: the guard's in-page listeners do not carry over into it, so a destructive click there is not refused in the page. The network layer still judges any request it sends.
360
485
  - Node's own `http`, `https` and `net` modules in the test process.
361
486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-write-guard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A read-only safety net for Playwright tests and AI browser agents: blocks writes at the network layer and refuses destructive clicks in the page.",
5
5
  "keywords": [
6
6
  "playwright",
package/src/core.mjs CHANGED
@@ -10,10 +10,8 @@
10
10
  * Throws `write-guard: <path>: <message>` on anything malformed.
11
11
  */
12
12
  export function compileConfig(raw) {
13
- const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', '*'];
14
- const VERBS = ['save', 'delete', 'remove', 'publish', 'share', 'submit', 'send', 'archive', 'upload',
15
- 'import', 'rename', 'duplicate', 'update', 'confirm', 'pay', 'purchase', 'invite', 'reset',
16
- 'restore', 'approve', 'reject', 'log out', 'sign out'];
13
+ const VERBS = ['save', 'delete', 'remove', 'publish', 'send', 'archive', 'pay', 'purchase',
14
+ 'log out', 'sign out', 'logout'];
17
15
  const fail = (path, message) => { throw new Error(`write-guard: ${path}: ${message}`); };
18
16
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
19
17
  const list = (v, path) => {
@@ -33,11 +31,16 @@ export function compileConfig(raw) {
33
31
  const sigs = (v, key) => list(v, key).map((s, i) => {
34
32
  const at = `${key}[${i}]`;
35
33
  if (!isObj(s)) fail(at, 'must be an object');
36
- if (!METHODS.includes(s.method)) fail(`${at}.method`, `must be one of ${METHODS.join(' | ')}`);
34
+ if (s.method !== '*' && !(typeof s.method === 'string' && /^[A-Za-z][A-Za-z0-9-]*$/.test(s.method))) {
35
+ fail(`${at}.method`, 'must be "*" or a method name');
36
+ }
37
+ const hs = s.headers === undefined || s.headers === null ? {} : s.headers;
38
+ if (!isObj(hs)) fail(`${at}.headers`, 'must be an object');
37
39
  return {
38
- method: s.method,
40
+ method: s.method.toUpperCase(),
39
41
  url: regex(s.url, `${at}.url`, ''),
40
42
  body: s.body === undefined || s.body === null ? null : regex(s.body, `${at}.body`, ''),
43
+ headers: Object.keys(hs).map((k) => ({ name: k.toLowerCase(), value: regex(hs[k], `${at}.headers.${k}`, '') })),
41
44
  };
42
45
  });
43
46
 
@@ -51,6 +54,7 @@ export function compileConfig(raw) {
51
54
  if (cfg.log !== undefined && cfg.log !== null && (typeof cfg.log !== 'string' || cfg.log === '')) {
52
55
  fail('log', 'must be a non-empty string or null');
53
56
  }
57
+ if (cfg.logBodies !== undefined && typeof cfg.logBodies !== 'boolean') fail('logBodies', 'must be a boolean');
54
58
 
55
59
  const policy = oneOf(cfg.policy, ['read-only', 'observe'], 'read-only', 'policy');
56
60
  const websockets = oneOf(cfg.websockets, ['allow', 'block'], 'allow', 'websockets');
@@ -73,6 +77,7 @@ export function compileConfig(raw) {
73
77
  icons: [...icons],
74
78
  websockets,
75
79
  log: cfg.log === undefined ? 'write-guard.jsonl' : cfg.log,
80
+ logBodies: cfg.logBodies === true,
76
81
  };
77
82
  }
78
83
 
@@ -81,8 +86,11 @@ export function decideRequest(request, config) {
81
86
  const method = String(request.method ?? '').toUpperCase();
82
87
  const url = String(request.url ?? '');
83
88
  const body = request.body;
89
+ const headers = {};
90
+ for (const k of Object.keys(request.headers ?? {})) headers[k.toLowerCase()] = request.headers[k];
84
91
  const matches = (s) => (s.method === '*' || s.method === method) && s.url.test(url)
85
- && (s.body === null || (typeof body === 'string' && s.body.test(body)));
92
+ && (s.body === null || (typeof body === 'string' && s.body.test(body)))
93
+ && s.headers.every((h) => typeof headers[h.name] === 'string' && h.value.test(headers[h.name]));
86
94
 
87
95
  const allowed = config.allow.findIndex(matches);
88
96
  if (allowed !== -1) return { action: 'allow', reason: `allow[${allowed}] marks it a read` };
package/src/install.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  // playwright-write-guard/install: guard one Playwright BrowserContext.
2
2
  //
3
- // The network side judges every request in a catch-all context route registered first, and
4
- // wraps every route a caller registers later so the guard judges before the caller's handler
5
- // can continue a request. The page side is an init script built from the decision core's own
6
- // source, with capture-phase listeners that refuse destructive controls before the app sees
7
- // them. No playwright import: everything goes through the context it is handed.
3
+ // The network side judges each request at egress, where it would leave the browser: a
4
+ // catch-all context route registered first (so it runs last), and the continue and fetch of
5
+ // every route a caller registers later. A caller's handler sees every request and may fulfill
6
+ // or abort it. The page side is an init script built from the decision core's own source,
7
+ // with capture-phase listeners that refuse destructive controls before the app sees them.
8
+ // No playwright import: everything goes through the context it is handed.
8
9
 
9
10
  import { existsSync, promises as fsp, readFileSync } from 'node:fs';
10
11
  import { resolve } from 'node:path';
@@ -40,7 +41,8 @@ async function setup(context, raw, cfg) {
40
41
  const emit = (event) => {
41
42
  const e = { ...event, at: new Date().toISOString() };
42
43
  events.push(e);
43
- counts[e.action === 'block' ? 'blocked' : 'observed']++;
44
+ if (e.action === 'block') counts.blocked++;
45
+ else if (e.action === 'observe') counts.observed++;
44
46
  if (logPath) {
45
47
  const line = `${JSON.stringify(e)}\n`;
46
48
  pending = pending.then(() => fsp.appendFile(logPath, line))
@@ -59,58 +61,59 @@ async function setup(context, raw, cfg) {
59
61
  throw err;
60
62
  }
61
63
 
62
- // Each request is judged once, however many handlers see it. True means pass it on.
63
- const decided = new WeakSet();
64
- const judge = async (route) => {
64
+ // Judges the request as it would leave through `kind` ('catch-all', 'continue' or 'fetch'):
65
+ // each of method, URL and body from `options` when given, else as the request reached this
66
+ // point (route.request() reflects earlier fallback overrides). Headers are a whole set,
67
+ // never merged, as Playwright replaces them. Then blocks it or lets it proceed.
68
+ const counted = new WeakSet();
69
+ const egress = async (route, options, kind) => {
65
70
  const req = route.request();
66
- if (decided.has(req)) return true;
67
- decided.add(req);
68
- counts.routed++;
69
- const method = req.method();
70
- const url = req.url();
71
+ const o = options ?? {};
72
+ const method = String(o.method || req.method()).toUpperCase();
73
+ const url = o.url || req.url();
74
+ const p = o.postData;
71
75
  let body = null;
72
- try { body = req.postData(); } catch { /* an undecodable body is judged as none */ }
73
- const v = decideRequest({ method, url, body }, cfg);
74
- if (v.action === 'allow') return true;
75
- emit({ kind: 'request', action: v.action, reason: v.reason, method, url });
76
- if (v.action === 'observe') return true;
77
- if (req.resourceType() === 'document') {
78
- await route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(method, url) });
79
- } else {
80
- await route.abort('blockedbyclient');
81
- }
82
- return false;
83
- };
76
+ if (p != null) body = typeof p === 'string' ? p : Buffer.isBuffer(p) ? p.toString('utf8') : JSON.stringify(p);
77
+ else try { body = req.postData(); } catch { /* an undecodable body is judged as none */ }
78
+ const headers = {};
79
+ for (const [k, val] of Object.entries(o.headers || req.headers())) headers[k.toLowerCase()] = String(val);
80
+ if (!counted.has(req)) { counted.add(req); counts.routed++; }
84
81
 
85
- // A caller handler's continue, fallback or fetch overrides are judged as the request they
86
- // make; one the original already matches was judged above. True means let it proceed.
87
- const judgeOverride = (route, options = {}) => {
88
- const req = route.request();
89
- let original = null;
90
- try { original = req.postData(); } catch { /* an undecodable body is judged as none */ }
91
- const p = options.postData;
92
- const body = p == null ? original : typeof p === 'string' ? p : Buffer.isBuffer(p) ? p.toString('utf8') : JSON.stringify(p);
93
- const method = String(options.method || req.method()).toUpperCase();
94
- const url = options.url || req.url();
95
- if (method === req.method() && url === req.url() && body === original) return { pass: true };
96
- const v = decideRequest({ method, url, body }, cfg);
97
- if (v.action !== 'allow') emit({ kind: 'request', action: v.action, reason: v.reason, method, url });
98
- return { pass: v.action !== 'block', method, url };
82
+ const v = decideRequest({ method, url, body, headers }, cfg);
83
+ if (v.action !== 'allow') {
84
+ emit({
85
+ kind: 'request', action: v.action, reason: v.reason, method, url,
86
+ ...(headers['content-type'] === undefined ? {} : { contentType: headers['content-type'] }),
87
+ ...(cfg.logBodies && body != null ? { body: body.slice(0, 500) } : {}),
88
+ });
89
+ }
90
+ if (v.action === 'block') {
91
+ if (kind === 'fetch') throw new Error(`write-guard: route.fetch of ${method} ${url} is refused under read-only`);
92
+ if (req.resourceType() === 'document') {
93
+ return route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(method, url) });
94
+ }
95
+ return route.abort('blockedbyclient');
96
+ }
97
+ // fallback, never continue, so handlers registered behind the catch-all still run.
98
+ return kind === 'catch-all' ? route.fallback() : route[kind](options);
99
99
  };
100
100
 
101
101
  // A predicate, not a glob, so no caller's unroute(url) can name it.
102
- await context.route(() => true, async (route) => { if (await judge(route)) await route.fallback(); });
102
+ await context.route(() => true, (route) => egress(route, undefined, 'catch-all'));
103
103
 
104
- if (cfg.websockets === 'block') {
105
- await context.routeWebSocket(() => true, async (ws) => {
104
+ await context.routeWebSocket(() => true, async (ws) => {
105
+ if (cfg.websockets === 'block') {
106
106
  emit({ kind: 'request', action: 'block', reason: 'websockets is "block": every WebSocket is refused', url: ws.url() });
107
107
  await ws.close({ code: 1008, reason: 'write-guard' }).catch(() => {});
108
- });
109
- }
108
+ } else {
109
+ emit({ kind: 'websocket', action: 'open', url: ws.url() });
110
+ ws.connectToServer();
111
+ }
112
+ });
110
113
 
111
114
  await context.addInitScript({ content: pageScript(raw) });
112
115
 
113
- patchRoutes(context, judge, judgeOverride);
116
+ patchRoutes(context, egress);
114
117
  if (cfg.policy === 'read-only') {
115
118
  lockRequest(context);
116
119
  // The context's dialog event is emitted before any page's, so this runs ahead of every
@@ -118,7 +121,7 @@ async function setup(context, raw, cfg) {
118
121
  context.on('dialog', (d) => d.dismiss().catch(() => {}));
119
122
  }
120
123
  const onPage = (page) => {
121
- patchRoutes(page, judge, judgeOverride);
124
+ patchRoutes(page, egress);
122
125
  if (cfg.policy === 'read-only') lockRequest(page);
123
126
  };
124
127
  for (const p of context.pages()) onPage(p);
@@ -139,37 +142,28 @@ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
139
142
  const blockedPage = (method, url) => `<!doctype html><html><head><meta charset="utf-8"><title>write-guard</title></head>`
140
143
  + `<body><p>write-guard blocked ${escapeHtml(method)} ${escapeHtml(url)}</p></body></html>`;
141
144
 
142
- // The route a caller's handler gets: continue, fallback and fetch judge their overrides first,
143
- // and everything else is the original Route.
144
- function judgedRoute(r, judgeOverride) {
145
+ // The route a caller's handler gets: continue and fetch are egress points, judged first, and
146
+ // everything else (fulfill, abort, fallback) is the original Route's.
147
+ function judgedRoute(r, egress) {
145
148
  return new Proxy(r, {
146
149
  get(obj, prop) {
147
- if (prop === 'continue' || prop === 'fallback') {
148
- return async (options) => (judgeOverride(obj, options).pass ? obj[prop](options) : obj.abort('blockedbyclient'));
149
- }
150
- if (prop === 'fetch') {
151
- return async (options) => {
152
- const v = judgeOverride(obj, options);
153
- if (!v.pass) throw new Error(`write-guard: route.fetch of ${v.method} ${v.url} is refused under read-only`);
154
- return obj.fetch(options);
155
- };
156
- }
150
+ if (prop === 'continue' || prop === 'fetch') return async (options) => egress(obj, options, prop);
157
151
  const v = Reflect.get(obj, prop, obj);
158
152
  return typeof v === 'function' ? v.bind(obj) : v;
159
153
  },
160
154
  });
161
155
  }
162
156
 
163
- // Every handler a caller registers asks the guard first; unroute and unrouteAll remove only
157
+ // Every handler a caller registers gets a judged route; unroute and unrouteAll remove only
164
158
  // the caller's own handlers.
165
- function patchRoutes(target, judge, judgeOverride) {
159
+ function patchRoutes(target, egress) {
166
160
  const route = target.route.bind(target);
167
161
  const unroute = target.unroute.bind(target);
168
162
  const tracked = [];
169
163
  const sameUrl = (a, b) => a === b
170
164
  || (a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags);
171
165
  target.route = (url, handler, options) => {
172
- const wrapped = async (r, request) => { if (await judge(r)) return handler(judgedRoute(r, judgeOverride), request); };
166
+ const wrapped = (r, request) => handler(judgedRoute(r, egress), request);
173
167
  tracked.push({ url, handler, wrapped });
174
168
  // The returned disposable calls target.unroute(url, wrapped), which lands below.
175
169
  return route(url, wrapped, options);
@@ -212,14 +206,22 @@ function pageScript(raw) {
212
206
  const bind = window.${BINDING};
213
207
  const preventDefault = Function.prototype.call.bind(Event.prototype.preventDefault);
214
208
  const stopImmediatePropagation = Function.prototype.call.bind(Event.prototype.stopImmediatePropagation);
209
+ const warn = console.warn.bind(console);
215
210
  const report = (e) => { try { Promise.resolve(bind(e)).catch(() => {}); } catch (_) {} };
216
- const CONTROL = 'button, a, [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image], summary, [onclick], [tabindex]';
217
- const labelOf = (el) => {
211
+ const CONTROL = 'button, a[href], [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image]';
212
+ const SUBMIT = 'button:not([type]), button[type=submit], input[type=submit]';
213
+ const ownLabel = (el) => {
218
214
  for (const v of [el.getAttribute('aria-label'), el.innerText, el.getAttribute('title'), el.value]) {
219
215
  if (typeof v === 'string' && v.trim()) return v.trim();
220
216
  }
221
217
  return '';
222
218
  };
219
+ // One level up: a control in a shadow root whose own label is empty takes its host's.
220
+ const labelOf = (el) => {
221
+ const own = ownLabel(el);
222
+ const root = el.getRootNode();
223
+ return own || !(root instanceof ShadowRoot) ? own : ownLabel(root.host);
224
+ };
223
225
  const iconsOf = (el) => {
224
226
  const out = [];
225
227
  for (const n of [el, ...el.querySelectorAll('[class]')]) {
@@ -232,15 +234,23 @@ function pageScript(raw) {
232
234
  const label = labelOf(el);
233
235
  const v = decideControl({ label, icons: iconsOf(el) }, cfg);
234
236
  if (v.action === 'allow') return;
235
- if (v.action === 'block') { preventDefault(e); stopImmediatePropagation(e); }
237
+ if (v.action === 'block') {
238
+ preventDefault(e);
239
+ stopImmediatePropagation(e);
240
+ warn('write-guard refused "' + label.slice(0, 120) + '": ' + v.reason);
241
+ }
236
242
  report({ kind: 'control', action: v.action, reason: v.reason, label: label.slice(0, 120) });
237
243
  };
238
- window.addEventListener('click', (e) => {
239
- if (e.target instanceof Element) judge(e.target.closest(CONTROL) || e.target, e);
244
+ const isControl = (n) => n instanceof Element && n.matches(CONTROL);
245
+ // The path crosses open shadow roots and stops at a closed one's host.
246
+ window.addEventListener('click', (e) => judge(e.composedPath().find(isControl), e), true);
247
+ window.addEventListener('submit', (e) => {
248
+ const form = e.target;
249
+ const fallback = form instanceof HTMLFormElement ? Array.prototype.find.call(form.elements, (n) => n.matches(SUBMIT)) : null;
250
+ judge(e.submitter || fallback, e);
240
251
  }, true);
241
- window.addEventListener('submit', (e) => judge(e.submitter || e.target, e), true);
242
252
  window.addEventListener('keydown', (e) => {
243
- if (e.key === 'Enter' || e.key === ' ') judge(document.activeElement, e);
253
+ if ((e.key === 'Enter' || e.key === ' ') && isControl(document.activeElement)) judge(document.activeElement, e);
244
254
  }, true);
245
255
  })();`;
246
256
  }
package/types/core.d.ts CHANGED
@@ -3,19 +3,29 @@
3
3
  /** `read-only` blocks writes; `observe` records them and lets them through. */
4
4
  export type Policy = 'read-only' | 'observe';
5
5
 
6
- /** A request method a signature can name. `*` matches any method. */
7
- export type SigMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | '*';
6
+ /**
7
+ * A request method a signature can name: `"*"` (any method) or a method name matching
8
+ * `^[A-Za-z][A-Za-z0-9-]*$` (for example `POST`, `REPORT` or `M-SEARCH`), compiled to upper
9
+ * case and compared with the request's method in upper case.
10
+ */
11
+ export type SigMethod = string;
8
12
 
9
13
  /**
10
14
  * A request signature. `url` and `body` are regex sources, compiled with no flags
11
15
  * (case-sensitive) and tested unanchored against the full URL and the request body.
12
- * A signature with `body` never matches a request that has no body.
16
+ * A signature with `body` never matches a request that has no body. A signature with
17
+ * `headers` matches only when every named header is present and its value matches.
13
18
  */
14
19
  export interface Sig {
15
20
  method: SigMethod;
16
21
  url: string;
17
22
  /** Absent or `null`: the signature names no body. */
18
23
  body?: string | null;
24
+ /**
25
+ * Header name to a regex source. Names compare case-insensitively; values are compiled
26
+ * with no flags and tested unanchored. Absent or `null`: the signature names no headers.
27
+ */
28
+ headers?: Record<string, string> | null;
19
29
  }
20
30
 
21
31
  /** The raw config. Every key is optional; unknown keys are ignored. */
@@ -32,19 +42,27 @@ export interface Config {
32
42
  /** Class name prefixes; a control with a class starting with one is destructive. */
33
43
  icons?: string[];
34
44
  };
35
- /** Default `true`: include the 23 built-in verbs in the text pattern. */
45
+ /**
46
+ * Default `true`: include the 11 built-in verbs in the text pattern: `save`, `delete`,
47
+ * `remove`, `publish`, `send`, `archive`, `pay`, `purchase`, `log out`, `sign out`, `logout`.
48
+ */
36
49
  defaultControls?: boolean;
37
50
  /** Default `"allow"`. */
38
51
  websockets?: 'allow' | 'block';
39
52
  /** JSONL audit log path, relative to the working directory. Default `"write-guard.jsonl"`; `null` turns it off. */
40
53
  log?: string | null;
54
+ /** Default `false`: when `true`, request events carry `body`, the first 500 characters of the request body. */
55
+ logBodies?: boolean;
41
56
  }
42
57
 
43
58
  export interface CompiledSig {
59
+ /** `"*"` or the method name in upper case. */
44
60
  method: SigMethod;
45
61
  url: RegExp;
46
62
  /** `null` when the signature names no body. */
47
63
  body: RegExp | null;
64
+ /** Header names in lower case; empty when the signature names no headers. */
65
+ headers: Array<{ name: string; value: RegExp }>;
48
66
  }
49
67
 
50
68
  /** What `compileConfig` returns: every default applied, every pattern compiled. */
@@ -58,6 +76,7 @@ export interface CompiledConfig {
58
76
  icons: string[];
59
77
  websockets: 'allow' | 'block';
60
78
  log: string | null;
79
+ logBodies: boolean;
61
80
  }
62
81
 
63
82
  export interface RequestInput {
@@ -65,6 +84,8 @@ export interface RequestInput {
65
84
  url: string;
66
85
  /** The request body; `null` or absent when it has none. */
67
86
  body?: string | null;
87
+ /** The request headers, names in any case (`decideRequest` lower-cases them); absent when it has none. */
88
+ headers?: Record<string, string>;
68
89
  }
69
90
 
70
91
  export interface RequestDecision {
@@ -3,22 +3,50 @@
3
3
  import type { BrowserContext } from 'playwright-core';
4
4
  import type { Config, Policy } from './core.js';
5
5
 
6
- /** One write the guard blocked or observed. Request events carry `method` and `url`; control events carry `label`. */
7
- export interface GuardEvent {
8
- kind: 'request' | 'control';
6
+ /** A write the guard blocked or observed at egress, or a WebSocket refused under `websockets: "block"`. */
7
+ export interface RequestEvent {
8
+ kind: 'request';
9
9
  action: 'block' | 'observe';
10
10
  reason: string;
11
+ /** The effective method, upper case. Absent for a refused WebSocket. */
11
12
  method?: string;
12
- url?: string;
13
- label?: string;
13
+ /** The effective URL, or the refused WebSocket's URL. */
14
+ url: string;
15
+ /** The effective `content-type` header, when the request has one. */
16
+ contentType?: string;
17
+ /** Only with `logBodies: true`: the first 500 characters of the effective body, when the request has one. */
18
+ body?: string;
14
19
  /** ISO 8601 timestamp. */
15
20
  at: string;
16
21
  }
17
22
 
23
+ /** A destructive control the page blocked or observed. */
24
+ export interface ControlEvent {
25
+ kind: 'control';
26
+ action: 'block' | 'observe';
27
+ reason: string;
28
+ /** Trimmed, at most 120 characters. */
29
+ label: string;
30
+ /** ISO 8601 timestamp. */
31
+ at: string;
32
+ }
33
+
34
+ /** A WebSocket opened under `websockets: "allow"`. Counts in neither `blocked` nor `observed`. */
35
+ export interface WebSocketEvent {
36
+ kind: 'websocket';
37
+ action: 'open';
38
+ url: string;
39
+ /** ISO 8601 timestamp. */
40
+ at: string;
41
+ }
42
+
43
+ /** One event the guard recorded. */
44
+ export type GuardEvent = RequestEvent | ControlEvent | WebSocketEvent;
45
+
18
46
  export interface GuardRecord {
19
47
  installed: true;
20
48
  policy: Policy;
21
- /** Requests the guard judged, each counted once. */
49
+ /** Requests the guard judged at egress, each counted once, at its first egress. WebSockets are not counted. */
22
50
  routed_requests: number;
23
51
  /** Request and control events with `action: "block"`. */
24
52
  blocked: number;