@openlines/opl-pi-sht 0.2.11 → 0.3.0

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.
Files changed (89) hide show
  1. package/README.md +92 -82
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +32 -7
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +287 -109
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/images/ss-init.png +0 -0
  82. package/images/ss-input-footer.png +0 -0
  83. package/images/ss-input-pet.png +0 -0
  84. package/images/ss-mode-custom.png +0 -0
  85. package/images/ss-questionnaire0.png +0 -0
  86. package/images/ss-questionnaire1.png +0 -0
  87. package/images/ss-simplebench.png +0 -0
  88. package/images/ss-todo.png +0 -0
  89. package/package.json +20 -12
@@ -13,6 +13,7 @@ import { planModeSegment } from "./plan-mode.js";
13
13
  import { chatModeSegment } from "./chat-mode.js";
14
14
  import { modeSwitcherSegment } from "./mode-switcher.js";
15
15
  import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
16
+ import { compactionsSegment } from "./compactions.js";
16
17
  import { statusSegment } from "./status.js";
17
18
  import { codexUsageSegment } from "./codex-usage.js";
18
19
  import { openRouterUsageSegment } from "./openrouter-usage.js";
@@ -37,6 +38,7 @@ const SEGMENTS = {
37
38
  chat_mode: chatModeSegment,
38
39
  mode_switcher: modeSwitcherSegment,
39
40
  session_stats: sessionStatsSegment,
41
+ compactions: compactionsSegment,
40
42
  perf_stats: perfStatsSegment,
41
43
  status: statusSegment,
42
44
  codex_usage: codexUsageSegment,
@@ -6,8 +6,21 @@ interface AgentModeState {
6
6
  appearance?: { modeColor?: string };
7
7
  }
8
8
 
9
+ /**
10
+ * Written by `opl-modes`, which may not be installed, and may be written by anything else
11
+ * that claims the name. Only a `{ mode: string }` is honoured; anything else renders nothing
12
+ * rather than throwing inside the footer's render loop.
13
+ */
9
14
  function readAgentModeState(): AgentModeState | undefined {
10
- return (globalThis as Record<string, unknown>).__agentMode as AgentModeState | undefined;
15
+ const state = (globalThis as Record<string, unknown>).__agentMode as unknown;
16
+ if (!state || typeof state !== "object") return undefined;
17
+ const { mode, appearance } = state as { mode?: unknown; appearance?: unknown };
18
+ if (typeof mode !== "string" || !mode) return undefined;
19
+ const modeColor =
20
+ appearance && typeof appearance === "object"
21
+ ? (appearance as { modeColor?: unknown }).modeColor
22
+ : undefined;
23
+ return { mode, ...(typeof modeColor === "string" ? { appearance: { modeColor } } : {}) };
11
24
  }
12
25
 
13
26
  export const modeSwitcherSegment = {
@@ -8,7 +8,7 @@ export const pathSegment = {
8
8
  const opts = ctx.options.path ?? {};
9
9
  const mode = opts.mode ?? "basename";
10
10
 
11
- let pwd = process.cwd();
11
+ let pwd = ctx.cwd || process.cwd();
12
12
  const home = process.env.HOME || process.env.USERPROFILE;
13
13
 
14
14
  if (mode === "basename") {
@@ -6,8 +6,15 @@ interface PlanModeState {
6
6
  mode: string;
7
7
  }
8
8
 
9
+ /**
10
+ * Written by `opl-modes`, which may not be installed. Only a `{ mode: string }` is honoured;
11
+ * anything else reads as "no state", so the segment stays invisible instead of throwing.
12
+ */
9
13
  function readPlanModeState(): PlanModeState | undefined {
10
- return (globalThis as Record<string, unknown>).__planMode as PlanModeState | undefined;
14
+ const state = (globalThis as Record<string, unknown>).__planMode as unknown;
15
+ if (!state || typeof state !== "object") return undefined;
16
+ const mode = (state as { mode?: unknown }).mode;
17
+ return typeof mode === "string" ? { mode } : undefined;
11
18
  }
12
19
 
13
20
  export const planModeSegment = {
@@ -5,7 +5,8 @@ import type { ColorScheme, ColorValue, SemanticColor } from "./types.js";
5
5
  // their absence signals "use rainbow" in thinking.ts.
6
6
  const DEFAULT_COLORS: ColorScheme = {
7
7
  pi: "accent",
8
- model: "#c07898",
8
+ // model: "#c07898",
9
+ model: "accent",
9
10
  path: "dim",
10
11
  git: "success",
11
12
  gitDirty: "warning",
@@ -128,5 +129,7 @@ export function resolveColorToRgb(
128
129
  }
129
130
  const match = probed.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
130
131
  if (!match) return null;
131
- return { r: parseInt(match[1]), g: parseInt(match[2]), b: parseInt(match[3]) };
132
+ const [r, g, b] = [match[1], match[2], match[3]];
133
+ if (r === undefined || g === undefined || b === undefined) return null;
134
+ return { r: parseInt(r, 10), g: parseInt(g, 10), b: parseInt(b, 10) };
132
135
  }
@@ -54,6 +54,7 @@ export type StatusLineSegmentId =
54
54
  | "chat_mode"
55
55
  | "mode_switcher"
56
56
  | "session_stats"
57
+ | "compactions"
57
58
  | "perf_stats"
58
59
  | "status"
59
60
  | "codex_usage"
@@ -127,6 +128,8 @@ export interface SegmentContext {
127
128
  contextWindow: number;
128
129
  usingSubscription: boolean;
129
130
  sessionStartTime: number;
131
+ /** Session directory (`ExtensionContext.cwd`): the base the Path segment renders. */
132
+ cwd: string;
130
133
  git: GitStatus;
131
134
  options: StatusLineSegmentOptions;
132
135
  width: number;
@@ -134,6 +137,7 @@ export interface SegmentContext {
134
137
  colors: ColorScheme;
135
138
  icons: IconSet;
136
139
  sessionStats: SessionStats;
140
+ compactions: number;
137
141
  agentStatus: AgentStatus;
138
142
  codexUsage: CodexUsageSnapshot | null;
139
143
  openRouterUsage: OpenRouterUsageSnapshot | null;
@@ -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 uses literal command matching. Absolute and `~/` entries match resolved paths and descendants for file tools. An empty list disables path rules.
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 bare `env`. Matching commands require confirmation. Without a confirmation UI they are blocked by default.
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. 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.
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
- <project cwd>/err/guardian.jsonl
49
+ <agent dir>/guardian-incidents.jsonl
42
50
  ```
43
51
 
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.
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
- "(^|\\s)env(\\s|$)",
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
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-guardian.json");
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(CONFIG_PATH, "utf8");
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 ${CONFIG_PATH}; using defaults.`],
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 ${CONFIG_PATH}; using defaults.`],
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: ToolCall[];
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
- export function isMalformedToolCall(block: unknown): block is ToolCall {
30
- if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "toolCall") {
31
- return false;
32
- }
33
- const toolCall = block as ToolCall;
34
- return !hasValue(toolCall.id) || !hasValue(toolCall.name);
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(isMalformedToolCall);
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((block) => block.type === "toolCall");
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 { join } from "node:path";
11
+ import { appendFile, chmod, mkdir, rename, stat } from "node:fs/promises";
12
+ import { dirname } from "node:path";
13
13
 
14
- import { loadGuardianConfig, type GuardianConfig } from "./config.js";
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
- export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
31
- const directory = join(cwd, "err");
32
- const path = join(cwd, LOG_PATH);
33
- await mkdir(directory, { recursive: true });
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, `${JSON.stringify(incident)}\n`, "utf8");
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 err/guardian.jsonl: ${detail}.`;
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: err/guardian.jsonl.`;
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(context.cwd, buildIncidentRecord(message, context, guarded.removedToolCalls));
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)) toolPath = event.input.command;
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 = operation === "bash" ? toolPath : toolPath.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
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
- 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
- }
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 };