@gotgenes/pi-permission-system 32.0.2 → 32.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,19 @@ 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
+ ## [32.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.2...pi-permission-system-v32.0.3) (2026-09-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** mask a value bound to a bare or suffixed key name ([f23b8b9](https://github.com/gotgenes/pi-packages/commit/f23b8b93ceafa732ffe9d5484e4a935094d06754)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
14
+ * **pi-permission-system:** stop writing a named secret into the permission logs ([cf4f370](https://github.com/gotgenes/pi-packages/commit/cf4f370d192d778e600a253331bbf6e42b3cd758)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** restate the log-redaction boundary as name-structural ([958cba1](https://github.com/gotgenes/pi-packages/commit/958cba124a1b799a7e6af959440a26d7cf722934)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
19
+ * **pi-permission-system:** correct the review-log knob's unredacted claim ([0664a6c](https://github.com/gotgenes/pi-packages/commit/0664a6cf6fbf3e75c4a6864cc582716dd6d15a26)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
20
+
8
21
  ## [32.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.1...pi-permission-system-v32.0.2) (2026-09-11)
9
22
 
10
23
 
package/README.md CHANGED
@@ -177,7 +177,8 @@ Hardening the gates against bypass, fail-closed corrections (breaking ones inclu
177
177
  - _Permissive defaults, trust profiles, or workflow presets._
178
178
  Your risk profile is not knowable from here, so defaults are least-privilege and common policies ship as documented recipes rather than preset keywords.
179
179
  - _Guessing what is sensitive._
180
- No built-in secret denylist, and log redaction is key-name-structural rather than predictive — a redactor that silently misses a key invites treating the log as safe to share.
180
+ No built-in secret denylist, and log redaction is name-structural rather than predictive: a value is masked because of the name that binds it — a log key, a shell variable, a request header field — never because of what it looks like.
181
+ A redactor that guesses invites treating the log as safe to share.
181
182
  - _Model judgment in the core._
182
183
  This package makes no LLM call and holds no model config; model-assisted judging attaches as a chain link over the authorizer seam instead.
183
184
  A link decides nothing until you name it in `authorizerChain`, and its `allow` on an excluded surface is downgraded to `defer`.
@@ -100,7 +100,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
100
100
  | Key | Default | Description |
101
101
  | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
102
  | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
103
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
103
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings, masked only where a name binds the secret — see [Log file sensitivity](#log-file-sensitivity) |
104
104
  | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
105
  | `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. |
106
106
  | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
@@ -1197,19 +1197,30 @@ Both logs are created **owner-only** (`0600`, in a `0700` directory), and a log
1197
1197
  The permission-forwarding request and response files are written the same way.
1198
1198
  This closes the shared-host case: another user on the same machine cannot read them.
1199
1199
 
1200
- 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.
1200
+ Values bound to a **sensitive name** — `authorization`, `token`, `secret`, `password`, `credential`, `cookie`, and a bare or suffixed `key` (`api_key`, `private_key`, `OPENROUTER_KEY`, `apiKey`), matched case-insensitively — are masked as `[redacted]` before anything is written.
1201
1201
  So a tool called with `{"authorization": "Bearer …"}` records `{"authorization": "[redacted]"}`.
1202
1202
 
1203
+ A bash command binds values to names too, and the same predicate answers for those.
1204
+ The command is parsed, and a value is masked when it is bound to a sensitive name by a shell assignment or a request header field:
1205
+
1206
+ ```text
1207
+ KEY="sk-or-v1-…" curl https://x → KEY=[redacted] curl https://x
1208
+ env MY_KEY=… deploy → env MY_KEY=[redacted] deploy
1209
+ curl -H "Authorization: Bearer sk-…" → curl -H "Authorization:[redacted]"
1210
+ ```
1211
+
1203
1212
  The boundary is worth stating exactly, because it is easy to over-read:
1204
1213
 
1205
- > A value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.
1214
+ > A value bound to a sensitive name is masked — whether the name is a log key, a shell variable, or a request header field.
1215
+ > A secret with no name bound to it, such as one typed as a `grep` pattern, is not.
1206
1216
 
1207
- A command string has no keys, so `deploy --token abc123` is logged unredacted.
1217
+ So `grep -r "sk-ant-…" .` and `deploy --token abc123` are both logged unredacted: the first binds the secret to nothing, and the second binds it to a flag rather than a name.
1208
1218
  The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
1219
+ A command the parser could not fully resolve, and a secret inside an inline-shell payload (`bash -c '…'`) or a heredoc body, are masked only as far as the parse reached.
1209
1220
 
1210
1221
  Every value the **review** log writes is narrowed to `reviewLogFieldMaxWidth` (1000 characters by default) and marked with an ellipsis, so a single pathological command cannot put tens of kilobytes in one entry.
1211
1222
  This is a length bound, not redaction: it never inspects a value to decide what to hide, and it applies to every field alike.
1212
- The two compose — a sensitive-keyed value is masked whole however long it was.
1223
+ The two compose, and masking runs first — a sensitively-named value is masked whole however long it was, and the cap never shortens one.
1213
1224
  The debug log is left unbounded, since it is opt-in and exists to be read in full.
1214
1225
 
1215
1226
  Practical guidance:
@@ -54,8 +54,9 @@ This makes it easy to verify which files the extension actually loaded:
54
54
  - This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes).
55
55
  The two are complementary rather than alternatives: a sandbox enforces which paths are in scope and in which direction, while this package decides whether a particular action on an in-scope path may proceed.
56
56
  [ADR 0013] §8 records that division and the seam that exports this package's scope decisions to a sandbox launcher.
57
- - The review log records bash command strings unredacted.
58
- 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.
57
+ - The review log records bash command strings, masked only where a name binds the secret.
58
+ Log files are created owner-only (`0600`), and a value bound to a sensitive name (`authorization`, `token`, `password`, a bare or suffixed `key`, …) is masked — whether the name is a log key, a shell variable, or a request header field.
59
+ A secret with no name bound to it, such as one typed as a `grep` pattern, is not.
59
60
  Review-log values are shortened at `reviewLogFieldMaxWidth` (1000 characters by default), which bounds the file's growth but is a length cap, not redaction.
60
61
  See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
61
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.2",
3
+ "version": "32.0.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,8 @@ export interface TSNode {
15
15
  readonly text: string;
16
16
  /** Absolute byte offset of this node's start in the parsed source. */
17
17
  readonly startIndex: number;
18
+ /** Absolute byte offset one past this node's end in the parsed source. */
19
+ readonly endIndex: number;
18
20
  readonly childCount: number;
19
21
  /** False for anonymous tokens (operators, delimiters); true for named nodes. */
20
22
  readonly isNamed: boolean;
@@ -0,0 +1,241 @@
1
+ import {
2
+ ARG_NODE_TYPES,
3
+ resolveNodeText,
4
+ } from "#src/access-intent/bash/node-text";
5
+ import { getWarmBashParser, type TSNode } from "#src/access-intent/bash/parser";
6
+ import { isPlainRecord } from "#src/value-guards";
7
+ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
8
+
9
+ /**
10
+ * Grammar-anchored masking of a secret bound to a sensitive name *inside* a
11
+ * bash command string.
12
+ *
13
+ * Key-name redaction (`log-redaction.ts`) masks a value because of the key it
14
+ * is bound to, and a command string is one opaque value under the key
15
+ * `command`. This module asks the same question of the names a command binds
16
+ * values to — a shell variable and an HTTP header field — so one predicate
17
+ * answers for all three binding forms.
18
+ *
19
+ * Every rule matches a **parse node**, never a substring of the command text.
20
+ * That is what keeps it usable: measured against a 12 MB review log (7146
21
+ * unique commands), a raw-string scan for a sensitively-named assignment
22
+ * matched ten commands and every one was embedded Python (`key=lambda x: x[1]`)
23
+ * or a `sed` pattern; the same rule anchored to a `variable_assignment` node
24
+ * matched none. See `docs/decisions/0010-permission-log-secret-exposure.md`.
25
+ *
26
+ * A value with no name bound to it — a secret typed as a `grep` pattern — is
27
+ * out of reach of a structural rule and stays unmasked.
28
+ */
29
+
30
+ /** The log keys whose value is a bash command string. */
31
+ export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
32
+ "command",
33
+ "executedUnit",
34
+ ]);
35
+
36
+ /** A range of the command to replace, and what to put in its place. */
37
+ interface MaskSpan {
38
+ readonly start: number;
39
+ readonly end: number;
40
+ readonly replacement: string;
41
+ }
42
+
43
+ /**
44
+ * Mask every sensitively-named value in a bash command string.
45
+ *
46
+ * Best-effort by design: a cold parser, a parse that throws, and a parse that
47
+ * recovered from a syntax error all yield whatever the walk did resolve rather
48
+ * than blanking the field, because the command text is the main reason the
49
+ * review log is read. It never throws — the writer sits under the fail-closed
50
+ * `tool_call` boundary, where a raised mask would cost the whole log line.
51
+ */
52
+ export function redactCommandSecrets(command: string): string {
53
+ if (!command) return command;
54
+
55
+ try {
56
+ const parser = getWarmBashParser();
57
+ if (!parser) return command;
58
+ const tree = parser.parse(command);
59
+ if (!tree) return command;
60
+ try {
61
+ const spans: MaskSpan[] = [];
62
+ collectMaskSpans(tree.rootNode, spans);
63
+ return applyMaskSpans(command, spans);
64
+ } finally {
65
+ tree.delete();
66
+ }
67
+ } catch {
68
+ return command;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Apply {@link redactCommandSecrets} to every command-bearing key in a log
74
+ * record.
75
+ *
76
+ * Recurses through plain objects and arrays, like the width cap beside it: all
77
+ * of today's producers write `command` and `executedUnit` at the top level, but
78
+ * a writer stage that only looks at the top level is one a later nested
79
+ * producer escapes without anyone noticing.
80
+ */
81
+ export function maskCommandFields<T>(details: T): T {
82
+ return maskValue(details, false) as T;
83
+ }
84
+
85
+ function maskValue(value: unknown, bindsCommand: boolean): unknown {
86
+ if (typeof value === "string") {
87
+ return bindsCommand ? redactCommandSecrets(value) : value;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ return value.map((entry) => maskValue(entry, bindsCommand));
91
+ }
92
+ if (isPlainRecord(value)) {
93
+ return Object.fromEntries(
94
+ Object.entries(value).map(([key, entry]) => [
95
+ key,
96
+ maskValue(entry, COMMAND_BEARING_LOG_KEYS.has(key)),
97
+ ]),
98
+ );
99
+ }
100
+ return value;
101
+ }
102
+
103
+ function collectMaskSpans(node: TSNode, spans: MaskSpan[]): void {
104
+ const span = maskSpanOf(node);
105
+ if (span) spans.push(span);
106
+ for (let i = 0; i < node.childCount; i++) {
107
+ const child = node.child(i);
108
+ if (child) collectMaskSpans(child, spans);
109
+ }
110
+ }
111
+
112
+ function maskSpanOf(node: TSNode): MaskSpan | null {
113
+ return (
114
+ assignmentValueSpan(node) ??
115
+ wordAssignmentSpan(node) ??
116
+ headerValueSpan(node)
117
+ );
118
+ }
119
+
120
+ /**
121
+ * `KEY="sk-…" curl …`, `KEY=sk-…`, `export OPENROUTER_KEY="sk-…"`.
122
+ *
123
+ * The span runs to the assignment node's own end rather than the value node's,
124
+ * so a value the grammar splits across several children is covered whole.
125
+ */
126
+ function assignmentValueSpan(node: TSNode): MaskSpan | null {
127
+ if (node.type !== "variable_assignment") return null;
128
+ const name = node.child(0);
129
+ if (!name || !isSensitiveName(name.text)) return null;
130
+ const value = node.child(2);
131
+ if (!value) return null;
132
+ return maskSpan(value.startIndex, node.endIndex, REDACTED_PLACEHOLDER);
133
+ }
134
+
135
+ /**
136
+ * `env MY_KEY=abc deploy`, which tree-sitter classifies as a plain `word`
137
+ * rather than an assignment because it follows a command name.
138
+ *
139
+ * The name must open with a letter or underscore, so a long option
140
+ * (`--my-key=abc`) cannot match: an option binds its value to a flag, and the
141
+ * flag forms are deliberately out of scope.
142
+ */
143
+ const WORD_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/;
144
+
145
+ function wordAssignmentSpan(node: TSNode): MaskSpan | null {
146
+ if (node.type !== "word") return null;
147
+ const match = WORD_ASSIGNMENT.exec(node.text);
148
+ if (!match || !isSensitiveName(match[1])) return null;
149
+ return maskSpan(
150
+ node.startIndex + match[0].length,
151
+ node.endIndex,
152
+ REDACTED_PLACEHOLDER,
153
+ );
154
+ }
155
+
156
+ /** `curl -H "Authorization: Bearer sk-…"`, in any of its quoting forms. */
157
+ const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*\S/;
158
+
159
+ function headerValueSpan(node: TSNode): MaskSpan | null {
160
+ if (!ARG_NODE_TYPES.has(node.type)) return null;
161
+ const match = HEADER_FIELD.exec(resolveNodeText(node));
162
+ const field = match?.[1];
163
+ if (!field || !isSensitiveName(field) || isCamelCased(field)) return null;
164
+ const colon = node.text.indexOf(":");
165
+ if (colon < 0) return null;
166
+ // The span swallows a closing quote, so the replacement puts one back and
167
+ // the masked argument stays quoted the way it was written.
168
+ return maskSpan(
169
+ node.startIndex + colon + 1,
170
+ node.endIndex,
171
+ REDACTED_PLACEHOLDER + openQuoteAt(node.text, colon),
172
+ );
173
+ }
174
+
175
+ /**
176
+ * An HTTP field name is hyphenated (`X-Api-Key`), never camel-cased.
177
+ *
178
+ * Without this the only false positives in the measured corpus were two
179
+ * records of `grep "legalDirectionalKeys: readonly"` — a search pattern over
180
+ * TypeScript source, which names a field of nothing.
181
+ */
182
+ function isCamelCased(field: string): boolean {
183
+ return /[a-z][A-Z]/.test(field);
184
+ }
185
+
186
+ /**
187
+ * The quote character still open at `index`, or the empty string.
188
+ *
189
+ * Read at the mask's own position rather than off the argument's first
190
+ * character: a field name can straddle a quote boundary (`Auth"orization: "$T`),
191
+ * and the quote the mask swallowed is the one open where it begins.
192
+ */
193
+ function openQuoteAt(text: string, index: number): string {
194
+ let quote = "";
195
+ for (let i = 0; i < index; i++) {
196
+ const char = text[i];
197
+ if (quote === "") {
198
+ if (char === '"' || char === "'") quote = char;
199
+ } else if (quote === '"' && char === "\\") {
200
+ i += 1;
201
+ } else if (char === quote) {
202
+ quote = "";
203
+ }
204
+ }
205
+ return quote;
206
+ }
207
+
208
+ function maskSpan(
209
+ start: number,
210
+ end: number,
211
+ replacement: string,
212
+ ): MaskSpan | null {
213
+ return start < end ? { start, end, replacement } : null;
214
+ }
215
+
216
+ /**
217
+ * Replace each span, outermost-wins and right to left.
218
+ *
219
+ * A sensitive assignment whose value is itself a header argument yields two
220
+ * spans, one inside the other; masking both would nest a placeholder inside a
221
+ * region already replaced. Working right to left keeps the earlier offsets
222
+ * valid as the string shortens.
223
+ */
224
+ function applyMaskSpans(command: string, spans: MaskSpan[]): string {
225
+ if (spans.length === 0) return command;
226
+
227
+ const ordered = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
228
+ const disjoint: MaskSpan[] = [];
229
+ for (const span of ordered) {
230
+ const previous = disjoint.at(-1);
231
+ if (previous && span.start < previous.end) continue;
232
+ disjoint.push(span);
233
+ }
234
+
235
+ let masked = command;
236
+ for (const span of disjoint.toReversed()) {
237
+ masked =
238
+ masked.slice(0, span.start) + span.replacement + masked.slice(span.end);
239
+ }
240
+ return masked;
241
+ }
@@ -1,3 +1,5 @@
1
+ import { isPlainRecord } from "#src/value-guards";
2
+
1
3
  /**
2
4
  * The permission review log's width bound (ADR 0011 §6).
3
5
  *
@@ -57,7 +59,7 @@ function capValue(value: unknown, maxWidth: number): unknown {
57
59
  if (Array.isArray(value)) {
58
60
  return value.map((entry) => capValue(entry, maxWidth));
59
61
  }
60
- if (isPlainObject(value)) {
62
+ if (isPlainRecord(value)) {
61
63
  return Object.fromEntries(
62
64
  Object.entries(value).map(([key, entry]) => [
63
65
  key,
@@ -67,16 +69,3 @@ function capValue(value: unknown, maxWidth: number): unknown {
67
69
  }
68
70
  return value;
69
71
  }
70
-
71
- /**
72
- * Whether a value is a record this cap should descend into.
73
- *
74
- * A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
75
- * plain object would change what the writer serializes, and the cap's job is
76
- * to shorten strings, not to reshape a value.
77
- */
78
- function isPlainObject(value: unknown): value is Record<string, unknown> {
79
- if (typeof value !== "object" || value === null) return false;
80
- const prototype: unknown = Object.getPrototypeOf(value);
81
- return prototype === Object.prototype || prototype === null;
82
- }
@@ -1,7 +1,7 @@
1
1
  import { createJsonSafeReplacer } from "./json-safe-stringify";
2
2
 
3
3
  /**
4
- * Key-name redaction for the permission logs.
4
+ * Name-based redaction for the permission logs.
5
5
  *
6
6
  * The technique is deliberately structural rather than predictive: a value is
7
7
  * masked because of the *name* it is bound to, never because of what it looks
@@ -9,19 +9,41 @@ import { createJsonSafeReplacer } from "./json-safe-stringify";
9
9
  * was measured against a real 6.7 MB review log and declined — see
10
10
  * `docs/decisions/0010-permission-log-secret-exposure.md`.
11
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.
12
+ * This module owns the predicate and the log-key binding form.
13
+ * `command-redaction.ts` asks the same predicate about the names a bash
14
+ * command binds values to.
15
+ *
16
+ * The boundary that follows, stated once: a value bound to a sensitive name is
17
+ * masked — whether the name is a log key, a shell variable, or a request
18
+ * header field. A secret with no name bound to it, such as one typed as a
19
+ * `grep` pattern, is not.
15
20
  */
16
21
 
17
22
  export const REDACTED_PLACEHOLDER = "[redacted]";
18
23
 
19
- const SENSITIVE_KEY_PATTERN =
20
- /authorization|api[-_]?key|secret|token|password|passwd|credential|cookie|private[-_]?key/i;
24
+ /**
25
+ * Names that bind a credential, matched case-insensitively.
26
+ *
27
+ * `api[-_]?keys?` and `private[-_]?keys?` are kept alongside the general
28
+ * name-boundary `key` rule rather than subsumed by it, so the whole predicate
29
+ * is a union with the pattern it replaced and can add a name but never drop
30
+ * one — the separator-less `apikey` matches only via the specific alternative.
31
+ */
32
+ const SENSITIVE_NAME_PATTERN =
33
+ /authorization|api[-_]?keys?|private[-_]?keys?|secret|token|password|passwd|credential|cookie|(?:^|[-_])keys?(?:$|[-_])/i;
34
+
35
+ /**
36
+ * A `key` bound as the tail of a camel-cased name (`apiKey`, `sortKeys`).
37
+ *
38
+ * Deliberately case-sensitive and separate from the pattern above: under `/i`
39
+ * the leading `[a-z0-9]` would match an uppercase letter and `Key` would match
40
+ * `key`, so `monkey` would read as sensitive.
41
+ */
42
+ const CAMEL_KEY_PATTERN = /[a-z0-9](?:Key|Keys)(?:$|[A-Z_-])/;
21
43
 
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);
44
+ /** True when a name binds a credential-bearing value. */
45
+ export function isSensitiveName(name: string): boolean {
46
+ return SENSITIVE_NAME_PATTERN.test(name) || CAMEL_KEY_PATTERN.test(name);
25
47
  }
26
48
 
27
49
  /**
@@ -36,7 +58,7 @@ export function redactedJsonStringify(value: unknown): string | undefined {
36
58
  return JSON.stringify(
37
59
  value,
38
60
  createJsonSafeReplacer((key, currentValue) =>
39
- currentValue != null && isSensitiveLogKey(key)
61
+ currentValue != null && isSensitiveName(key)
40
62
  ? REDACTED_PLACEHOLDER
41
63
  : currentValue,
42
64
  ),
@@ -4,6 +4,7 @@ import {
4
4
  EXTENSION_ID,
5
5
  type PermissionSystemExtensionConfig,
6
6
  } from "#src/config/extension-config";
7
+ import { maskCommandFields } from "./command-redaction";
7
8
  import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
8
9
  import {
9
10
  OWNER_ONLY_FILE_MODE,
@@ -39,13 +40,41 @@ export function createPermissionSystemLogger(
39
40
  const hardened = new Set<string>();
40
41
 
41
42
  /**
42
- * The only place a log line is produced.
43
+ * The transform stages every log line passes through, in the order they must
44
+ * run.
45
+ *
46
+ * Command masking runs first, and it runs for both streams. Capping a command
47
+ * before masking it would hand the masker a truncated command — a parse of
48
+ * something the agent never ran — and the debug stream carries the same
49
+ * payload as the review stream, so a mask that skipped it would only move the
50
+ * exposure rather than close it.
43
51
  *
44
52
  * `maxFieldWidth` bounds every string the line carries; it is supplied for
45
53
  * the review stream and withheld for the debug stream, which is opt-in and
46
- * exists to be read in full. Capping happens before redaction, which masks
47
- * by key name and so still masks a sensitive value whole.
54
+ * exists to be read in full. Capping happens before key-name redaction, which
55
+ * masks by name and so still masks a sensitive value whole.
48
56
  */
57
+ const prepareLogLine = (
58
+ stream: "debug" | "review",
59
+ event: string,
60
+ details: Record<string, unknown>,
61
+ maxFieldWidth?: number,
62
+ ): string | undefined => {
63
+ const masked = maskCommandFields(details);
64
+ const bounded =
65
+ maxFieldWidth === undefined
66
+ ? masked
67
+ : capLogFieldWidths(masked, maxFieldWidth);
68
+ return redactedJsonStringify({
69
+ timestamp: new Date().toISOString(),
70
+ extension: EXTENSION_ID,
71
+ stream,
72
+ event,
73
+ ...bounded,
74
+ });
75
+ };
76
+
77
+ /** The only place a log line is produced. */
49
78
  const writeLine = (
50
79
  stream: "debug" | "review",
51
80
  path: string,
@@ -59,17 +88,7 @@ export function createPermissionSystemLogger(
59
88
  }
60
89
 
61
90
  try {
62
- const bounded =
63
- maxFieldWidth === undefined
64
- ? details
65
- : capLogFieldWidths(details, maxFieldWidth);
66
- const line = redactedJsonStringify({
67
- timestamp: new Date().toISOString(),
68
- extension: EXTENSION_ID,
69
- stream,
70
- event,
71
- ...bounded,
72
- });
91
+ const line = prepareLogLine(stream, event, details, maxFieldWidth);
73
92
  if (!line) {
74
93
  return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
75
94
  }
@@ -6,6 +6,22 @@ export function toRecord(value: unknown): Record<string, unknown> {
6
6
  return value as Record<string, unknown>;
7
7
  }
8
8
 
9
+ /**
10
+ * Whether a value is a record a structural walk should descend into.
11
+ *
12
+ * A class instance (a `Date`, an `Error`) is not: rebuilding one as a plain
13
+ * object would change what a serializer downstream writes. Distinct from
14
+ * {@link toRecord}, which reads a value as a record without asking how it was
15
+ * built.
16
+ */
17
+ export function isPlainRecord(
18
+ value: unknown,
19
+ ): value is Record<string, unknown> {
20
+ if (typeof value !== "object" || value === null) return false;
21
+ const prototype: unknown = Object.getPrototypeOf(value);
22
+ return prototype === Object.prototype || prototype === null;
23
+ }
24
+
9
25
  export function getNonEmptyString(value: unknown): string | null {
10
26
  if (typeof value !== "string") {
11
27
  return null;