@openlines/opl-pi-sht 0.2.7 → 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 +5 -3
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-modes.json.sample +3 -3
- package/configs/opl-webaccess.json.sample +5 -1
- package/extensions/opl-browser/README.md +20 -6
- package/extensions/opl-browser/browser.ts +76 -19
- package/extensions/opl-browser/config.ts +5 -0
- package/extensions/opl-browser/index.ts +13 -5
- package/extensions/opl-browser/validate.ts +123 -5
- package/extensions/opl-guardian/README.md +4 -4
- package/extensions/opl-guardian/guardian.ts +5 -1
- package/extensions/opl-guardian/index.ts +22 -52
- package/extensions/opl-modes/README.md +2 -2
- package/extensions/opl-questionnaire/README.md +1 -1
- package/extensions/opl-questionnaire/index.ts +36 -21
- package/extensions/opl-webaccess/README.md +8 -5
- package/extensions/opl-webaccess/config.ts +56 -5
- package/extensions/opl-webaccess/extract.ts +39 -14
- package/extensions/opl-webaccess/index.ts +27 -7
- package/extensions/opl-webaccess/providers/gemini.ts +6 -1
- package/extensions/opl-webaccess/providers/tavily.ts +6 -1
- package/extensions/opl-webaccess/storage.ts +8 -2
- package/extensions/opl-webaccess/utils.ts +117 -2
- package/package.json +2 -2
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.
|
|
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>
|
|
@@ -123,8 +125,8 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
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
|

|
|
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
|

|
|
130
132
|
- **`opl-todo`** tracks branch-aware tasks that persist across a session and reconstruct from history.
|
|
@@ -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
|
}
|
|
@@ -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": "
|
|
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
|
|
53
|
-
|
|
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) =>
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
81
|
-
return { content: [{ type: "text", text
|
|
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,
|
|
84
|
-
const preview =
|
|
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:
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
21
|
-
*
|
|
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
|
}
|
|
@@ -6,7 +6,7 @@ A configurable session and tool safety extension. `opl-guardian` filters malform
|
|
|
6
6
|
|
|
7
7
|
Copy `configs/opl-guardian.json.sample` to `~/.pi/agent/configs/opl-guardian.json` and customize it. For a package install, find the sample under the installed package's `configs/` directory. All settings are optional; absent settings use the defaults from the sample.
|
|
8
8
|
|
|
9
|
-
- `permissionGate.patterns` is a list of case-insensitive JavaScript regex strings. A match prompts for confirmation in the terminal or over RPC; Yes allows the command, while No or cancel blocks it. An empty list disables pattern matching. `permissionGate.blockWithoutUI` defaults to `true` and blocks a match when confirmation UI is unavailable. Configured patterns replace the defaults; update existing config files manually to adopt changes to default patterns.
|
|
9
|
+
- `permissionGate.patterns` is a list of case-insensitive JavaScript regex strings. A match prompts for confirmation in the terminal or over RPC; Yes allows the command, while No or cancel blocks it. The prompt defaults to No (fail closed), so an accidental or unattended confirmation denies rather than approves. An empty list disables pattern matching. `permissionGate.blockWithoutUI` defaults to `true` and blocks a match when confirmation UI is unavailable. Configured patterns replace the defaults; update existing config files manually to adopt changes to default patterns.
|
|
10
10
|
- `protectedPaths.paths` contains `{ "path", "deny" }` entries. Valid denied operations are `read`, `write`, `edit`, and `bash`. Bare names match exact path segments for file tools; Bash uses literal command matching. Absolute and `~/` entries match resolved paths and descendants for file tools. An empty list disables path rules.
|
|
11
11
|
- `confirmDestructive` controls confirmations for starting a new session (`clearSession`), resuming another session when unanswered user work exists (`switchWithUnsavedWork`), and forking (`forkSession`). All default to `true`. `confirmDestructive.blockWithoutUI` also defaults to `true`; set it to `false` to allow these actions without an interactive confirmation.
|
|
12
12
|
- `dropMalformedToolCalls` defaults to `true`. Set it to `false` to disable only malformed-call filtering and its incident log.
|
|
@@ -23,7 +23,7 @@ The default patterns cover recursive `rm` flags (including `-rf`, `-fr`, and sep
|
|
|
23
23
|
|
|
24
24
|
### Protected paths
|
|
25
25
|
|
|
26
|
-
Defaults protect `.env` from read/write/edit/Bash, `.git/` from read/write/edit, `node_modules/` from write/edit, and `~/.pi/agent/auth.json` from read/write/edit/Bash. File-tool path checks compare both the supplied path and its filesystem-resolved target, including existing symlinks and symlinked parents for new files; paths that cannot be safely resolved are blocked when an applicable path rule is active. A path denial is a hard block and takes precedence over a dangerous-command confirmation for the same Bash call. These are preflight checks, not filesystem confinement: paths can change between the check and the tool operation. Bash matching is literal and cannot reliably detect indirect access through scripts, variables, or shell expansion;
|
|
26
|
+
Defaults protect `.env` from read/write/edit/Bash, `.git/` from read/write/edit, `node_modules/` from write/edit, and `~/.pi/agent/auth.json` from read/write/edit/Bash. File-tool path checks compare both the supplied path and its filesystem-resolved target, including existing symlinks and symlinked parents for new files; paths that cannot be safely resolved are blocked when an applicable path rule is active. A path denial is a hard block and takes precedence over a dangerous-command confirmation for the same Bash call. These are preflight checks, not filesystem confinement: paths can change between the check and the tool operation. Bash matching is literal and cannot reliably detect indirect access through scripts, variables, or shell expansion (for example `cat "$HOME/.pi/agent/auth.json"` misses the auth file); it also over-blocks commands that merely *mention* a protected path or name (for example `grep .env notes.md`). Do not rely on guardian alone as a security boundary against untrusted shell commands.
|
|
27
27
|
|
|
28
28
|
### Destructive session actions
|
|
29
29
|
|
|
@@ -31,7 +31,7 @@ Starting a new session, resuming another session with a pending user message, an
|
|
|
31
31
|
|
|
32
32
|
### Malformed provider tool calls
|
|
33
33
|
|
|
34
|
-
A tool call is malformed when its ID or name is blank after trimming whitespace. The extension drops malformed calls before Pi persists or replays them, preserves valid sibling calls, and turns invalid-only responses into a text-only stop so the user can continue. Unfamiliar but non-empty tool names are not rejected.
|
|
34
|
+
A tool call is malformed when its ID or name is blank after trimming whitespace. The extension drops malformed calls before Pi persists or replays them, preserves valid sibling calls, and turns invalid-only responses into a text-only stop so the user can continue. Unfamiliar but non-empty tool names are not rejected. As defense in depth, the `tool_call` boundary also blocks any blank-id/name call that slips through, so a malformed call can never execute even if the `message_end` filter is bypassed.
|
|
35
35
|
|
|
36
36
|
## Forensic JSONL
|
|
37
37
|
|
|
@@ -41,7 +41,7 @@ Each malformed-call incident appends one JSON object to:
|
|
|
41
41
|
<project cwd>/err/guardian.jsonl
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Records include timestamp, session/project/provider/model metadata, and removed tool-call blocks. The log does not contain prompts, assistant text, thinking, or tool results, but tool-call arguments may include paths, commands, or user text. Treat it as local diagnostic data and ignore `err/` in version control where appropriate. On POSIX, a newly created log is restricted to mode `0600` on a best-effort basis. If logging fails, malformed calls are still removed and the diagnostic reports the write failure.
|
|
44
|
+
Records include timestamp (falling back to the current time when a provider omitted or zeroed it), session/project/provider/model metadata, and removed tool-call blocks. The log does not contain prompts, assistant text, thinking, or tool results, but tool-call arguments may include paths, commands, or user text. Treat it as local diagnostic data and ignore `err/` in version control where appropriate. On POSIX, a newly created log is restricted to mode `0600` on a best-effort basis. If logging fails, malformed calls are still removed and the diagnostic reports the write failure.
|
|
45
45
|
|
|
46
46
|
## Testing
|
|
47
47
|
|
|
@@ -56,8 +56,12 @@ export function buildIncidentRecord(
|
|
|
56
56
|
context: IncidentContext,
|
|
57
57
|
removedToolCalls: ToolCall[],
|
|
58
58
|
): GuardianIncident {
|
|
59
|
+
// A missing/zero/NaN timestamp yields an invalid Date whose toISOString() throws,
|
|
60
|
+
// which would silently lose the forensic record. Fall back to the current time.
|
|
61
|
+
const timestamp =
|
|
62
|
+
Number.isFinite(message.timestamp) && message.timestamp > 0 ? message.timestamp : Date.now();
|
|
59
63
|
return {
|
|
60
|
-
timestamp: new Date(
|
|
64
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
61
65
|
kind: "malformed_tool_call",
|
|
62
66
|
sessionId: context.sessionId,
|
|
63
67
|
cwd: context.cwd,
|