@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 +7 -6
- package/configs/opl-guardian.json.sample +28 -0
- package/extensions/opl-footer/README.md +1 -1
- package/extensions/opl-footer/index.ts +10 -4
- package/extensions/opl-footer/segments/context.ts +13 -1
- package/extensions/opl-footer/types.ts +1 -0
- package/extensions/opl-guardian/README.md +25 -27
- package/extensions/opl-guardian/config.ts +199 -0
- package/extensions/opl-guardian/index.ts +158 -6
- package/extensions/opl-guardian/policies.ts +123 -0
- package/package.json +2 -2
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.
|
|
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) |
|
|
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).  | 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
|

|
|
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`**
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
282
|
-
//
|
|
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
|
|
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 =
|
|
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 };
|
|
@@ -1,51 +1,49 @@
|
|
|
1
1
|
# opl-guardian
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
## What it guards
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
### Protected paths
|
|
28
25
|
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
32
|
+
### Malformed provider tool calls
|
|
36
33
|
|
|
37
|
-
|
|
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
|
-
|
|
36
|
+
## Forensic JSONL
|
|
40
37
|
|
|
41
|
-
|
|
38
|
+
Each malformed-call incident appends one JSON object to:
|
|
42
39
|
|
|
43
40
|
```text
|
|
44
|
-
guardian.
|
|
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
|
|
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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
4
|
-
"description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context,
|
|
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"
|