@openlines/opl-pi-sht 0.2.6 → 0.2.8

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
@@ -17,7 +17,9 @@ A portable collection of various Pi coding agent extensions. Repository director
17
17
  <div align="center">
18
18
 
19
19
  >
20
- > ### v0.2.6 (24 Sept 2026) - **Post-compaction context estimate**: `opl-footer` keeps the context bar visible with explicitly approximate values until Pi reports fresh usage. <br/>see [CHANGELOG.md](CHANGELOG.md) for more details.
20
+ > ### v0.2.8 (29 Sept 2026) - **SSRF and tool-safety hardening**: `opl-webaccess` and `opl-browser` block private/link-local hosts by default and cloud metadata always (localhost stays available for dev), fetch redirects are re-checked per hop, screenshots refuse to overwrite existing files, and `evaluate` no longer crashes on `undefined`. <br/>See [CHANGELOG.md](CHANGELOG.md) for details.
21
+ >
22
+ > `Current project state also marked 'SAFE' for pi v0.99.0 release`<br/>*confirmed this because I was literally typing to push the patches when I noticed the release notif*
21
23
  >
22
24
 
23
25
  </div>
@@ -84,7 +86,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
84
86
  | [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
85
87
  | [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
86
88
  | [`opl-ctxtrim`](extensions/opl-ctxtrim/README.md) | Trims verbose`ctx_*` tool-schema descriptions on outbound provider requests (~67% smaller schema, ~4,700-6,300 tokens/request). Built specifically for the [context-mode](https://github.com/mksglu/context-mode) extension. | No commands/tools; no config. |
87
- | [`opl-guardian`](extensions/opl-guardian/README.md) | Drops malformed assistant tool calls before session persistence or replay, with project-local JSONL evidence. | No commands/tools; no config. |
89
+ | [`opl-guardian`](extensions/opl-guardian/README.md) | Configurable tool and session safety: dangerous-Bash confirmation, protected paths, destructive-session confirmation, and malformed-call filtering. | No commands/tools; `opl-guardian.json`. |
88
90
  | [`opl-todo`](extensions/opl-todo/README.md) | Branch-aware task tool, overlay, and task list. | `todo`, `/todos`; `opl-todo.json`. |
89
91
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
90
92
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
@@ -116,15 +118,15 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
116
118
  - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs. Execute-mode auto-exit needs pi >= 0.87.0 (the `agent_before_settle` boundary).
117
119
  ![custom mode sample](images/ss-mode-custom.png)
118
120
  - `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
119
- - **`opl-guardian`** stops a malformed provider tool-call record from poisoning the session: it removes calls missing an ID or name before Pi persists or replays them, while preserving dropped records in `err/guardian.jsonl` for an upstream report.
121
+ - **`opl-guardian`** combines configurable safety checks in one extension: it confirms dangerous Bash commands in the terminal or over RPC, blocks configured protected paths for file tools (including symlink targets), and confirms session clear/switch-with-pending-work/fork actions. Without a UI, configured dangerous commands and session actions block by default. It also removes malformed provider tool calls before persistence or replay and records incidents in `err/guardian.jsonl`. Protected-path Bash matching is literal best-effort, not shell sandboxing.
120
122
 
121
123
 
122
124
  ### Move through work faster
123
125
 
124
126
  - **`opl-init`** deterministically crawls and refines `AGENTS.md` with a single out-of-band model call (never a synthetic user message), writes it, and reloads context so the session runs on the new guide. A current fingerprint means zero model calls; mid-session invocations wait for the agent to settle instead of interrupting it. Needs pi >= 0.86.0.
125
127
  ![init](images/ss-init.png)
126
- - **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only and screenshots stay in the project directory.
127
- - **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch, a 10 MB response cap, and a 30s timeout.
128
+ - **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only, blocks private/link-local hosts by default and cloud metadata always (SSRF guard; localhost is allowed for dev), and screenshots must use `.png`/`.jpg`, stay in the project directory, and never overwrite an existing file.
129
+ - **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch that blocks private/link-local hosts by default and cloud metadata always, re-checks every redirect hop (SSRF guard; localhost is allowed for dev), a 10 MB response cap, and a 30s timeout.
128
130
  - **`opl-simplebench`** benchmarks models on deterministic closed-answer contracts, instruction-following, and tool-call generation so you pick a model on evidence, not vibes.
129
131
  ![simplebench](images/ss-simplebench.png)
130
132
  - **`opl-todo`** tracks branch-aware tasks that persist across a session and reconstruct from history.
@@ -150,7 +152,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
150
152
  | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle), `opl-guardian` |
151
153
  | Research and drive the web | `opl-webaccess`, `opl-browser` |
152
154
  | Choose models with data | `opl-simplebench` |
153
- | The full, coordinated experience | all ten |
155
+ | The full, coordinated experience | all eleven |
154
156
 
155
157
  Selecting `opl-footer`, `opl-input`, or `opl-modes` installs all three, because they share active-mode state.
156
158
 
@@ -171,7 +173,7 @@ Installing extensions adds tool schemas (name + description + JSON parameters) t
171
173
  | `opl-init` | command only (no tool) | ~0 |
172
174
  | `opl-input` | UI only | ~0 |
173
175
  | `opl-footer` | UI only | ~0 |
174
- | `opl-guardian` | none (session-integrity guard) | ~0 |
176
+ | `opl-guardian` | none (tool/session safety; no added tool schemas) | ~0 |
175
177
  | `opl-ctxtrim` | none (payload transformer) | net negative |
176
178
 
177
179
  Command descriptions add roughly another ~120 tokens collectively, and only if your build surfaces them in the prompt or help block.
@@ -195,9 +197,10 @@ The fixed cost of a full install is small and paid once per session, then cached
195
197
 
196
198
  Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For a Pi package installation, the source directory is `~/.pi/agent/npm/node_modules/@openlines/opl-pi-sht/configs/` (npm) or `~/.pi/agent/git/github.com/linellazatin/opl-pi-sht/configs/` (Git):
197
199
 
198
- - `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`
200
+ - `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`, `opl-guardian.json`
199
201
  - `opl-browser` has optional configuration (`opl-browser.json`); all fields default, so it works without any config file.
200
202
  - `opl-simplebench` has optional `opl-simplebench.json`; copy `configs/opl-simplebench.json.sample` to configure DDGS/SearXNG research and llama metadata endpoints.
203
+ - `opl-guardian` has an optional `opl-guardian.json`; copy `configs/opl-guardian.json.sample` and manually migrate any old permission-gate or protected-path settings. It does not load legacy files. Configured `permissionGate.patterns` replace the defaults, so copied live patterns do not automatically inherit later default-rule updates.
201
204
  - `opl-init` and `opl-questionnaire` have no external configuration.
202
205
  - Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
203
206
  - `opl-modes` owns active-mode appearance. Each mode's `appearance.prefix`, `prefixColor`, and `borderColor` style `opl-input`; `appearance.modeColor` styles `opl-footer`'s unified mode label. Renderers retain hardcoded fallbacks.
@@ -1,9 +1,10 @@
1
1
  {
2
- "_comment": "Optional. Copy to ~/.pi/agent/configs/opl-browser.json. All fields optional; defaults shown.",
2
+ "_comment": "Optional. Copy to ~/.pi/agent/configs/opl-browser.json. All fields optional; defaults shown. allowPrivateNetwork (default false) blocks navigate/new_page from reaching private/link-local ranges; loopback (localhost) is always allowed for local development, and cloud-metadata endpoints are always blocked.",
3
3
  "headless": true,
4
4
  "width": 1280,
5
5
  "height": 800,
6
6
  "navigationTimeoutMs": 30000,
7
7
  "previewChars": 4000,
8
- "getChars": 30000
8
+ "getChars": 30000,
9
+ "allowPrivateNetwork": false
9
10
  }
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": "Copy to ~/.pi/agent/configs/opl-guardian.json and customize. This replaces standalone permission-gate, protected-paths, and confirm-destructive settings.",
3
+ "permissionGate": {
4
+ "patterns": [
5
+ "\\brm\\s+(?:(?:-[a-z]+|--[a-z-]+)\\s+)*(?:-[a-z]*r[a-z]*|--recursive)(?=\\s|$)",
6
+ "\\bsudo\\b",
7
+ "\\b(chmod|chown)\\s+(?:-[a-z]+\\s+)*777(?:\\b|$)",
8
+ "\\bprintenv\\b",
9
+ "(^|\\s)env(\\s|$)"
10
+ ],
11
+ "blockWithoutUI": true
12
+ },
13
+ "protectedPaths": {
14
+ "paths": [
15
+ { "path": ".env", "deny": ["read", "write", "edit", "bash"] },
16
+ { "path": ".git/", "deny": ["read", "write", "edit"] },
17
+ { "path": "node_modules/", "deny": ["write", "edit"] },
18
+ { "path": "~/.pi/agent/auth.json", "deny": ["read", "write", "edit", "bash"] }
19
+ ]
20
+ },
21
+ "confirmDestructive": {
22
+ "clearSession": true,
23
+ "switchWithUnsavedWork": true,
24
+ "forkSession": true,
25
+ "blockWithoutUI": true
26
+ },
27
+ "dropMalformedToolCalls": true
28
+ }
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "shortcuts": {
8
8
  "_comment": "Shortcut that cycles through all enabled, visible modes in registry order. Default: ctrl+alt+m.",
9
- "cycleMode": "shift+tab"
9
+ "cycleMode": "ctrl+alt+m"
10
10
  },
11
11
  "cleanup": {
12
12
  "_comment": "Set to true to delete the plan file automatically after plan_complete is called.",
@@ -17,8 +17,8 @@
17
17
  "_comment_lazyTools": "Tool names withheld from the active set at rest and enabled on demand via the load_tools tool. Only affects modes that would otherwise include them (off/execute and custom modes that list them). Core built-ins, plan_complete, and load_tools are protected and ignored. Omit or leave empty to keep all tools always active.",
18
18
  "lazyTools": [],
19
19
  "bashPatterns": {
20
- "_comment_safe": "Replace-only: providing bashPatterns.safePatterns replaces the built-in safe list. This is the default safe allowlist for every mode and it is checked per shell segment (&&, ||, a lone &, |, ;, newline, $(...), backticks, <( and >( each start a segment), so every chained or backgrounded command must be allowed. Quote-aware: grep \\\"a|b\\\" stays one command, but a $(...) or backtick payload still runs inside double quotes, so it is split out and checked too. Interpreters and tools that can write files or exec programs from their own arguments (awk, sed, xargs, perl, python, env) are deliberately not listed. A mode overrides it with its own safePatterns or opts out of Bash gating with unrestrictedBash. Shown here with the built-in list.",
21
- "_comment_destructive": "Replace-only: providing bashPatterns.destructivePatterns replaces the built-in destructive list. This is the default destructive blocklist for every mode; a mode overrides it with its own destructivePatterns or opts out of Bash gating with unrestrictedBash. Program-name entries are anchored to command position (match each segment start) so read-only commands that only mention rm/cp/sh in an argument stay allowed, while flag and redirect entries stay unanchored - that is how find -fprint, sort -o in any position, git log --output, npm audit fix, uniq's output operand, and a bare > file are caught even behind a safe command. Descriptor duplication (2>&1, >&2) and redirects into /dev/null are not writes and stay allowed. Shown here with the built-in list.",
20
+ "_comment_safe": "Replace-only: providing bashPatterns.safePatterns replaces the built-in safe list. This is the default safe allowlist for every mode and it is checked per shell segment (&&, ||, a lone &, |, ;, newline, $(...), backticks, <( and >( each start a segment), so every chained or backgrounded command must be allowed. Quote-aware: grep \\\"a|b\\\" stays one command, but a $(...) or backtick payload still runs inside double quotes, so it is split out and checked too. Interpreters and tools that can write files or exec programs from their own arguments (awk, sed, xargs, perl, python, env) are deliberately not listed. A mode overrides it with its own safePatterns or opts out of Bash gating with unrestrictedBash. An empty top-level safePatterns array falls back to the built-in list (it cannot opt out); set a per-mode safePatterns: [] to opt that mode out. Shown here with the built-in list.",
21
+ "_comment_destructive": "Replace-only: providing bashPatterns.destructivePatterns replaces the built-in destructive list. This is the default destructive blocklist for every mode; a mode overrides it with its own destructivePatterns or opts out of Bash gating with unrestrictedBash. Program-name entries are anchored to command position (match each segment start) so read-only commands that only mention rm/cp/sh in an argument stay allowed, while flag and redirect entries stay unanchored - that is how find -fprint, sort -o in any position, git log --output, npm audit fix, uniq's output operand, and a bare > file are caught even behind a safe command. Descriptor duplication (2>&1, >&2) and redirects into /dev/null are not writes and stay allowed. An empty top-level destructivePatterns array falls back to the built-in list (it cannot opt out); set a per-mode destructivePatterns: [] to opt that mode out. Shown here with the built-in list.",
22
22
  "safePatterns": [
23
23
  "^\\s*cat\\b",
24
24
  "^\\s*head\\b",
@@ -1,7 +1,11 @@
1
1
  {
2
- "_comment_limits": "Optional top-level caps: maxContentChars caps the initial web_search/fetch_content body (default 30000); maxRetrievalChars caps one get_search_content page (default 30000). Pass offset=N to get_search_content to continue a truncated retrieval.",
2
+ "_comment_limits": "Optional top-level caps: maxContentChars caps the initial web_search/fetch_content body (default 30000); maxRetrievalChars caps one get_search_content page (default 30000). Pass offset=N to get_search_content to continue a truncated retrieval. maxSearchQueries caps how many queries one web_search call runs (default 10); maxFetchUrls caps how many URLs one fetch_content call fetches (default 20). Excess inputs are skipped and reported in the tool output.",
3
+ "_comment_network": "allowPrivateNetwork defaults to false: fetch_content rejects private-range and link-local hosts. Loopback (localhost/127.0.0.1/::1) is always allowed for local development, and cloud-metadata endpoints are always blocked. Set allowPrivateNetwork true to reach private ranges (LAN servers). This does NOT affect provider API endpoints (ddgs.apiUrl / searxng.instanceUrl), which remain configurable.",
4
+ "allowPrivateNetwork": false,
3
5
  "maxContentChars": 30000,
4
6
  "maxRetrievalChars": 30000,
7
+ "maxSearchQueries": 10,
8
+ "maxFetchUrls": 20,
5
9
  "provider": "searxng",
6
10
  "providers": {
7
11
  "gemini": {
@@ -45,15 +45,25 @@ Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
45
45
  which only sees raw HTTP HTML. Static pages: `fetch_content`; rendered or
46
46
  interacted-with pages: `browser:extract`. With a `selector`, the matched element
47
47
  is converted verbatim (no article detection): Readability's candidate scoring is
48
- a whole-document heuristic and mispicks inside small subtrees.
48
+ a whole-document heuristic and mispicks inside small subtrees. A selector that
49
+ matches multiple elements returns a clear error instead of a strict-mode crash,
50
+ and a selector with no match reports `(no elements match ...)`.
49
51
  - **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
50
52
  CSS-selector interaction, viewport control, multi-page management.
51
53
  - **Scheme + path guards.** `navigate`/`new_page` accept http(s) only; `file:`,
52
- `data:`, and `javascript:` are rejected. Screenshot paths are confined to the
53
- project directory, so a bad `path` cannot overwrite arbitrary files.
54
+ `data:`, and `javascript:` are rejected, private-range and link-local hosts are
55
+ blocked by default, and cloud-metadata endpoints are always blocked (SSRF guard).
56
+ Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local
57
+ development; set `allowPrivateNetwork: true` for private ranges. Redirect targets
58
+ are re-checked on the final URL. Screenshot paths must use `.png`/`.jpg`, stay
59
+ inside the project directory, and are refused when the file already exists, so a
60
+ bad action cannot overwrite or plant arbitrary files.
54
61
  - **Per-page capture.** Console messages and network requests are buffered per
55
62
  page as they occur; `console` and `network` actions return the active page's
56
- buffer.
63
+ buffer, capped at the most recent `200` entries per page so an active
64
+ long-running page cannot grow the buffer without bound. `navigate: back`/
65
+ `forward` on a fresh session reports `(no history to go back/forward)` instead of
66
+ silently returning the unchanged page.
57
67
  - **One reused browser per session.** Launched on first use, closed automatically
58
68
  on `session_shutdown`, or on demand via `action: "close"`.
59
69
 
@@ -68,6 +78,7 @@ Optional `~/.pi/agent/configs/opl-browser.json` (see `opl-browser.json.sample`):
68
78
  | `navigationTimeoutMs` | `30000` | Default navigation and `wait_for` timeout. |
69
79
  | `previewChars` | `4000` | Inline threshold; larger outputs are stored and previewed. |
70
80
  | `getChars` | `30000` | Characters returned by one `action: "get"` page. |
81
+ | `allowPrivateNetwork` | `false` | Allow `navigate`/`new_page` to reach private/link-local ranges (loopback is always allowed; cloud metadata is always blocked). |
71
82
 
72
83
  ### Dependencies
73
84
 
@@ -86,7 +97,8 @@ npx playwright install chromium
86
97
  index.ts Pi wiring: registers the single `browser` tool, TTL result store,
87
98
  preview/handle logic, and session_shutdown cleanup.
88
99
  browser.ts Playwright driver: browser/context/page lifecycle, per-page console
89
- and network buffers, and the action switch.
100
+ and network buffers, and the action switch. Tool calls are serialized
101
+ on the single shared context.
90
102
  validate.ts URL/path guards: http/https-only navigation, screenshot path confined
91
103
  to the project directory.
92
104
  paging.ts Bounded `get` pagination and continuation metadata.
@@ -98,4 +110,6 @@ config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
98
110
  Interaction is CSS-selector based. Snapshot-uid interaction (referencing elements
99
111
  by ids returned from `snapshot`) is intentionally not implemented; use CSS
100
112
  selectors, which are simpler and robust. The in-memory result store expires
101
- entries after one hour or when the browser is closed.
113
+ entries after one hour or when the browser is closed; expiry is checked on read,
114
+ not only when another result is stored, so an idle entry past its TTL is never
115
+ served.
@@ -12,22 +12,37 @@ let activeIndex = 0;
12
12
  const consoleBuf = new WeakMap<Page, string[]>();
13
13
  const networkBuf = new WeakMap<Page, string[]>();
14
14
 
15
+ /** Cap on per-page console/network log lines kept in memory. */
16
+ export const MAX_LOG_ENTRIES = 200;
17
+
18
+ /** Push one log line, trimming the oldest entries past the cap. */
19
+ export function pushLogEntry(entries: string[], entry: string, max = MAX_LOG_ENTRIES): string[] {
20
+ entries.push(entry);
21
+ if (entries.length > max) entries.splice(0, entries.length - max);
22
+ return entries;
23
+ }
24
+
15
25
  function track(page: Page, cfg: BrowserConfig): void {
16
26
  consoleBuf.set(page, []);
17
27
  networkBuf.set(page, []);
18
28
  page.setDefaultNavigationTimeout(cfg.navigationTimeoutMs);
19
- page.on("console", (m: ConsoleMessage) => consoleBuf.get(page)?.push(`[${m.type()}] ${m.text()}`));
29
+ page.on("console", (m: ConsoleMessage) => {
30
+ const buf = consoleBuf.get(page);
31
+ if (buf) pushLogEntry(buf, `[${m.type()}] ${m.text()}`);
32
+ });
20
33
  page.on("requestfinished", async (req) => {
21
34
  try {
22
35
  const res = await req.response();
23
- networkBuf.get(page)?.push(`${req.method()} ${res?.status() ?? "?"} ${req.url()}`);
36
+ const buf = networkBuf.get(page);
37
+ if (buf) pushLogEntry(buf, `${req.method()} ${res?.status() ?? "?"} ${req.url()}`);
24
38
  } catch {
25
39
  /* response unavailable */
26
40
  }
27
41
  });
28
- page.on("requestfailed", (req) =>
29
- networkBuf.get(page)?.push(`${req.method()} FAILED ${req.url()} (${req.failure()?.errorText ?? "unknown"})`),
30
- );
42
+ page.on("requestfailed", (req) => {
43
+ const buf = networkBuf.get(page);
44
+ if (buf) pushLogEntry(buf, `${req.method()} FAILED ${req.url()} (${req.failure()?.errorText ?? "unknown"})`);
45
+ });
31
46
  }
32
47
 
33
48
  async function ensure(cfg: BrowserConfig): Promise<BrowserContext> {
@@ -46,6 +61,13 @@ function page(): Page {
46
61
  return pages[activeIndex] ?? pages[pages.length - 1];
47
62
  }
48
63
 
64
+ /** Navigate with the host validated both before the request and on the final URL, so a
65
+ * public URL that redirects to a private/link-local host is rejected. */
66
+ async function gotoAllowed(target: Page, url: string, cfg: BrowserConfig): Promise<void> {
67
+ await target.goto(assertHttpUrl(url, { allowPrivateNetwork: cfg.allowPrivateNetwork }), { waitUntil: "domcontentloaded" });
68
+ assertHttpUrl(target.url(), { allowPrivateNetwork: cfg.allowPrivateNetwork });
69
+ }
70
+
49
71
  export interface BrowserActionResult {
50
72
  text: string;
51
73
  file?: string;
@@ -67,9 +89,23 @@ export interface BrowserParams {
67
89
  height?: number;
68
90
  }
69
91
 
70
- export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
92
+ let last: Promise<unknown> = Promise.resolve();
93
+ /** Serialize state-mutating browser work: a single shared context cannot service
94
+ * concurrent tool calls safely (activeIndex, page(), ensure() all race). Each new
95
+ * action is queued behind the previous one, and failures never break the chain. */
96
+ function serialize<T>(task: () => Promise<T>): Promise<T> {
97
+ const run = last.then(task, task);
98
+ last = run.then(() => undefined, () => undefined);
99
+ return run;
100
+ }
101
+
102
+ export function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
103
+ return serialize(() => runActionInternal(p, cfg));
104
+ }
105
+
106
+ async function runActionInternal(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
71
107
  if (p.action === "close") {
72
- await closeBrowser();
108
+ await closeBrowserInternal();
73
109
  return { text: "Browser closed." };
74
110
  }
75
111
 
@@ -78,10 +114,15 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
78
114
  switch (p.action) {
79
115
  case "navigate": {
80
116
  const url = p.url ?? "";
81
- if (url === "back") { await page().goBack(); }
82
- else if (url === "forward") { await page().goForward(); }
83
- else if (url === "reload") { await page().reload(); }
84
- else if (url) { await page().goto(assertHttpUrl(url), { waitUntil: "domcontentloaded" }); }
117
+ if (url === "back" || url === "forward") {
118
+ // Playwright returns null (no throw) when history is exhausted; report it
119
+ // clearly instead of silently returning the unchanged page.
120
+ const moved = url === "back" ? await page().goBack() : await page().goForward();
121
+ if (!moved) return { text: `(no history to go ${url})` };
122
+ return { text: `${page().url()} — ${await page().title()}` };
123
+ }
124
+ if (url === "reload") await page().reload();
125
+ else if (url) await gotoAllowed(page(), url, cfg);
85
126
  else throw new Error("navigate requires url (or back|forward|reload)");
86
127
  return { text: `${page().url()} — ${await page().title()}` };
87
128
  }
@@ -91,9 +132,18 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
91
132
  }
92
133
  case "extract": {
93
134
  const selector = p.selector;
94
- const html = selector
95
- ? await page().locator(selector).evaluate((el: Element) => el.outerHTML)
96
- : await page().content();
135
+ let html: string;
136
+ if (selector) {
137
+ const loc = page().locator(selector);
138
+ const count = await loc.count();
139
+ if (count === 0) return { text: `(no elements match "${selector}")` };
140
+ if (count > 1) {
141
+ throw new Error(`extract selector "${selector}" matched ${count} elements; use a more specific selector`);
142
+ }
143
+ html = await loc.evaluate((el: Element) => el.outerHTML);
144
+ } else {
145
+ html = await page().content();
146
+ }
97
147
  const { markdown } = extractMarkdown(html, { raw: Boolean(selector) });
98
148
  return { text: markdown || "(empty extraction)" };
99
149
  }
@@ -130,15 +180,18 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
130
180
  case "evaluate": {
131
181
  if (!p.script) throw new Error("evaluate requires script");
132
182
  const value = await page().evaluate(p.script);
133
- return { text: typeof value === "string" ? value : JSON.stringify(value, null, 2) };
183
+ if (value === undefined) return { text: "(undefined result)" };
184
+ return { text: typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "(undefined result)") };
134
185
  }
135
186
  case "console": {
136
187
  const msgs = consoleBuf.get(page()) ?? [];
137
- return { text: msgs.length ? msgs.join("\n") : "(no console messages)" };
188
+ const suffix = msgs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} entries)` : "";
189
+ return { text: msgs.length ? msgs.join("\n") + suffix : "(no console messages)" };
138
190
  }
139
191
  case "network": {
140
192
  const reqs = networkBuf.get(page()) ?? [];
141
- return { text: reqs.length ? reqs.join("\n") : "(no network requests)" };
193
+ const suffix = reqs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} requests)` : "";
194
+ return { text: reqs.length ? reqs.join("\n") + suffix : "(no network requests)" };
142
195
  }
143
196
  case "wait_for": {
144
197
  const timeout = p.timeoutMs ?? cfg.navigationTimeoutMs;
@@ -155,7 +208,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
155
208
  const pg = await ctx.newPage();
156
209
  track(pg, cfg);
157
210
  activeIndex = ctx.pages().length - 1;
158
- if (p.url) await pg.goto(assertHttpUrl(p.url), { waitUntil: "domcontentloaded" });
211
+ if (p.url) await gotoAllowed(pg, p.url, cfg);
159
212
  return { text: `Opened page [${activeIndex}] ${pg.url()}` };
160
213
  }
161
214
  case "select_page": {
@@ -182,7 +235,11 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
182
235
  }
183
236
  }
184
237
 
185
- export async function closeBrowser(): Promise<void> {
238
+ export function closeBrowser(): Promise<void> {
239
+ return serialize(closeBrowserInternal);
240
+ }
241
+
242
+ async function closeBrowserInternal(): Promise<void> {
186
243
  try {
187
244
  await context?.close();
188
245
  await browser?.close();
@@ -9,6 +9,9 @@ export interface BrowserConfig {
9
9
  navigationTimeoutMs: number;
10
10
  previewChars: number;
11
11
  getChars: number;
12
+ /** Allow navigate/new_page to reach private/link-local ranges (loopback is always
13
+ * allowed; cloud metadata is always blocked). Default false. */
14
+ allowPrivateNetwork: boolean;
12
15
  }
13
16
 
14
17
  export const DEFAULT_CONFIG: BrowserConfig = {
@@ -18,6 +21,7 @@ export const DEFAULT_CONFIG: BrowserConfig = {
18
21
  navigationTimeoutMs: 30000,
19
22
  previewChars: 4000,
20
23
  getChars: 30000,
24
+ allowPrivateNetwork: false,
21
25
  };
22
26
 
23
27
  const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "configs", "opl-browser.json");
@@ -36,5 +40,6 @@ export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
36
40
  navigationTimeoutMs: user.navigationTimeoutMs ?? DEFAULT_CONFIG.navigationTimeoutMs,
37
41
  previewChars: user.previewChars ?? DEFAULT_CONFIG.previewChars,
38
42
  getChars: user.getChars ?? DEFAULT_CONFIG.getChars,
43
+ allowPrivateNetwork: user.allowPrivateNetwork ?? DEFAULT_CONFIG.allowPrivateNetwork,
39
44
  };
40
45
  }
@@ -62,6 +62,11 @@ export default function (pi: ExtensionAPI) {
62
62
  if (!p.responseId) return err("get requires responseId");
63
63
  const hit = STORE.get(p.responseId);
64
64
  if (!hit) return err(`No stored result for ${p.responseId} (expires after 1h or on browser close).`);
65
+ // Enforce TTL on read, not just on the next store.
66
+ if (Date.now() - hit.timestamp > TTL_MS) {
67
+ STORE.delete(p.responseId);
68
+ return err(`No stored result for ${p.responseId} (expires after 1h or on browser close).`);
69
+ }
65
70
  const page = paginateStored(hit.text, p.offset ?? 0, cfg.getChars);
66
71
  return {
67
72
  content: [{ type: "text", text: page.text + continuationNotice(page) }],
@@ -76,15 +81,18 @@ export default function (pi: ExtensionAPI) {
76
81
  return err(`browser ${p.action}: ${e instanceof Error ? e.message : String(e)}`);
77
82
  }
78
83
 
84
+ // Defensive: never let a missing/invalid action text crash the size gate.
85
+ const text = String(result.text ?? "");
86
+
79
87
  // Small outputs return inline; large outputs are stored and previewed.
80
- if (result.text.length <= cfg.previewChars) {
81
- return { content: [{ type: "text", text: result.text }], details: result.file ? { file: result.file } : {} };
88
+ if (text.length <= cfg.previewChars) {
89
+ return { content: [{ type: "text", text }], details: result.file ? { file: result.file } : {} };
82
90
  }
83
- const id = store(p.action, result.text);
84
- const preview = result.text.slice(0, cfg.previewChars);
91
+ const id = store(p.action, text);
92
+ const preview = text.slice(0, cfg.previewChars);
85
93
  return {
86
94
  content: [{ type: "text", text: `${preview}\n\n… truncated. Full result via action:get responseId: ${id}` }],
87
- details: { responseId: id, action: p.action, chars: result.text.length },
95
+ details: { responseId: id, action: p.action, chars: text.length },
88
96
  };
89
97
  },
90
98
  });
@@ -1,10 +1,119 @@
1
1
  // Pure URL/path guards for the browser tool. No Playwright import so tests load it directly.
2
2
 
3
+ import { existsSync } from "node:fs";
3
4
  import { resolve, sep } from "node:path";
4
5
 
5
- /** Normalize a URL, allowing only http/https so file:/data:/javascript: URLs can't
6
- * be navigated (file:// + evaluate = local file bytes returned into model context). */
7
- export function assertHttpUrl(url: string): string {
6
+ export interface HttpUrlOptions {
7
+ /** Allow private, link-local, and reserved network hosts (default false). Loopback is always allowed; cloud metadata is always blocked. */
8
+ allowPrivateNetwork?: boolean;
9
+ }
10
+
11
+ /** Well-known cloud metadata hostnames that resolve to instance-credential endpoints. */
12
+ const BLOCKED_METADATA_HOSTS = new Set([
13
+ "metadata",
14
+ "metadata.google.internal",
15
+ "instance-data",
16
+ "instance-data.ec2.internal",
17
+ ]);
18
+
19
+ /** Cloud metadata IPv6 literals (e.g. AWS IMDS over IPv6). Always blocked. */
20
+ const BLOCKED_METADATA_V6 = new Set(["fd00:ec2::254"]);
21
+
22
+ type HostClass = "loopback" | "metadata" | "private" | "public";
23
+
24
+ function ipv4FromInteger(n: number): number[] | null {
25
+ if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null;
26
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
27
+ }
28
+
29
+ function ipv4Octets(host: string): number[] | null {
30
+ if (/^\d+$/.test(host)) return ipv4FromInteger(Number(host));
31
+ const octets = host.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : NaN));
32
+ if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
33
+ return octets;
34
+ }
35
+
36
+ function isLoopbackIpv4(o: number[]): boolean {
37
+ return o[0] === 127;
38
+ }
39
+
40
+ function isMetadataIpv4(o: number[]): boolean {
41
+ return (
42
+ (o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) ||
43
+ (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200)
44
+ );
45
+ }
46
+
47
+ function isPrivateIpv4(o: number[]): boolean {
48
+ const [a, b, c] = o;
49
+ return (
50
+ a === 0 || a === 10 ||
51
+ (a === 100 && b >= 64 && b <= 127) ||
52
+ (a === 169 && b === 254) ||
53
+ (a === 172 && b >= 16 && b <= 31) ||
54
+ (a === 192 && b === 168) ||
55
+ (a === 192 && b === 0 && (c === 0 || c === 2)) ||
56
+ (a === 198 && (b === 18 || b === 19)) ||
57
+ (a === 198 && b === 51 && c === 100) ||
58
+ (a === 203 && b === 0 && c === 113) ||
59
+ a >= 224
60
+ );
61
+ }
62
+
63
+ function ipv4MappedOctets(ip: string): number[] | null {
64
+ const dotted = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
65
+ if (dotted) return dotted.slice(1).map(Number);
66
+ const hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
67
+ if (hex) {
68
+ const n = (parseInt(hex[1], 16) << 16) | parseInt(hex[2], 16);
69
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function classifyIpv4Octets(o: number[]): HostClass {
75
+ if (isLoopbackIpv4(o)) return "loopback";
76
+ if (isMetadataIpv4(o)) return "metadata";
77
+ if (isPrivateIpv4(o)) return "private";
78
+ return "public";
79
+ }
80
+
81
+ function classifyIpv6Host(host: string): HostClass {
82
+ const ip = host.replace(/^\[/, "").replace(/\]$/, "").replace(/%.*$/, "").toLowerCase();
83
+ if (BLOCKED_METADATA_V6.has(ip)) return "metadata";
84
+ if (ip === "::1") return "loopback";
85
+ const mapped = ipv4MappedOctets(ip);
86
+ if (mapped) return classifyIpv4Octets(mapped);
87
+ if (ip === "::") return "private";
88
+ if (/^f[cd]/.test(ip)) return "private";
89
+ if (/^fe[89ab]/.test(ip)) return "private";
90
+ if (/^ff/.test(ip)) return "private";
91
+ return "public";
92
+ }
93
+
94
+ function classifyHost(host: string): HostClass {
95
+ const lowered = host.toLowerCase();
96
+ if (BLOCKED_METADATA_HOSTS.has(lowered)) return "metadata";
97
+ if (lowered === "localhost" || lowered.endsWith(".localhost")) return "loopback";
98
+ if (lowered.includes(":")) return classifyIpv6Host(lowered);
99
+ const octets = ipv4Octets(lowered);
100
+ return octets ? classifyIpv4Octets(octets) : "public";
101
+ }
102
+
103
+ function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean): boolean {
104
+ const cls = classifyHost(host);
105
+ if (cls === "metadata") return true;
106
+ if (cls === "loopback" || cls === "public") return false;
107
+ return !allowPrivateNetwork;
108
+ }
109
+
110
+ /**
111
+ * Normalize a URL, allowing only http/https, so `file:`/`data:`/`javascript:` URLs and SSRF
112
+ * targets can't be navigated (file:// + evaluate = local file bytes returned into model context).
113
+ * Loopback is allowed by default for local development; private/link-local/reserved ranges are
114
+ * blocked unless allowPrivateNetwork is set; cloud metadata is always blocked.
115
+ */
116
+ export function assertHttpUrl(url: string, opts: HttpUrlOptions = {}): string {
8
117
  let parsed: URL;
9
118
  try {
10
119
  parsed = new URL(url);
@@ -14,16 +123,25 @@ export function assertHttpUrl(url: string): string {
14
123
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
124
  throw new Error(`only http/https URLs are allowed, got ${parsed.protocol}//`);
16
125
  }
126
+ if (isBlockedNetworkHost(parsed.hostname, opts.allowPrivateNetwork === true)) {
127
+ throw new Error(`blocked network host "${parsed.hostname}" — private, link-local, reserved, and cloud-metadata URLs are not allowed (set allowPrivateNetwork to opt in to private ranges; metadata is always blocked)`);
128
+ }
17
129
  return parsed.href;
18
130
  }
19
131
 
20
- /** Require a screenshot path to stay within the project directory (guard against
21
- * overwriting absolute or parent-relative user files with PNG bytes). */
132
+ /** Require a screenshot path to stay within the project directory and refuse to overwrite
133
+ * existing files (guard against overwriting project files or writing outside the repo). */
22
134
  export function safeScreenshotPath(file: string, cwd: string = process.cwd()): string {
23
135
  const root = resolve(cwd);
24
136
  const abs = resolve(cwd, file);
25
137
  if (abs !== root && !abs.startsWith(root + sep)) {
26
138
  throw new Error(`screenshot path must stay within the project directory: ${file}`);
27
139
  }
140
+ if (!/\.(png|jpe?g)$/i.test(file)) {
141
+ throw new Error(`screenshot path must end in .png or .jpg: ${file}`);
142
+ }
143
+ if (existsSync(abs)) {
144
+ throw new Error(`screenshot path already exists (refusing to overwrite): ${file}`);
145
+ }
28
146
  return file;
29
147
  }