@gotgenes/pi-permission-system 23.0.1 → 23.0.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [23.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.2...pi-permission-system-v23.0.3) (2026-07-26)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** preserve tool expansion in inline permission prompts ([6a0d241](https://github.com/gotgenes/pi-packages/commit/6a0d241291f8eefa51493b683e12514a83f295bd))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document tool expansion during permission prompts ([f4098d3](https://github.com/gotgenes/pi-packages/commit/f4098d331efc72538ee3836ffb4056beee8cbed0))
19
+
20
+ ## [23.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.1...pi-permission-system-v23.0.2) (2026-07-26)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **pi-permission-system:** create forwarding request files owner-only ([8c77c72](https://github.com/gotgenes/pi-packages/commit/8c77c7228a52d3b57397c7d53d301d002087d92f)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
26
+ * **pi-permission-system:** create permission logs owner-only ([6043cf8](https://github.com/gotgenes/pi-packages/commit/6043cf81bbe6332290d2d6bc8882ba1eadf181cc)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
27
+ * **pi-permission-system:** mask sensitive-keyed values in permission logs ([05cb12a](https://github.com/gotgenes/pi-packages/commit/05cb12a1c85e3030f6de3a4ed20cf1cd150e2d56)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
28
+ * **pi-permission-system:** redact generic tool input in the review log ([2035fb2](https://github.com/gotgenes/pi-packages/commit/2035fb2276efd75daa8d1f4e6e2af8e67763a9f8)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
29
+
30
+
31
+ ### Documentation
32
+
33
+ * **pi-permission-system:** link ADR 0010 by absolute URL from shipped docs ([0384af6](https://github.com/gotgenes/pi-packages/commit/0384af6483bd1a2d7f00a5a91efbe241c6d6898f)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
34
+ * **pi-permission-system:** record ADR 0010 on permission-log secret exposure ([c13b48a](https://github.com/gotgenes/pi-packages/commit/c13b48a9a14635d25af18a2c0f70bf765141a623)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
35
+
8
36
  ## [23.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.0...pi-permission-system-v23.0.1) (2026-07-25)
9
37
 
10
38
 
package/README.md CHANGED
@@ -66,6 +66,7 @@ All permissions use one of three states:
66
66
 
67
67
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
68
68
  In an interactive TUI session the prompt is an inline keybind dialog — `y` approve, `s` approve for this session, `n` deny, `r` deny with a reason — where each hotkey arms and a second press confirms (configurable via `doublePressToConfirm`).
69
+ Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) keeps working while the dialog is open, so you can expand a truncated tool preview before deciding.
69
70
  See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui) for the hotkeys and [docs/session-approvals.md](docs/session-approvals.md) for session-scoped rules and pattern suggestions.
70
71
 
71
72
  The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
@@ -98,16 +98,16 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
98
98
 
99
99
  ## Runtime Knobs
100
100
 
101
- | Key | Default | Description |
102
- | --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
- | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
104
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
105
- | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
106
- | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
107
- | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
108
- | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
109
- | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
110
- | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
101
+ | Key | Default | Description |
102
+ | --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
104
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings verbatim — see [Log file sensitivity](#log-file-sensitivity) |
105
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
106
+ | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
107
+ | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
108
+ | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
109
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
110
+ | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
111
111
 
112
112
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
113
113
  No debug output is printed to the terminal.
@@ -127,6 +127,10 @@ Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option
127
127
  With `doublePressToConfirm` enabled (the default), a letter hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
128
128
  Set `doublePressToConfirm` to `false` to commit on the first press.
129
129
 
130
+ Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) stays live while the dialog is open, so you can expand a truncated tool preview before deciding.
131
+ It only toggles the display — it never resolves, commits, or arms the pending decision.
132
+ While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
133
+
130
134
  Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
131
135
 
132
136
  ### `piInfrastructureReadPaths` patterns
@@ -903,7 +907,38 @@ Additional behaviors:
903
907
  - The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
904
908
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
905
909
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
906
- - Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls
910
+ - Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked (see [Log file sensitivity](#log-file-sensitivity))
911
+
912
+ ---
913
+
914
+ ## Log file sensitivity
915
+
916
+ The review log is enabled by default and records what the agent actually did, which means it records payload as well as decisions: the complete bash command string for every bash decision, and a bounded JSON preview of the tool input for other tools.
917
+ The debug log carries the same payload when `debugLog` is on.
918
+
919
+ Two protections apply.
920
+
921
+ Both logs are created **owner-only** (`0600`, in a `0700` directory), and a log created by an earlier version is tightened on the next write.
922
+ The permission-forwarding request and response files are written the same way.
923
+ This closes the shared-host case: another user on the same machine cannot read them.
924
+
925
+ Values bound to a **sensitive key name** — `authorization`, `token`, `secret`, `password`, `credential`, `cookie`, `api_key`, `private_key`, matched case-insensitively — are masked as `[redacted]` before anything is written.
926
+ So a tool called with `{"authorization": "Bearer …"}` records `{"authorization": "[redacted]"}`.
927
+
928
+ The boundary is worth stating exactly, because it is easy to over-read:
929
+
930
+ > A value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.
931
+
932
+ A command string has no keys, so `deploy --token abc123` is logged verbatim.
933
+ The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
934
+
935
+ Practical guidance:
936
+
937
+ - Treat both log files as sensitive when sharing them: scrub before pasting into an issue or a chat.
938
+ - Set `"permissionReviewLog": false` (and leave `debugLog` off) for a session that will handle credentials on the command line.
939
+ - Owner-only modes do not protect against anything running as you, including a backup or cloud-sync agent that copies your home directory.
940
+
941
+ [ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
907
942
 
908
943
  ---
909
944
 
@@ -51,3 +51,8 @@ This makes it easy to verify which files the extension actually loaded:
51
51
 
52
52
  - If a dangerous action is possible via an allowed tool, policy must explicitly restrict it
53
53
  - This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes)
54
+ - The review log records bash command strings verbatim.
55
+ Log files are created owner-only (`0600`), and values bound to a sensitive key name (`authorization`, `token`, `password`, …) are masked — but a secret embedded in a command string is not.
56
+ See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
57
+
58
+ [ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "23.0.1",
3
+ "version": "23.0.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -18,6 +18,10 @@ import {
18
18
  type ForwardedSessionApproval,
19
19
  type PermissionForwardingLocation,
20
20
  } from "#src/authority/permission-forwarding";
21
+ import {
22
+ OWNER_ONLY_DIRECTORY_MODE,
23
+ OWNER_ONLY_FILE_MODE,
24
+ } from "#src/log-file-permissions";
21
25
  import type { PermissionUiPromptSource } from "#src/permission-events";
22
26
  import type { DebugReviewLogger } from "#src/session-logger";
23
27
 
@@ -185,7 +189,7 @@ export function ensureDirectoryExists(
185
189
  description: string,
186
190
  ): boolean {
187
191
  try {
188
- mkdirSync(path, { recursive: true });
192
+ mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
189
193
  return true;
190
194
  } catch (error) {
191
195
  logPermissionForwardingError(
@@ -365,7 +369,12 @@ export function writeJsonFileAtomic(
365
369
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
366
370
 
367
371
  try {
368
- writeFileSync(tempPath, JSON.stringify(value), "utf-8");
372
+ // `rename` preserves the temp file's mode, so setting it here is enough —
373
+ // a response overwriting an existing file also comes through a fresh temp.
374
+ writeFileSync(tempPath, JSON.stringify(value), {
375
+ encoding: "utf-8",
376
+ mode: OWNER_ONLY_FILE_MODE,
377
+ });
369
378
  renameSync(tempPath, filePath);
370
379
  } catch (error) {
371
380
  safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ExtensionContext,
3
3
  ExtensionUIContext,
4
+ KeybindingsManager,
4
5
  } from "@earendil-works/pi-coding-agent";
5
6
  import {
6
7
  type Component,
@@ -35,9 +36,12 @@ import {
35
36
  /** The subset of the session UI surface the inline dialog needs. */
36
37
  export type PermissionPromptUi = Pick<
37
38
  ExtensionUIContext,
38
- "select" | "input" | "custom"
39
+ "select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
39
40
  >;
40
41
 
42
+ /** The keybindings surface the dialog consults; only `matches` is read (ISP). */
43
+ type PromptKeybindings = Pick<KeybindingsManager, "matches">;
44
+
41
45
  /** The resolved presentation context selected once per activation. */
42
46
  export interface PermissionPromptView {
43
47
  mode: ExtensionContext["mode"];
@@ -97,12 +101,13 @@ export function presentInlinePermissionPrompt(
97
101
  sessionScope: options?.sessionScope,
98
102
  };
99
103
  return view.ui.custom<PermissionPromptDecision>(
100
- (tui, theme, _keybindings, done) =>
104
+ (tui, theme, keybindings, done) =>
101
105
  new PermissionPromptComponent(
102
106
  theme,
103
107
  config,
104
108
  title,
105
109
  message,
110
+ (data) => handleToolsExpandAction(data, keybindings, view.ui),
106
111
  () => {
107
112
  tui.requestRender();
108
113
  },
@@ -112,6 +117,31 @@ export function presentInlinePermissionPrompt(
112
117
  );
113
118
  }
114
119
 
120
+ /**
121
+ * Forward Pi's tool-expansion action while the dialog holds keyboard focus.
122
+ *
123
+ * A focused `ctx.ui.custom` component consumes every keystroke, so `Ctrl+O`
124
+ * would otherwise be dead for the duration of an ask — exactly when the user
125
+ * most needs to see the full pending tool invocation. Returns `true` when the
126
+ * keystroke was the action (and was handled), so the caller stops before
127
+ * mapping it to a {@link PromptEvent}; expansion is a display concern and must
128
+ * never reach the decision model.
129
+ *
130
+ * Deliberately does not request a render: `setToolsExpanded` re-renders the
131
+ * host itself, and the dialog's own lines are unaffected by tool expansion.
132
+ */
133
+ function handleToolsExpandAction(
134
+ data: string,
135
+ keybindings: PromptKeybindings,
136
+ ui: PermissionPromptUi,
137
+ ): boolean {
138
+ if (!keybindings.matches(data, "app.tools.expand")) {
139
+ return false;
140
+ }
141
+ ui.setToolsExpanded(!ui.getToolsExpanded());
142
+ return true;
143
+ }
144
+
115
145
  class PermissionPromptComponent implements Component {
116
146
  private state: PromptViewState;
117
147
  private reasonBuffer = "";
@@ -121,6 +151,7 @@ class PermissionPromptComponent implements Component {
121
151
  private readonly config: PromptModelConfig,
122
152
  private readonly title: string,
123
153
  private readonly message: string,
154
+ private readonly handleAppAction: (data: string) => boolean,
124
155
  private readonly requestRender: () => void,
125
156
  private readonly done: (decision: PermissionPromptDecision) => void,
126
157
  ) {
@@ -151,6 +182,9 @@ class PermissionPromptComponent implements Component {
151
182
  this.handleReasonInput(data);
152
183
  return;
153
184
  }
185
+ if (this.handleAppAction(data)) {
186
+ return;
187
+ }
154
188
  const event = this.toEvent(data);
155
189
  if (event) {
156
190
  this.apply(event);
@@ -1,11 +1,14 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
-
5
4
  import type {
6
5
  ShellToolsConfig,
7
6
  UnifiedPermissionConfig,
8
7
  } from "./config-loader";
8
+ import {
9
+ OWNER_ONLY_DIRECTORY_MODE,
10
+ restrictExistingPathToOwner,
11
+ } from "./log-file-permissions";
9
12
 
10
13
  export const EXTENSION_ID = "pi-permission-system";
11
14
 
@@ -94,7 +97,11 @@ export function ensurePermissionSystemLogsDirectory(
94
97
  logsDir: string,
95
98
  ): string | undefined {
96
99
  try {
97
- mkdirSync(logsDir, { recursive: true });
100
+ // `recursive` applies the mode to every directory this creates, so a fresh
101
+ // install also gets an owner-only extension config dir. Directories that
102
+ // already exist are untouched by `mkdirSync`, hence the explicit tighten.
103
+ mkdirSync(logsDir, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
104
+ restrictExistingPathToOwner(logsDir, OWNER_ONLY_DIRECTORY_MODE);
98
105
  return undefined;
99
106
  } catch (error) {
100
107
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * JSON serialization that survives the values a permission log actually
3
+ * carries: `Error` instances, `bigint`s, and object graphs with cycles.
4
+ *
5
+ * Lives apart from the JSONL writer because both the log path and the
6
+ * permission-prompt path serialize tool input, and only one of them redacts.
7
+ */
8
+
9
+ /**
10
+ * Rewrites a value before the standard JSON-safe handling runs.
11
+ * Returning a replacement short-circuits nothing — the replacement itself
12
+ * flows through the `Error` / `bigint` / cycle handling below.
13
+ */
14
+ export type JsonValueTransform = (key: string, value: unknown) => unknown;
15
+
16
+ /**
17
+ * Build a `JSON.stringify` replacer. Each call owns a fresh `seen` set, so a
18
+ * replacer must not be reused across `stringify` calls.
19
+ */
20
+ export function createJsonSafeReplacer(
21
+ transform?: JsonValueTransform,
22
+ ): (key: string, value: unknown) => unknown {
23
+ const seen = new WeakSet<object>();
24
+
25
+ return (key: string, rawValue: unknown): unknown => {
26
+ const value = transform ? transform(key, rawValue) : rawValue;
27
+
28
+ if (value instanceof Error) {
29
+ return {
30
+ name: value.name,
31
+ message: value.message,
32
+ stack: value.stack,
33
+ };
34
+ }
35
+
36
+ if (typeof value === "bigint") {
37
+ return value.toString();
38
+ }
39
+
40
+ if (typeof value === "object" && value !== null) {
41
+ if (seen.has(value)) {
42
+ return "[Circular]";
43
+ }
44
+ seen.add(value);
45
+ }
46
+
47
+ return value;
48
+ };
49
+ }
50
+
51
+ /** Serialize `value` to JSON, tolerating errors, bigints, and cycles. */
52
+ export function safeJsonStringify(value: unknown): string | undefined {
53
+ return JSON.stringify(value, createJsonSafeReplacer());
54
+ }
@@ -0,0 +1,34 @@
1
+ import { chmodSync } from "node:fs";
2
+
3
+ /**
4
+ * Owner-only POSIX modes for the extension's on-disk artifacts.
5
+ *
6
+ * The permission logs record bash command strings and tool-input previews, and
7
+ * the forwarding files carry the same text between sessions. Left to the
8
+ * process umask they are created world-readable (0644 / 0755 under the common
9
+ * default), which is only acceptable on a single-user host.
10
+ */
11
+
12
+ export const OWNER_ONLY_FILE_MODE = 0o600;
13
+ export const OWNER_ONLY_DIRECTORY_MODE = 0o700;
14
+
15
+ /**
16
+ * Best-effort tightening of an existing path's mode.
17
+ *
18
+ * Creation-time modes cover new files, but an installation that predates this
19
+ * hardening already has a world-readable log that no `mode` option will fix —
20
+ * hence the explicit `chmod`.
21
+ *
22
+ * Never throws, and never reports. On Windows `chmod` only toggles the
23
+ * read-only bit and can reject a directory outright; warning about that every
24
+ * session would be noise, since the file there is governed by NTFS ACL
25
+ * inheritance rather than POSIX modes. A hardening failure must also never
26
+ * break the gate, which is the caller's real work.
27
+ */
28
+ export function restrictExistingPathToOwner(path: string, mode: number): void {
29
+ try {
30
+ chmodSync(path, mode);
31
+ } catch {
32
+ // Intentionally ignored — see above.
33
+ }
34
+ }
@@ -0,0 +1,44 @@
1
+ import { createJsonSafeReplacer } from "./json-safe-stringify";
2
+
3
+ /**
4
+ * Key-name redaction for the permission logs.
5
+ *
6
+ * The technique is deliberately structural rather than predictive: a value is
7
+ * masked because of the *name* it is bound to, never because of what it looks
8
+ * like. Value-shape secret detection (provider prefixes, entropy heuristics)
9
+ * was measured against a real 6.7 MB review log and declined — see
10
+ * `docs/decisions/0010-permission-log-secret-exposure.md`.
11
+ *
12
+ * The boundary that follows from this, stated once: a value bound to a
13
+ * sensitive key name is masked; a secret embedded in a bash command string is
14
+ * not, because a command string has no keys.
15
+ */
16
+
17
+ export const REDACTED_PLACEHOLDER = "[redacted]";
18
+
19
+ const SENSITIVE_KEY_PATTERN =
20
+ /authorization|api[-_]?key|secret|token|password|passwd|credential|cookie|private[-_]?key/i;
21
+
22
+ /** True when a log key names a credential-bearing value. */
23
+ export function isSensitiveLogKey(key: string): boolean {
24
+ return SENSITIVE_KEY_PATTERN.test(key);
25
+ }
26
+
27
+ /**
28
+ * `safeJsonStringify` with sensitive-keyed values masked.
29
+ *
30
+ * Masking runs inside the replacer, so the structure beneath a sensitive key
31
+ * is never visited and the traversal's existing cycle guard is reused — one
32
+ * walk, not two. A `null` or `undefined` value is left alone so an absent
33
+ * field does not read as a suppressed one.
34
+ */
35
+ export function redactedJsonStringify(value: unknown): string | undefined {
36
+ return JSON.stringify(
37
+ value,
38
+ createJsonSafeReplacer((key, currentValue) =>
39
+ currentValue != null && isSensitiveLogKey(key)
40
+ ? REDACTED_PLACEHOLDER
41
+ : currentValue,
42
+ ),
43
+ );
44
+ }
package/src/logging.ts CHANGED
@@ -4,35 +4,11 @@ import {
4
4
  EXTENSION_ID,
5
5
  type PermissionSystemExtensionConfig,
6
6
  } from "./extension-config";
7
-
8
- export function safeJsonStringify(value: unknown): string | undefined {
9
- const seen = new WeakSet<object>();
10
- return JSON.stringify(value, (_key, currentValue) => {
11
- if (currentValue instanceof Error) {
12
- return {
13
- name: currentValue.name,
14
- message: currentValue.message,
15
- stack: currentValue.stack,
16
- };
17
- }
18
-
19
- if (typeof currentValue === "bigint") {
20
- return currentValue.toString();
21
- }
22
-
23
- if (typeof currentValue === "object" && currentValue !== null) {
24
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
25
- if (seen.has(currentValue)) {
26
- return "[Circular]";
27
- }
28
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
29
- seen.add(currentValue);
30
- }
31
-
32
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
33
- return currentValue;
34
- });
35
- }
7
+ import {
8
+ OWNER_ONLY_FILE_MODE,
9
+ restrictExistingPathToOwner,
10
+ } from "./log-file-permissions";
11
+ import { redactedJsonStringify } from "./log-redaction";
36
12
 
37
13
  export interface PermissionSystemLogger {
38
14
  debug: (
@@ -56,6 +32,10 @@ export function createPermissionSystemLogger(
56
32
  options: PermissionSystemLoggerOptions,
57
33
  ): PermissionSystemLogger {
58
34
  const { debugLogPath, reviewLogPath, ensureLogsDirectory } = options;
35
+ // Per-session, so a log inherited from an earlier version is tightened once
36
+ // rather than on every line. Lives in the closure because the factory is
37
+ // re-invoked per session, unlike module scope, which now outlives one.
38
+ const hardened = new Set<string>();
59
39
 
60
40
  const writeLine = (
61
41
  stream: "debug" | "review",
@@ -69,7 +49,7 @@ export function createPermissionSystemLogger(
69
49
  }
70
50
 
71
51
  try {
72
- const line = safeJsonStringify({
52
+ const line = redactedJsonStringify({
73
53
  timestamp: new Date().toISOString(),
74
54
  extension: EXTENSION_ID,
75
55
  stream,
@@ -79,7 +59,14 @@ export function createPermissionSystemLogger(
79
59
  if (!line) {
80
60
  return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
81
61
  }
82
- appendFileSync(path, `${line}\n`, "utf-8");
62
+ appendFileSync(path, `${line}\n`, {
63
+ encoding: "utf-8",
64
+ mode: OWNER_ONLY_FILE_MODE,
65
+ });
66
+ if (!hardened.has(path)) {
67
+ hardened.add(path);
68
+ restrictExistingPathToOwner(path, OWNER_ONLY_FILE_MODE);
69
+ }
83
70
  return undefined;
84
71
  } catch (error) {
85
72
  const message = error instanceof Error ? error.message : String(error);
@@ -1,4 +1,5 @@
1
- import { safeJsonStringify } from "./logging";
1
+ import { safeJsonStringify } from "./json-safe-stringify";
2
+ import { redactedJsonStringify } from "./log-redaction";
2
3
 
3
4
  export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
4
5
  export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
@@ -24,8 +25,23 @@ export function formatCount(
24
25
  return `${value} ${value === 1 ? singular : plural}`;
25
26
  }
26
27
 
28
+ /** Serialize tool input for display in a permission prompt, unredacted. */
27
29
  export function serializeToolInputPreview(input: unknown): string {
28
- const serialized = safeJsonStringify(input);
30
+ return normalizeSerializedPreview(safeJsonStringify(input));
31
+ }
32
+
33
+ /**
34
+ * Serialize tool input for the review log, masking sensitive-keyed values.
35
+ *
36
+ * The log path needs its own entry point because the input is flattened to a
37
+ * string here — by the time it reaches the JSONL writer its keys are gone, so
38
+ * that boundary's redaction pass can no longer see them.
39
+ */
40
+ export function serializeRedactedToolInputPreview(input: unknown): string {
41
+ return normalizeSerializedPreview(redactedJsonStringify(input));
42
+ }
43
+
44
+ function normalizeSerializedPreview(serialized: string | undefined): string {
29
45
  if (!serialized || serialized === "{}" || serialized === "null") {
30
46
  return "";
31
47
  }
@@ -2,6 +2,7 @@ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
3
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
4
4
  import {
5
+ serializeRedactedToolInputPreview,
5
6
  serializeToolInputPreview,
6
7
  TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
7
8
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
@@ -145,9 +146,12 @@ export class ToolPreviewFormatter {
145
146
 
146
147
  // ── Log formatting ──────────────────────────────────────────────────────
147
148
 
148
- /** Serialize `input` to inline JSON and truncate at `toolInputLogPreviewMaxLength`. */
149
+ /**
150
+ * Serialize `input` to inline JSON and truncate at
151
+ * `toolInputLogPreviewMaxLength`, masking sensitive-keyed values.
152
+ */
149
153
  formatGenericToolInputForLog(input: unknown): string | undefined {
150
- const inline = serializeToolInputPreview(input);
154
+ const inline = serializeRedactedToolInputPreview(input);
151
155
  return inline
152
156
  ? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
153
157
  : undefined;