@openlines/opl-pi-sht 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ A portable collection of various Pi coding agent extensions. Repository director
17
17
  <div align="center">
18
18
 
19
19
  >
20
- > ### v0.2.5 (24 Sept 2026) - **Dev dependency floor**: `@earendil-works/pi-coding-agent` is now `^0.87.0` <br/>see [CHANGELOG.md](CHANGELOG.md) for more details.
20
+ > ### v0.2.7 (29 Sept 2026) - **Unified `opl-guardian` safety**: adds configurable Bash confirmations, symlink-aware protected file-tool paths, destructive-session confirmations, and malformed-call filtering. <br/>See [CHANGELOG.md](CHANGELOG.md) for details.
21
21
  >
22
22
 
23
23
  </div>
@@ -84,7 +84,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
84
84
  | [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
85
85
  | [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
86
86
  | [`opl-ctxtrim`](extensions/opl-ctxtrim/README.md) | Trims verbose`ctx_*` tool-schema descriptions on outbound provider requests (~67% smaller schema, ~4,700-6,300 tokens/request). Built specifically for the [context-mode](https://github.com/mksglu/context-mode) extension. | No commands/tools; no config. |
87
- | [`opl-guardian`](extensions/opl-guardian/README.md) | Drops malformed assistant tool calls before session persistence or replay, with project-local JSONL evidence. | No commands/tools; no config. |
87
+ | [`opl-guardian`](extensions/opl-guardian/README.md) | Configurable tool and session safety: dangerous-Bash confirmation, protected paths, destructive-session confirmation, and malformed-call filtering. | No commands/tools; `opl-guardian.json`. |
88
88
  | [`opl-todo`](extensions/opl-todo/README.md) | Branch-aware task tool, overlay, and task list. | `todo`, `/todos`; `opl-todo.json`. |
89
89
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
90
90
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
@@ -116,7 +116,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
116
116
  - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs. Execute-mode auto-exit needs pi >= 0.87.0 (the `agent_before_settle` boundary).
117
117
  ![custom mode sample](images/ss-mode-custom.png)
118
118
  - `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
119
- - **`opl-guardian`** stops a malformed provider tool-call record from poisoning the session: it removes calls missing an ID or name before Pi persists or replays them, while preserving dropped records in `err/guardian.jsonl` for an upstream report.
119
+ - **`opl-guardian`** combines configurable safety checks in one extension: it confirms dangerous Bash commands in the terminal or over RPC, blocks configured protected paths for file tools (including symlink targets), and confirms session clear/switch-with-pending-work/fork actions. Without a UI, configured dangerous commands and session actions block by default. It also removes malformed provider tool calls before persistence or replay and records incidents in `err/guardian.jsonl`. Protected-path Bash matching is literal best-effort, not shell sandboxing.
120
120
 
121
121
 
122
122
  ### Move through work faster
@@ -150,7 +150,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
150
150
  | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle), `opl-guardian` |
151
151
  | Research and drive the web | `opl-webaccess`, `opl-browser` |
152
152
  | Choose models with data | `opl-simplebench` |
153
- | The full, coordinated experience | all ten |
153
+ | The full, coordinated experience | all eleven |
154
154
 
155
155
  Selecting `opl-footer`, `opl-input`, or `opl-modes` installs all three, because they share active-mode state.
156
156
 
@@ -171,7 +171,7 @@ Installing extensions adds tool schemas (name + description + JSON parameters) t
171
171
  | `opl-init` | command only (no tool) | ~0 |
172
172
  | `opl-input` | UI only | ~0 |
173
173
  | `opl-footer` | UI only | ~0 |
174
- | `opl-guardian` | none (session-integrity guard) | ~0 |
174
+ | `opl-guardian` | none (tool/session safety; no added tool schemas) | ~0 |
175
175
  | `opl-ctxtrim` | none (payload transformer) | net negative |
176
176
 
177
177
  Command descriptions add roughly another ~120 tokens collectively, and only if your build surfaces them in the prompt or help block.
@@ -195,9 +195,10 @@ The fixed cost of a full install is small and paid once per session, then cached
195
195
 
196
196
  Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For a Pi package installation, the source directory is `~/.pi/agent/npm/node_modules/@openlines/opl-pi-sht/configs/` (npm) or `~/.pi/agent/git/github.com/linellazatin/opl-pi-sht/configs/` (Git):
197
197
 
198
- - `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`
198
+ - `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`, `opl-guardian.json`
199
199
  - `opl-browser` has optional configuration (`opl-browser.json`); all fields default, so it works without any config file.
200
200
  - `opl-simplebench` has optional `opl-simplebench.json`; copy `configs/opl-simplebench.json.sample` to configure DDGS/SearXNG research and llama metadata endpoints.
201
+ - `opl-guardian` has an optional `opl-guardian.json`; copy `configs/opl-guardian.json.sample` and manually migrate any old permission-gate or protected-path settings. It does not load legacy files. Configured `permissionGate.patterns` replace the defaults, so copied live patterns do not automatically inherit later default-rule updates.
201
202
  - `opl-init` and `opl-questionnaire` have no external configuration.
202
203
  - Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
203
204
  - `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.
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": "Copy to ~/.pi/agent/configs/opl-guardian.json and customize. This replaces standalone permission-gate, protected-paths, and confirm-destructive settings.",
3
+ "permissionGate": {
4
+ "patterns": [
5
+ "\\brm\\s+(?:(?:-[a-z]+|--[a-z-]+)\\s+)*(?:-[a-z]*r[a-z]*|--recursive)(?=\\s|$)",
6
+ "\\bsudo\\b",
7
+ "\\b(chmod|chown)\\s+(?:-[a-z]+\\s+)*777(?:\\b|$)",
8
+ "\\bprintenv\\b",
9
+ "(^|\\s)env(\\s|$)"
10
+ ],
11
+ "blockWithoutUI": true
12
+ },
13
+ "protectedPaths": {
14
+ "paths": [
15
+ { "path": ".env", "deny": ["read", "write", "edit", "bash"] },
16
+ { "path": ".git/", "deny": ["read", "write", "edit"] },
17
+ { "path": "node_modules/", "deny": ["write", "edit"] },
18
+ { "path": "~/.pi/agent/auth.json", "deny": ["read", "write", "edit", "bash"] }
19
+ ]
20
+ },
21
+ "confirmDestructive": {
22
+ "clearSession": true,
23
+ "switchWithUnsavedWork": true,
24
+ "forkSession": true,
25
+ "blockWithoutUI": true
26
+ },
27
+ "dropMalformedToolCalls": true
28
+ }
@@ -75,7 +75,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
75
75
  | `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
76
76
  | `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
77
77
  | `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
78
- | `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Usage comes from `ctx.getContextUsage()`; when Pi reports it as unknown (right after compaction or a context edit) the segment renders an explicit `(--%)` instead of a stale value. Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
78
+ | `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Usage comes from `ctx.getContextUsage()`; when Pi reports it as unknown right after compaction, the segment estimates the rebuilt projection with Pi’s `estimateTokens()` and marks the bar and values with `≈` until a fresh assistant response provides exact usage. If no projection is available, it renders `(--%)` rather than a stale value. Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
79
79
  | `cost` | `$<amount>` (4 decimals, e.g. `$0.0123`) | `$` dim, amount in `cost` colour (`muted` by default). Four decimals keep cheap local or short sessions distinguishable instead of pinning at `$0.00`. Shows dim `(no pricing)` when the session total is zero on a non-local model (provider has no pricing configured), and `(local model)` for local models |
80
80
  | `thinking` | `Thinking: <LEVEL>` | Dim label, CAPS level with per-level colour; always visible |
81
81
  | `mode_switcher` | Unified active mode label | Reads state published by `opl-modes`; `appearance.modeColor` controls the mode value, with hardcoded `muted` fallback |
@@ -5,6 +5,7 @@ import type { TUI } from "@earendil-works/pi-tui";
5
5
 
6
6
  import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js";
7
7
  import { renderSegment } from "./segments/index.js";
8
+ import { estimateContextUsage } from "./segments/context.js";
8
9
  import { createAgentStatusTracker } from "./segments/status.js";
9
10
  import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
10
11
  import { getEffectiveConfig } from "./config.js";
@@ -278,12 +279,16 @@ export default function footer(pi: ExtensionAPI) {
278
279
  ? thinkingEvents.reduce((_, e) => e.thinkingLevel ?? "off", "off")
279
280
  : null;
280
281
 
281
- // Prefer Pi's canonical context usage (0.87), which is null when usage is unknown
282
- // (e.g. right after compaction, before the next assistant reply) or after a context
283
- // edit. Fall back to the model's window when unavailable.
282
+ // Prefer Pi's canonical context usage (0.87). Immediately after compaction it is
283
+ // null until the next assistant reply, so estimate the rebuilt projection instead.
284
284
  const usage = typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
285
285
  const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
286
- const contextPercent = usage?.percent ?? null;
286
+ const projectedMessages = ctx.sessionManager?.buildSessionProjection?.().messages;
287
+ const estimatedContextUsage = usage?.percent === null && contextWindow > 0 && projectedMessages
288
+ ? estimateContextUsage(projectedMessages, contextWindow)
289
+ : null;
290
+ const contextPercent = usage?.percent ?? estimatedContextUsage?.percent ?? null;
291
+ const contextEstimated = estimatedContextUsage !== null;
287
292
 
288
293
  // Get git status (cached). Skip the probes entirely when no visible row renders the
289
294
  // git segment — otherwise an unused cell keeps spawning git once per second.
@@ -322,6 +327,7 @@ export default function footer(pi: ExtensionAPI) {
322
327
  sessionId: ctx.sessionManager?.getSessionId?.(),
323
328
  usageStats,
324
329
  contextPercent,
330
+ contextEstimated,
325
331
  contextWindow,
326
332
  usingSubscription,
327
333
  sessionStartTime,
@@ -1,3 +1,5 @@
1
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
2
+
1
3
  import type { RenderedSegment, SegmentContext } from "../types.js";
2
4
  import { applyColor, resolveColorToRgb } from "../theme.js";
3
5
  import { color } from "./helpers.js";
@@ -19,6 +21,14 @@ export function lerp(a: number, b: number, t: number): number {
19
21
  return Math.round(a + (b - a) * t);
20
22
  }
21
23
 
24
+ export function estimateContextUsage(
25
+ messages: Parameters<typeof estimateTokens>[0][],
26
+ contextWindow: number,
27
+ ): { tokens: number; percent: number } {
28
+ const tokens = messages.reduce((total, message) => total + estimateTokens(message), 0);
29
+ return { tokens, percent: (tokens / contextWindow) * 100 };
30
+ }
31
+
22
32
  function positionColor(
23
33
  pos: number,
24
34
  barWidth: number,
@@ -75,7 +85,9 @@ export const contextPctSegment = {
75
85
 
76
86
  // Show: (00.00%) 99999 / 99999
77
87
  const used = Math.round((pct / 100) * ctx.contextWindow);
78
- const pctLabel = `(${pct.toFixed(2)}%) ${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`;
88
+ const pctLabel = ctx.contextEstimated
89
+ ? `≈${pct.toFixed(2)}% ≈${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`
90
+ : `(${pct.toFixed(2)}%) ${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`;
79
91
  const pctStr = color(ctx, "contextLabel", pctLabel);
80
92
 
81
93
  return { content: `${bar} ${pctStr}`, visible: true };
@@ -119,6 +119,7 @@ export interface SegmentContext {
119
119
  sessionId: string | undefined;
120
120
  usageStats: UsageStats;
121
121
  contextPercent: number | null;
122
+ contextEstimated: boolean;
122
123
  contextWindow: number;
123
124
  usingSubscription: boolean;
124
125
  sessionStartTime: number;
@@ -1,51 +1,49 @@
1
1
  # opl-guardian
2
2
 
3
- Prevents malformed assistant tool calls from entering Pi session history. It runs on every provider and model through Pi's `message_end` hook, removes protocol-invalid calls before Pi persists or replays them, and records each removal as JSON Lines evidence.
3
+ A configurable session and tool safety extension. `opl-guardian` filters malformed assistant tool calls, prompts before dangerous Bash commands, blocks operations on protected paths, and confirms destructive session transitions. It replaces the standalone `permission-gate`, `protected-paths`, and `confirm-destructive` extensions while retaining malformed-call diagnostics. It adds no tools, commands, prompts, or model-facing schema overhead.
4
4
 
5
- >
6
- > This is the first feature that opl-guardian can do for now. If I happen to encounter another error/issue that can be (possibly) fixed via extensions, I might add that up in this extension.
7
- >
5
+ ## Configuration
8
6
 
9
- ## Commands, tools, and configuration
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.
10
8
 
11
- No commands, tools, or configuration. Loading the extension is the entire interface and adds no prompt or tool-schema overhead.
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.
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
+ - `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
+ - `dropMalformedToolCalls` defaults to `true`. Set it to `false` to disable only malformed-call filtering and its incident log.
12
13
 
13
- ## What it guards
14
+ Malformed or invalid config sections generate a warning when a UI is available and fall back to safe defaults for the affected settings. Invalid individual regexes or protected-path entries are reported; valid entries continue to apply. Empty arrays are explicit opt-outs.
14
15
 
15
- A tool call is malformed when its `id` or `name` is blank after trimming whitespace. Such a record cannot be safely replayed through OpenAI-compatible tool-call APIs: a later provider request may reject it and block the session until its history is abandoned.
16
+ If migrating from the standalone extensions, manually move their settings into the corresponding `permissionGate` and `protectedPaths` sections. `opl-guardian` does not read or merge legacy config files. Credits to pi's sample extensions [here](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions).
16
17
 
17
- `opl-guardian` does not reject an unfamiliar but non-empty tool name. That is a normal model/tool-resolution error, not an invalid protocol envelope.
18
+ ## What it guards
18
19
 
19
- | Assistant response | Guardian behavior |
20
- | --- | --- |
21
- | No malformed calls | Leaves the response unchanged. |
22
- | Valid and malformed calls | Drops only malformed calls, preserves valid calls and `toolUse`, appends a diagnostic, and lets Pi execute the valid calls normally. |
23
- | Only malformed calls | Removes every call, returns a text-only `stop` response, and tells the user to send another prompt. |
20
+ ### Dangerous Bash commands
24
21
 
25
- When a UI is available, every incident also produces a warning notification.
22
+ The default patterns cover recursive `rm` flags (including `-rf`, `-fr`, and separate `-f -r`), `sudo`, `chmod`/`chown` to mode `777`, `printenv`, and bare `env`. Matching commands require confirmation. Without a confirmation UI they are blocked by default.
26
23
 
27
- ## Forensic JSONL
24
+ ### Protected paths
28
25
 
29
- Each incident appends one JSON object to:
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; do not rely on guardian alone as a security boundary against untrusted shell commands.
30
27
 
31
- ```text
32
- <project cwd>/err/guardian.jsonl
33
- ```
28
+ ### Destructive session actions
29
+
30
+ Starting a new session, resuming another session with a pending user message, and forking prompt for confirmation by default. Configured actions are canceled if no UI is available unless `confirmDestructive.blockWithoutUI` is explicitly set to `false`.
34
31
 
35
- A record includes timestamp, session ID, project CWD, provider, model, response ID when available, and the removed `toolCall` blocks. It does not log prompts, assistant text, thinking, or tool results.
32
+ ### Malformed provider tool calls
36
33
 
37
- Tool-call arguments can still contain project paths, commands, or user text. Add `err/` to the project’s version-control ignore policy if it is not already ignored, and treat the file as local diagnostic data.
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.
38
35
 
39
- If the log write fails, guardian still removes the malformed calls. The session diagnostic and UI warning report that the forensic file could not be written rather than claiming it was saved.
36
+ ## Forensic JSONL
40
37
 
41
- ## Architecture
38
+ Each malformed-call incident appends one JSON object to:
42
39
 
43
40
  ```text
44
- guardian.ts Pure malformed-call filter and JSONL incident-record builder.
45
- index.ts Pi message_end handler, project-local JSONL append, UI notification.
41
+ <project cwd>/err/guardian.jsonl
46
42
  ```
47
43
 
48
- The extension has no runtime dependencies. Run its checks with:
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.
45
+
46
+ ## Testing
49
47
 
50
48
  ```bash
51
49
  npm run test:opl-guardian
@@ -0,0 +1,199 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type Op = "read" | "write" | "edit" | "bash";
6
+
7
+ export interface PathEntry {
8
+ path: string;
9
+ deny: Op[];
10
+ }
11
+
12
+ export interface GuardianConfig {
13
+ permissionGate: {
14
+ patterns: RegExp[];
15
+ blockWithoutUI: boolean;
16
+ };
17
+ protectedPaths: {
18
+ paths: PathEntry[];
19
+ };
20
+ confirmDestructive: {
21
+ clearSession: boolean;
22
+ switchWithUnsavedWork: boolean;
23
+ forkSession: boolean;
24
+ blockWithoutUI: boolean;
25
+ };
26
+ dropMalformedToolCalls: boolean;
27
+ }
28
+
29
+ const DEFAULT_PATTERN_STRINGS = [
30
+ "\\brm\\s+(?:(?:-[a-z]+|--[a-z-]+)\\s+)*(?:-[a-z]*r[a-z]*|--recursive)(?=\\s|$)",
31
+ "\\bsudo\\b",
32
+ "\\b(chmod|chown)\\s+(?:-[a-z]+\\s+)*777(?:\\b|$)",
33
+ "\\bprintenv\\b",
34
+ "(^|\\s)env(\\s|$)",
35
+ ];
36
+
37
+ const DEFAULT_PATHS: PathEntry[] = [
38
+ { path: ".env", deny: ["read", "write", "edit", "bash"] },
39
+ { path: ".git/", deny: ["read", "write", "edit"] },
40
+ { path: "node_modules/", deny: ["write", "edit"] },
41
+ { path: "~/.pi/agent/auth.json", deny: ["read", "write", "edit", "bash"] },
42
+ ];
43
+
44
+ export const DEFAULT_CONFIG: GuardianConfig = {
45
+ permissionGate: {
46
+ patterns: DEFAULT_PATTERN_STRINGS.map((pattern) => new RegExp(pattern, "i")),
47
+ blockWithoutUI: true,
48
+ },
49
+ protectedPaths: { paths: DEFAULT_PATHS },
50
+ confirmDestructive: {
51
+ clearSession: true,
52
+ switchWithUnsavedWork: true,
53
+ forkSession: true,
54
+ blockWithoutUI: true,
55
+ },
56
+ dropMalformedToolCalls: true,
57
+ };
58
+
59
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-guardian.json");
60
+ const OPS = new Set<Op>(["read", "write", "edit", "bash"]);
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function cloneDefaults(): GuardianConfig {
67
+ return {
68
+ permissionGate: {
69
+ patterns: DEFAULT_PATTERN_STRINGS.map((pattern) => new RegExp(pattern, "i")),
70
+ blockWithoutUI: true,
71
+ },
72
+ protectedPaths: { paths: DEFAULT_PATHS.map((entry) => ({ ...entry, deny: [...entry.deny] })) },
73
+ confirmDestructive: { ...DEFAULT_CONFIG.confirmDestructive },
74
+ dropMalformedToolCalls: true,
75
+ };
76
+ }
77
+
78
+ export function parseGuardianConfig(value: unknown): { config: GuardianConfig; warnings: string[] } {
79
+ const config = cloneDefaults();
80
+ const warnings: string[] = [];
81
+
82
+ if (value === undefined) return { config, warnings };
83
+ if (!isRecord(value)) {
84
+ return { config, warnings: ["Invalid opl-guardian config: expected a JSON object; using defaults."] };
85
+ }
86
+
87
+ if (value.permissionGate !== undefined) {
88
+ if (!isRecord(value.permissionGate)) {
89
+ warnings.push("Invalid permissionGate section; using defaults.");
90
+ } else {
91
+ const section = value.permissionGate;
92
+ if (section.patterns !== undefined) {
93
+ if (!Array.isArray(section.patterns)) {
94
+ warnings.push("permissionGate.patterns must be an array; using defaults.");
95
+ } else if (section.patterns.length === 0) {
96
+ config.permissionGate.patterns = [];
97
+ } else {
98
+ const patterns: RegExp[] = [];
99
+ for (const pattern of section.patterns) {
100
+ if (typeof pattern !== "string") {
101
+ warnings.push("permissionGate.patterns entries must be strings; ignoring invalid entry.");
102
+ continue;
103
+ }
104
+ try {
105
+ patterns.push(new RegExp(pattern, "i"));
106
+ } catch {
107
+ warnings.push("Invalid permissionGate regex; ignoring it.");
108
+ }
109
+ }
110
+ config.permissionGate.patterns = patterns.length > 0
111
+ ? patterns
112
+ : DEFAULT_PATTERN_STRINGS.map((pattern) => new RegExp(pattern, "i"));
113
+ }
114
+ }
115
+ if (section.blockWithoutUI !== undefined) {
116
+ if (typeof section.blockWithoutUI === "boolean") {
117
+ config.permissionGate.blockWithoutUI = section.blockWithoutUI;
118
+ } else {
119
+ warnings.push("permissionGate.blockWithoutUI must be a boolean; using true.");
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ if (value.protectedPaths !== undefined) {
126
+ if (!isRecord(value.protectedPaths)) {
127
+ warnings.push("Invalid protectedPaths section; using defaults.");
128
+ } else if (value.protectedPaths.paths !== undefined) {
129
+ const inputPaths = value.protectedPaths.paths;
130
+ if (!Array.isArray(inputPaths)) {
131
+ warnings.push("protectedPaths.paths must be an array; using defaults.");
132
+ } else if (inputPaths.length === 0) {
133
+ config.protectedPaths.paths = [];
134
+ } else {
135
+ const paths: PathEntry[] = [];
136
+ for (const item of inputPaths) {
137
+ if (!isRecord(item) || typeof item.path !== "string" || item.path.trim().length === 0 ||
138
+ !Array.isArray(item.deny) || !item.deny.every((op): op is Op => typeof op === "string" && OPS.has(op as Op))) {
139
+ warnings.push("Invalid protectedPaths entry; ignoring it.");
140
+ continue;
141
+ }
142
+ paths.push({ path: item.path, deny: item.deny as Op[] });
143
+ }
144
+ config.protectedPaths.paths = paths.length > 0
145
+ ? paths
146
+ : DEFAULT_PATHS.map((entry) => ({ ...entry, deny: [...entry.deny] }));
147
+ }
148
+ }
149
+ }
150
+
151
+ if (value.confirmDestructive !== undefined) {
152
+ if (!isRecord(value.confirmDestructive)) {
153
+ warnings.push("Invalid confirmDestructive section; using defaults.");
154
+ } else {
155
+ const keys = ["clearSession", "switchWithUnsavedWork", "forkSession", "blockWithoutUI"] as const;
156
+ for (const key of keys) {
157
+ const setting = value.confirmDestructive[key];
158
+ if (setting === undefined) continue;
159
+ if (typeof setting === "boolean") {
160
+ config.confirmDestructive[key] = setting;
161
+ } else {
162
+ warnings.push(`confirmDestructive.${key} must be a boolean; using true.`);
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ if (value.dropMalformedToolCalls !== undefined) {
169
+ if (typeof value.dropMalformedToolCalls === "boolean") {
170
+ config.dropMalformedToolCalls = value.dropMalformedToolCalls;
171
+ } else {
172
+ warnings.push("dropMalformedToolCalls must be a boolean; using true.");
173
+ }
174
+ }
175
+
176
+ return { config, warnings };
177
+ }
178
+
179
+ export function loadGuardianConfig(): { config: GuardianConfig; warnings: string[] } {
180
+ let raw: string;
181
+ try {
182
+ raw = readFileSync(CONFIG_PATH, "utf8");
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseGuardianConfig(undefined);
185
+ return {
186
+ config: cloneDefaults(),
187
+ warnings: [`Could not read ${CONFIG_PATH}; using defaults.`],
188
+ };
189
+ }
190
+
191
+ try {
192
+ return parseGuardianConfig(JSON.parse(raw));
193
+ } catch {
194
+ return {
195
+ config: cloneDefaults(),
196
+ warnings: [`Invalid JSON in ${CONFIG_PATH}; using defaults.`],
197
+ };
198
+ }
199
+ }
@@ -1,13 +1,25 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ isToolCallEventType,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ type MessageEndEvent,
7
+ type SessionBeforeForkEvent,
8
+ type SessionBeforeSwitchEvent,
9
+ type ToolCallEvent,
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";
3
13
  import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
4
14
  import { join } from "node:path";
5
15
 
16
+ import { loadGuardianConfig, type GuardianConfig } from "./config.js";
6
17
  import {
7
18
  buildIncidentRecord,
8
19
  guardAssistantMessage,
9
20
  type GuardianIncident,
10
21
  } from "./guardian.js";
22
+ import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
11
23
 
12
24
  const LOG_PATH = join("err", "guardian.jsonl");
13
25
 
@@ -66,16 +78,156 @@ export async function guardMessageEnd(
66
78
  };
67
79
  }
68
80
 
69
- export default function (pi: ExtensionAPI) {
70
- pi.on("message_end", async (event, ctx) => {
71
- if (event.message.role !== "assistant") return;
81
+ export function createToolCallHandler(config: GuardianConfig) {
82
+ return async (event: ToolCallEvent, ctx: ExtensionContext) => {
83
+ let toolPath: string | undefined;
84
+ if (isToolCallEventType("read", event)) toolPath = event.input.path;
85
+ else if (isToolCallEventType("write", event)) toolPath = event.input.path;
86
+ else if (isToolCallEventType("edit", event)) toolPath = event.input.path;
87
+ else if (isToolCallEventType("bash", event)) toolPath = event.input.command;
88
+
89
+ if (toolPath !== undefined) {
90
+ const blocked = getProtectedPathBlock(event.toolName, toolPath, config.protectedPaths.paths, ctx.cwd);
91
+ if (blocked) {
92
+ const reason = blocked.unresolved
93
+ ? `[opl-guardian] Cannot safely resolve path "${blocked.path}"; ${blocked.operation} denied by protected-path policy.`
94
+ : `[opl-guardian] Path "${blocked.path}" is protected (${blocked.operation} denied).`;
95
+ if (ctx.hasUI) ctx.ui.notify(reason, "warning");
96
+ return { block: true, reason };
97
+ }
98
+ }
99
+
100
+ if (!isToolCallEventType("bash", event) ||
101
+ !config.permissionGate.patterns.some((pattern) => pattern.test(event.input.command))) return undefined;
102
+
103
+ if (!ctx.hasUI) {
104
+ if (!config.permissionGate.blockWithoutUI) return undefined;
105
+ return { block: true, reason: "[opl-guardian] Command blocked because it matches a dangerous pattern and no confirmation UI is available." };
106
+ }
107
+
108
+ const command = event.input.command;
109
+ if (ctx.mode === "rpc") {
110
+ const preview = command.replace(/\s+/g, " ").trim();
111
+ try {
112
+ const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, ["Yes", "No"]);
113
+ if (choice === "Yes") return undefined;
114
+ } catch {
115
+ // A failed RPC dialog is not approval.
116
+ }
117
+ return { block: true, reason: "[opl-guardian] Command blocked; confirmation was declined or unavailable." };
118
+ }
119
+
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;
167
+ const reason = "[opl-guardian] Command blocked by user because it matches a dangerous pattern.";
168
+ ctx.ui.notify(reason, "warning");
169
+ return { block: true, reason };
170
+ };
171
+ }
172
+
173
+ export function createMessageEndHandler(config: GuardianConfig) {
174
+ return async (
175
+ event: MessageEndEvent,
176
+ ctx: Pick<ExtensionContext, "cwd" | "sessionManager" | "ui">,
177
+ ) => {
178
+ if (!config.dropMalformedToolCalls || event.message.role !== "assistant") return undefined;
72
179
  const guarded = await guardMessageEnd(event.message, {
73
180
  cwd: ctx.cwd,
74
181
  sessionId: ctx.sessionManager.getSessionId(),
75
182
  });
76
- if (!guarded) return;
77
-
183
+ if (!guarded) return undefined;
78
184
  ctx.ui?.notify(guarded.diagnostic, "warning");
79
185
  return { message: guarded.message };
186
+ };
187
+ }
188
+
189
+ export function createSessionBeforeSwitchHandler(config: GuardianConfig) {
190
+ return async (event: SessionBeforeSwitchEvent, ctx: ExtensionContext) => {
191
+ let title: string;
192
+ let message: string;
193
+ if (event.reason === "new") {
194
+ if (!config.confirmDestructive.clearSession) return undefined;
195
+ title = "Clear session?";
196
+ message = "This will delete all messages in the current session.";
197
+ } else {
198
+ if (!config.confirmDestructive.switchWithUnsavedWork ||
199
+ !hasPendingUserWork(ctx.sessionManager.getBranch())) return undefined;
200
+ title = "Switch session?";
201
+ message = "You have unanswered work in the current session. Switch anyway?";
202
+ }
203
+
204
+ if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
205
+ return await ctx.ui.confirm(title, message) ? undefined : { cancel: true };
206
+ };
207
+ }
208
+
209
+ export function createSessionBeforeForkHandler(config: GuardianConfig) {
210
+ return async (event: SessionBeforeForkEvent, ctx: ExtensionContext) => {
211
+ if (!config.confirmDestructive.forkSession) return undefined;
212
+ if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
213
+ const confirmed = await ctx.ui.confirm(
214
+ `Fork from entry ${event.entryId.slice(0, 8)}?`,
215
+ "Create a new session branch from this point?",
216
+ );
217
+ return confirmed ? undefined : { cancel: true };
218
+ };
219
+ }
220
+
221
+ export default function (pi: ExtensionAPI) {
222
+ const { config, warnings } = loadGuardianConfig();
223
+ pi.on("session_start", async (_event, ctx) => {
224
+ if (warnings.length && ctx.hasUI) {
225
+ ctx.ui.notify(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`, "warning");
226
+ }
80
227
  });
228
+
229
+ pi.on("tool_call", createToolCallHandler(config));
230
+ pi.on("session_before_switch", createSessionBeforeSwitchHandler(config));
231
+ pi.on("session_before_fork", createSessionBeforeForkHandler(config));
232
+ pi.on("message_end", createMessageEndHandler(config));
81
233
  }
@@ -0,0 +1,123 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import type { Op, PathEntry } from "./config.js";
8
+
9
+ function resolveConfiguredPath(path: string, cwd: string): string {
10
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
11
+ return resolve(cwd, path);
12
+ }
13
+
14
+ function physicalPath(path: string, depth = 0): string | undefined {
15
+ if (depth > 40) return undefined;
16
+ let current = path;
17
+ const missing: string[] = [];
18
+ while (true) {
19
+ let stat;
20
+ try {
21
+ stat = lstatSync(current);
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
24
+ const parent = dirname(current);
25
+ if (parent === current) return undefined;
26
+ missing.unshift(basename(current));
27
+ current = parent;
28
+ continue;
29
+ }
30
+ try {
31
+ return resolve(realpathSync(current), ...missing);
32
+ } catch {
33
+ if (!stat.isSymbolicLink()) return undefined;
34
+ try {
35
+ return physicalPath(resolve(dirname(current), readlinkSync(current), ...missing), depth + 1);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ function isAbsoluteOrHomeRelative(path: string): boolean {
44
+ return isAbsolute(path) || path.startsWith("~/");
45
+ }
46
+
47
+ function matchesResolvedPath(toolPath: string, entryPath: string, cwd: string): boolean {
48
+ const resolvedTool = resolveConfiguredPath(toolPath, cwd);
49
+ const resolvedEntry = resolveConfiguredPath(entryPath, cwd);
50
+ const directoryPrefix = resolvedEntry.endsWith(sep) ? resolvedEntry : `${resolvedEntry}${sep}`;
51
+ return resolvedTool === resolvedEntry || resolvedTool.startsWith(directoryPrefix);
52
+ }
53
+
54
+ export function matchesProtectedPath(toolPath: string, entry: PathEntry, cwd: string): boolean {
55
+ if (isAbsoluteOrHomeRelative(entry.path)) {
56
+ return matchesResolvedPath(toolPath, entry.path, cwd);
57
+ }
58
+
59
+ const segment = entry.path.replace(/[\\/]+$/, "");
60
+ return toolPath.split(/[\\/]/).some((part) => part === segment);
61
+ }
62
+
63
+ export function hasPendingUserWork(entries: SessionEntry[]): boolean {
64
+ let pending = false;
65
+ for (const entry of entries) {
66
+ if (entry.type !== "message") continue;
67
+ if (entry.message.role === "assistant") pending = false;
68
+ else if (entry.message.role === "user") pending = true;
69
+ }
70
+ return pending;
71
+ }
72
+
73
+ export function getProtectedPathBlock(
74
+ toolName: string,
75
+ toolPath: string,
76
+ paths: PathEntry[],
77
+ cwd: string,
78
+ ): { path: string; operation: Op; unresolved?: boolean } | undefined {
79
+ if (toolName !== "read" && toolName !== "write" && toolName !== "edit" && toolName !== "bash") {
80
+ return undefined;
81
+ }
82
+
83
+ const operation = toolName;
84
+ const guardedPaths = paths.filter((entry) => entry.deny.includes(operation));
85
+ if (guardedPaths.length === 0) return undefined;
86
+ let normalizedTool = operation === "bash" ? toolPath : toolPath.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
87
+ if (operation !== "bash") {
88
+ if (normalizedTool.startsWith("@")) normalizedTool = normalizedTool.slice(1);
89
+ if (normalizedTool.startsWith("file://")) {
90
+ try {
91
+ normalizedTool = fileURLToPath(normalizedTool);
92
+ } catch {
93
+ return { path: toolPath, operation, unresolved: true };
94
+ }
95
+ }
96
+ }
97
+ const canonicalTool = operation === "bash" ? undefined : physicalPath(resolveConfiguredPath(normalizedTool, cwd));
98
+ if (operation !== "bash" && !canonicalTool) {
99
+ return { path: toolPath, operation, unresolved: true };
100
+ }
101
+ for (const entry of guardedPaths) {
102
+ let matches: boolean;
103
+ if (operation === "bash") {
104
+ if (isAbsoluteOrHomeRelative(entry.path)) {
105
+ const resolvedEntry = resolveConfiguredPath(entry.path, cwd);
106
+ matches = toolPath.includes(entry.path) || toolPath.includes(resolvedEntry);
107
+ } else {
108
+ matches = toolPath.includes(entry.path.replace(/[\\/]+$/, ""));
109
+ }
110
+ } else {
111
+ matches = matchesProtectedPath(normalizedTool, entry, cwd) || matchesProtectedPath(canonicalTool!, entry, cwd);
112
+ if (!matches && isAbsoluteOrHomeRelative(entry.path)) {
113
+ const canonicalEntry = physicalPath(resolveConfiguredPath(entry.path, cwd));
114
+ if (!canonicalEntry) return { path: entry.path, operation, unresolved: true };
115
+ matches = matchesResolvedPath(canonicalTool!, canonicalEntry, cwd);
116
+ }
117
+ }
118
+
119
+ if (matches) return { path: entry.path, operation };
120
+ }
121
+
122
+ return undefined;
123
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.5",
4
- "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
3
+ "version": "0.2.7",
4
+ "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, tool and session safety, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/linellazatin/opl-pi-sht.git"