playwright-write-guard 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +365 -2
- package/bin/browser.mjs +56 -0
- package/package.json +76 -4
- package/src/browser.mjs +29 -0
- package/src/core.mjs +109 -0
- package/src/fixture.mjs +105 -0
- package/src/init-page.mjs +9 -0
- package/src/install.mjs +265 -0
- package/types/browser.d.ts +31 -0
- package/types/core.d.ts +97 -0
- package/types/init-page.d.ts +9 -0
- package/types/install.d.ts +49 -0
- package/types/test.d.ts +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Venkata Sandeep Takasi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,366 @@
|
|
|
1
|
-
#
|
|
1
|
+
# playwright-write-guard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A read-only safety net for Playwright tests and AI browser agents. It blocks every request whose method is not GET, HEAD or OPTIONS (unless you allow-list it) at the network layer, refuses destructive clicks inside the page before the app sees them, and writes a JSON audit log of every write it blocked or observed. Use it with Playwright Test through one import, or with Playwright MCP and the Playwright CLI either through a one-line `initPage` config entry or through a launcher that owns the browser the agent attaches to. It has no runtime dependencies and no build step. It is a safety net, not a security boundary: a restricted test account is the boundary.
|
|
4
|
+
|
|
5
|
+
## What it is and why
|
|
6
|
+
|
|
7
|
+
Pointing an automated browser at a real app is risky when the run should only look. A test that clicks through a settings page, or an agent asked to "find out what this dashboard shows", can press Save, Delete or Send, or fire a POST, and change real data.
|
|
8
|
+
|
|
9
|
+
This package puts a read-only guard in front of the browser:
|
|
10
|
+
|
|
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.
|
|
13
|
+
- **Log.** Every write it blocked, or only observed under the `observe` policy, is recorded as one JSON line.
|
|
14
|
+
|
|
15
|
+
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
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm i -D playwright-write-guard
|
|
21
|
+
npx playwright install chromium
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It needs Node 20 or newer and Playwright 1.59 or newer: `@playwright/test` for the fixture, and `playwright-core` for the launcher and its command. Using the `initPage` entry with an agent needs a Node that can `require` an ES module (see below).
|
|
25
|
+
|
|
26
|
+
## Use with Playwright Test
|
|
27
|
+
|
|
28
|
+
Swap one import. Everything else in your tests stays the same:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// before
|
|
32
|
+
// import { test, expect } from '@playwright/test';
|
|
33
|
+
import { test, expect } from 'playwright-write-guard/test';
|
|
34
|
+
|
|
35
|
+
test('lists items without changing them', async ({ page }) => {
|
|
36
|
+
await page.goto('https://app.example.test/items');
|
|
37
|
+
await expect(page.getByRole('list')).toBeVisible();
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
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:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"policy": "read-only",
|
|
46
|
+
"allow": [
|
|
47
|
+
{ "method": "POST", "url": "^https://app[.]example[.]test/graphql$" }
|
|
48
|
+
],
|
|
49
|
+
"controls": { "icons": ["icon-trash"] }
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To set the config in the test file instead, use `test.use`:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
test.use({ writeGuardConfig: { policy: 'read-only', allow: [] } });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each test attaches what the guard saw as `write-guard.json` (open it from the HTML report or the test's attachments). The fixture never writes a JSONL log file; use that attachment instead.
|
|
60
|
+
|
|
61
|
+
If you run Playwright with `-c` from another directory, the fixture will not find `write-guard.config.json` and silently falls back to the defaults. Set `WRITE_GUARD_CONFIG` to the config file's path:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
WRITE_GUARD_CONFIG=/path/to/write-guard.config.json npx playwright test -c config/playwright.config.mjs
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Use with AI agents
|
|
68
|
+
|
|
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`.
|
|
70
|
+
|
|
71
|
+
### Config mode: the `initPage` entry
|
|
72
|
+
|
|
73
|
+
Playwright MCP and the Playwright CLI can run a script on every new tab before any tool acts. Point them at this package's entry. For Playwright MCP, put this in a file and pass it with `--config`; for the Playwright CLI, put it in `.playwright/cli.config.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"browser": {
|
|
78
|
+
"initPage": ["/absolute/path/to/node_modules/playwright-write-guard/src/init-page.mjs"],
|
|
79
|
+
"contextOptions": { "serviceWorkers": "block" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The entry is loaded with `require`, so the agent server's Node must be able to `require` an ES module: Node 20.19 or newer, or 22.12 or newer. The guard reads its config with `loadConfig()` from the agent server's working directory; set `WRITE_GUARD_CONFIG` to an absolute path when in doubt. The audit log is written there too.
|
|
85
|
+
|
|
86
|
+
In this mode the guard lives in the same process as the agent's tools, so the agent can remove it with its own code-running and routing tools. Deny those tools. In Claude Code, add these to `permissions.deny`:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"permissions": {
|
|
91
|
+
"deny": [
|
|
92
|
+
"mcp__playwright__browser_run_code_unsafe",
|
|
93
|
+
"mcp__playwright__browser_route",
|
|
94
|
+
"mcp__playwright__browser_unroute",
|
|
95
|
+
"Bash(playwright-cli run-code:*)",
|
|
96
|
+
"Bash(playwright-cli route:*)",
|
|
97
|
+
"Bash(playwright-cli unroute:*)"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Without those entries the agent can remove the guard.
|
|
104
|
+
|
|
105
|
+
### Launcher mode: a guarded browser the agent attaches to
|
|
106
|
+
|
|
107
|
+
The launcher starts Chromium, guards its one context, and prints a WebSocket endpoint:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
npx playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--port <n>]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It prints `endpoint: <ws endpoint>`. Attach the agent to it:
|
|
114
|
+
|
|
115
|
+
- Playwright MCP: start it with `--endpoint <ws endpoint>` and without `--isolated`. `--isolated` makes a new, unguarded context.
|
|
116
|
+
- Playwright CLI: `playwright-cli attach --endpoint=<ws endpoint>`.
|
|
117
|
+
|
|
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.
|
|
119
|
+
|
|
120
|
+
## The audit log
|
|
121
|
+
|
|
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.
|
|
123
|
+
|
|
124
|
+
A blocked request and a refused control look like this (one line each in the file):
|
|
125
|
+
|
|
126
|
+
```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"}
|
|
128
|
+
{"kind":"control","action":"block","reason":"\"Delete item\" matches a destructive label; policy read-only","label":"Delete item","at":"2026-01-15T09:30:02.000Z"}
|
|
129
|
+
```
|
|
130
|
+
|
|
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.
|
|
132
|
+
|
|
133
|
+
## What it cannot see
|
|
134
|
+
|
|
135
|
+
The guard judges requests the browser sends through its context and clicks the page makes. These are outside it:
|
|
136
|
+
|
|
137
|
+
- 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
|
+
- An agent's run-code tool, and any browser the agent launches itself. In config mode, deny those tools as shown above.
|
|
139
|
+
- 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"`.
|
|
141
|
+
- WebRTC and WebTransport.
|
|
142
|
+
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
143
|
+
- Drag-and-drop, and pointer or mouse handlers that act before `click`.
|
|
144
|
+
- Client-side-only destructive actions that no click or submit reaches.
|
|
145
|
+
- 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.
|
|
146
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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.
|
|
153
|
+
- 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
|
+
- Node's own `http`, `https` and `net` modules in the test process.
|
|
155
|
+
|
|
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.
|
|
157
|
+
|
|
158
|
+
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
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
The package has five entry points, each with its own type declarations:
|
|
163
|
+
|
|
164
|
+
| Import | Exports |
|
|
165
|
+
|---|---|
|
|
166
|
+
| `playwright-write-guard` | `compileConfig`, `decideRequest`, `decideControl` |
|
|
167
|
+
| `playwright-write-guard/install` | `install`, `loadConfig` |
|
|
168
|
+
| `playwright-write-guard/test` | `test`, `expect` |
|
|
169
|
+
| `playwright-write-guard/init-page` | the default export, an `initPage` function |
|
|
170
|
+
| `playwright-write-guard/browser` | `startGuardedBrowser` |
|
|
171
|
+
|
|
172
|
+
It also installs one command, `playwright-write-guard-browser`. It needs Node 20 or newer and Playwright 1.59 or newer (`@playwright/test` for the fixture; `playwright-core` is needed at runtime only by `playwright-write-guard/browser` and the command, and the other entry points use the context Playwright gives them).
|
|
173
|
+
|
|
174
|
+
### Config
|
|
175
|
+
|
|
176
|
+
Every key is optional, and unknown keys are ignored:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"policy": "read-only",
|
|
181
|
+
"allow": [],
|
|
182
|
+
"writes": [],
|
|
183
|
+
"controls": { "text": [], "icons": [] },
|
|
184
|
+
"defaultControls": true,
|
|
185
|
+
"websockets": "allow",
|
|
186
|
+
"log": "write-guard.jsonl"
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| Key | Type | Default | Meaning |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| `policy` | `"read-only"` or `"observe"` | `"read-only"` | `read-only` blocks writes and destructive controls; `observe` records them and lets them through |
|
|
193
|
+
| `allow` | list of Sig | `[]` | requests that are never writes |
|
|
194
|
+
| `writes` | list of Sig | `[]` | requests that are writes even when their method is GET, HEAD or OPTIONS |
|
|
195
|
+
| `controls.text` | list of regex sources | `[]` | extra label patterns that make a control destructive |
|
|
196
|
+
| `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 |
|
|
199
|
+
| `log` | path or `null` | `"write-guard.jsonl"` | the JSONL audit log, relative to the process's working directory; `null` turns it off |
|
|
200
|
+
|
|
201
|
+
A **Sig** (request signature) is `{ "method": M, "url": U, "body": B }`:
|
|
202
|
+
|
|
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.
|
|
204
|
+
- `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
|
+
- `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.
|
|
207
|
+
|
|
208
|
+
### Writes and controls
|
|
209
|
+
|
|
210
|
+
**A request is a write** when its method, upper-cased, is not GET, HEAD or OPTIONS, or it matches a `writes` signature, **and** it matches no `allow` signature. The method is upper-cased before the GET, HEAD or OPTIONS check. `allow` always wins: `{ "method": "POST", "url": "^https://app[.]example[.]com/graphql" }` in `allow` lets a read-only GraphQL endpoint through.
|
|
211
|
+
|
|
212
|
+
**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
|
+
|
|
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`
|
|
215
|
+
|
|
216
|
+
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
|
+
|
|
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.
|
|
219
|
+
|
|
220
|
+
### The decision core: `playwright-write-guard`
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
import { compileConfig, decideRequest, decideControl } from 'playwright-write-guard';
|
|
224
|
+
|
|
225
|
+
const config = compileConfig({ allow: [{ method: 'POST', url: '^https://app[.]example[.]com/graphql' }] });
|
|
226
|
+
decideRequest({ method: 'POST', url: 'https://app.example.com/items', body: '{}' }, config);
|
|
227
|
+
// { action: 'block', reason: '...' }
|
|
228
|
+
decideControl({ label: 'Delete item', icons: [] }, config);
|
|
229
|
+
// { action: 'block', reason: '...' }
|
|
230
|
+
```
|
|
231
|
+
|
|
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"`.
|
|
234
|
+
- `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
|
+
- `reason` is a short human-readable explanation; its wording is not part of the API.
|
|
236
|
+
|
|
237
|
+
These three functions reference nothing outside their own arguments, because the installer injects their source text into every page.
|
|
238
|
+
|
|
239
|
+
### The installer: `playwright-write-guard/install`
|
|
240
|
+
|
|
241
|
+
`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
|
+
|
|
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.
|
|
248
|
+
6. Under `read-only`, dismisses every dialog (`alert`, `confirm`, `prompt`, `beforeunload`) on every page, so a native confirmation before a delete is declined.
|
|
249
|
+
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
|
+
|
|
251
|
+
If the binding `__writeGuardReport` is already registered on the context, another process guards it (as when an agent attached to the launcher also loads the `initPage` entry), and `install` throws `write-guard: this context is already guarded by another process; use one mode, not both`. That check happens before anything is installed, so a refused `install` leaves nothing behind.
|
|
252
|
+
|
|
253
|
+
`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
|
+
|
|
255
|
+
**Events.** Every blocked or observed write is one event:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
{ kind: 'request' | 'control', action: 'block' | 'observe', reason: string,
|
|
259
|
+
method?: string, url?: string, label?: string, at: string }
|
|
260
|
+
```
|
|
261
|
+
|
|
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.
|
|
263
|
+
|
|
264
|
+
**The handle.**
|
|
265
|
+
|
|
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.
|
|
267
|
+
- `close()` resolves after the last pending log append and returns the final `record()`. The guard stays active until the context closes.
|
|
268
|
+
|
|
269
|
+
**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.
|
|
270
|
+
|
|
271
|
+
**`loadConfig(path?)`** returns a raw config to pass to `install`. It reads, synchronously with `fs.readFileSync`, the first of: `path`; else the file named by the environment variable `WRITE_GUARD_CONFIG`; else `write-guard.config.json` when that file exists; else it uses `{}`. Relative paths resolve against the process's working directory. A file named by `path` or `WRITE_GUARD_CONFIG` must exist, or `loadConfig` throws. When the environment variable `WRITE_GUARD_POLICY` is set to a non-empty value, it overrides `policy`. The result is validated with `compileConfig`, so a file that does not parse as JSON, an invalid config or an invalid `WRITE_GUARD_POLICY` throws. A file that cannot be read throws `write-guard: <file>: cannot be read: <system message>`, and a file that does not parse throws `write-guard: <file>: is not valid JSON: <parser message>`, where `<file>` is the path as resolved. Every entry point below that defaults to `loadConfig()` therefore honours `WRITE_GUARD_CONFIG` and `WRITE_GUARD_POLICY`; a config you pass explicitly to `install`, `test.use` or `startGuardedBrowser` is used as given.
|
|
272
|
+
|
|
273
|
+
### The Playwright Test fixture: `playwright-write-guard/test`
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
import { test, expect } from 'playwright-write-guard/test';
|
|
277
|
+
|
|
278
|
+
test.use({ writeGuardConfig: { policy: 'read-only', allow: [] } });
|
|
279
|
+
|
|
280
|
+
test('lists items without changing them', async ({ page }) => {
|
|
281
|
+
await page.goto('https://app.example.com/items');
|
|
282
|
+
await expect(page.getByRole('list')).toBeVisible();
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- `test` and `expect` are Playwright Test's, extended with one option fixture, `writeGuardConfig`, settable with `test.use`. It defaults to `loadConfig()`, which resolves relative to the working directory: running Playwright with `-c` from another directory needs `WRITE_GUARD_CONFIG` to find the config.
|
|
287
|
+
- The `context` fixture is created with `serviceWorkers: 'block'` and guarded with `install`, with `log` forced to `null` (the fixture never writes a log file). Under `read-only` that also locks `context.request` and `page.request`. A test whose effective `serviceWorkers` option is not `'block'` fails at fixture setup with an error starting `write-guard: serviceWorkers is`, because a service worker could send requests the guard never sees.
|
|
288
|
+
- The `request` fixture throws `write-guard: the request fixture is refused under read-only` under `read-only`, and works under `observe`. A test that uses `request` under read-only therefore fails at fixture setup with that message.
|
|
289
|
+
- Under both policies, while a guarded test or hook runs, these throw an error starting `write-guard:` because they would make a browser context, request context or connection the guard cannot see: `browser.newContext`, `browser.newPage`, `playwright.request.newContext`, the global `fetch`, and the browser types' `launch`, `launchPersistentContext`, `launchServer`, `connect` and `connectOverCDP`. Playwright's own creation of the test's context is the one exception.
|
|
290
|
+
- Each test attaches its guard's `record()` as `write-guard.json` (`application/json`).
|
|
291
|
+
|
|
292
|
+
### The `initPage` entry: `playwright-write-guard/init-page`
|
|
293
|
+
|
|
294
|
+
The default export is `async ({ page }) => install(page.context(), loadConfig())`, and is idempotent per context: every new tab calls it, and the context is guarded once. Playwright MCP and the Playwright CLI run each `browser.initPage` entry with `require(path).default({ page })` in their own process for every new tab, before any tool acts. Load it with this config (MCP's `--config` file, or the CLI's `.playwright/cli.config.json`):
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
{
|
|
298
|
+
"browser": {
|
|
299
|
+
"initPage": ["/absolute/path/to/node_modules/playwright-write-guard/src/init-page.mjs"],
|
|
300
|
+
"contextOptions": { "serviceWorkers": "block" }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Because it is loaded with `require`, nothing in its import graph uses top-level await, and Node must support `require` of ES modules: Node 20.19 or newer, or 22.12 or newer. `loadConfig()` resolves against the agent server's working directory, so set `WRITE_GUARD_CONFIG` to an absolute path when in doubt. The log, when on, is written there too.
|
|
306
|
+
|
|
307
|
+
In this mode the guard lives in the same process as the agent's tools, so the agent can remove it. Deny the tools that can, for example in Claude Code's `permissions.deny`:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"permissions": {
|
|
312
|
+
"deny": [
|
|
313
|
+
"mcp__playwright__browser_run_code_unsafe",
|
|
314
|
+
"mcp__playwright__browser_route",
|
|
315
|
+
"mcp__playwright__browser_unroute",
|
|
316
|
+
"Bash(playwright-cli run-code:*)",
|
|
317
|
+
"Bash(playwright-cli route:*)",
|
|
318
|
+
"Bash(playwright-cli unroute:*)"
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Without those entries the agent can remove the guard.
|
|
325
|
+
|
|
326
|
+
### The launcher: `playwright-write-guard/browser`
|
|
327
|
+
|
|
328
|
+
`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
|
+
|
|
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.
|
|
331
|
+
|
|
332
|
+
The command does the same from a shell:
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
npx playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--port <n>]
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`--config` is passed to `loadConfig` (so without it, `WRITE_GUARD_CONFIG` and `write-guard.config.json` apply), `--headed` sets `headless` to false, and `--host` and `--port` default to `127.0.0.1` and `0`. It prints `endpoint: <ws endpoint>` on a line of its own, then how to attach: Playwright MCP with `--endpoint <ws endpoint>` and without `--isolated` (which uses `contexts()[0]`), or the Playwright CLI with `playwright-cli attach --endpoint=<ws endpoint>`. It stays up until SIGINT or SIGTERM, then closes the browser and exits 0. An unknown flag or a flag missing its value prints the usage line and exits 1.
|
|
339
|
+
|
|
340
|
+
### What it cannot see
|
|
341
|
+
|
|
342
|
+
- `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
|
+
- An agent's run-code tool, and any browser the agent launches itself.
|
|
344
|
+
- 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"`.
|
|
346
|
+
- WebRTC and WebTransport.
|
|
347
|
+
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
348
|
+
- Drag-and-drop, and pointer or mouse handlers that act before `click`.
|
|
349
|
+
- Client-side-only destructive actions that no click or submit reaches.
|
|
350
|
+
- 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.
|
|
351
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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.
|
|
359
|
+
- 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
|
+
- Node's own `http`, `https` and `net` modules in the test process.
|
|
361
|
+
|
|
362
|
+
It is a safety net, not a security boundary. A restricted test account is the boundary.
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
MIT. See [LICENSE](LICENSE). Written by Venkata Sandeep Takasi.
|
package/bin/browser.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// playwright-write-guard-browser: start a guarded Chromium and print how an agent attaches.
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { startGuardedBrowser } from '../src/browser.mjs';
|
|
6
|
+
import { loadConfig } from '../src/install.mjs';
|
|
7
|
+
|
|
8
|
+
const USAGE = 'usage: playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--port <n>]';
|
|
9
|
+
|
|
10
|
+
let args;
|
|
11
|
+
try {
|
|
12
|
+
({ values: args } = parseArgs({
|
|
13
|
+
options: { config: { type: 'string' }, headed: { type: 'boolean' }, host: { type: 'string' }, port: { type: 'string' } },
|
|
14
|
+
}));
|
|
15
|
+
} catch {
|
|
16
|
+
args = null;
|
|
17
|
+
}
|
|
18
|
+
const port = args?.port === undefined ? 0 : Number(args.port);
|
|
19
|
+
if (!args || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
20
|
+
console.error(USAGE);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let guarded;
|
|
25
|
+
try {
|
|
26
|
+
guarded = await startGuardedBrowser({
|
|
27
|
+
config: loadConfig(args.config),
|
|
28
|
+
headless: !args.headed,
|
|
29
|
+
host: args.host ?? '127.0.0.1',
|
|
30
|
+
port,
|
|
31
|
+
});
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(err?.message ?? String(err));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ws = guarded.wsEndpoint;
|
|
38
|
+
console.log(`endpoint: ${ws}`);
|
|
39
|
+
console.log('Attach one of:');
|
|
40
|
+
console.log(` Playwright MCP: --endpoint ${ws} (without --isolated, which would make a new, unguarded context)`);
|
|
41
|
+
console.log(` Playwright CLI: playwright-cli attach --endpoint=${ws}`);
|
|
42
|
+
console.log('Stop with Ctrl+C.');
|
|
43
|
+
|
|
44
|
+
// Playwright's own signal handlers close the browser and exit 130; this process closes it
|
|
45
|
+
// itself and exits 0, so they go.
|
|
46
|
+
process.removeAllListeners('SIGINT');
|
|
47
|
+
process.removeAllListeners('SIGTERM');
|
|
48
|
+
let stopping = false;
|
|
49
|
+
const stop = async () => {
|
|
50
|
+
if (stopping) return;
|
|
51
|
+
stopping = true;
|
|
52
|
+
await guarded.close().catch((err) => console.error(err?.message ?? String(err)));
|
|
53
|
+
process.exit(0);
|
|
54
|
+
};
|
|
55
|
+
process.on('SIGINT', stop);
|
|
56
|
+
process.on('SIGTERM', stop);
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playwright-write-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"playwright",
|
|
7
|
+
"testing",
|
|
8
|
+
"read-only",
|
|
9
|
+
"safety",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"mcp"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Venkata Sandeep Takasi",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/SandeepTakasi/playwright-write-guard.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/SandeepTakasi/playwright-write-guard#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/SandeepTakasi/playwright-write-guard/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./types/core.d.ts",
|
|
30
|
+
"default": "./src/core.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./install": {
|
|
33
|
+
"types": "./types/install.d.ts",
|
|
34
|
+
"default": "./src/install.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./test": {
|
|
37
|
+
"types": "./types/test.d.ts",
|
|
38
|
+
"default": "./src/fixture.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./init-page": {
|
|
41
|
+
"types": "./types/init-page.d.ts",
|
|
42
|
+
"default": "./src/init-page.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./browser": {
|
|
45
|
+
"types": "./types/browser.d.ts",
|
|
46
|
+
"default": "./src/browser.mjs"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"bin": {
|
|
50
|
+
"playwright-write-guard-browser": "./bin/browser.mjs"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"src",
|
|
54
|
+
"bin",
|
|
55
|
+
"types",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "node --test test/*.test.mjs && playwright test --pass-with-no-tests"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@playwright/test": ">=1.59",
|
|
64
|
+
"playwright-core": ">=1.59"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@playwright/test": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"playwright-core": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@playwright/test": "1.64.0",
|
|
76
|
+
"playwright-core": "1.64.0"
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// playwright-write-guard/browser: a guarded Chromium that an agent attaches to. The guard runs
|
|
2
|
+
// in this process, so an attached client cannot remove it. Its context.route overrides, its
|
|
3
|
+
// route.fetch() and any context other than this one are not seen (see the README's limits).
|
|
4
|
+
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import { install, loadConfig } from './install.mjs';
|
|
7
|
+
|
|
8
|
+
export async function startGuardedBrowser({ config = loadConfig(), headless = true, host = '127.0.0.1', port = 0, title = 'write-guard' } = {}) {
|
|
9
|
+
const browser = await chromium.launch({ headless });
|
|
10
|
+
try {
|
|
11
|
+
const context = await browser.newContext({ serviceWorkers: 'block' });
|
|
12
|
+
const guard = await install(context, config);
|
|
13
|
+
const { endpoint } = await browser.bind(title, { host, port });
|
|
14
|
+
return {
|
|
15
|
+
wsEndpoint: endpoint,
|
|
16
|
+
browser,
|
|
17
|
+
context,
|
|
18
|
+
guard,
|
|
19
|
+
async close() {
|
|
20
|
+
await guard.close();
|
|
21
|
+
await context.close();
|
|
22
|
+
await browser.close();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
} catch (err) {
|
|
26
|
+
await browser.close().catch(() => {});
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/core.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// playwright-write-guard: the decision core. Pure functions, no I/O, no imports.
|
|
2
|
+
//
|
|
3
|
+
// Every exported function is self-contained: it references nothing outside its own
|
|
4
|
+
// arguments and body, because the installer injects `fn.toString()` into pages, where a
|
|
5
|
+
// capture-phase click listener must decide synchronously. Keep it that way: no module-level
|
|
6
|
+
// helpers or constants, and inline whatever a function needs.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate a raw config and apply its defaults. `null` and `undefined` mean `{}`.
|
|
10
|
+
* Throws `write-guard: <path>: <message>` on anything malformed.
|
|
11
|
+
*/
|
|
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'];
|
|
17
|
+
const fail = (path, message) => { throw new Error(`write-guard: ${path}: ${message}`); };
|
|
18
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
19
|
+
const list = (v, path) => {
|
|
20
|
+
if (v === undefined) return [];
|
|
21
|
+
if (!Array.isArray(v)) fail(path, 'must be a list');
|
|
22
|
+
return v;
|
|
23
|
+
};
|
|
24
|
+
const oneOf = (v, allowed, dflt, path) => {
|
|
25
|
+
if (v === undefined) return dflt;
|
|
26
|
+
if (!allowed.includes(v)) fail(path, `must be one of ${allowed.join(' | ')}`);
|
|
27
|
+
return v;
|
|
28
|
+
};
|
|
29
|
+
const regex = (src, path, flags) => {
|
|
30
|
+
if (typeof src !== 'string' || src === '') fail(path, 'must be a non-empty regex string');
|
|
31
|
+
try { return new RegExp(src, flags); } catch (e) { return fail(path, `does not compile: ${e.message}`); }
|
|
32
|
+
};
|
|
33
|
+
const sigs = (v, key) => list(v, key).map((s, i) => {
|
|
34
|
+
const at = `${key}[${i}]`;
|
|
35
|
+
if (!isObj(s)) fail(at, 'must be an object');
|
|
36
|
+
if (!METHODS.includes(s.method)) fail(`${at}.method`, `must be one of ${METHODS.join(' | ')}`);
|
|
37
|
+
return {
|
|
38
|
+
method: s.method,
|
|
39
|
+
url: regex(s.url, `${at}.url`, ''),
|
|
40
|
+
body: s.body === undefined || s.body === null ? null : regex(s.body, `${at}.body`, ''),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const cfg = raw ?? {};
|
|
45
|
+
if (!isObj(cfg)) fail('config', 'must be an object');
|
|
46
|
+
const controls = cfg.controls === undefined ? {} : cfg.controls;
|
|
47
|
+
if (!isObj(controls)) fail('controls', 'must be an object');
|
|
48
|
+
if (cfg.defaultControls !== undefined && typeof cfg.defaultControls !== 'boolean') {
|
|
49
|
+
fail('defaultControls', 'must be a boolean');
|
|
50
|
+
}
|
|
51
|
+
if (cfg.log !== undefined && cfg.log !== null && (typeof cfg.log !== 'string' || cfg.log === '')) {
|
|
52
|
+
fail('log', 'must be a non-empty string or null');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const policy = oneOf(cfg.policy, ['read-only', 'observe'], 'read-only', 'policy');
|
|
56
|
+
const websockets = oneOf(cfg.websockets, ['allow', 'block'], 'allow', 'websockets');
|
|
57
|
+
const allow = sigs(cfg.allow, 'allow');
|
|
58
|
+
const writes = sigs(cfg.writes, 'writes');
|
|
59
|
+
const extra = list(controls.text, 'controls.text');
|
|
60
|
+
extra.forEach((s, i) => regex(s, `controls.text[${i}]`, 'i'));
|
|
61
|
+
const icons = list(controls.icons, 'controls.icons');
|
|
62
|
+
icons.forEach((s, i) => { if (typeof s !== 'string' || s === '') fail(`controls.icons[${i}]`, 'must be a non-empty string'); });
|
|
63
|
+
|
|
64
|
+
// The built-in verbs match as whole words ("save", not "saved"); user sources are regexes.
|
|
65
|
+
const parts = [...(cfg.defaultControls === false ? [] : [`\\b(?:${VERBS.join('|')})\\b`]),
|
|
66
|
+
...extra.map((s) => `(?:${s})`)];
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
policy,
|
|
70
|
+
allow,
|
|
71
|
+
writes,
|
|
72
|
+
text: parts.length ? new RegExp(parts.join('|'), 'i') : null,
|
|
73
|
+
icons: [...icons],
|
|
74
|
+
websockets,
|
|
75
|
+
log: cfg.log === undefined ? 'write-guard.jsonl' : cfg.log,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `block` (read-only) or `observe` (observe) for a write, else `allow`. */
|
|
80
|
+
export function decideRequest(request, config) {
|
|
81
|
+
const method = String(request.method ?? '').toUpperCase();
|
|
82
|
+
const url = String(request.url ?? '');
|
|
83
|
+
const body = request.body;
|
|
84
|
+
const matches = (s) => (s.method === '*' || s.method === method) && s.url.test(url)
|
|
85
|
+
&& (s.body === null || (typeof body === 'string' && s.body.test(body)));
|
|
86
|
+
|
|
87
|
+
const allowed = config.allow.findIndex(matches);
|
|
88
|
+
if (allowed !== -1) return { action: 'allow', reason: `allow[${allowed}] marks it a read` };
|
|
89
|
+
const hit = config.writes.findIndex(matches);
|
|
90
|
+
let what;
|
|
91
|
+
if (hit !== -1) what = `${method} matches writes[${hit}]`;
|
|
92
|
+
else if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) what = `${method} is not a read method`;
|
|
93
|
+
else return { action: 'allow', reason: `${method} is a read method and matches no writes signature` };
|
|
94
|
+
return config.policy === 'observe'
|
|
95
|
+
? { action: 'observe', reason: `${what}; policy observe` }
|
|
96
|
+
: { action: 'block', reason: `${what}; policy read-only` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `block` (read-only) or `observe` (observe) for a destructive control, else `allow`. */
|
|
100
|
+
export function decideControl(control, config) {
|
|
101
|
+
const label = String(control.label ?? '').trim();
|
|
102
|
+
const icon = (control.icons ?? []).find((c) => config.icons.some((p) => String(c).startsWith(p)));
|
|
103
|
+
const textHit = label !== '' && config.text !== null && config.text.test(label);
|
|
104
|
+
if (!textHit && icon === undefined) return { action: 'allow', reason: 'not a destructive control' };
|
|
105
|
+
const what = textHit ? `"${label.slice(0, 80)}" matches a destructive label` : `icon ${icon} matches a destructive icon`;
|
|
106
|
+
return config.policy === 'observe'
|
|
107
|
+
? { action: 'observe', reason: `${what}; policy observe` }
|
|
108
|
+
: { action: 'block', reason: `${what}; policy read-only` };
|
|
109
|
+
}
|
package/src/fixture.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// playwright-write-guard/test: Playwright Test's `test` and `expect`, with every context guarded.
|
|
2
|
+
//
|
|
3
|
+
// The test's own context is guarded with `install` (which also locks context.request and
|
|
4
|
+
// page.request under read-only). Worker-level locks refuse the Playwright APIs that would make
|
|
5
|
+
// a context, a request client or a connection the guard cannot see (newContext, newPage,
|
|
6
|
+
// request.newContext, the global fetch, and the launch and connect family) while a guarded test
|
|
7
|
+
// or hook runs. Nothing else is refused: Node's own http, https and net are not seen.
|
|
8
|
+
// Playwright's own creation of the test's context (and, under observe, of the request
|
|
9
|
+
// fixture) is the one exception, and the window that allows it is shut again straight after.
|
|
10
|
+
|
|
11
|
+
import { test as base, expect } from '@playwright/test';
|
|
12
|
+
import { compileConfig } from './core.mjs';
|
|
13
|
+
import { install, loadConfig } from './install.mjs';
|
|
14
|
+
|
|
15
|
+
// Open only while Playwright builds this test's context, or its request fixture.
|
|
16
|
+
let contextWindow = false;
|
|
17
|
+
let requestWindow = false;
|
|
18
|
+
|
|
19
|
+
const refuse = (what) => new Error(
|
|
20
|
+
`write-guard: ${what} is refused: it would make a context, request client or connection the guard cannot see. `
|
|
21
|
+
+ 'Drive the app through the test\'s own page and context.');
|
|
22
|
+
|
|
23
|
+
export const test = base.extend({
|
|
24
|
+
// The guard's config. Evaluated when a test first needs it, so a bad config file is that
|
|
25
|
+
// test's error rather than a failure to load the file. `test.use` replaces it, used as given.
|
|
26
|
+
writeGuardConfig: [async ({}, use) => { await use(loadConfig()); }, { option: true }],
|
|
27
|
+
|
|
28
|
+
// A service worker can send requests the guard's route never sees.
|
|
29
|
+
serviceWorkers: 'block',
|
|
30
|
+
|
|
31
|
+
// Once per worker. It depends on `browser`, so Playwright has launched its own browser
|
|
32
|
+
// before the launch family is locked.
|
|
33
|
+
_writeGuardWorker: [async ({ browser, playwright }, use) => {
|
|
34
|
+
const restore = [];
|
|
35
|
+
const lock = (obj, name, label, open = () => false) => {
|
|
36
|
+
if (typeof obj?.[name] !== 'function') return;
|
|
37
|
+
const original = obj[name];
|
|
38
|
+
restore.push(() => { obj[name] = original; });
|
|
39
|
+
obj[name] = async (...args) => {
|
|
40
|
+
if (open()) return original.apply(obj, args);
|
|
41
|
+
throw refuse(label);
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
lock(browser, 'newContext', 'browser.newContext()', () => contextWindow);
|
|
45
|
+
lock(browser, 'newPage', 'browser.newPage()');
|
|
46
|
+
lock(playwright.request, 'newContext', 'request.newContext()', () => requestWindow);
|
|
47
|
+
lock(globalThis, 'fetch', 'fetch() (Node\'s own, from the test)');
|
|
48
|
+
for (const type of [playwright.chromium, playwright.firefox, playwright.webkit]) {
|
|
49
|
+
for (const method of ['launch', 'launchPersistentContext', 'launchServer', 'connect', 'connectOverCDP']) {
|
|
50
|
+
lock(type, method, `${type.name()}.${method}()`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await use();
|
|
55
|
+
} finally {
|
|
56
|
+
for (const r of restore.reverse()) r();
|
|
57
|
+
}
|
|
58
|
+
}, { scope: 'worker', auto: true }],
|
|
59
|
+
|
|
60
|
+
// Set up before the auto fixture below forces the context, so the window is open exactly
|
|
61
|
+
// while Playwright builds this test's context, and shut whatever happens.
|
|
62
|
+
_writeGuardOpen: [async ({}, use) => {
|
|
63
|
+
contextWindow = true;
|
|
64
|
+
try { await use(); } finally { contextWindow = false; }
|
|
65
|
+
}, { auto: true }],
|
|
66
|
+
|
|
67
|
+
// Every guarded test gets the built-in context, used or not, so the window always closes in
|
|
68
|
+
// the context fixture below rather than staying open through a test that never asked for a page.
|
|
69
|
+
_writeGuardContext: [async ({ context }, use) => { await use(); }, { auto: true }],
|
|
70
|
+
|
|
71
|
+
// Wraps the built-in context rather than creating one, so storageState, video and trace
|
|
72
|
+
// settings all survive.
|
|
73
|
+
context: async ({ context, writeGuardConfig, serviceWorkers }, use, testInfo) => {
|
|
74
|
+
contextWindow = false;
|
|
75
|
+
// install cannot change how the context was created; a project that asked for service
|
|
76
|
+
// workers made a context the guard cannot fully see.
|
|
77
|
+
if (serviceWorkers !== 'block') {
|
|
78
|
+
throw new Error(`write-guard: serviceWorkers is '${serviceWorkers}'; the guard needs 'block', or a service worker can send requests it never sees`);
|
|
79
|
+
}
|
|
80
|
+
const guard = await install(context, { ...writeGuardConfig, log: null });
|
|
81
|
+
try {
|
|
82
|
+
await use(context);
|
|
83
|
+
} finally {
|
|
84
|
+
await testInfo.attach('write-guard.json', { body: JSON.stringify(await guard.close(), null, 2), contentType: 'application/json' });
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Playwright builds the request fixture with request.newContext(), so under observe the
|
|
89
|
+
// window opens for that. Fixtures are set up in parameter order, so this one comes first.
|
|
90
|
+
// Under read-only this is where the fixture is refused, at setup, before anything is built.
|
|
91
|
+
_writeGuardRequestWindow: async ({ writeGuardConfig }, use) => {
|
|
92
|
+
if (compileConfig(writeGuardConfig).policy === 'read-only') {
|
|
93
|
+
throw new Error('write-guard: the request fixture is refused under read-only');
|
|
94
|
+
}
|
|
95
|
+
requestWindow = true;
|
|
96
|
+
try { await use(); } finally { requestWindow = false; }
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
request: async ({ _writeGuardRequestWindow, request }, use) => {
|
|
100
|
+
requestWindow = false;
|
|
101
|
+
await use(request);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export { expect };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// playwright-write-guard/init-page: the `browser.initPage` entry for Playwright MCP and the
|
|
2
|
+
// Playwright CLI. They load it with require(), so nothing it imports uses top-level await.
|
|
3
|
+
|
|
4
|
+
import { install, loadConfig } from './install.mjs';
|
|
5
|
+
|
|
6
|
+
/** Guards `page.context()` with `loadConfig()`; install() makes it once per context. */
|
|
7
|
+
export default async function initPage({ page }) {
|
|
8
|
+
return install(page.context(), loadConfig());
|
|
9
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// playwright-write-guard/install: guard one Playwright BrowserContext.
|
|
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.
|
|
8
|
+
|
|
9
|
+
import { existsSync, promises as fsp, readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { compileConfig, decideControl, decideRequest } from './core.mjs';
|
|
12
|
+
|
|
13
|
+
const BINDING = '__writeGuardReport';
|
|
14
|
+
const ALREADY = 'write-guard: this context is already guarded by another process; use one mode, not both';
|
|
15
|
+
const SENDS = new Set(['fetch', 'get', 'post', 'put', 'patch', 'delete', 'head']);
|
|
16
|
+
const guarded = new WeakMap();
|
|
17
|
+
|
|
18
|
+
/** Guard a browser context; a second call on the same context returns the first call's handle. */
|
|
19
|
+
export async function install(context, config = {}) {
|
|
20
|
+
// Synchronous up to here, so concurrent calls (one per new tab) share one install. A
|
|
21
|
+
// malformed config throws before anything is stored or installed.
|
|
22
|
+
if (!guarded.has(context)) guarded.set(context, setup(context, config, compileConfig(config)));
|
|
23
|
+
return guarded.get(context);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function setup(context, raw, cfg) {
|
|
27
|
+
const events = [];
|
|
28
|
+
const counts = { routed: 0, blocked: 0, observed: 0 };
|
|
29
|
+
const logPath = cfg.log === null ? null : resolve(cfg.log);
|
|
30
|
+
let pending = Promise.resolve();
|
|
31
|
+
|
|
32
|
+
const record = () => ({
|
|
33
|
+
installed: true,
|
|
34
|
+
policy: cfg.policy,
|
|
35
|
+
routed_requests: counts.routed,
|
|
36
|
+
blocked: counts.blocked,
|
|
37
|
+
observed: counts.observed,
|
|
38
|
+
events: events.map((e) => ({ ...e })),
|
|
39
|
+
});
|
|
40
|
+
const emit = (event) => {
|
|
41
|
+
const e = { ...event, at: new Date().toISOString() };
|
|
42
|
+
events.push(e);
|
|
43
|
+
counts[e.action === 'block' ? 'blocked' : 'observed']++;
|
|
44
|
+
if (logPath) {
|
|
45
|
+
const line = `${JSON.stringify(e)}\n`;
|
|
46
|
+
pending = pending.then(() => fsp.appendFile(logPath, line))
|
|
47
|
+
.catch((err) => process.emitWarning(`write-guard: cannot append to ${logPath}: ${err.message}`));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// First, before anything else: a refused install must leave nothing behind.
|
|
52
|
+
try {
|
|
53
|
+
await context.exposeBinding(BINDING, (_source, e) => {
|
|
54
|
+
if (!e || e.kind !== 'control' || (e.action !== 'block' && e.action !== 'observe')) return;
|
|
55
|
+
emit({ kind: 'control', action: e.action, reason: String(e.reason), label: String(e.label ?? '').trim().slice(0, 120) });
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (/already registered/.test(String(err?.message))) throw new Error(ALREADY);
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
|
|
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) => {
|
|
65
|
+
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
|
+
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
|
+
};
|
|
84
|
+
|
|
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 };
|
|
99
|
+
};
|
|
100
|
+
|
|
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(); });
|
|
103
|
+
|
|
104
|
+
if (cfg.websockets === 'block') {
|
|
105
|
+
await context.routeWebSocket(() => true, async (ws) => {
|
|
106
|
+
emit({ kind: 'request', action: 'block', reason: 'websockets is "block": every WebSocket is refused', url: ws.url() });
|
|
107
|
+
await ws.close({ code: 1008, reason: 'write-guard' }).catch(() => {});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await context.addInitScript({ content: pageScript(raw) });
|
|
112
|
+
|
|
113
|
+
patchRoutes(context, judge, judgeOverride);
|
|
114
|
+
if (cfg.policy === 'read-only') {
|
|
115
|
+
lockRequest(context);
|
|
116
|
+
// The context's dialog event is emitted before any page's, so this runs ahead of every
|
|
117
|
+
// listener a caller adds later; their accept() then finds the dialog already handled.
|
|
118
|
+
context.on('dialog', (d) => d.dismiss().catch(() => {}));
|
|
119
|
+
}
|
|
120
|
+
const onPage = (page) => {
|
|
121
|
+
patchRoutes(page, judge, judgeOverride);
|
|
122
|
+
if (cfg.policy === 'read-only') lockRequest(page);
|
|
123
|
+
};
|
|
124
|
+
for (const p of context.pages()) onPage(p);
|
|
125
|
+
context.on('page', onPage);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
record,
|
|
129
|
+
async close() {
|
|
130
|
+
// Appends chain onto `pending`; wait until no new one arrived while waiting.
|
|
131
|
+
let last;
|
|
132
|
+
do { last = pending; await last; } while (last !== pending);
|
|
133
|
+
return record();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
139
|
+
const blockedPage = (method, url) => `<!doctype html><html><head><meta charset="utf-8"><title>write-guard</title></head>`
|
|
140
|
+
+ `<body><p>write-guard blocked ${escapeHtml(method)} ${escapeHtml(url)}</p></body></html>`;
|
|
141
|
+
|
|
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
|
+
return new Proxy(r, {
|
|
146
|
+
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
|
+
}
|
|
157
|
+
const v = Reflect.get(obj, prop, obj);
|
|
158
|
+
return typeof v === 'function' ? v.bind(obj) : v;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Every handler a caller registers asks the guard first; unroute and unrouteAll remove only
|
|
164
|
+
// the caller's own handlers.
|
|
165
|
+
function patchRoutes(target, judge, judgeOverride) {
|
|
166
|
+
const route = target.route.bind(target);
|
|
167
|
+
const unroute = target.unroute.bind(target);
|
|
168
|
+
const tracked = [];
|
|
169
|
+
const sameUrl = (a, b) => a === b
|
|
170
|
+
|| (a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags);
|
|
171
|
+
target.route = (url, handler, options) => {
|
|
172
|
+
const wrapped = async (r, request) => { if (await judge(r)) return handler(judgedRoute(r, judgeOverride), request); };
|
|
173
|
+
tracked.push({ url, handler, wrapped });
|
|
174
|
+
// The returned disposable calls target.unroute(url, wrapped), which lands below.
|
|
175
|
+
return route(url, wrapped, options);
|
|
176
|
+
};
|
|
177
|
+
target.unroute = async (url, handler) => {
|
|
178
|
+
for (const t of tracked.filter((x) => sameUrl(x.url, url) && (!handler || x.handler === handler || x.wrapped === handler))) {
|
|
179
|
+
tracked.splice(tracked.indexOf(t), 1);
|
|
180
|
+
await unroute(t.url, t.wrapped);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
// unrouteAll's `behavior` option is ignored; the guard never waits on a caller's handlers.
|
|
184
|
+
target.unrouteAll = async () => {
|
|
185
|
+
for (const t of tracked.splice(0)) await unroute(t.url, t.wrapped);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The request object stays readable (Playwright's own teardown reads it); only sends throw.
|
|
190
|
+
function lockRequest(target) {
|
|
191
|
+
const original = target.request;
|
|
192
|
+
const locked = new Proxy(original, {
|
|
193
|
+
get(obj, prop) {
|
|
194
|
+
if (typeof prop === 'string' && SENDS.has(prop)) {
|
|
195
|
+
return async () => { throw new Error(`write-guard: ${prop} through APIRequestContext is refused under read-only`); };
|
|
196
|
+
}
|
|
197
|
+
const v = Reflect.get(obj, prop, obj);
|
|
198
|
+
return typeof v === 'function' ? v.bind(obj) : v;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
Object.defineProperty(target, 'request', { get: () => locked, configurable: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The page side. A capture-phase listener must decide synchronously, so the decision core
|
|
205
|
+
// runs in the page, compiled from the same raw config.
|
|
206
|
+
function pageScript(raw) {
|
|
207
|
+
return `(() => {
|
|
208
|
+
const compileConfig = ${compileConfig.toString()};
|
|
209
|
+
const decideControl = ${decideControl.toString()};
|
|
210
|
+
const cfg = compileConfig(${JSON.stringify(raw ?? {})});
|
|
211
|
+
// Captured before any page script runs, so the page cannot swap them out.
|
|
212
|
+
const bind = window.${BINDING};
|
|
213
|
+
const preventDefault = Function.prototype.call.bind(Event.prototype.preventDefault);
|
|
214
|
+
const stopImmediatePropagation = Function.prototype.call.bind(Event.prototype.stopImmediatePropagation);
|
|
215
|
+
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) => {
|
|
218
|
+
for (const v of [el.getAttribute('aria-label'), el.innerText, el.getAttribute('title'), el.value]) {
|
|
219
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
220
|
+
}
|
|
221
|
+
return '';
|
|
222
|
+
};
|
|
223
|
+
const iconsOf = (el) => {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const n of [el, ...el.querySelectorAll('[class]')]) {
|
|
226
|
+
for (const c of (n.getAttribute('class') || '').split(/\\s+/)) if (c) out.push(c);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
230
|
+
const judge = (el, e) => {
|
|
231
|
+
if (!(el instanceof Element)) return;
|
|
232
|
+
const label = labelOf(el);
|
|
233
|
+
const v = decideControl({ label, icons: iconsOf(el) }, cfg);
|
|
234
|
+
if (v.action === 'allow') return;
|
|
235
|
+
if (v.action === 'block') { preventDefault(e); stopImmediatePropagation(e); }
|
|
236
|
+
report({ kind: 'control', action: v.action, reason: v.reason, label: label.slice(0, 120) });
|
|
237
|
+
};
|
|
238
|
+
window.addEventListener('click', (e) => {
|
|
239
|
+
if (e.target instanceof Element) judge(e.target.closest(CONTROL) || e.target, e);
|
|
240
|
+
}, true);
|
|
241
|
+
window.addEventListener('submit', (e) => judge(e.submitter || e.target, e), true);
|
|
242
|
+
window.addEventListener('keydown', (e) => {
|
|
243
|
+
if (e.key === 'Enter' || e.key === ' ') judge(document.activeElement, e);
|
|
244
|
+
}, true);
|
|
245
|
+
})();`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read a raw config: `path`, else WRITE_GUARD_CONFIG, else write-guard.config.json when it
|
|
250
|
+
* exists, else {}. WRITE_GUARD_POLICY, when non-empty, overrides `policy`. Validated, returned raw.
|
|
251
|
+
*/
|
|
252
|
+
export function loadConfig(path) {
|
|
253
|
+
const named = path || process.env.WRITE_GUARD_CONFIG;
|
|
254
|
+
const file = named ? resolve(named) : existsSync(resolve('write-guard.config.json')) ? resolve('write-guard.config.json') : null;
|
|
255
|
+
let raw = {};
|
|
256
|
+
if (file) {
|
|
257
|
+
let text;
|
|
258
|
+
try { text = readFileSync(file, 'utf8'); } catch (err) { throw new Error(`write-guard: ${file}: cannot be read: ${err.message}`); }
|
|
259
|
+
try { raw = JSON.parse(text) ?? {}; } catch (err) { throw new Error(`write-guard: ${file}: is not valid JSON: ${err.message}`); }
|
|
260
|
+
}
|
|
261
|
+
const policy = process.env.WRITE_GUARD_POLICY;
|
|
262
|
+
if (policy && raw !== null && typeof raw === 'object' && !Array.isArray(raw)) raw = { ...raw, policy };
|
|
263
|
+
compileConfig(raw);
|
|
264
|
+
return raw;
|
|
265
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// playwright-write-guard/browser: a guarded Chromium that an agent attaches to.
|
|
2
|
+
|
|
3
|
+
import type { Browser, BrowserContext } from 'playwright-core';
|
|
4
|
+
import type { Config } from './core.js';
|
|
5
|
+
import type { GuardHandle } from './install.js';
|
|
6
|
+
|
|
7
|
+
export interface GuardedBrowserOptions {
|
|
8
|
+
/** Default `loadConfig()`. */
|
|
9
|
+
config?: Config;
|
|
10
|
+
/** Default `true`. */
|
|
11
|
+
headless?: boolean;
|
|
12
|
+
/** Default `"127.0.0.1"`. */
|
|
13
|
+
host?: string;
|
|
14
|
+
/** Default `0`: the OS picks a free port. */
|
|
15
|
+
port?: number;
|
|
16
|
+
/** Default `"write-guard"`. */
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GuardedBrowser {
|
|
21
|
+
/** The endpoint `browser.bind` returned, `ws://<host>:<port>/<id>`. */
|
|
22
|
+
wsEndpoint: string;
|
|
23
|
+
browser: Browser;
|
|
24
|
+
/** The one guarded context; an attached client sees it as `contexts()[0]`. */
|
|
25
|
+
context: BrowserContext;
|
|
26
|
+
guard: GuardHandle;
|
|
27
|
+
/** Flushes the guard's log, then closes the context and the browser. */
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function startGuardedBrowser(options?: GuardedBrowserOptions): Promise<GuardedBrowser>;
|
package/types/core.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// playwright-write-guard: the decision core. Pure functions, no I/O, no Playwright types.
|
|
2
|
+
|
|
3
|
+
/** `read-only` blocks writes; `observe` records them and lets them through. */
|
|
4
|
+
export type Policy = 'read-only' | 'observe';
|
|
5
|
+
|
|
6
|
+
/** A request method a signature can name. `*` matches any method. */
|
|
7
|
+
export type SigMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | '*';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A request signature. `url` and `body` are regex sources, compiled with no flags
|
|
11
|
+
* (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.
|
|
13
|
+
*/
|
|
14
|
+
export interface Sig {
|
|
15
|
+
method: SigMethod;
|
|
16
|
+
url: string;
|
|
17
|
+
/** Absent or `null`: the signature names no body. */
|
|
18
|
+
body?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The raw config. Every key is optional; unknown keys are ignored. */
|
|
22
|
+
export interface Config {
|
|
23
|
+
/** Default `"read-only"`. */
|
|
24
|
+
policy?: Policy;
|
|
25
|
+
/** Requests matching any of these are never writes. */
|
|
26
|
+
allow?: Sig[];
|
|
27
|
+
/** Requests matching any of these are writes even when their method is GET, HEAD or OPTIONS. */
|
|
28
|
+
writes?: Sig[];
|
|
29
|
+
controls?: {
|
|
30
|
+
/** Regex sources, compiled case-insensitive, added to the built-in verbs. */
|
|
31
|
+
text?: string[];
|
|
32
|
+
/** Class name prefixes; a control with a class starting with one is destructive. */
|
|
33
|
+
icons?: string[];
|
|
34
|
+
};
|
|
35
|
+
/** Default `true`: include the 23 built-in verbs in the text pattern. */
|
|
36
|
+
defaultControls?: boolean;
|
|
37
|
+
/** Default `"allow"`. */
|
|
38
|
+
websockets?: 'allow' | 'block';
|
|
39
|
+
/** JSONL audit log path, relative to the working directory. Default `"write-guard.jsonl"`; `null` turns it off. */
|
|
40
|
+
log?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CompiledSig {
|
|
44
|
+
method: SigMethod;
|
|
45
|
+
url: RegExp;
|
|
46
|
+
/** `null` when the signature names no body. */
|
|
47
|
+
body: RegExp | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What `compileConfig` returns: every default applied, every pattern compiled. */
|
|
51
|
+
export interface CompiledConfig {
|
|
52
|
+
policy: Policy;
|
|
53
|
+
allow: CompiledSig[];
|
|
54
|
+
writes: CompiledSig[];
|
|
55
|
+
/** The built-in verbs (unless `defaultControls` is false) and `controls.text`, as one case-insensitive pattern; `null` when there are none. */
|
|
56
|
+
text: RegExp | null;
|
|
57
|
+
/** `controls.icons`; there are no built-in icons. */
|
|
58
|
+
icons: string[];
|
|
59
|
+
websockets: 'allow' | 'block';
|
|
60
|
+
log: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RequestInput {
|
|
64
|
+
method: string;
|
|
65
|
+
url: string;
|
|
66
|
+
/** The request body; `null` or absent when it has none. */
|
|
67
|
+
body?: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RequestDecision {
|
|
71
|
+
action: 'allow' | 'block' | 'observe';
|
|
72
|
+
reason: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ControlInput {
|
|
76
|
+
/** The control's label. */
|
|
77
|
+
label: string;
|
|
78
|
+
/** Every class name on the control and its descendants. */
|
|
79
|
+
icons: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ControlDecision {
|
|
83
|
+
action: 'allow' | 'block' | 'observe';
|
|
84
|
+
reason: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate a raw config and apply its defaults. `null` and `undefined` mean `{}`.
|
|
89
|
+
* Throws `write-guard: <path>: <message>` on anything malformed.
|
|
90
|
+
*/
|
|
91
|
+
export function compileConfig(raw?: Config | null): CompiledConfig;
|
|
92
|
+
|
|
93
|
+
/** `block` (read-only) or `observe` (observe) for a write, else `allow`. */
|
|
94
|
+
export function decideRequest(request: RequestInput, config: CompiledConfig): RequestDecision;
|
|
95
|
+
|
|
96
|
+
/** `block` (read-only) or `observe` (observe) for a destructive control, else `allow`. */
|
|
97
|
+
export function decideControl(control: ControlInput, config: CompiledConfig): ControlDecision;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// playwright-write-guard/init-page: the `browser.initPage` entry for Playwright MCP and the Playwright CLI.
|
|
2
|
+
|
|
3
|
+
import type { Page } from 'playwright-core';
|
|
4
|
+
import type { GuardHandle } from './install.js';
|
|
5
|
+
|
|
6
|
+
/** Guards `page.context()` with `loadConfig()`; once per context, however many pages it is handed. */
|
|
7
|
+
declare function initPage(args: { page: Page }): Promise<GuardHandle>;
|
|
8
|
+
|
|
9
|
+
export default initPage;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// playwright-write-guard/install: the installer every entry point shares.
|
|
2
|
+
|
|
3
|
+
import type { BrowserContext } from 'playwright-core';
|
|
4
|
+
import type { Config, Policy } from './core.js';
|
|
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';
|
|
9
|
+
action: 'block' | 'observe';
|
|
10
|
+
reason: string;
|
|
11
|
+
method?: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
/** ISO 8601 timestamp. */
|
|
15
|
+
at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GuardRecord {
|
|
19
|
+
installed: true;
|
|
20
|
+
policy: Policy;
|
|
21
|
+
/** Requests the guard judged, each counted once. */
|
|
22
|
+
routed_requests: number;
|
|
23
|
+
/** Request and control events with `action: "block"`. */
|
|
24
|
+
blocked: number;
|
|
25
|
+
/** Request and control events with `action: "observe"`. */
|
|
26
|
+
observed: number;
|
|
27
|
+
events: GuardEvent[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GuardHandle {
|
|
31
|
+
/** A snapshot of what the guard has seen so far. */
|
|
32
|
+
record(): GuardRecord;
|
|
33
|
+
/** Resolves after the last pending log append with the final record. The guard stays active until the context closes. */
|
|
34
|
+
close(): Promise<GuardRecord>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Guard a browser context. Compiles `config` (default `{}`); a second call on the same
|
|
39
|
+
* context returns the first call's handle. Throws `write-guard: this context is already
|
|
40
|
+
* guarded by another process; use one mode, not both` when another process has guarded it.
|
|
41
|
+
*/
|
|
42
|
+
export function install(context: BrowserContext, config?: Config): Promise<GuardHandle>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read a config: `path`, else `WRITE_GUARD_CONFIG`, else `write-guard.config.json` when it
|
|
46
|
+
* exists, else `{}`. `WRITE_GUARD_POLICY`, when set, overrides `policy`. Validated through
|
|
47
|
+
* `compileConfig`; returns the raw config.
|
|
48
|
+
*/
|
|
49
|
+
export function loadConfig(path?: string): Config;
|
package/types/test.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// playwright-write-guard/test: Playwright Test's `test` and `expect`, with every context guarded.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PlaywrightTestArgs,
|
|
5
|
+
PlaywrightTestOptions,
|
|
6
|
+
PlaywrightWorkerArgs,
|
|
7
|
+
PlaywrightWorkerOptions,
|
|
8
|
+
TestType,
|
|
9
|
+
} from '@playwright/test';
|
|
10
|
+
import type { Config } from './core.js';
|
|
11
|
+
|
|
12
|
+
export interface WriteGuardOptions {
|
|
13
|
+
/** The guard's config, settable with `test.use`. Default `loadConfig()`. Its `log` is ignored: the fixture never writes a log file. */
|
|
14
|
+
writeGuardConfig: Config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare const test: TestType<
|
|
18
|
+
PlaywrightTestArgs & PlaywrightTestOptions & WriteGuardOptions,
|
|
19
|
+
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export { expect } from '@playwright/test';
|