@openlines/opl-pi-sht 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-guardian.json.sample +28 -0
- package/configs/opl-modes.json.sample +3 -3
- package/configs/opl-webaccess.json.sample +5 -1
- package/extensions/opl-browser/README.md +20 -6
- package/extensions/opl-browser/browser.ts +76 -19
- package/extensions/opl-browser/config.ts +5 -0
- package/extensions/opl-browser/index.ts +13 -5
- package/extensions/opl-browser/validate.ts +123 -5
- package/extensions/opl-guardian/README.md +25 -27
- package/extensions/opl-guardian/config.ts +199 -0
- package/extensions/opl-guardian/guardian.ts +5 -1
- package/extensions/opl-guardian/index.ts +129 -7
- package/extensions/opl-guardian/policies.ts +123 -0
- package/extensions/opl-modes/README.md +2 -2
- package/extensions/opl-questionnaire/README.md +1 -1
- package/extensions/opl-questionnaire/index.ts +36 -21
- package/extensions/opl-webaccess/README.md +8 -5
- package/extensions/opl-webaccess/config.ts +56 -5
- package/extensions/opl-webaccess/extract.ts +39 -14
- package/extensions/opl-webaccess/index.ts +27 -7
- package/extensions/opl-webaccess/providers/gemini.ts +6 -1
- package/extensions/opl-webaccess/providers/tavily.ts +6 -1
- package/extensions/opl-webaccess/storage.ts +8 -2
- package/extensions/opl-webaccess/utils.ts +117 -2
- package/package.json +3 -3
|
@@ -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. The prompt defaults to No (fail closed), so an accidental or unattended confirmation denies rather than approves. An empty list disables pattern matching. `permissionGate.blockWithoutUI` defaults to `true` and blocks a match when confirmation UI is unavailable. Configured patterns replace the defaults; update existing config files manually to adopt changes to default patterns.
|
|
10
|
+
- `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 (for example `cat "$HOME/.pi/agent/auth.json"` misses the auth file); it also over-blocks commands that merely *mention* a protected path or name (for example `grep .env notes.md`). Do not rely on guardian alone as a security boundary against untrusted shell commands.
|
|
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. As defense in depth, the `tool_call` boundary also blocks any blank-id/name call that slips through, so a malformed call can never execute even if the `message_end` filter is bypassed.
|
|
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 (falling back to the current time when a provider omitted or zeroed it), session/project/provider/model metadata, and removed tool-call blocks. The log does not contain prompts, assistant text, thinking, or tool results, but tool-call arguments may include paths, commands, or user text. Treat it as local diagnostic data and ignore `err/` in version control where appropriate. On POSIX, a newly created log is restricted to mode `0600` on a best-effort basis. If logging fails, malformed calls are still removed and the diagnostic reports the write failure.
|
|
45
|
+
|
|
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
|
+
}
|
|
@@ -56,8 +56,12 @@ export function buildIncidentRecord(
|
|
|
56
56
|
context: IncidentContext,
|
|
57
57
|
removedToolCalls: ToolCall[],
|
|
58
58
|
): GuardianIncident {
|
|
59
|
+
// A missing/zero/NaN timestamp yields an invalid Date whose toISOString() throws,
|
|
60
|
+
// which would silently lose the forensic record. Fall back to the current time.
|
|
61
|
+
const timestamp =
|
|
62
|
+
Number.isFinite(message.timestamp) && message.timestamp > 0 ? message.timestamp : Date.now();
|
|
59
63
|
return {
|
|
60
|
-
timestamp: new Date(
|
|
64
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
61
65
|
kind: "malformed_tool_call",
|
|
62
66
|
sessionId: context.sessionId,
|
|
63
67
|
cwd: context.cwd,
|
|
@@ -1,13 +1,23 @@
|
|
|
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";
|
|
3
11
|
import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
|
|
4
12
|
import { join } from "node:path";
|
|
5
13
|
|
|
14
|
+
import { loadGuardianConfig, type GuardianConfig } from "./config.js";
|
|
6
15
|
import {
|
|
7
16
|
buildIncidentRecord,
|
|
8
17
|
guardAssistantMessage,
|
|
9
18
|
type GuardianIncident,
|
|
10
19
|
} from "./guardian.js";
|
|
20
|
+
import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
|
|
11
21
|
|
|
12
22
|
const LOG_PATH = join("err", "guardian.jsonl");
|
|
13
23
|
|
|
@@ -38,9 +48,14 @@ function diagnostic(removed: number, logError?: unknown): string {
|
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
|
|
51
|
+
// For a mixed response (valid tool calls remain), prepend the notice rather than
|
|
52
|
+
// appending a trailing text block after toolCall blocks: providers such as Anthropic
|
|
53
|
+
// require each tool_use to be followed by a tool_result, so a trailing text block does
|
|
54
|
+
// not round-trip through the wire format. Text-before-tool-calls is the standard
|
|
55
|
+
// assistant shape. convertToLlm passes assistant messages through as-is.
|
|
41
56
|
const content = invalidOnly
|
|
42
57
|
? [{ type: "text" as const, text: `${text} No tool was executed; send another prompt to continue.` }]
|
|
43
|
-
: [
|
|
58
|
+
: [{ type: "text" as const, text }, ...message.content];
|
|
44
59
|
return { ...message, content, stopReason: invalidOnly ? "stop" : "toolUse" };
|
|
45
60
|
}
|
|
46
61
|
|
|
@@ -66,16 +81,123 @@ export async function guardMessageEnd(
|
|
|
66
81
|
};
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
export function createToolCallHandler(config: GuardianConfig) {
|
|
85
|
+
return async (event: ToolCallEvent, ctx: ExtensionContext) => {
|
|
86
|
+
// Defense in depth: createMessageEndHandler already drops malformed tool calls
|
|
87
|
+
// (blank id or name) before they reach execution, but block any that still get
|
|
88
|
+
// here anyway (e.g. a replay path or a provider that streamed them directly).
|
|
89
|
+
// Optional chaining tolerates a *missing* id/name, not just a blank string.
|
|
90
|
+
if (!event.toolCallId?.trim() || !event.toolName?.trim()) {
|
|
91
|
+
return { block: true, reason: "[opl-guardian] Blocked a malformed tool call with a blank id or name." };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let toolPath: string | undefined;
|
|
95
|
+
if (isToolCallEventType("read", event)) toolPath = event.input.path;
|
|
96
|
+
else if (isToolCallEventType("write", event)) toolPath = event.input.path;
|
|
97
|
+
else if (isToolCallEventType("edit", event)) toolPath = event.input.path;
|
|
98
|
+
else if (isToolCallEventType("bash", event)) toolPath = event.input.command;
|
|
99
|
+
|
|
100
|
+
if (toolPath !== undefined) {
|
|
101
|
+
const blocked = getProtectedPathBlock(event.toolName, toolPath, config.protectedPaths.paths, ctx.cwd);
|
|
102
|
+
if (blocked) {
|
|
103
|
+
const reason = blocked.unresolved
|
|
104
|
+
? `[opl-guardian] Cannot safely resolve path "${blocked.path}"; ${blocked.operation} denied by protected-path policy.`
|
|
105
|
+
: `[opl-guardian] Path "${blocked.path}" is protected (${blocked.operation} denied).`;
|
|
106
|
+
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
107
|
+
return { block: true, reason };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!isToolCallEventType("bash", event) ||
|
|
112
|
+
!config.permissionGate.patterns.some((pattern) => pattern.test(event.input.command))) return undefined;
|
|
113
|
+
|
|
114
|
+
if (!ctx.hasUI) {
|
|
115
|
+
if (!config.permissionGate.blockWithoutUI) return undefined;
|
|
116
|
+
return { block: true, reason: "[opl-guardian] Command blocked because it matches a dangerous pattern and no confirmation UI is available." };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const command = event.input.command;
|
|
120
|
+
const preview = command.replace(/\s+/g, " ").trim();
|
|
121
|
+
// Fail closed: order "No" first so an accidental confirmation (or a replayed
|
|
122
|
+
// input event hitting a freshly-focused dialog) denies instead of approves.
|
|
123
|
+
// Only an explicit "Yes" selection approves the command.
|
|
124
|
+
const options = ["No", "Yes"];
|
|
125
|
+
if (ctx.mode === "rpc") {
|
|
126
|
+
try {
|
|
127
|
+
const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, options);
|
|
128
|
+
if (choice === "Yes") return undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
// A failed RPC dialog is not approval.
|
|
131
|
+
}
|
|
132
|
+
return { block: true, reason: "[opl-guardian] Command blocked; confirmation was declined or unavailable." };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, options);
|
|
136
|
+
if (choice === "Yes") return undefined;
|
|
137
|
+
const reason = "[opl-guardian] Command blocked by user because it matches a dangerous pattern.";
|
|
138
|
+
ctx.ui.notify(reason, "warning");
|
|
139
|
+
return { block: true, reason };
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createMessageEndHandler(config: GuardianConfig) {
|
|
144
|
+
return async (
|
|
145
|
+
event: MessageEndEvent,
|
|
146
|
+
ctx: Pick<ExtensionContext, "cwd" | "sessionManager" | "ui">,
|
|
147
|
+
) => {
|
|
148
|
+
if (!config.dropMalformedToolCalls || event.message.role !== "assistant") return undefined;
|
|
72
149
|
const guarded = await guardMessageEnd(event.message, {
|
|
73
150
|
cwd: ctx.cwd,
|
|
74
151
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
75
152
|
});
|
|
76
|
-
if (!guarded) return;
|
|
77
|
-
|
|
153
|
+
if (!guarded) return undefined;
|
|
78
154
|
ctx.ui?.notify(guarded.diagnostic, "warning");
|
|
79
155
|
return { message: guarded.message };
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createSessionBeforeSwitchHandler(config: GuardianConfig) {
|
|
160
|
+
return async (event: SessionBeforeSwitchEvent, ctx: ExtensionContext) => {
|
|
161
|
+
let title: string;
|
|
162
|
+
let message: string;
|
|
163
|
+
if (event.reason === "new") {
|
|
164
|
+
if (!config.confirmDestructive.clearSession) return undefined;
|
|
165
|
+
title = "Clear session?";
|
|
166
|
+
message = "This will delete all messages in the current session.";
|
|
167
|
+
} else {
|
|
168
|
+
if (!config.confirmDestructive.switchWithUnsavedWork ||
|
|
169
|
+
!hasPendingUserWork(ctx.sessionManager.getBranch())) return undefined;
|
|
170
|
+
title = "Switch session?";
|
|
171
|
+
message = "You have unanswered work in the current session. Switch anyway?";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
|
|
175
|
+
return await ctx.ui.confirm(title, message) ? undefined : { cancel: true };
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createSessionBeforeForkHandler(config: GuardianConfig) {
|
|
180
|
+
return async (event: SessionBeforeForkEvent, ctx: ExtensionContext) => {
|
|
181
|
+
if (!config.confirmDestructive.forkSession) return undefined;
|
|
182
|
+
if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
|
|
183
|
+
const confirmed = await ctx.ui.confirm(
|
|
184
|
+
`Fork from entry ${event.entryId.slice(0, 8)}?`,
|
|
185
|
+
"Create a new session branch from this point?",
|
|
186
|
+
);
|
|
187
|
+
return confirmed ? undefined : { cancel: true };
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export default function (pi: ExtensionAPI) {
|
|
192
|
+
const { config, warnings } = loadGuardianConfig();
|
|
193
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
194
|
+
if (warnings.length && ctx.hasUI) {
|
|
195
|
+
ctx.ui.notify(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`, "warning");
|
|
196
|
+
}
|
|
80
197
|
});
|
|
198
|
+
|
|
199
|
+
pi.on("tool_call", createToolCallHandler(config));
|
|
200
|
+
pi.on("session_before_switch", createSessionBeforeSwitchHandler(config));
|
|
201
|
+
pi.on("session_before_fork", createSessionBeforeForkHandler(config));
|
|
202
|
+
pi.on("message_end", createMessageEndHandler(config));
|
|
81
203
|
}
|
|
@@ -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
|
+
}
|
|
@@ -9,7 +9,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
|
|
|
9
9
|
- `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
|
|
10
10
|
- `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
|
|
11
11
|
- `--chat` and `--plan` start Pi in the corresponding read-only mode.
|
|
12
|
-
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `
|
|
12
|
+
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `ctrl+alt+m` does not collide with any built-in Pi keybinding; set `shortcuts.cycleMode` to rebind.
|
|
13
13
|
|
|
14
14
|
Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A
|
|
|
17
17
|
|
|
18
18
|
### Mode behavior
|
|
19
19
|
|
|
20
|
-
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
|
|
20
|
+
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy. The top-level `bashPatterns.safePatterns`/`destructivePatterns` behave differently: an empty top-level array falls back to the built-in list (it cannot opt out), so opt a mode out with a per-mode `safePatterns: []` or `destructivePatterns: []` instead.
|
|
21
21
|
|
|
22
22
|
The shared destructive base blocks file mutation primitives (`rm`, `rmdir`, `mv`, `cp`, `mkdir`, `touch`, `chmod`, `chown`, `tee`, `dd`, `shred`, `truncate`), `find -delete`/`-exec`/`-execdir`/`--exec-batch` and find's file-writing predicates (`-fprint`, `-fprint0`, `-fprintf`, `-fls`), any `--output` file (`sort --output`, `git log/diff/show --output`), `sort -o`/`tree -o` in any argument position, `fd -x`/`-X` (and clusters like `-Hx`), `rg --pre`, `jq`'s environment access (`jq -n env`, `$ENV`), `npm`/`yarn`/`pip` install and update forms including `npm audit fix`, state-changing `git` subcommands (including `branch -d`/`-D`/`-m`/`-M`/`-c`/`-C`/`--delete`/`--unset-upstream` and the writing `git remote` subcommands), `sudo`/`su`, `kill`/`pkill`, shell spawns, editors, `uniq`'s output operand (`uniq -c in.txt out.txt`; flags are not operands, so `uniq -c sorted.txt` stays a read), and every redirect that names a file - while descriptor duplication (`2>&1`, `>&2`, `>&-`) and a redirect whose target is exactly `/dev/null` are not writes and stay allowed (`> /dev/null/../tmp/x` is a write, so a path that merely starts with that prefix is blocked). `env` and `printenv` are not safe-listed, since they could dump provider API keys into the model context. Program-name entries are anchored to command position and matched per segment, so a read-only command that merely *mentions* a dangerous word in an argument (`du -sh`, `find . -name '*.sh'`, `ls cp/`, `cat mv.sh`, `git log --grep=rm`, `grep -rn 'touch' src`) stays allowed, and descriptor duplication (`2>&1`, `>&2`, `>&-`) is not mistaken for a file write. Every destructive pattern is tested against the command and each of its segments, with and without quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. That blocklist split deliberately ignores quotes, so an operator inside a quoted string (`echo "a > b"`) is over-blocked rather than trusted. This is still a heuristic backstop, not a process-isolation boundary: `npx` and any command whose *name* comes from an expansion are outside what a pattern list can catch.
|
|
23
23
|
|
|
@@ -12,7 +12,7 @@ Adds a model-callable interactive questionnaire for discrete choices, preference
|
|
|
12
12
|
|
|
13
13
|
- Renders one question as a compact option list and multiple questions as tabs with a final Submit tab.
|
|
14
14
|
- Questions require an `id`, prompt, options, and optional label or free-text `allowOther` response.
|
|
15
|
-
- Rejects duplicate IDs and questions that cannot be answered; returns `(no response)` for an empty custom answer.
|
|
15
|
+
- Rejects duplicate IDs, blank IDs, and questions that cannot be answered; returns `(no response)` for an empty custom answer.
|
|
16
16
|
- Returns structured answers containing question ID, selected value and label, custom-text flag, and option index where applicable.
|
|
17
17
|
- Blocks submission until every multi-question prompt has an answer.
|
|
18
18
|
- Returns a clear error in headless sessions so the model can use plain-text questions instead.
|