@openlines/opl-pi-sht 0.1.18 → 0.1.19

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
@@ -1,5 +1,11 @@
1
1
  # opl-pi-sht
2
2
 
3
+ [![gh stars](https://img.shields.io/github/stars/linellazatin/opl-pi-sht?logo=github&color=ffffe0)](https://github.com/linellazatin/opl-pi-sht)
4
+ [![gh release](https://img.shields.io/github/v/release/linellazatin/opl-pi-sht?display_name=release&logo=github&color=ffffe0)](https://github.com/linellazatin/opl-pi-sht)
5
+ [![npm version](https://img.shields.io/npm/v/%40openlines%2Fopl-pi-sht?logo=npm&color=cb3837)](https://www.npmjs.com/package/@openlines/opl-pi-sht)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@openlines/opl-pi-sht?logo=npm&color=cb3837)](https://www.npmjs.com/package/@openlines/opl-pi-sht)
7
+ [![license](https://img.shields.io/npm/l/@openlines/opl-pi-sht)](./LICENSE)
8
+
3
9
  **Cut token cost, run the agent safely, and drop your MCP servers.**
4
10
 
5
11
  A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
@@ -94,7 +100,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
94
100
 
95
101
  ### Run the agent without babysitting it
96
102
 
97
- - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns, with destructive-pattern checks that fire even inside otherwise-safe commands. The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
103
+ - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns, with destructive-pattern checks that fire even inside otherwise-safe commands (also `find -delete`, `git clean`, `truncate`, `sudo`, and quote-obfuscated `r"m"`; `env`/`printenv` no longer safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
98
104
  ![custom mode sample](images/ss-mode-custom.png)
99
105
  - `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
100
106
 
@@ -103,8 +109,8 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
103
109
 
104
110
  - **`opl-init`** writes a fingerprinted `AGENTS.md` so any agent starts already oriented in a repo. It writes a baseline before asking the model to refine the guide, so smaller models cannot leave the update only in chat; it re-runs only when the tree actually changes.
105
111
  ![init](images/ss-init.png)
106
- - **`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.
107
- - **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results.
112
+ - **`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.
113
+ - **`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.
108
114
  - **`opl-simplebench`** benchmarks models on deterministic closed-answer contracts, instruction-following, and tool-call generation so you pick a model on evidence, not vibes.
109
115
  ![simplebench](images/ss-simplebench.png)
110
116
  - **`opl-todo`** tracks branch-aware tasks that persist across a session and reconstruct from history.
@@ -180,7 +186,7 @@ Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For
180
186
  - `opl-init` and `opl-questionnaire` have no external configuration.
181
187
  - Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
182
188
  - `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.
183
- - `opl-modes.bashPatterns` is the shared read-only Bash policy for chat and plan. Use per-mode pattern fields only when those modes intentionally need different policies.
189
+ - `opl-modes.bashPatterns` is the shared read-only Bash policy now applied to every mode by default; a mode overrides it with its own `safePatterns`/`destructivePatterns` or disables it with `unrestrictedBash: true`.
184
190
  - `opl-modes.lazyTools` withholds heavy tool schemas (e.g. `subagent`, `browser`, `simplebench`) from the resting prefix and enables them on demand via `load_tools`, shrinking the per-session prompt-cache write.
185
191
 
186
192
  See each extension README for commands, behavior, configuration fields, runtime constraints, and architecture.
@@ -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 safePatterns replaces ALL defaults for both built-in chat/plan modes (shared base). Omit to keep built-in safe patterns. Shown here with the built-in list.",
21
- "_comment_destructive": "Replace-only: providing destructivePatterns replaces ALL defaults for both built-in chat/plan modes (shared base). Omit to keep built-in destructive patterns. 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; 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. Shown here with the built-in list.",
22
22
  "safePatterns": [
23
23
  "^\\s*cat\\b",
24
24
  "^\\s*head\\b",
@@ -43,8 +43,6 @@
43
43
  "^\\s*which\\b",
44
44
  "^\\s*whereis\\b",
45
45
  "^\\s*type\\b",
46
- "^\\s*env\\b",
47
- "^\\s*printenv\\b",
48
46
  "^\\s*uname\\b",
49
47
  "^\\s*whoami\\b",
50
48
  "^\\s*date\\b",
@@ -74,12 +72,14 @@
74
72
  "\\btee\\b",
75
73
  "\\bdd\\b",
76
74
  "\\bshred\\b",
75
+ "\\btruncate\\b",
76
+ "\\s-(delete|exec|execdir)\\b",
77
77
  "(^|[^<])>(?!>|&)",
78
78
  ">>",
79
79
  "\\bnpm\\s+(install|uninstall|update|ci)",
80
80
  "\\byarn\\s+(add|remove|install)",
81
81
  "\\bpip\\s+(install|uninstall)",
82
- "\\bgit\\s+(add|commit|push|merge|rebase|reset|checkout|branch\\s+-)",
82
+ "\\bgit\\s+(add|commit|push|merge|rebase|reset|checkout|branch\\s+-|clean|update-ref|tag\\s+-|cherry-pick|revert|am|apply)",
83
83
  "\\bsudo\\b",
84
84
  "\\bsu\\b",
85
85
  "\\bkill\\b",
@@ -47,6 +47,9 @@ Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
47
47
  a whole-document heuristic and mispicks inside small subtrees.
48
48
  - **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
49
49
  CSS-selector interaction, viewport control, multi-page management.
50
+ - **Scheme + path guards.** `navigate`/`new_page` accept http(s) only; `file:`,
51
+ `data:`, and `javascript:` are rejected. Screenshot paths are confined to the
52
+ project directory, so a bad `path` cannot overwrite arbitrary files.
50
53
  - **Per-page capture.** Console messages and network requests are buffered per
51
54
  page as they occur; `console` and `network` actions return the active page's
52
55
  buffer.
@@ -82,6 +85,8 @@ index.ts Pi wiring: registers the single `browser` tool, TTL result store,
82
85
  preview/handle logic, and session_shutdown cleanup.
83
86
  browser.ts Playwright driver: browser/context/page lifecycle, per-page console
84
87
  and network buffers, and the action switch.
88
+ validate.ts URL/path guards: http/https-only navigation, screenshot path confined
89
+ to the project directory.
85
90
  extract.ts Pure rendered-HTML → markdown pipeline (linkedom + Readability +
86
91
  turndown), duplicated from opl-webaccess to keep installs independent.
87
92
  config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
@@ -1,5 +1,6 @@
1
1
  import { chromium, type Browser, type BrowserContext, type Page, type ConsoleMessage } from "playwright";
2
2
  import { extractMarkdown } from "./extract.js";
3
+ import { assertHttpUrl, safeScreenshotPath } from "./validate.js";
3
4
  import type { BrowserConfig } from "./config.js";
4
5
 
5
6
  // ponytail: single module-level Chromium instance reused across tool calls.
@@ -80,7 +81,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
80
81
  if (url === "back") { await page().goBack(); }
81
82
  else if (url === "forward") { await page().goForward(); }
82
83
  else if (url === "reload") { await page().reload(); }
83
- else if (url) { await page().goto(url, { waitUntil: "domcontentloaded" }); }
84
+ else if (url) { await page().goto(assertHttpUrl(url), { waitUntil: "domcontentloaded" }); }
84
85
  else throw new Error("navigate requires url (or back|forward|reload)");
85
86
  return { text: `${page().url()} — ${await page().title()}` };
86
87
  }
@@ -97,7 +98,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
97
98
  return { text: markdown || "(empty extraction)" };
98
99
  }
99
100
  case "screenshot": {
100
- const file = p.path ?? `opl-browser-${Date.now()}.png`;
101
+ const file = safeScreenshotPath(p.path ?? `opl-browser-${Date.now()}.png`);
101
102
  await page().screenshot({ path: file, fullPage: p.fullPage ?? false });
102
103
  return { text: `Screenshot saved to ${file}`, file };
103
104
  }
@@ -154,7 +155,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
154
155
  const pg = await ctx.newPage();
155
156
  track(pg, cfg);
156
157
  activeIndex = ctx.pages().length - 1;
157
- if (p.url) await pg.goto(p.url, { waitUntil: "domcontentloaded" });
158
+ if (p.url) await pg.goto(assertHttpUrl(p.url), { waitUntil: "domcontentloaded" });
158
159
  return { text: `Opened page [${activeIndex}] ${pg.url()}` };
159
160
  }
160
161
  case "select_page": {
@@ -0,0 +1,29 @@
1
+ // Pure URL/path guards for the browser tool. No Playwright import so tests load it directly.
2
+
3
+ import { resolve, sep } from "node:path";
4
+
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 {
8
+ let parsed: URL;
9
+ try {
10
+ parsed = new URL(url);
11
+ } catch {
12
+ throw new Error(`invalid URL: ${url}`);
13
+ }
14
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
+ throw new Error(`only http/https URLs are allowed, got ${parsed.protocol}//`);
16
+ }
17
+ return parsed.href;
18
+ }
19
+
20
+ /** Require a screenshot path to stay within the project directory (guard against
21
+ * overwriting absolute or parent-relative user files with PNG bytes). */
22
+ export function safeScreenshotPath(file: string, cwd: string = process.cwd()): string {
23
+ const root = resolve(cwd);
24
+ const abs = resolve(cwd, file);
25
+ if (abs !== root && !abs.startsWith(root + sep)) {
26
+ throw new Error(`screenshot path must stay within the project directory: ${file}`);
27
+ }
28
+ return file;
29
+ }
@@ -19,7 +19,9 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `p
19
19
 
20
20
  Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. Destructive patterns are checked even when a command matches a safe pattern. User-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them.
21
21
 
22
- Custom modes can add or override modes with prompts, tool lists, Bash patterns, model overrides, visibility, enabled state, `plan_complete` permission, execute-handoff permission, labels, and `appearance`. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color.
22
+ The shared destructive base also blocks file mutation primitives (`rm`, `find -delete`/`-exec`, `truncate`, `git clean`/`update-ref`/`tag -d`, `sudo`, shell spawns, and output redirects). `env` and `printenv` are no longer safe-listed, since they could dump provider API keys into the model context. Destructive matching also runs against a quote/backslash-stripped command skeleton, so `r"m"` cannot dodge `\brm\b`; this is still a heuristic backstop, not a process-isolation boundary.
23
+
24
+ Custom modes can add or override modes with prompts, tool lists, Bash patterns, model overrides, visibility, enabled state, `plan_complete` permission, execute-handoff permission, labels, and `appearance`. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color. A custom mode that omits its own `safePatterns`/`destructivePatterns` inherits the whole shared base; a mode overrides a component with its own array or disables all gating with `unrestrictedBash: true`. Built-in `off` and `execute` stay unrestricted.
23
25
 
24
26
  ### Execute handoff
25
27
 
@@ -31,7 +33,7 @@ Any mode can start plan execution via the mode picker's `Execute:` entries or `/
31
33
 
32
34
  A custom mode with no `tools` array inherits **all** tools, including `write` and `edit` — it is write-capable by default. Always specify an explicit read-only tool list for restrictive modes.
33
35
 
34
- Mode state is persisted in session entries and restored on session resume or branch changes. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts.
36
+ Mode state is persisted in session entries and restored on session resume or branch changes. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts. On resume, an unknown mode name in a session entry is ignored (falling back to normal), and plan filenames containing path separators or `..` are dropped, so a stale or foreign branch cannot leave the session unrestricted or point plan reads outside `.pi/plans/`.
35
37
 
36
38
  ## Configuration
37
39
 
@@ -80,7 +82,8 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
80
82
  | `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
81
83
  | `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
82
84
  | `chatAllowedTools` / `planAllowedTools` | **Deprecated compatibility aliases** for the built-in tool lists. They are used only when the corresponding `modes.<name>.tools` is omitted; migrate to `modes.chat.tools` or `modes.plan.tools`. |
83
- | `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Replace the shared Bash policy for built-in chat and plan. Per-mode pattern fields intentionally override this shared policy when those modes need to differ. |
85
+ | `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Shared Bash policy applied to every mode by default; a mode overrides with its own `modes.<name>.safePatterns`/`destructivePatterns` or disables gating with `modes.<name>.unrestrictedBash: true`. |
86
+ | `modes.<name>.unrestrictedBash` | Set `true` to exempt a custom mode from the Bash policy entirely (no safe/destructive patterns). |
84
87
  | `lazyTools` | Tool names withheld from the active set at rest and enabled on demand (see Lazy tool loading below). |
85
88
  | `modes.<name>` | Add or override a mode, including `model`, `tools`, patterns, `allowPlanComplete`, `allowExecute`, `visible`, `enabled`, `prompt`, `labels`, and `appearance`. |
86
89
 
@@ -46,13 +46,13 @@ const DEFAULT_CHAT_MODE_TOOLS: string[] = [
46
46
  // ─── Bash Safety ─────────────────────────────────────────────────────────────
47
47
 
48
48
  /** Default safe command patterns — only these are allowed in read-only modes (chat and plan). */
49
- const DEFAULT_SAFE_PATTERNS: RegExp[] = [
49
+ export const DEFAULT_SAFE_PATTERNS: RegExp[] = [
50
50
  /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/,
51
51
  /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*cd\b/,
52
52
  /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/,
53
53
  /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/,
54
54
  /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/,
55
- /^\s*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/,
55
+ /^\s*uname\b/, /^\s*whoami\b/,
56
56
  /^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*free\b/,
57
57
  /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*jq\b/,
58
58
  /^\s*git\s+(status|log|diff|show|branch|remote)/i,
@@ -63,15 +63,16 @@ const DEFAULT_SAFE_PATTERNS: RegExp[] = [
63
63
  ];
64
64
 
65
65
  /** Default destructive command patterns — always blocked in read-only modes, even if matching a safe pattern. */
66
- const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
66
+ export const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
67
67
  /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i,
68
68
  /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i,
69
- /\btee\b/i, /\bdd\b/i, /\bshred\b/i,
69
+ /\btee\b/i, /\bdd\b/i, /\bshred\b/i, /\btruncate\b/i,
70
+ /\s-(delete|exec|execdir)\b/i,
70
71
  /(^|[^<])>(?!>|&)/, />>/,
71
72
  /\bnpm\s+(install|uninstall|update|ci)/i,
72
73
  /\byarn\s+(add|remove|install)/i,
73
74
  /\bpip\s+(install|uninstall)/i,
74
- /\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s+-)/i,
75
+ /\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s+-|clean|update-ref|tag\s+-|cherry-pick|revert|am|apply)/i,
75
76
  /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i,
76
77
  /\b(sh|bash|zsh)\b/i,
77
78
  /\b(vim?|nano|emacs|code|subl)\b/i,
@@ -362,6 +363,22 @@ function compilePatterns(patterns: string[] | undefined): RegExp[] | undefined {
362
363
  return patterns.map((p) => new RegExp(p, "i"));
363
364
  }
364
365
 
366
+ /** Resolve the Bash pattern pair for a newly registered custom mode from the shared
367
+ * base (top-level bashPatterns), with per-mode overrides and an explicit opt-out.
368
+ * Omitted components inherit; explicit arrays (even empty) replace; `unrestrictedBash`
369
+ * disables Bash gating entirely for that mode. */
370
+ export function resolveCustomPatterns(def: {
371
+ safePatterns?: string[];
372
+ destructivePatterns?: string[];
373
+ unrestrictedBash?: boolean;
374
+ }): { safe?: RegExp[]; destructive?: RegExp[] } {
375
+ if (def.unrestrictedBash) return { safe: undefined, destructive: undefined };
376
+ return {
377
+ safe: def.safePatterns !== undefined ? compilePatterns(def.safePatterns) : SAFE_COMMAND_PATTERNS,
378
+ destructive: def.destructivePatterns !== undefined ? compilePatterns(def.destructivePatterns) : DESTRUCTIVE_PATTERNS,
379
+ };
380
+ }
381
+
365
382
  /** Register a mode with the registry. Called by extensions at startup. */
366
383
  export function registerMode(name: string, definition: ModeDefinition): void {
367
384
  MODE_REGISTRY.set(name, definition);
@@ -447,11 +464,12 @@ function initModeRegistry(): void {
447
464
  appearance: def.appearance ?? existing.appearance,
448
465
  });
449
466
  } else {
467
+ const patterns = resolveCustomPatterns(def);
450
468
  registerMode(name, {
451
469
  prompt: def.prompt ?? "",
452
470
  tools: def.tools,
453
- safePatterns: compilePatterns(def.safePatterns),
454
- destructivePatterns: compilePatterns(def.destructivePatterns),
471
+ safePatterns: patterns.safe,
472
+ destructivePatterns: patterns.destructive,
455
473
  allowPlanComplete: def.allowPlanComplete ?? false,
456
474
  allowExecute: def.allowExecute ?? true,
457
475
  visible: def.visible ?? true,
@@ -52,6 +52,7 @@ import {
52
52
  } from "./config.js";
53
53
  import {
54
54
  isSafeCommand,
55
+ isDestructive,
55
56
  extractPlanText,
56
57
  isPlanLike,
57
58
  ensurePlanDir,
@@ -575,10 +576,10 @@ export default function modeSwitcher(pi: ExtensionAPI) {
575
576
  }
576
577
  }
577
578
 
578
- // Check destructive patterns (if defined, must not match any)
579
+ // Check destructive patterns (if defined, must not match any) — also against
580
+ // a quote/backslash-stripped skeleton so r"m"/r\m obfuscation can't dodge \brm\b.
579
581
  if (modeDef.destructivePatterns && modeDef.destructivePatterns.length > 0) {
580
- const matchesDestructive = modeDef.destructivePatterns.some((pattern) => pattern.test(command));
581
- if (matchesDestructive) {
582
+ if (isDestructive(command, modeDef.destructivePatterns)) {
582
583
  return {
583
584
  block: true,
584
585
  reason: `[mode-switcher] Command blocked — destructive pattern in ${mode} mode: ${command}`,
@@ -88,6 +88,13 @@ function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): v
88
88
  } satisfies AgentModeBlob);
89
89
  }
90
90
 
91
+ /** Reject activePlanFile values that could escape PLAN_DIR via path components or traversal. */
92
+ function normalizePlanFile(file: string | null | undefined): string | null {
93
+ if (!file) return null;
94
+ if (file.includes("/") || file.includes("\\") || file.includes("..")) return null;
95
+ return file;
96
+ }
97
+
91
98
  /**
92
99
  * Restore state from the most recent relevant session entry on the current branch.
93
100
  * Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries
@@ -102,9 +109,11 @@ export function restore(
102
109
 
103
110
  if (entry.customType === ENTRY_TYPE) {
104
111
  const data = entry.data as AgentModeBlob | undefined;
105
- if (data?.mode) {
112
+ // Fail closed: only apply modes the registry actually knows, otherwise a stale or
113
+ // foreign-branch entry would leave the session unrestricted (no tools, no patterns).
114
+ if (data?.mode && getModeDefinition(data.mode)) {
106
115
  state.mode = data.mode;
107
- state.activePlanFile = data.activePlanFile ?? null;
116
+ state.activePlanFile = normalizePlanFile(data.activePlanFile);
108
117
  state.refining = false;
109
118
  syncGlobalThis();
110
119
  return true;
@@ -121,7 +130,7 @@ export function restore(
121
130
  const data = entry.data as { mode?: string; activePlanFile?: string | null } | undefined;
122
131
  if (data?.mode === "plan" || data?.mode === "execute") {
123
132
  state.mode = data.mode as AgentMode;
124
- state.activePlanFile = data.activePlanFile ?? null;
133
+ state.activePlanFile = normalizePlanFile(data.activePlanFile);
125
134
  state.refining = false;
126
135
  syncGlobalThis();
127
136
  return true;
@@ -125,6 +125,8 @@ export interface UserModeDefinition {
125
125
  tools?: string[];
126
126
  safePatterns?: string[];
127
127
  destructivePatterns?: string[];
128
+ /** Set true to skip Bash gating entirely for this mode (no safe/destructive patterns). */
129
+ unrestrictedBash?: boolean;
128
130
  labels?: ModeLabelConfig;
129
131
  appearance?: ModeAppearanceConfig;
130
132
  allowPlanComplete?: boolean;
@@ -10,7 +10,14 @@ import type { PlanFileSummary } from "./types.js";
10
10
  /** Check if command matches safe patterns and not destructive patterns. */
11
11
  export function isSafeCommand(command: string): boolean {
12
12
  return SAFE_COMMAND_PATTERNS.some((p) => p.test(command))
13
- && !DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
13
+ && !isDestructive(command, DESTRUCTIVE_PATTERNS);
14
+ }
15
+
16
+ /** Whether a command, or its quote/backslash-stripped skeleton, matches any destructive
17
+ * pattern. Stripping defeats shell expands like `r"m"` -> `rm` that dodge \brm\b. */
18
+ export function isDestructive(command: string, patterns: RegExp[]): boolean {
19
+ const skeleton = command.replace(/["'\\]/g, "");
20
+ return patterns.some((p) => p.test(command) || p.test(skeleton));
14
21
  }
15
22
 
16
23
  const PLAN_ACTION_VERB = /^\s*\d+\.\s+(?:\*{1,2})?(?:add|creat|updat|fix|remov|refactor|implement|modif|chang|edit|writ|build|run|install|configur|set\s+up|delet|mov|renam|inject|migrat|replac|extract|test|deploy|integrat|convert)/i;
@@ -14,6 +14,7 @@ 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.
17
18
  - Caps initial tool output at 30,000 characters, then keeps it for retrieval for one hour or until the session ends.
18
19
  - Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
19
20
 
@@ -2,16 +2,45 @@ 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 } from "./utils.js";
5
+ import { errorMessage, isAbortError, isPdfUrl, isPdfContentType, assertHttpUrl } from "./utils.js";
6
6
  import type { ExtractedContent } from "./types.js";
7
7
 
8
8
  const CONCURRENT_LIMIT = 3;
9
+ const MAX_FETCH_BYTES = 10 * 1024 * 1024; // 10 MB response cap
10
+ const FETCH_TIMEOUT_MS = 30_000;
9
11
 
10
12
  const td = new TurndownService({
11
13
  headingStyle: "atx",
12
14
  codeBlockStyle: "fenced",
13
15
  });
14
16
 
17
+ /** Read a response body into bytes, aborting past maxBytes (guards memory against huge payloads). */
18
+ async function readBufferCapped(response: Response, maxBytes: number): Promise<ArrayBuffer> {
19
+ const declared = Number(response.headers.get("content-length") ?? "0");
20
+ if (declared > maxBytes) throw new Error(`Response too large (${declared} bytes)`);
21
+ const reader = response.body?.getReader();
22
+ if (!reader) return await response.arrayBuffer();
23
+ const chunks: Uint8Array[] = [];
24
+ let total = 0;
25
+ for (;;) {
26
+ const { done, value } = await reader.read();
27
+ if (done) break;
28
+ total += value.byteLength;
29
+ if (total > maxBytes) {
30
+ await reader.cancel().catch(() => {});
31
+ throw new Error(`Response exceeds ${maxBytes} byte limit`);
32
+ }
33
+ chunks.push(value);
34
+ }
35
+ const merged = new Uint8Array(total);
36
+ let offset = 0;
37
+ for (const chunk of chunks) {
38
+ merged.set(chunk, offset);
39
+ offset += chunk.byteLength;
40
+ }
41
+ return merged.buffer;
42
+ }
43
+
15
44
  export async function fetchAllContent(
16
45
  urls: string[],
17
46
  signal?: AbortSignal
@@ -28,8 +57,12 @@ export async function fetchAllContent(
28
57
 
29
58
  async function fetchOne(url: string, signal?: AbortSignal): Promise<ExtractedContent> {
30
59
  try {
31
- const response = await fetch(url, {
32
- signal,
60
+ const target = assertHttpUrl(url);
61
+ const fetchSignal = signal
62
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
63
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS);
64
+ const response = await fetch(target, {
65
+ signal: fetchSignal,
33
66
  headers: {
34
67
  "User-Agent": "Mozilla/5.0 (compatible; pi-web-access/1.0)",
35
68
  Accept: "text/html,application/xhtml+xml,application/pdf,*/*",
@@ -43,7 +76,7 @@ async function fetchOne(url: string, signal?: AbortSignal): Promise<ExtractedCon
43
76
  const contentType = response.headers.get("content-type") ?? "";
44
77
 
45
78
  if (isPdfUrl(url) || isPdfContentType(contentType)) {
46
- const buffer = await response.arrayBuffer();
79
+ const buffer = await readBufferCapped(response, MAX_FETCH_BYTES);
47
80
  const content = await extractPdfBuffer(buffer);
48
81
  return { url, title: url, content, error: null };
49
82
  }
@@ -51,7 +84,7 @@ async function fetchOne(url: string, signal?: AbortSignal): Promise<ExtractedCon
51
84
  const isHtml =
52
85
  contentType.includes("text/html") || contentType.includes("application/xhtml");
53
86
 
54
- const body = await response.text();
87
+ const body = new TextDecoder().decode(await readBufferCapped(response, MAX_FETCH_BYTES));
55
88
 
56
89
  if (!isHtml) {
57
90
  // Plain text, markdown, JSON, etc. — return as-is
@@ -27,9 +27,9 @@ export async function searchGemini(
27
27
 
28
28
  let response: Response;
29
29
  try {
30
- response = await fetch(`${base}/models/${model}:generateContent?key=${apiKey}`, {
30
+ response = await fetch(`${base}/models/${model}:generateContent`, {
31
31
  method: "POST",
32
- headers: { "Content-Type": "application/json" },
32
+ headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
33
33
  body: JSON.stringify({
34
34
  contents: [{ role: "user", parts: [{ text: query }] }],
35
35
  tools: [{ google_search: {} }],
@@ -26,3 +26,17 @@ export function isPdfUrl(url: string): boolean {
26
26
  export function isPdfContentType(contentType: string): boolean {
27
27
  return contentType.toLowerCase().includes("application/pdf");
28
28
  }
29
+
30
+ /** Normalize a URL, allowing only http/https (file:/data:/etc. are rejected). */
31
+ export function assertHttpUrl(url: string): string {
32
+ let parsed: URL;
33
+ try {
34
+ parsed = new URL(url);
35
+ } catch {
36
+ throw new Error(`Invalid URL: ${url}`);
37
+ }
38
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
39
+ throw new Error(`Unsupported protocol "${parsed.protocol}//" — only http/https`);
40
+ }
41
+ return parsed.href;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",