@openlines/opl-pi-sht 0.2.7 → 0.2.9

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.
@@ -8,8 +8,6 @@ import {
8
8
  type SessionBeforeSwitchEvent,
9
9
  type ToolCallEvent,
10
10
  } from "@earendil-works/pi-coding-agent";
11
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
12
- import { Container, SelectList, Spacer, Text, type SelectItem } from "@earendil-works/pi-tui";
13
11
  import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
14
12
  import { join } from "node:path";
15
13
 
@@ -22,6 +20,12 @@ import {
22
20
  import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
23
21
 
24
22
  const LOG_PATH = join("err", "guardian.jsonl");
23
+ const RED = "\u001b[31m";
24
+ const RESET_FOREGROUND = "\u001b[39m";
25
+
26
+ function red(text: string): string {
27
+ return `${RED}${text}${RESET_FOREGROUND}`;
28
+ }
25
29
 
26
30
  export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
27
31
  const directory = join(cwd, "err");
@@ -50,9 +54,14 @@ function diagnostic(removed: number, logError?: unknown): string {
50
54
  }
51
55
 
52
56
  function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
57
+ // For a mixed response (valid tool calls remain), prepend the notice rather than
58
+ // appending a trailing text block after toolCall blocks: providers such as Anthropic
59
+ // require each tool_use to be followed by a tool_result, so a trailing text block does
60
+ // not round-trip through the wire format. Text-before-tool-calls is the standard
61
+ // assistant shape. convertToLlm passes assistant messages through as-is.
53
62
  const content = invalidOnly
54
63
  ? [{ type: "text" as const, text: `${text} No tool was executed; send another prompt to continue.` }]
55
- : [...message.content, { type: "text" as const, text }];
64
+ : [{ type: "text" as const, text }, ...message.content];
56
65
  return { ...message, content, stopReason: invalidOnly ? "stop" : "toolUse" };
57
66
  }
58
67
 
@@ -80,6 +89,14 @@ export async function guardMessageEnd(
80
89
 
81
90
  export function createToolCallHandler(config: GuardianConfig) {
82
91
  return async (event: ToolCallEvent, ctx: ExtensionContext) => {
92
+ // Defense in depth: createMessageEndHandler already drops malformed tool calls
93
+ // (blank id or name) before they reach execution, but block any that still get
94
+ // here anyway (e.g. a replay path or a provider that streamed them directly).
95
+ // Optional chaining tolerates a *missing* id/name, not just a blank string.
96
+ if (!event.toolCallId?.trim() || !event.toolName?.trim()) {
97
+ return { block: true, reason: "[opl-guardian] Blocked a malformed tool call with a blank id or name." };
98
+ }
99
+
83
100
  let toolPath: string | undefined;
84
101
  if (isToolCallEventType("read", event)) toolPath = event.input.path;
85
102
  else if (isToolCallEventType("write", event)) toolPath = event.input.path;
@@ -92,7 +109,7 @@ export function createToolCallHandler(config: GuardianConfig) {
92
109
  const reason = blocked.unresolved
93
110
  ? `[opl-guardian] Cannot safely resolve path "${blocked.path}"; ${blocked.operation} denied by protected-path policy.`
94
111
  : `[opl-guardian] Path "${blocked.path}" is protected (${blocked.operation} denied).`;
95
- if (ctx.hasUI) ctx.ui.notify(reason, "warning");
112
+ if (ctx.hasUI) ctx.ui.notify(red(reason), "warning");
96
113
  return { block: true, reason };
97
114
  }
98
115
  }
@@ -106,10 +123,14 @@ export function createToolCallHandler(config: GuardianConfig) {
106
123
  }
107
124
 
108
125
  const command = event.input.command;
126
+ const preview = command.replace(/\s+/g, " ").trim();
127
+ // Fail closed: order "No" first so an accidental confirmation (or a replayed
128
+ // input event hitting a freshly-focused dialog) denies instead of approves.
129
+ // Only an explicit "Yes" selection approves the command.
130
+ const options = ["No", "Yes"];
109
131
  if (ctx.mode === "rpc") {
110
- const preview = command.replace(/\s+/g, " ").trim();
111
132
  try {
112
- const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, ["Yes", "No"]);
133
+ const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
113
134
  if (choice === "Yes") return undefined;
114
135
  } catch {
115
136
  // A failed RPC dialog is not approval.
@@ -117,55 +138,10 @@ export function createToolCallHandler(config: GuardianConfig) {
117
138
  return { block: true, reason: "[opl-guardian] Command blocked; confirmation was declined or unavailable." };
118
139
  }
119
140
 
120
- const items: SelectItem[] = [
121
- { value: "yes", label: "Yes" },
122
- { value: "no", label: "No" },
123
- ];
124
- const choice = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
125
- const border = new DynamicBorder((text: string) => theme.fg("border", text));
126
- const label = new Text(theme.fg("text", "Dangerous command detected:"), 1, 0);
127
- const preview = command.replace(/\s+/g, " ").trim();
128
- const commandText = new Text(theme.fg("error", preview.length > 256 ? `${preview.slice(0, 256)}…` : preview), 1, 0);
129
- const question = new Text(theme.fg("text", "Allow this command?"), 1, 0);
130
- const selectList = new SelectList(items, Math.min(items.length, 10), {
131
- selectedPrefix: (text) => theme.fg("accent", text),
132
- selectedText: (text) => theme.fg("accent", text),
133
- description: (text) => theme.fg("muted", text),
134
- scrollInfo: (text) => theme.fg("dim", text),
135
- noMatch: (text) => theme.fg("warning", text),
136
- });
137
- selectList.onSelect = (item) => done(item.value);
138
- selectList.onCancel = () => done(null);
139
-
140
- const container = new Container();
141
- container.addChild(border);
142
- container.addChild(new Spacer());
143
- container.addChild(label);
144
- container.addChild(commandText);
145
- container.addChild(new Spacer());
146
- container.addChild(question);
147
- container.addChild(selectList);
148
- container.addChild(new Spacer());
149
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
150
- container.addChild(border);
151
-
152
- return {
153
- render(width: number) { return container.render(width); },
154
- invalidate() { container.invalidate(); },
155
- handleInput(data: string) {
156
- if (keybindings.matches(data, "app.tools.expand")) {
157
- ctx.ui.setToolsExpanded(!ctx.ui.getToolsExpanded());
158
- return;
159
- }
160
- selectList.handleInput(data);
161
- tui.requestRender();
162
- },
163
- };
164
- });
165
-
166
- if (choice === "yes") return undefined;
141
+ const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
142
+ if (choice === "Yes") return undefined;
167
143
  const reason = "[opl-guardian] Command blocked by user because it matches a dangerous pattern.";
168
- ctx.ui.notify(reason, "warning");
144
+ ctx.ui.notify(red(reason), "warning");
169
145
  return { block: true, reason };
170
146
  };
171
147
  }
@@ -181,7 +157,7 @@ export function createMessageEndHandler(config: GuardianConfig) {
181
157
  sessionId: ctx.sessionManager.getSessionId(),
182
158
  });
183
159
  if (!guarded) return undefined;
184
- ctx.ui?.notify(guarded.diagnostic, "warning");
160
+ ctx.ui?.notify(red(guarded.diagnostic), "warning");
185
161
  return { message: guarded.message };
186
162
  };
187
163
  }
@@ -202,7 +178,7 @@ export function createSessionBeforeSwitchHandler(config: GuardianConfig) {
202
178
  }
203
179
 
204
180
  if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
205
- return await ctx.ui.confirm(title, message) ? undefined : { cancel: true };
181
+ return await ctx.ui.confirm(red(title), red(message)) ? undefined : { cancel: true };
206
182
  };
207
183
  }
208
184
 
@@ -211,8 +187,8 @@ export function createSessionBeforeForkHandler(config: GuardianConfig) {
211
187
  if (!config.confirmDestructive.forkSession) return undefined;
212
188
  if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
213
189
  const confirmed = await ctx.ui.confirm(
214
- `Fork from entry ${event.entryId.slice(0, 8)}?`,
215
- "Create a new session branch from this point?",
190
+ red(`Fork from entry ${event.entryId.slice(0, 8)}?`),
191
+ red("Create a new session branch from this point?"),
216
192
  );
217
193
  return confirmed ? undefined : { cancel: true };
218
194
  };
@@ -222,7 +198,7 @@ export default function (pi: ExtensionAPI) {
222
198
  const { config, warnings } = loadGuardianConfig();
223
199
  pi.on("session_start", async (_event, ctx) => {
224
200
  if (warnings.length && ctx.hasUI) {
225
- ctx.ui.notify(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`, "warning");
201
+ ctx.ui.notify(red(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`), "warning");
226
202
  }
227
203
  });
228
204
 
@@ -9,7 +9,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
9
9
  - `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
10
10
  - `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
- - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `shift+tab` is also Pi's built-in `app.thinking.cycle`, so both fire on one press; rebind either action in `~/.pi/agent/keybindings.json` (or pick another `shortcuts.cycleMode`) if you want them separate.
12
+ - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `ctrl+alt+m` does not collide with any built-in Pi keybinding; set `shortcuts.cycleMode` to rebind.
13
13
 
14
14
  Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
15
15
 
@@ -17,7 +17,7 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A
17
17
 
18
18
  ### Mode behavior
19
19
 
20
- Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
20
+ Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy. The top-level `bashPatterns.safePatterns`/`destructivePatterns` behave differently: an empty top-level array falls back to the built-in list (it cannot opt out), so opt a mode out with a per-mode `safePatterns: []` or `destructivePatterns: []` instead.
21
21
 
22
22
  The shared destructive base blocks file mutation primitives (`rm`, `rmdir`, `mv`, `cp`, `mkdir`, `touch`, `chmod`, `chown`, `tee`, `dd`, `shred`, `truncate`), `find -delete`/`-exec`/`-execdir`/`--exec-batch` and find's file-writing predicates (`-fprint`, `-fprint0`, `-fprintf`, `-fls`), any `--output` file (`sort --output`, `git log/diff/show --output`), `sort -o`/`tree -o` in any argument position, `fd -x`/`-X` (and clusters like `-Hx`), `rg --pre`, `jq`'s environment access (`jq -n env`, `$ENV`), `npm`/`yarn`/`pip` install and update forms including `npm audit fix`, state-changing `git` subcommands (including `branch -d`/`-D`/`-m`/`-M`/`-c`/`-C`/`--delete`/`--unset-upstream` and the writing `git remote` subcommands), `sudo`/`su`, `kill`/`pkill`, shell spawns, editors, `uniq`'s output operand (`uniq -c in.txt out.txt`; flags are not operands, so `uniq -c sorted.txt` stays a read), and every redirect that names a file - while descriptor duplication (`2>&1`, `>&2`, `>&-`) and a redirect whose target is exactly `/dev/null` are not writes and stay allowed (`> /dev/null/../tmp/x` is a write, so a path that merely starts with that prefix is blocked). `env` and `printenv` are not safe-listed, since they could dump provider API keys into the model context. Program-name entries are anchored to command position and matched per segment, so a read-only command that merely *mentions* a dangerous word in an argument (`du -sh`, `find . -name '*.sh'`, `ls cp/`, `cat mv.sh`, `git log --grep=rm`, `grep -rn 'touch' src`) stays allowed, and descriptor duplication (`2>&1`, `>&2`, `>&-`) is not mistaken for a file write. Every destructive pattern is tested against the command and each of its segments, with and without quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. That blocklist split deliberately ignores quotes, so an operator inside a quoted string (`echo "a > b"`) is over-blocked rather than trusted. This is still a heuristic backstop, not a process-isolation boundary: `npx` and any command whose *name* comes from an expansion are outside what a pattern list can catch.
23
23
 
@@ -12,7 +12,7 @@ Adds a model-callable interactive questionnaire for discrete choices, preference
12
12
 
13
13
  - Renders one question as a compact option list and multiple questions as tabs with a final Submit tab.
14
14
  - Questions require an `id`, prompt, options, and optional label or free-text `allowOther` response.
15
- - Rejects duplicate IDs and questions that cannot be answered; returns `(no response)` for an empty custom answer.
15
+ - Rejects duplicate IDs, blank IDs, and questions that cannot be answered; returns `(no response)` for an empty custom answer.
16
16
  - Returns structured answers containing question ID, selected value and label, custom-text flag, and option index where applicable.
17
17
  - Blocks submission until every multi-question prompt has an answer.
18
18
  - Returns a clear error in headless sessions so the model can use plain-text questions instead.
@@ -27,16 +27,41 @@ import {
27
27
  type RenderOption,
28
28
  } from "./types.js";
29
29
 
30
- function errorResult(
30
+ /** Build an error tool result. Marked as an error (not a cancellation) so the
31
+ * result card renders the message instead of mislabeling it "Cancelled". */
32
+ export function errorResult(
31
33
  message: string,
32
- questions: Question[] = [],
33
- ): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
34
+ ): { content: { type: "text"; text: string }[]; isError: true; details: QuestionnaireResult } {
34
35
  return {
35
36
  content: [{ type: "text", text: message }],
36
- details: { questions, answers: [], cancelled: true },
37
+ isError: true,
38
+ details: { questions: [], answers: [], cancelled: false },
37
39
  };
38
40
  }
39
41
 
42
+ /** Validate the question list. Returns the first error message, or null when valid.
43
+ * Blank ids are rejected (answers are keyed by id and "" would produce an empty key),
44
+ * as are duplicate ids and questions with nothing selectable. */
45
+ export function validateQuestions(questions: Question[]): string | null {
46
+ const seenIds = new Set<string>();
47
+ for (const q of questions) {
48
+ if (q.id.trim() === "") {
49
+ return "Error: Question id must be a non-empty string";
50
+ }
51
+ if (seenIds.has(q.id)) {
52
+ return `Error: Duplicate question id "${q.id}" — question ids must be unique`;
53
+ }
54
+ seenIds.add(q.id);
55
+ }
56
+ for (const q of questions) {
57
+ const allowOther = q.allowOther !== false;
58
+ if (q.options.length === 0 && !allowOther) {
59
+ return `Error: Question "${q.id}" has no options and allowOther is false — nothing is selectable`;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
40
65
  export default function questionnaire(pi: ExtensionAPI) {
41
66
  pi.registerTool({
42
67
  name: "questionnaire",
@@ -58,23 +83,9 @@ export default function questionnaire(pi: ExtensionAPI) {
58
83
  return errorResult("Error: No questions provided");
59
84
  }
60
85
 
61
- // Reject duplicate ids — answers are keyed by id and would silently collide.
62
- const seenIds = new Set<string>();
63
- for (const q of params.questions) {
64
- if (seenIds.has(q.id)) {
65
- return errorResult(`Error: Duplicate question id "${q.id}" — question ids must be unique`);
66
- }
67
- seenIds.add(q.id);
68
- }
69
-
70
- // Reject unselectable questions (no options and no free-text fallback).
71
- for (const q of params.questions) {
72
- const allowOther = q.allowOther !== false;
73
- if (q.options.length === 0 && !allowOther) {
74
- return errorResult(
75
- `Error: Question "${q.id}" has no options and allowOther is false — nothing is selectable`,
76
- );
77
- }
86
+ const validationError = validateQuestions(params.questions);
87
+ if (validationError) {
88
+ return errorResult(validationError);
78
89
  }
79
90
 
80
91
  // Normalize questions with defaults
@@ -424,6 +435,10 @@ export default function questionnaire(pi: ExtensionAPI) {
424
435
  },
425
436
 
426
437
  renderResult(result, _options, theme, _context) {
438
+ if (result.isError) {
439
+ const text = result.content[0];
440
+ return new Text(theme.fg("error", text?.type === "text" ? text.text : ""), 0, 0);
441
+ }
427
442
  const details = result.details as QuestionnaireResult | undefined;
428
443
  if (!details) {
429
444
  const text = result.content[0];
@@ -5,8 +5,8 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
7
  - Tools: `web_search`, `fetch_content`, and `get_search_content`.
8
- - `web_search` accepts `query` or parallel `queries`.
9
- - `fetch_content` accepts `url` or `urls` with at most three requests in flight.
8
+ - `web_search` accepts `query` or parallel `queries` (at most 10 per call).
9
+ - `fetch_content` accepts `url` or `urls` (at most 20 per call) with at most three requests in flight.
10
10
  - `get_search_content` uses a prior `responseId`, with `queryIndex`, `urlIndex`, or exact `url` selection. It returns one bounded page; pass `offset` to continue a truncated retrieval.
11
11
  - No slash commands or shortcuts.
12
12
 
@@ -14,8 +14,8 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
14
14
 
15
15
  - Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
16
16
  - Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
17
- - Restricts `fetch_content` to http/https, caps each response at 10 MB, and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
18
- - Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour or until the session ends.
17
+ - Restricts `fetch_content` to http/https and blocks private-range, link-local, and reserved hosts by default, re-checks every redirect hop, and always blocks cloud-metadata endpoints (SSRF guard). Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local development; set `allowPrivateNetwork: true` to reach private ranges. Caps each response at 10 MB and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
18
+ - Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour (expiry is enforced on read, not just on write) or until the session ends.
19
19
  - Pages `get_search_content` results (default 30,000 characters per page) with `offset` continuation, so a model never loads the whole stored body in one call.
20
20
  - Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
21
21
 
@@ -33,7 +33,7 @@ Copy [`configs/opl-webaccess.json.sample`](../../configs/opl-webaccess.json.samp
33
33
  }
34
34
  ```
35
35
 
36
- Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables. Missing or invalid config falls back to Gemini defaults; an unknown provider returns an error.
36
+ Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables. Malformed config falls back to Gemini defaults - a non-object config, a non-string `provider`, a non-object `providers` map, or non-positive cap values (`maxContentChars`/`maxRetrievalChars`) are all treated as unset; an unknown provider returns an error.
37
37
 
38
38
  Optional top-level caps control how much content reaches the model:
39
39
 
@@ -41,6 +41,9 @@ Optional top-level caps control how much content reaches the model:
41
41
  |---|---|---|
42
42
  | `maxContentChars` | `30000` | Cap on the initial `web_search`/`fetch_content` body. |
43
43
  | `maxRetrievalChars` | `30000` | Cap on one `get_search_content` page. Pass `offset` to continue. |
44
+ | `maxSearchQueries` | `10` | Cap on how many queries one `web_search` call runs. Excess queries are skipped and reported. |
45
+ | `maxFetchUrls` | `20` | Cap on how many URLs one `fetch_content` call fetches. Excess URLs are skipped and reported. |
46
+ | `allowPrivateNetwork` | `false` | Allow `fetch_content` to reach private/link-local ranges (loopback is always allowed; cloud metadata is always blocked). Provider API endpoints (e.g. `ddgs.apiUrl`, `searxng.instanceUrl`) are excluded from this guard. |
44
47
 
45
48
  Install extraction dependencies before use:
46
49
 
@@ -22,16 +22,36 @@ export interface WebAccessConfig {
22
22
  maxContentChars?: number;
23
23
  /** Cap on one get_search_content retrieval page (chars). */
24
24
  maxRetrievalChars?: number;
25
+ /** Cap on the number of queries a single web_search call may run. */
26
+ maxSearchQueries?: number;
27
+ /** Cap on the number of URLs a single fetch_content call may fetch. */
28
+ maxFetchUrls?: number;
29
+ /** Allow fetch_content to reach private/link-local ranges (loopback is always
30
+ * allowed; cloud metadata is always blocked). Default false. */
31
+ allowPrivateNetwork?: boolean;
25
32
  }
26
33
 
27
34
  export const DEFAULT_MAX_CONTENT_CHARS = 30_000;
28
35
  export const DEFAULT_MAX_RETRIEVAL_CHARS = 30_000;
36
+ export const DEFAULT_MAX_SEARCH_QUERIES = 10;
37
+ export const DEFAULT_MAX_FETCH_URLS = 20;
29
38
 
30
- /** Resolve configurable content caps, falling back to defaults. */
31
- export function resolveCaps(cfg: Pick<WebAccessConfig, "maxContentChars" | "maxRetrievalChars">) {
39
+ /** Coerce an unknown value into a positive integer, else the fallback. */
40
+ function positiveInt(value: unknown, fallback: number): number {
41
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
42
+ }
43
+
44
+ /** Resolve configurable caps, falling back to defaults when a cap is missing or not
45
+ * a positive finite number (a string/negative/zero cap would otherwise make
46
+ * truncate/slice produce empty, inverted, or unbounded output). */
47
+ export function resolveCaps(
48
+ cfg: Pick<WebAccessConfig, "maxContentChars" | "maxRetrievalChars" | "maxSearchQueries" | "maxFetchUrls">,
49
+ ) {
32
50
  return {
33
- maxContentChars: cfg.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS,
34
- maxRetrievalChars: cfg.maxRetrievalChars ?? DEFAULT_MAX_RETRIEVAL_CHARS,
51
+ maxContentChars: positiveInt(cfg.maxContentChars, DEFAULT_MAX_CONTENT_CHARS),
52
+ maxRetrievalChars: positiveInt(cfg.maxRetrievalChars, DEFAULT_MAX_RETRIEVAL_CHARS),
53
+ maxSearchQueries: positiveInt(cfg.maxSearchQueries, DEFAULT_MAX_SEARCH_QUERIES),
54
+ maxFetchUrls: positiveInt(cfg.maxFetchUrls, DEFAULT_MAX_FETCH_URLS),
35
55
  };
36
56
  }
37
57
 
@@ -50,12 +70,43 @@ const DEFAULT_CONFIG: WebAccessConfig = {
50
70
 
51
71
  export function loadConfig(): WebAccessConfig {
52
72
  if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
73
+ let parsed: unknown;
53
74
  try {
54
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebAccessConfig;
75
+ parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
55
76
  } catch {
56
77
  console.error(`[opl-webaccess] failed to parse ${CONFIG_PATH}, using defaults`);
57
78
  return DEFAULT_CONFIG;
58
79
  }
80
+ return normalizeConfig(parsed);
81
+ }
82
+
83
+ /** Validate a parsed config so malformed fields fall back to defaults instead of
84
+ * silently misbehaving downstream (a non-object config, a non-string provider, or
85
+ * a non-object providers map are all treated as unset). Cap values are validated
86
+ * in resolveCaps. */
87
+ function normalizeConfig(parsed: unknown): WebAccessConfig {
88
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
89
+ console.error("[opl-webaccess] config is not a JSON object, using defaults");
90
+ return DEFAULT_CONFIG;
91
+ }
92
+ const record = parsed as Record<string, unknown>;
93
+ const provider =
94
+ typeof record.provider === "string" && record.provider.trim() !== ""
95
+ ? record.provider
96
+ : DEFAULT_CONFIG.provider;
97
+ const providers =
98
+ typeof record.providers === "object" && record.providers !== null && !Array.isArray(record.providers)
99
+ ? (record.providers as Record<string, ProviderConfig>)
100
+ : DEFAULT_CONFIG.providers;
101
+ return {
102
+ provider,
103
+ providers,
104
+ maxContentChars: record.maxContentChars as number | undefined,
105
+ maxRetrievalChars: record.maxRetrievalChars as number | undefined,
106
+ maxSearchQueries: record.maxSearchQueries as number | undefined,
107
+ maxFetchUrls: record.maxFetchUrls as number | undefined,
108
+ allowPrivateNetwork: record.allowPrivateNetwork === true,
109
+ };
59
110
  }
60
111
 
61
112
  export function getApiKey(cfg: ProviderConfig): string {
@@ -2,12 +2,15 @@ import { Readability } from "@mozilla/readability";
2
2
  import { parseHTML } from "linkedom";
3
3
  import TurndownService from "turndown";
4
4
  import { extractPdfBuffer } from "./pdf.js";
5
- import { errorMessage, isAbortError, isPdfUrl, isPdfContentType, assertHttpUrl } from "./utils.js";
5
+ import { errorMessage, isAbortError, isPdfUrl, isPdfContentType, assertHttpUrl, type HttpUrlOptions } from "./utils.js";
6
6
  import type { ExtractedContent } from "./types.js";
7
7
 
8
8
  const CONCURRENT_LIMIT = 3;
9
9
  const MAX_FETCH_BYTES = 10 * 1024 * 1024; // 10 MB response cap
10
10
  const FETCH_TIMEOUT_MS = 30_000;
11
+ const MAX_REDIRECTS = 5;
12
+ /** Ceiling on the number of URLs a single fetch_content call may include. */
13
+ export const MAX_FETCH_URLS = 20;
11
14
 
12
15
  const td = new TurndownService({
13
16
  headingStyle: "atx",
@@ -43,31 +46,53 @@ async function readBufferCapped(response: Response, maxBytes: number): Promise<A
43
46
 
44
47
  export async function fetchAllContent(
45
48
  urls: string[],
46
- signal?: AbortSignal
49
+ signal?: AbortSignal,
50
+ opts: HttpUrlOptions = {},
51
+ maxUrls: number = MAX_FETCH_URLS,
47
52
  ): Promise<ExtractedContent[]> {
53
+ const bounded = urls.slice(0, maxUrls);
48
54
  const results: ExtractedContent[] = [];
49
- for (let i = 0; i < urls.length; i += CONCURRENT_LIMIT) {
55
+ for (let i = 0; i < bounded.length; i += CONCURRENT_LIMIT) {
50
56
  if (signal?.aborted) break;
51
- const batch = urls.slice(i, i + CONCURRENT_LIMIT);
52
- const batchResults = await Promise.all(batch.map((url) => fetchOne(url, signal)));
57
+ const batch = bounded.slice(i, i + CONCURRENT_LIMIT);
58
+ const batchResults = await Promise.all(batch.map((url) => fetchOne(url, signal, opts)));
53
59
  results.push(...batchResults);
54
60
  }
55
61
  return results;
56
62
  }
57
63
 
58
- async function fetchOne(url: string, signal?: AbortSignal): Promise<ExtractedContent> {
64
+ /**
65
+ * Fetch with redirects followed manually so each hop is re-validated. The native
66
+ * `redirect: "follow"` would let a public URL bounce to a private/link-local host
67
+ * past the `assertHttpUrl` check.
68
+ */
69
+ async function fetchWithRedirectValidation(
70
+ url: string,
71
+ fetchSignal: AbortSignal,
72
+ opts: HttpUrlOptions
73
+ ): Promise<Response> {
74
+ let current = assertHttpUrl(url, opts);
75
+ const headers = {
76
+ "User-Agent": "Mozilla/5.0 (compatible; pi-web-access/1.0)",
77
+ Accept: "text/html,application/xhtml+xml,application/pdf,*/*",
78
+ };
79
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
80
+ const response = await fetch(current, { signal: fetchSignal, headers, redirect: "manual" });
81
+ if (response.status < 300 || response.status >= 400) return response;
82
+ const location = response.headers.get("location");
83
+ if (!location) return response; // 3xx without a Location: treat as final
84
+ await response.body?.cancel().catch(() => {});
85
+ current = assertHttpUrl(new URL(location, current).href, opts);
86
+ }
87
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
88
+ }
89
+
90
+ async function fetchOne(url: string, signal?: AbortSignal, opts: HttpUrlOptions = {}): Promise<ExtractedContent> {
59
91
  try {
60
- const target = assertHttpUrl(url);
61
92
  const fetchSignal = signal
62
93
  ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
63
94
  : AbortSignal.timeout(FETCH_TIMEOUT_MS);
64
- const response = await fetch(target, {
65
- signal: fetchSignal,
66
- headers: {
67
- "User-Agent": "Mozilla/5.0 (compatible; pi-web-access/1.0)",
68
- Accept: "text/html,application/xhtml+xml,application/pdf,*/*",
69
- },
70
- });
95
+ const response = await fetchWithRedirectValidation(url, fetchSignal, opts);
71
96
 
72
97
  if (!response.ok) {
73
98
  return { url, title: "", content: "", error: `HTTP ${response.status}: ${url}` };
@@ -14,7 +14,15 @@ import { loadConfig, resolveCaps } from "./config.js";
14
14
  import type { StoredData } from "./types.js";
15
15
 
16
16
  export default function (pi: ExtensionAPI) {
17
- const caps = resolveCaps(loadConfig());
17
+ const config = loadConfig();
18
+ const caps = resolveCaps(config);
19
+ const allowPrivateNetwork = config.allowPrivateNetwork === true;
20
+
21
+ // Note shown to the model when the per-call URL/query cap drops some inputs.
22
+ const skippedNote = (dropped: number, limit: number, singular: string, plural: string) =>
23
+ dropped > 0
24
+ ? `\n\n*Note: ${dropped} additional ${dropped === 1 ? singular : plural} beyond the per-call limit of ${limit} ${dropped === 1 ? "was" : "were"} skipped.*`
25
+ : "";
18
26
 
19
27
  pi.on("session_start", async (_event, ctx) => {
20
28
  restoreFromSession(ctx);
@@ -34,18 +42,27 @@ export default function (pi: ExtensionAPI) {
34
42
  ),
35
43
  }),
36
44
  async execute(_toolCallId, params, signal) {
37
- const queryList = params.queries ?? (params.query ? [params.query] : null);
38
- if (!queryList?.length) {
45
+ const rawQueries = params.queries ?? (params.query ? [params.query] : null);
46
+ if (!rawQueries?.length) {
39
47
  return {
40
48
  content: [{ type: "text", text: "Error: provide either query or queries." }],
41
49
  isError: true,
42
50
  details: {} as Record<string, unknown>,
43
51
  };
44
52
  }
53
+ const queryList = rawQueries.slice(0, caps.maxSearchQueries);
54
+ const dropped = rawQueries.length - queryList.length;
45
55
 
46
56
  let results;
47
57
  try {
48
- results = await Promise.all(queryList.map((q) => searchWeb(q, signal)));
58
+ // allSettled so one provider's throw (non-JSON body, missing key) cannot
59
+ // discard the sibling queries' results.
60
+ const settled = await Promise.allSettled(queryList.map((q) => searchWeb(q, signal)));
61
+ results = settled.map((s, i) =>
62
+ s.status === "fulfilled"
63
+ ? s.value
64
+ : { query: queryList[i], answer: "", results: [], error: errorMessage(s.reason) }
65
+ );
49
66
  } catch (err) {
50
67
  return {
51
68
  content: [{ type: "text", text: `Error: ${errorMessage(err)}` }],
@@ -70,8 +87,9 @@ export default function (pi: ExtensionAPI) {
70
87
  });
71
88
 
72
89
  const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
90
+ const note = skippedNote(dropped, caps.maxSearchQueries, "query", "queries");
73
91
  return {
74
- content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
92
+ content: [{ type: "text", text: `${body}${note}\n\n*responseId: ${id}*` }],
75
93
  details: { responseId: id, queryCount: results.length },
76
94
  };
77
95
  },
@@ -101,8 +119,9 @@ export default function (pi: ExtensionAPI) {
101
119
  }
102
120
 
103
121
  let results;
122
+ const dropped = urlList.length > caps.maxFetchUrls ? urlList.length - caps.maxFetchUrls : 0;
104
123
  try {
105
- results = await fetchAllContent(urlList, signal);
124
+ results = await fetchAllContent(urlList, signal, { allowPrivateNetwork }, caps.maxFetchUrls);
106
125
  } catch (err) {
107
126
  return {
108
127
  content: [{ type: "text", text: `Error: ${errorMessage(err)}` }],
@@ -123,8 +142,9 @@ export default function (pi: ExtensionAPI) {
123
142
  });
124
143
 
125
144
  const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
145
+ const note = skippedNote(dropped, caps.maxFetchUrls, "URL", "URLs");
126
146
  return {
127
- content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
147
+ content: [{ type: "text", text: `${body}${note}\n\n*responseId: ${id}*` }],
128
148
  details: { responseId: id, urlCount: results.length },
129
149
  };
130
150
  },
@@ -41,7 +41,12 @@ export async function searchGemini(
41
41
  return { query, answer: "", results: [], error: errorMessage(err) };
42
42
  }
43
43
 
44
- const data = (await response.json()) as GeminiResponse;
44
+ let data: GeminiResponse;
45
+ try {
46
+ data = (await response.json()) as GeminiResponse;
47
+ } catch {
48
+ return { query, answer: "", results: [], error: `Gemini API error: HTTP ${response.status} (non-JSON response)` };
49
+ }
45
50
 
46
51
  if (!response.ok) {
47
52
  const msg = data.error?.message ?? `HTTP ${response.status}`;
@@ -39,7 +39,12 @@ export async function searchTavily(
39
39
  return { query, answer: "", results: [], error: errorMessage(err) };
40
40
  }
41
41
 
42
- const data = (await response.json()) as TavilyResponse;
42
+ let data: TavilyResponse;
43
+ try {
44
+ data = (await response.json()) as TavilyResponse;
45
+ } catch {
46
+ return { query, answer: "", results: [], error: `Tavily API error: HTTP ${response.status} (non-JSON response)` };
47
+ }
43
48
 
44
49
  if (!response.ok) {
45
50
  return { query, answer: "", results: [], error: `Tavily API error: HTTP ${response.status}` };
@@ -23,8 +23,14 @@ export function storeResult(id: string, data: StoredData): void {
23
23
  evictExpired();
24
24
  }
25
25
 
26
- export function getResult(id: string): StoredData | null {
27
- return store.get(id) ?? null;
26
+ export function getResult(id: string, now: number = Date.now()): StoredData | null {
27
+ const data = store.get(id);
28
+ if (!data) return null;
29
+ if (now - data.timestamp >= CACHE_TTL_MS) {
30
+ store.delete(id);
31
+ return null;
32
+ }
33
+ return data;
28
34
  }
29
35
 
30
36
  export function clearStore(): void {