@openlines/opl-pi-sht 0.2.12 → 0.3.1
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 +101 -85
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package-lock.json +3 -3
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +151 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +159 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +146 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +22 -8
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +87 -28
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package-lock.json +3 -3
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/package.json +19 -12
|
@@ -7,9 +7,11 @@ A configurable session and tool safety extension. `opl-guardian` filters malform
|
|
|
7
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.
|
|
8
8
|
|
|
9
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
|
|
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 matches path-shaped words in the command, resolved against the session directory and through symlinks. Absolute and `~/` entries match resolved paths and descendants for file tools, and are matched in Bash by the same resolution plus the final path segment. An empty list disables path rules. Entries name exact paths: `.env` does not cover `.env.local` or `.env.production`, and no glob syntax is supported — list each path you want denied.
|
|
11
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
12
|
- `dropMalformedToolCalls` defaults to `true`. Set it to `false` to disable only malformed-call filtering and its incident log.
|
|
13
|
+
- `logging.incidentFile` chooses where incidents go. It defaults to `null`, which is `<agent dir>/guardian-incidents.jsonl`; a relative path also resolves under the agent dir, an absolute or `~/` path is used as given, and `false` stops writing. The session cwd is never consulted, so opening a repository cannot leave a log inside it.
|
|
14
|
+
- `logging.maxBytes` defaults to `262144`. Before an append that would pass it, the current log moves to `guardian-incidents.jsonl.1` and a fresh file starts: one generation of history, nothing older is kept.
|
|
13
15
|
|
|
14
16
|
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.
|
|
15
17
|
|
|
@@ -19,11 +21,15 @@ If migrating from the standalone extensions, manually move their settings into t
|
|
|
19
21
|
|
|
20
22
|
### Dangerous Bash commands
|
|
21
23
|
|
|
22
|
-
The default patterns cover recursive `rm` flags (including `-rf`, `-fr`, and separate `-f -r`), `sudo`, `chmod`/`chown` to mode `777`, `printenv`, and
|
|
24
|
+
The default patterns cover recursive `rm` flags (including `-rf`, `-fr`, and separate `-f -r`), `sudo`, `chmod`/`chown` to mode `777`, `printenv`, and `env` **as a command** — `env`, `env | grep PATH`, `; env`, `$(env)` prompt, while `grep -n 'env' README.md` or `cat config/env.ts` do not. Matching commands require confirmation. Without a confirmation UI they are blocked by default.
|
|
23
25
|
|
|
24
26
|
### Protected paths
|
|
25
27
|
|
|
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.
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
Bash rules match **words**, not substrings: the command is split on whitespace, quotes, and shell punctuation, and each word is compared to the entry by path segment and by resolution against the session directory, including symlinks. Blocked, measured: `cat .env`, `cat /project/.env`, `head -2 .env | tail -1`, `git show HEAD:.env`, `python -c 'open(".env")'`, `x=".env"; cat $x`, `export F=$(cat .env)`, and `cat alias` where `alias` is a symlink to the protected file. Allowed, measured: `grep -rn "process.env" src`, `cat .env.example`, `cat .env.local`, `ls env` — the older substring rule blocked all of those too, which made the rule impossible to live with.
|
|
31
|
+
|
|
32
|
+
Entries name exact paths and there is no glob syntax: `.env` does not cover `.env.local` or `.env.production`, so list each path you want denied. Two further limits are structural, so Bash protection stays **advisory** while `read`/`write`/`edit` is where the guarantee lives. A name that never appears whole in the text cannot be recognised — `cat .en""v` and `$(printf '\x2eenv')` pass — and a glob names no single file to check (`rm -f .env*`). Because words resolve against the session directory, `cd elsewhere && cat .env` is judged as if the `cd` had not happened.
|
|
27
33
|
|
|
28
34
|
### Destructive session actions
|
|
29
35
|
|
|
@@ -33,15 +39,21 @@ Starting a new session, resuming another session with a pending user message, an
|
|
|
33
39
|
|
|
34
40
|
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.
|
|
35
41
|
|
|
42
|
+
A Bash call whose `command` is not a string is blocked outright rather than coerced: there is no command to inspect, and matching a coerced array (`String(["rm","-rf"])` reads as `rm,-rf`) would confirm the wrong thing.
|
|
43
|
+
|
|
36
44
|
## Forensic JSONL
|
|
37
45
|
|
|
38
46
|
Each malformed-call incident appends one JSON object to:
|
|
39
47
|
|
|
40
48
|
```text
|
|
41
|
-
<
|
|
49
|
+
<agent dir>/guardian-incidents.jsonl
|
|
42
50
|
```
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
That is pi's own directory (`PI_CODING_AGENT_DIR`, default `~/.pi/agent`), not the project: an untracked `err/` directory inside whatever repository a session happens to open is noise, and it would hold fragments of provider payloads. Override the location with `logging.incidentFile`, or set it to `false` to keep no log at all.
|
|
53
|
+
|
|
54
|
+
Records carry the timestamp (falling back to the current time when a provider omitted or zeroed it), session/project/provider/model metadata, and one summary per dropped tool call: tool name, sorted argument *key* names, and the byte size of the arguments. Argument values are never recorded - a malformed call can carry file contents, commands or credentials, and those belong in the session transcript, not a log file. So the log holds no prompts, assistant text, thinking, or tool results, but argument key names are still names: a tool that labels an argument after its content would show up here.
|
|
55
|
+
|
|
56
|
+
Treat it as local diagnostic data. 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
57
|
|
|
46
58
|
## Testing
|
|
47
59
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
export type Op = "read" | "write" | "edit" | "bash";
|
|
6
7
|
|
|
@@ -9,6 +10,18 @@ export interface PathEntry {
|
|
|
9
10
|
deny: Op[];
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export interface GuardianLogging {
|
|
14
|
+
/**
|
|
15
|
+
* Where a malformed-call incident is recorded. Never resolved against the session cwd:
|
|
16
|
+
* forensics in the open repository would be untracked noise holding provider fragments.
|
|
17
|
+
* `null` uses `<agent dir>/guardian-incidents.jsonl`, a relative value resolves under the
|
|
18
|
+
* agent dir, `~` expands to the home directory, and `false` stops writing.
|
|
19
|
+
*/
|
|
20
|
+
incidentFile: string | false | null;
|
|
21
|
+
/** Rotate the log to `<file>.1` before an append that would pass this many bytes. */
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
export interface GuardianConfig {
|
|
13
26
|
permissionGate: {
|
|
14
27
|
patterns: RegExp[];
|
|
@@ -24,14 +37,20 @@ export interface GuardianConfig {
|
|
|
24
37
|
blockWithoutUI: boolean;
|
|
25
38
|
};
|
|
26
39
|
dropMalformedToolCalls: boolean;
|
|
40
|
+
logging: GuardianLogging;
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
export const DEFAULT_LOGGING: GuardianLogging = {
|
|
44
|
+
incidentFile: null,
|
|
45
|
+
maxBytes: 256 * 1024,
|
|
46
|
+
};
|
|
47
|
+
|
|
29
48
|
const DEFAULT_PATTERN_STRINGS = [
|
|
30
49
|
"\\brm\\s+(?:(?:-[a-z]+|--[a-z-]+)\\s+)*(?:-[a-z]*r[a-z]*|--recursive)(?=\\s|$)",
|
|
31
50
|
"\\bsudo\\b",
|
|
32
51
|
"\\b(chmod|chown)\\s+(?:-[a-z]+\\s+)*777(?:\\b|$)",
|
|
33
52
|
"\\bprintenv\\b",
|
|
34
|
-
"(
|
|
53
|
+
"(?:^|[;&|()\\n])\\s*env(?:[\\s)|]|$)",
|
|
35
54
|
];
|
|
36
55
|
|
|
37
56
|
const DEFAULT_PATHS: PathEntry[] = [
|
|
@@ -54,9 +73,13 @@ export const DEFAULT_CONFIG: GuardianConfig = {
|
|
|
54
73
|
blockWithoutUI: true,
|
|
55
74
|
},
|
|
56
75
|
dropMalformedToolCalls: true,
|
|
76
|
+
logging: { ...DEFAULT_LOGGING },
|
|
57
77
|
};
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
/** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
|
|
80
|
+
export function configPath(): string {
|
|
81
|
+
return join(getAgentDir(), "configs", "opl-guardian.json");
|
|
82
|
+
}
|
|
60
83
|
const OPS = new Set<Op>(["read", "write", "edit", "bash"]);
|
|
61
84
|
|
|
62
85
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -72,6 +95,7 @@ function cloneDefaults(): GuardianConfig {
|
|
|
72
95
|
protectedPaths: { paths: DEFAULT_PATHS.map((entry) => ({ ...entry, deny: [...entry.deny] })) },
|
|
73
96
|
confirmDestructive: { ...DEFAULT_CONFIG.confirmDestructive },
|
|
74
97
|
dropMalformedToolCalls: true,
|
|
98
|
+
logging: { ...DEFAULT_LOGGING },
|
|
75
99
|
};
|
|
76
100
|
}
|
|
77
101
|
|
|
@@ -173,18 +197,60 @@ export function parseGuardianConfig(value: unknown): { config: GuardianConfig; w
|
|
|
173
197
|
}
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
if (value.logging !== undefined) {
|
|
201
|
+
if (!isRecord(value.logging)) {
|
|
202
|
+
warnings.push("Invalid logging section; using defaults.");
|
|
203
|
+
} else {
|
|
204
|
+
const file = value.logging.incidentFile;
|
|
205
|
+
if (file === undefined || file === null) {
|
|
206
|
+
config.logging.incidentFile = DEFAULT_LOGGING.incidentFile;
|
|
207
|
+
} else if (file === false || typeof file === "string") {
|
|
208
|
+
config.logging.incidentFile = file;
|
|
209
|
+
} else {
|
|
210
|
+
warnings.push("logging.incidentFile must be a path or false; using the agent-directory default.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const maxBytes = value.logging.maxBytes;
|
|
214
|
+
if (maxBytes === undefined) {
|
|
215
|
+
config.logging.maxBytes = DEFAULT_LOGGING.maxBytes;
|
|
216
|
+
} else if (typeof maxBytes === "number" && Number.isFinite(maxBytes) && maxBytes >= 1024) {
|
|
217
|
+
config.logging.maxBytes = Math.floor(maxBytes);
|
|
218
|
+
} else {
|
|
219
|
+
warnings.push("logging.maxBytes must be a number of at least 1024; using the default.");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
176
224
|
return { config, warnings };
|
|
177
225
|
}
|
|
178
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Resolve where incident records go. Absolute paths are used as given, `~/` expands to the
|
|
229
|
+
* home directory, and anything else lands under pi's agent directory - the session cwd is
|
|
230
|
+
* never an input, so opening a repository cannot leave a log file inside it.
|
|
231
|
+
*/
|
|
232
|
+
export function incidentLogPath(logging: GuardianLogging): string | null {
|
|
233
|
+
if (logging.incidentFile === false) return null;
|
|
234
|
+
const value = logging.incidentFile;
|
|
235
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
236
|
+
return join(getAgentDir(), "guardian-incidents.jsonl");
|
|
237
|
+
}
|
|
238
|
+
const trimmed = value.trim();
|
|
239
|
+
if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
|
|
240
|
+
if (isAbsolute(trimmed)) return trimmed;
|
|
241
|
+
return join(getAgentDir(), trimmed);
|
|
242
|
+
}
|
|
243
|
+
|
|
179
244
|
export function loadGuardianConfig(): { config: GuardianConfig; warnings: string[] } {
|
|
245
|
+
const file = configPath();
|
|
180
246
|
let raw: string;
|
|
181
247
|
try {
|
|
182
|
-
raw = readFileSync(
|
|
248
|
+
raw = readFileSync(file, "utf8");
|
|
183
249
|
} catch (error) {
|
|
184
250
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseGuardianConfig(undefined);
|
|
185
251
|
return {
|
|
186
252
|
config: cloneDefaults(),
|
|
187
|
-
warnings: [`Could not read ${
|
|
253
|
+
warnings: [`Could not read ${file}; using defaults.`],
|
|
188
254
|
};
|
|
189
255
|
}
|
|
190
256
|
|
|
@@ -193,7 +259,7 @@ export function loadGuardianConfig(): { config: GuardianConfig; warnings: string
|
|
|
193
259
|
} catch {
|
|
194
260
|
return {
|
|
195
261
|
config: cloneDefaults(),
|
|
196
|
-
warnings: [`Invalid JSON in ${
|
|
262
|
+
warnings: [`Invalid JSON in ${file}; using defaults.`],
|
|
197
263
|
};
|
|
198
264
|
}
|
|
199
265
|
}
|
|
@@ -5,6 +5,13 @@ export interface IncidentContext {
|
|
|
5
5
|
cwd: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export interface RemovedToolCallSummary {
|
|
9
|
+
name: string;
|
|
10
|
+
argumentKeys: string[];
|
|
11
|
+
/** Size of the arguments that were dropped; the values themselves are never recorded. */
|
|
12
|
+
argumentsBytes: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export interface GuardianIncident {
|
|
9
16
|
timestamp: string;
|
|
10
17
|
kind: "malformed_tool_call";
|
|
@@ -14,7 +21,7 @@ export interface GuardianIncident {
|
|
|
14
21
|
model: string;
|
|
15
22
|
responseId?: string;
|
|
16
23
|
action: "dropped";
|
|
17
|
-
removedToolCalls:
|
|
24
|
+
removedToolCalls: RemovedToolCallSummary[];
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface GuardResult {
|
|
@@ -26,20 +33,28 @@ function hasValue(value: unknown): value is string {
|
|
|
26
33
|
return typeof value === "string" && value.trim().length > 0;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
function isToolCallBlock(block: unknown): block is ToolCall {
|
|
37
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "toolCall";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A toolCall block whose id or name is missing: pi cannot route it, and leaving it in the
|
|
41
|
+
* message fails the whole turn, so the guardian drops it. */
|
|
42
|
+
export function isMalformedToolCall(block: unknown): boolean {
|
|
43
|
+
return isToolCallBlock(block) && (!hasValue(block.id) || !hasValue(block.name));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The same test as a type predicate, so `filter` can type the removed blocks as ToolCall[]
|
|
47
|
+
// without narrowing ToolCall out of the kept content array.
|
|
48
|
+
function isMalformedToolCallBlock(block: unknown): block is ToolCall {
|
|
49
|
+
return isMalformedToolCall(block);
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
export function guardAssistantMessage(message: AssistantMessage): GuardResult | undefined {
|
|
38
|
-
const removedToolCalls = message.content.filter(
|
|
53
|
+
const removedToolCalls = message.content.filter(isMalformedToolCallBlock);
|
|
39
54
|
if (removedToolCalls.length === 0) return undefined;
|
|
40
55
|
|
|
41
56
|
const content = message.content.filter((block) => !isMalformedToolCall(block));
|
|
42
|
-
const hasValidToolCall = content.some(
|
|
57
|
+
const hasValidToolCall = content.some(isToolCallBlock);
|
|
43
58
|
|
|
44
59
|
return {
|
|
45
60
|
removedToolCalls,
|
|
@@ -69,6 +84,23 @@ export function buildIncidentRecord(
|
|
|
69
84
|
model: message.model,
|
|
70
85
|
...(message.responseId ? { responseId: message.responseId } : {}),
|
|
71
86
|
action: "dropped",
|
|
72
|
-
removedToolCalls,
|
|
87
|
+
removedToolCalls: removedToolCalls.map(summarizeRemovedToolCall),
|
|
73
88
|
};
|
|
74
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Incident records are kept for diagnostics, not as a copy of the transcript: argument
|
|
93
|
+
* *names* say which call went wrong, while the values can carry file contents, commands or
|
|
94
|
+
* credentials straight out of the session.
|
|
95
|
+
*/
|
|
96
|
+
export function summarizeRemovedToolCall(call: ToolCall): RemovedToolCallSummary {
|
|
97
|
+
const args = (call as { arguments?: unknown }).arguments;
|
|
98
|
+
const keys = args && typeof args === "object" ? Object.keys(args as Record<string, unknown>).sort() : [];
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
try {
|
|
101
|
+
bytes = Buffer.byteLength(JSON.stringify(args ?? {}));
|
|
102
|
+
} catch {
|
|
103
|
+
bytes = -1; // not serializable; the size is unknown but nothing is leaked either
|
|
104
|
+
}
|
|
105
|
+
return { name: hasValue(call.name) ? call.name : "(unnamed)", argumentKeys: keys, argumentsBytes: bytes };
|
|
106
|
+
}
|
|
@@ -8,10 +8,16 @@ import {
|
|
|
8
8
|
type SessionBeforeSwitchEvent,
|
|
9
9
|
type ToolCallEvent,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
|
|
12
|
-
import {
|
|
11
|
+
import { appendFile, chmod, mkdir, rename, stat } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_LOGGING,
|
|
16
|
+
incidentLogPath,
|
|
17
|
+
loadGuardianConfig,
|
|
18
|
+
type GuardianConfig,
|
|
19
|
+
type GuardianLogging,
|
|
20
|
+
} from "./config.js";
|
|
15
21
|
import {
|
|
16
22
|
buildIncidentRecord,
|
|
17
23
|
guardAssistantMessage,
|
|
@@ -19,7 +25,6 @@ import {
|
|
|
19
25
|
} from "./guardian.js";
|
|
20
26
|
import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
|
|
21
27
|
|
|
22
|
-
const LOG_PATH = join("err", "guardian.jsonl");
|
|
23
28
|
const RED = "\u001b[31m";
|
|
24
29
|
const RESET_FOREGROUND = "\u001b[39m";
|
|
25
30
|
|
|
@@ -27,30 +32,49 @@ function red(text: string): string {
|
|
|
27
32
|
return `${RED}${text}${RESET_FOREGROUND}`;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Append one forensic record. The path never depends on the session cwd, so opening an
|
|
37
|
+
* untrusted repository cannot leave an untracked log full of provider payloads inside it.
|
|
38
|
+
* Once an append would pass `logging.maxBytes` the current file moves to `<file>.1`: one
|
|
39
|
+
* generation of history is kept, anything older is dropped.
|
|
40
|
+
*/
|
|
41
|
+
export async function appendIncident(
|
|
42
|
+
incident: GuardianIncident,
|
|
43
|
+
logging: GuardianLogging = DEFAULT_LOGGING,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const path = incidentLogPath(logging);
|
|
46
|
+
if (path === null) return;
|
|
47
|
+
|
|
48
|
+
const line = `${JSON.stringify(incident)}\n`;
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
34
50
|
|
|
35
51
|
let existed = true;
|
|
52
|
+
let size = 0;
|
|
36
53
|
try {
|
|
37
|
-
await stat(path);
|
|
54
|
+
size = (await stat(path)).size;
|
|
38
55
|
} catch {
|
|
39
56
|
existed = false;
|
|
40
57
|
}
|
|
58
|
+
if (existed && size + Buffer.byteLength(line) > logging.maxBytes) {
|
|
59
|
+
await rename(path, `${path}.1`).catch(() => {});
|
|
60
|
+
existed = false;
|
|
61
|
+
}
|
|
41
62
|
|
|
42
|
-
await appendFile(path,
|
|
63
|
+
await appendFile(path, line, "utf8");
|
|
43
64
|
if (!existed && process.platform !== "win32") {
|
|
44
65
|
await chmod(path, 0o600).catch(() => {});
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
function diagnostic(removed: number, logError?: unknown): string {
|
|
69
|
+
function diagnostic(removed: number, incidentFile: string | null, logError?: unknown): string {
|
|
70
|
+
if (incidentFile === null) {
|
|
71
|
+
return `[opl-guardian] Dropped ${removed} malformed provider tool call(s); incident logging is off (logging.incidentFile: false).`;
|
|
72
|
+
}
|
|
49
73
|
if (logError) {
|
|
50
74
|
const detail = logError instanceof Error ? logError.message : String(logError);
|
|
51
|
-
return `[opl-guardian] Dropped malformed provider tool call(s), but could not write
|
|
75
|
+
return `[opl-guardian] Dropped malformed provider tool call(s), but could not write ${incidentFile}: ${detail}.`;
|
|
52
76
|
}
|
|
53
|
-
return `[opl-guardian] Dropped ${removed} malformed provider tool call(s). Details:
|
|
77
|
+
return `[opl-guardian] Dropped ${removed} malformed provider tool call(s). Details: ${incidentFile}.`;
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
|
|
@@ -67,20 +91,20 @@ function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly:
|
|
|
67
91
|
|
|
68
92
|
export async function guardMessageEnd(
|
|
69
93
|
message: AssistantMessage,
|
|
70
|
-
context: { cwd: string; sessionId: string },
|
|
94
|
+
context: { cwd: string; sessionId: string; logging?: GuardianLogging },
|
|
71
95
|
): Promise<{ message: AssistantMessage; diagnostic: string } | undefined> {
|
|
72
96
|
const guarded = guardAssistantMessage(message);
|
|
73
97
|
if (!guarded) return undefined;
|
|
74
98
|
|
|
75
99
|
let logError: unknown;
|
|
76
100
|
try {
|
|
77
|
-
await appendIncident(
|
|
101
|
+
await appendIncident(buildIncidentRecord(message, context, guarded.removedToolCalls), context.logging ?? DEFAULT_LOGGING);
|
|
78
102
|
} catch (error) {
|
|
79
103
|
logError = error;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
const invalidOnly = !guarded.message.content.some((block) => block.type === "toolCall");
|
|
83
|
-
const notice = diagnostic(guarded.removedToolCalls.length, logError);
|
|
107
|
+
const notice = diagnostic(guarded.removedToolCalls.length, incidentLogPath(context.logging ?? DEFAULT_LOGGING), logError);
|
|
84
108
|
return {
|
|
85
109
|
message: appendDiagnostic(guarded.message, notice, invalidOnly),
|
|
86
110
|
diagnostic: notice,
|
|
@@ -101,7 +125,16 @@ export function createToolCallHandler(config: GuardianConfig) {
|
|
|
101
125
|
if (isToolCallEventType("read", event)) toolPath = event.input.path;
|
|
102
126
|
else if (isToolCallEventType("write", event)) toolPath = event.input.path;
|
|
103
127
|
else if (isToolCallEventType("edit", event)) toolPath = event.input.path;
|
|
104
|
-
else if (isToolCallEventType("bash", event)
|
|
128
|
+
else if (isToolCallEventType("bash", event) && typeof event.input.command === "string") {
|
|
129
|
+
toolPath = event.input.command;
|
|
130
|
+
} else if (isToolCallEventType("bash", event)) {
|
|
131
|
+
// The host types `command` as a string. A provider that sends an array or object here
|
|
132
|
+
// hands over something uninspectable, and coercing it would let ["rm","-rf"] read as
|
|
133
|
+
// "rm,-rf" to the matchers. Fail closed, the way a blank id or name does.
|
|
134
|
+
const reason = "[opl-guardian] Blocked a bash tool call whose command was not a string.";
|
|
135
|
+
if (ctx.hasUI) ctx.ui.notify(red(reason), "warning");
|
|
136
|
+
return { block: true, reason };
|
|
137
|
+
}
|
|
105
138
|
|
|
106
139
|
if (toolPath !== undefined) {
|
|
107
140
|
const blocked = getProtectedPathBlock(event.toolName, toolPath, config.protectedPaths.paths, ctx.cwd);
|
|
@@ -115,6 +148,7 @@ export function createToolCallHandler(config: GuardianConfig) {
|
|
|
115
148
|
}
|
|
116
149
|
|
|
117
150
|
if (!isToolCallEventType("bash", event) ||
|
|
151
|
+
typeof event.input.command !== "string" ||
|
|
118
152
|
!config.permissionGate.patterns.some((pattern) => pattern.test(event.input.command))) return undefined;
|
|
119
153
|
|
|
120
154
|
if (!ctx.hasUI) {
|
|
@@ -155,6 +189,7 @@ export function createMessageEndHandler(config: GuardianConfig) {
|
|
|
155
189
|
const guarded = await guardMessageEnd(event.message, {
|
|
156
190
|
cwd: ctx.cwd,
|
|
157
191
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
192
|
+
logging: config.logging,
|
|
158
193
|
});
|
|
159
194
|
if (!guarded) return undefined;
|
|
160
195
|
ctx.ui?.notify(red(guarded.diagnostic), "warning");
|
|
@@ -60,6 +60,43 @@ export function matchesProtectedPath(toolPath: string, entry: PathEntry, cwd: st
|
|
|
60
60
|
return toolPath.split(/[\\/]/).some((part) => part === segment);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Split a shell command into the words a path could appear as. Quotes, command
|
|
65
|
+
* substitutions, redirections and separators are treated as boundaries, so
|
|
66
|
+
* `python -c 'open(".env")'` yields `.env` while `"process.env"` stays one word.
|
|
67
|
+
*/
|
|
68
|
+
function commandTokens(command: string): string[] {
|
|
69
|
+
const tokens: string[] = [];
|
|
70
|
+
for (const raw of command.split(/[\s'"`|&;()<>:,=]+/)) {
|
|
71
|
+
const token = raw.replace(/^[-+]+/, "").replace(/[,.;:]+$/, "");
|
|
72
|
+
if (token.length >= 2) tokens.push(token);
|
|
73
|
+
}
|
|
74
|
+
return tokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Does this command mention the protected path?
|
|
79
|
+
*
|
|
80
|
+
* Matching is per token and path-aware, so argument text no longer trips the
|
|
81
|
+
* rule (`grep "process.env" src` is not a read of `.env`), and tokens are
|
|
82
|
+
* resolved through symlinks the same way the read/write/edit rules are. It is
|
|
83
|
+
* still advisory for bash: a command that builds a path at run time
|
|
84
|
+
* (`x=.env; cat $x`, `cat .en""v`) cannot be recognised from text, and a
|
|
85
|
+
* `cd`-then-relative-path sequence resolves against the session cwd.
|
|
86
|
+
*/
|
|
87
|
+
export function bashCommandMentionsPath(command: string, entry: PathEntry, cwd: string): boolean {
|
|
88
|
+
for (const token of commandTokens(command)) {
|
|
89
|
+
if (matchesProtectedPath(token, entry, cwd)) return true;
|
|
90
|
+
const resolved = resolveConfiguredPath(token, cwd);
|
|
91
|
+
if (matchesResolvedPath(resolved, entry.path, cwd)) return true;
|
|
92
|
+
const physical = physicalPath(resolved);
|
|
93
|
+
if (physical && (matchesProtectedPath(physical, entry, cwd) || matchesResolvedPath(physical, entry.path, cwd))) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
export function hasPendingUserWork(entries: SessionEntry[]): boolean {
|
|
64
101
|
let pending = false;
|
|
65
102
|
for (const entry of entries) {
|
|
@@ -83,7 +120,10 @@ export function getProtectedPathBlock(
|
|
|
83
120
|
const operation = toolName;
|
|
84
121
|
const guardedPaths = paths.filter((entry) => entry.deny.includes(operation));
|
|
85
122
|
if (guardedPaths.length === 0) return undefined;
|
|
86
|
-
let normalizedTool =
|
|
123
|
+
let normalizedTool = toolPath;
|
|
124
|
+
if (operation !== "bash") {
|
|
125
|
+
normalizedTool = toolPath.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
126
|
+
}
|
|
87
127
|
if (operation !== "bash") {
|
|
88
128
|
if (normalizedTool.startsWith("@")) normalizedTool = normalizedTool.slice(1);
|
|
89
129
|
if (normalizedTool.startsWith("file://")) {
|
|
@@ -99,21 +139,17 @@ export function getProtectedPathBlock(
|
|
|
99
139
|
return { path: toolPath, operation, unresolved: true };
|
|
100
140
|
}
|
|
101
141
|
for (const entry of guardedPaths) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
142
|
+
// Bash gets token/path matching; its `toolPath` is the whole command, so a
|
|
143
|
+
// substring test there would police argument text instead of file access.
|
|
144
|
+
const matches = operation === "bash"
|
|
145
|
+
? bashCommandMentionsPath(toolPath, entry, cwd)
|
|
146
|
+
: matchesProtectedPath(normalizedTool, entry, cwd) || matchesProtectedPath(canonicalTool!, entry, cwd);
|
|
147
|
+
if (!matches && operation !== "bash" && isAbsoluteOrHomeRelative(entry.path)) {
|
|
148
|
+
const canonicalEntry = physicalPath(resolveConfiguredPath(entry.path, cwd));
|
|
149
|
+
if (!canonicalEntry) return { path: entry.path, operation, unresolved: true };
|
|
150
|
+
const matched = matchesResolvedPath(canonicalTool!, canonicalEntry, cwd);
|
|
151
|
+
if (!matched) continue;
|
|
152
|
+
return { path: entry.path, operation };
|
|
117
153
|
}
|
|
118
154
|
|
|
119
155
|
if (matches) return { path: entry.path, operation };
|
|
@@ -9,13 +9,19 @@ Generates or refreshes a repository-specific `AGENTS.md` guide through `/init`.
|
|
|
9
9
|
|
|
10
10
|
## Extension features
|
|
11
11
|
|
|
12
|
-
- Produces a deterministic repository guide with package commands and a file-type inventory, then refines it with **one** `ctx.modelRegistry.streamSimple()` call on the current model (pi >= 0.86.0). The refinement never enters the conversation: no synthetic user message, no turn in the transcript, no `write` tool needed, and read-only/chat/plan modes refine identically.
|
|
12
|
+
- Produces a deterministic repository guide with package commands and a file-type inventory, then refines it with **one** `ctx.modelRegistry.streamSimple()` call on the current model at `reasoning: "minimal"` (pi >= 0.86.0). The refinement never enters the conversation: no synthetic user message, no turn in the transcript, no `write` tool needed, and read-only/chat/plan modes refine identically. `"minimal"` is the lowest thinking level pi-ai accepts; an earlier `{ reasoning: false }` read as *unset*, so the call silently paid the provider's default thinking level.
|
|
13
13
|
- The model only sees a bounded evidence packet (the baseline guide, every `package.json`'s full scripts block, and the first ~2 KB of each root/workspace `README`/`CLAUDE.md`, hard 24 KB budget with an explicit truncation line). The crawl is the only explorer.
|
|
14
14
|
- The extension owns the fingerprint marker: model output is stripped of fences and any `opl-init:fp` comment, and the exact marker is appended as the final line. A model can never corrupt or fake a "current" guide.
|
|
15
15
|
- Refine failure (no model, no auth, provider error, empty output) writes the deterministic baseline and notifies `refine failed` - `/init` only fails to write if the write itself fails.
|
|
16
16
|
- Mid-session `/init` waits for the agent to settle (`ctx.waitForIdle()`), recomputes the fingerprint, and only then crawls. It never steers or interrupts the task in flight.
|
|
17
|
-
- Crawls to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries
|
|
18
|
-
- Fingerprints dirty tracked + untracked
|
|
17
|
+
- Crawls to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries and the rendered tree at 300 lines with explicit omission markers, and re-walks declared `pnpm-workspace.yaml`/Cargo members with their own depth-3 budget. Workspace globs escape every metacharacter except `*` and `?`, because real member patterns carry them (`packages/app(one`, `libs/c++/*`); an unescaped one threw `SyntaxError` and aborted the entire crawl. A glob that compiles to nothing is skipped, reported in `Crawl.skippedGlobs`, and named in a tree line so the skip is visible in the guide.
|
|
18
|
+
- Fingerprints dirty tracked + untracked files (plus HEAD and a generator schema version), excluding the root `AGENTS.md` itself, so writing the guide never makes it immediately stale and re-editing an already-modified file is never read as "current". Content hashing is **budgeted** (`FP_LIMITS`: 2 MB per file, 32 MB total, 5,000 paths): past the per-file or total ceiling a file contributes `name|size|mtime` instead of its bytes, and past the path ceiling a path contributes its name plus `OVER_CAP`. Budget state is folded into the digest, so the fingerprint cannot oscillate. Without the ceilings a single 200 MB build artifact cost 143 ms and +200 MB of RSS on every `/init`.
|
|
19
|
+
|
|
20
|
+
## User-owned operational notes
|
|
21
|
+
|
|
22
|
+
`/init` preserves an existing `## Operational notes` section verbatim, including nested `###` subsections, whitespace and line endings. The section ends at the next level-1 or level-2 Markdown heading outside a fenced code block, or at the end of the document. The final `opl-init:fp` marker belongs to the extension, not the section. The preserved section is placed after the regenerated guide and before that marker, whether model refinement succeeds or falls back to the deterministic crawl. Other hand-edited sections are replaced; if the protected heading is absent, generation is unchanged.
|
|
23
|
+
|
|
24
|
+
Use one section with that exact, case-sensitive title. Duplicate sections cancel the update, as does a detected file change during refinement; resolve duplicates or rerun `/init` after editing. This protects against `/init` updates only, not ordinary agent edits or other programs.
|
|
19
25
|
|
|
20
26
|
## Requirements
|
|
21
27
|
|
|
@@ -23,4 +29,6 @@ Refinement needs pi 0.86.0 or newer (`ctx.modelRegistry.streamSimple`, `ctx.relo
|
|
|
23
29
|
|
|
24
30
|
## Architecture
|
|
25
31
|
|
|
26
|
-
`
|
|
32
|
+
Every `git` call goes through one guard. A repository is attacker-chosen data and can declare programs of its own (`[diff "x"] textconv`, `core.fsmonitor`, `core.hooksPath`, or a system/user config naming a helper) that git runs while answering what looks like a read-only query. The guard passes `--no-pager`, `-c core.fsmonitor=false`, `-c core.hooksPath=`, `-c protocol.ext.allow=never`, `-c credential.helper=`, `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`, `GIT_TERMINAL_PROMPT=0` and `GIT_OPTIONAL_LOCKS=0`, plus `--no-ext-diff --no-textconv` on diff-family subcommands. Probes still ask for name-only or porcelain output, because that is all a fingerprint needs. The block is duplicated in `opl-footer/git-status.ts` - installs are per-directory, so the two cannot share a module - and `tests/net-guard-parity.test.mjs` fails if the copies drift.
|
|
33
|
+
|
|
34
|
+
`index.ts` owns crawling, fingerprinting, the evidence packet, the model call, output validation, and writing. Git repositories fingerprint `schema version + HEAD + a budgeted digest of dirty tracked and untracked files` (root `AGENTS.md` excluded, content replaced by `size|mtime` past the byte ceilings); non-git repositories use sorted path/size/mtime tuples. `gitOutput` caps captured output at 4 MB because every probe asks for path lists or a single value. `/init` writes the file itself and reloads the session context; the main agent is never enlisted.
|