@gotgenes/pi-permission-system 33.0.0 → 33.0.2

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,27 @@ 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
+ ## [33.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.1...pi-permission-system-v33.0.2) (2026-09-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** stop writing a named secret inside an inline-shell payload ([eaeb297](https://github.com/gotgenes/pi-packages/commit/eaeb297e0f20b5ceeb935f0d1a062ebf9daa17ec)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
14
+ * **pi-permission-system:** mask a secret in a quote-stitched inline-shell payload ([4b79c10](https://github.com/gotgenes/pi-packages/commit/4b79c105c21d3400ff25050eb19490f6c71ede61)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
15
+ * **pi-permission-system:** mask a secret in a payload behind an indirection wrapper ([c17959a](https://github.com/gotgenes/pi-packages/commit/c17959a87b2a43816796c9deb9f7b28ae9ceb436)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** record the payload widening and accept the heredoc residual ([8544508](https://github.com/gotgenes/pi-packages/commit/85445088fad6ce7d10df95cd661e98c0c3fd587f)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
20
+ * **pi-permission-system:** record the coarse and wrapper-peeled payload cases ([c3644d6](https://github.com/gotgenes/pi-packages/commit/c3644d60e8dbcd9f332d2509711df05356844e03)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
21
+
22
+ ## [33.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.0...pi-permission-system-v33.0.1) (2026-09-19)
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **pi-permission-system:** document the serving-liveness and bound-channel mechanism in the integration spec ([#942](https://github.com/gotgenes/pi-packages/issues/942)) ([2786acc](https://github.com/gotgenes/pi-packages/commit/2786accaf40136f192e6f8d187058ae60c7555b5)), closes [#942](https://github.com/gotgenes/pi-packages/issues/942)
28
+
8
29
  ## [33.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.1.0...pi-permission-system-v33.0.0) (2026-09-18)
9
30
 
10
31
 
@@ -1292,6 +1292,7 @@ The command is parsed, and a value is masked when it is bound to a sensitive nam
1292
1292
  KEY="sk-or-v1-…" curl https://x → KEY=[redacted] curl https://x
1293
1293
  env MY_KEY=… deploy → env MY_KEY=[redacted] deploy
1294
1294
  curl -H "Authorization: Bearer sk-…" → curl -H "Authorization:[redacted]"
1295
+ bash -c 'TOKEN=sk-… deploy' → bash -c 'TOKEN=[redacted] deploy'
1295
1296
  ```
1296
1297
 
1297
1298
  The boundary is worth stating exactly, because it is easy to over-read:
@@ -1301,7 +1302,15 @@ The boundary is worth stating exactly, because it is easy to over-read:
1301
1302
 
1302
1303
  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.
1303
1304
  The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
1304
- 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.
1305
+ A command the parser could not fully resolve is masked only as far as the parse reached.
1306
+ A secret inside a **heredoc body** (`cat > .env <<'EOF'` / `API_KEY=…` / `EOF`) is not masked at all: a heredoc body is literal data rather than shell, and re-parsing one as shell is how the log's own Python and TypeScript heredocs come to read as assignments — measured at six false positives and no true ones, so [ADR 0010] declines it.
1307
+ An **inline-shell payload** (`bash -c '…'`, `sh -c "…"`, `eval '…'`) *is* masked, because the package already knows that argument is shell — including one reached through a wrapper (`sudo bash -c '…'`, `xargs -I{} sh -c '…'`).
1308
+ An interpreter's payload (`python3 -c '…'`) is not, for the same reason a heredoc body is not.
1309
+ Where a payload is stitched together across quote boundaries (`bash -c 'TOKEN='"$SECRET"`), the whole argument is replaced rather than just the value, because no single offset maps the value back onto the command:
1310
+
1311
+ ```text
1312
+ bash -c 'TOKEN='"$SECRET" → bash -c [redacted]
1313
+ ```
1305
1314
 
1306
1315
  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.
1307
1316
  This is a length bound, not redaction: it never inspects a value to decide what to hide, and it applies to every field alike.
@@ -160,7 +160,9 @@ The polling session publishes the session id it polls, and a child checks that i
160
160
 
161
161
  The announcement goes out on two channels, because a child cannot always reach the same one.
162
162
  A child running inside its parent's process reads a process-global registry.
163
+ A stale mark left in that registry by a session that died without `session_shutdown` suppresses the fast-fail and falls back to the timeout — the safe direction for that channel.
163
164
  A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
165
+ That `serving/` directory is created on demand and never removed.
164
166
 
165
167
  For an out-of-process target, four things count as "not draining":
166
168
 
@@ -171,7 +173,7 @@ For an out-of-process target, four things count as "not draining":
171
173
  | A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
172
174
  | A record for a different session id | The child is forwarding somewhere nobody is listening |
173
175
 
174
- If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
176
+ If the target is not draining its inbox, the child gives up after a two-second grace window (`PERMISSION_FORWARDING_SERVING_GRACE_MS`) rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
175
177
 
176
178
  ```text
177
179
  [pi-permission-system] Running bash command 'pwd' requires approval, but no
@@ -180,6 +182,7 @@ permission requests.
180
182
  ```
181
183
 
182
184
  The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
185
+ Absence of a record is deliberately **not** read as unknown: a cleanly exited parent leaves nothing behind, so treating absence as "maybe serving" would restore the full-timeout stall that the heartbeat exists to end.
183
186
  A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
184
187
  That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
185
188
 
@@ -188,6 +191,7 @@ None of them is reported as a user denial, because no user was ever asked.
188
191
 
189
192
  The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
190
193
  When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
194
+ The child's `forwarded_permission.no_serving_session` entry also records `servingChannel` and `servingState` beside the ids observed, since "exited", "killed", and "polling a different session id" are different diagnoses the shared denial string does not distinguish.
191
195
 
192
196
  When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
193
197
  The serving node records its decider on the response — a rule of its own (with the surface, pattern, and origin that matched), the link that ruled, or the human who answered its dialog — and the child records it nested under a `forwarded` frame:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "33.0.0",
3
+ "version": "33.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,6 +6,7 @@ import {
6
6
  type CommandWord,
7
7
  classifyWrapperWords,
8
8
  executedUnitOf,
9
+ inlineShellPayloadIndex,
9
10
  isTransparentWrapper,
10
11
  type WrapperKind,
11
12
  } from "./wrapper-analysis";
@@ -263,6 +264,30 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
263
264
  return out;
264
265
  }
265
266
 
267
+ /**
268
+ * The node holding a `command` node's inline-shell payload — the inner program
269
+ * of `bash -c '…'`, `sh -c "…"`, or `eval '…'` — or `null` for any other
270
+ * command.
271
+ *
272
+ * The node rather than its text, because the log's command masker re-parses the
273
+ * payload and offsets the spans it recovers by the node's `startIndex`
274
+ * (`logging/command-redaction.ts`, #923). {@link executedUnitOf} answers the
275
+ * text question for display and cannot serve that one: it unquotes, unwraps
276
+ * nested indirection, and drops a result that adds nothing — all of which lose
277
+ * the correspondence to the command as written.
278
+ *
279
+ * The payload set is the *shell* set, which is what keeps an interpreter
280
+ * (`python3 -c`, `node -e`) out: its payload is another language, so re-parsing
281
+ * it as bash would read a secret out of embedded Python.
282
+ */
283
+ export function inlineShellPayloadNode(command: TSNode): TSNode | null {
284
+ const nodes = commandWordNodes(command);
285
+ const index = inlineShellPayloadIndex(
286
+ nodes.map((node) => ({ text: node.text, offset: node.startIndex })),
287
+ );
288
+ return index === -1 ? null : (nodes.at(index) ?? null);
289
+ }
290
+
266
291
  function collectCommandsInto(
267
292
  node: TSNode,
268
293
  inherited: UnitScope,
@@ -433,16 +458,32 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
433
458
  * list means a pure assignment with no `command_name`.
434
459
  */
435
460
  function readCommandWords(node: TSNode): CommandWord[] {
436
- const words: CommandWord[] = [];
437
- let unitStart: number | undefined;
461
+ const nodes = commandWordNodes(node);
462
+ const unitStart = nodes.at(0)?.startIndex ?? 0;
463
+ return nodes.map((child) => ({
464
+ text: child.text,
465
+ offset: child.startIndex - unitStart,
466
+ }));
467
+ }
468
+
469
+ /**
470
+ * The nodes {@link readCommandWords} reports words for, in the same order.
471
+ *
472
+ * Split out so a consumer that needs a *node* rather than a word — the log's
473
+ * command masker, which offsets a re-parse by the payload node's `startIndex` —
474
+ * walks the identical filtered list. Two walks over the same children with the
475
+ * same filter, written twice, is how the two come to disagree about which word
476
+ * is at which index.
477
+ */
478
+ function commandWordNodes(node: TSNode): TSNode[] {
479
+ const nodes: TSNode[] = [];
438
480
  for (let i = 0; i < node.childCount; i++) {
439
481
  const child = node.child(i);
440
482
  if (!child?.isNamed) continue;
441
483
  if (child.type === "variable_assignment") continue;
442
- unitStart ??= child.startIndex;
443
- words.push({ text: child.text, offset: child.startIndex - unitStart });
484
+ nodes.push(child);
444
485
  }
445
- return words;
486
+ return nodes;
446
487
  }
447
488
 
448
489
  /**
@@ -59,6 +59,57 @@ export function classifyWrapperWords(
59
59
  return undefined;
60
60
  }
61
61
 
62
+ /**
63
+ * Index within `words` of the inline-shell payload — the inner program a shell or
64
+ * `eval` runs — or `-1` when the unit carries none.
65
+ *
66
+ * Indirection layers are peeled first, so `sudo bash -c '…'` answers the
67
+ * payload's index rather than `-1`. That is not a convenience:
68
+ * {@link executedUnitOf} peels them too, so a consumer that did not would mask a
69
+ * secret under `executedUnit` and write it verbatim under `command` — the
70
+ * inconsistency #923 reports, reintroduced one wrapper layer up.
71
+ *
72
+ * The index names the payload's *position*, which a vacant one still has
73
+ * (`bash -c`), so each caller decides for itself what reading past the end of
74
+ * `words` is worth.
75
+ */
76
+ export function inlineShellPayloadIndex(words: readonly CommandWord[]): number {
77
+ let base = 0;
78
+ let current = words;
79
+
80
+ for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
81
+ const direct = directPayloadIndex(current);
82
+ if (direct !== -1) return base + direct;
83
+ if (classifyWrapperWords(current) !== "indirection") return -1;
84
+
85
+ const start = innerCommandIndex(current);
86
+ if (start === -1 || start >= current.length) return -1;
87
+ const end = execTerminatorIndex(current, start);
88
+ base += start;
89
+ current = current.slice(start, end);
90
+ }
91
+ return -1;
92
+ }
93
+
94
+ /**
95
+ * The payload index of a unit that is *already* the shell or `eval` running it.
96
+ *
97
+ * `eval` takes its program as the first argument (no `-c`, so the flag scan
98
+ * answers -1 and the index falls out as 1); a shell takes it after the `-c`
99
+ * cluster. Any other command name carries no inline program at all, which is
100
+ * what keeps an interpreter (`python3 -c`, `node -e`) out: its payload is
101
+ * another language, not shell.
102
+ */
103
+ function directPayloadIndex(words: readonly CommandWord[]): number {
104
+ const commandName = wrapperName(words);
105
+ if (commandName === undefined) return -1;
106
+ const isShell = SHELL_WRAPPER_NAMES.has(commandName);
107
+ if (commandName !== "eval" && !isShell) return -1;
108
+ const flagIndex = shortFlagCIndex(words.slice(1).map((word) => word.text));
109
+ if (isShell && flagIndex === -1) return -1;
110
+ return flagIndex + 2;
111
+ }
112
+
62
113
  // ── Wrapper vocabulary ───────────────────────────────────────────────────────
63
114
 
64
115
  /**
@@ -201,13 +252,16 @@ function nothingNew(text: string | null, unitText: string): string | null {
201
252
  return text.startsWith("-") ? null : text;
202
253
  }
203
254
 
204
- /** The inline-shell payload argument, unquoted; `null` when absent. */
255
+ /**
256
+ * The inline-shell payload argument, unquoted; `null` when absent.
257
+ *
258
+ * Reads the **direct** index: {@link unwrapIndirection} has already peeled every
259
+ * wrapper layer by the time it reaches its opaque branch, so peeling again would
260
+ * look past a shell that is itself an outer wrapper's payload.
261
+ */
205
262
  function opaquePayload(words: readonly CommandWord[]): string | null {
206
- const args = words.slice(1);
207
- // `eval` takes its program as the first argument (no `-c`, so the index is
208
- // -1); a shell takes it after the `-c` cluster.
209
- const flagIndex = shortFlagCIndex(args.map((word) => word.text));
210
- const payload = args[flagIndex + 1] as CommandWord | undefined;
263
+ const index = directPayloadIndex(words);
264
+ const payload = index === -1 ? undefined : words.at(index);
211
265
  return payload === undefined ? null : unquote(payload.text);
212
266
  }
213
267
 
@@ -1,8 +1,13 @@
1
+ import { inlineShellPayloadNode } from "#src/access-intent/bash/command-enumeration";
1
2
  import {
2
3
  ARG_NODE_TYPES,
3
4
  resolveNodeText,
4
5
  } from "#src/access-intent/bash/node-text";
5
- import { getWarmBashParser, type TSNode } from "#src/access-intent/bash/parser";
6
+ import {
7
+ type BashReparser,
8
+ getWarmBashParser,
9
+ type TSNode,
10
+ } from "#src/access-intent/bash/parser";
6
11
  import { isPlainRecord } from "#src/value-guards";
7
12
  import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
8
13
 
@@ -25,6 +30,14 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
25
30
  *
26
31
  * A value with no name bound to it — a secret typed as a `grep` pattern — is
27
32
  * out of reach of a structural rule and stays unmasked.
33
+ *
34
+ * An inline-shell payload (`bash -c '…'`, `eval "…"`) is one opaque token to
35
+ * the outer parse, so it is re-parsed on its own and the recovered spans are
36
+ * shifted onto the command as written. The widening is restricted to the
37
+ * payloads the wrapper analyzer already identifies as shell: a heredoc body is
38
+ * not one, and applying these rules to the 915 `<<'EOF'` bodies in the same
39
+ * corpus matched six commands, every one embedded Python or TypeScript written
40
+ * to a file (#923).
28
41
  */
29
42
 
30
43
  /** The log keys whose value is a bash command string. */
@@ -55,20 +68,167 @@ export function redactCommandSecrets(command: string): string {
55
68
  try {
56
69
  const parser = getWarmBashParser();
57
70
  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
- }
71
+ return applyMaskSpans(command, collectSpansIn(parser, command, 0, 0));
67
72
  } catch {
68
73
  return command;
69
74
  }
70
75
  }
71
76
 
77
+ /**
78
+ * Every mask span in `source`, shifted by `offset` to its place in the command
79
+ * being masked, including the spans of any inline-shell payload `source`
80
+ * carries.
81
+ *
82
+ * A payload is re-parsed from its **verbatim inner slice** rather than
83
+ * `resolveNodeText`'s shell value: the resolved text concatenates children and
84
+ * expands `$HOME`, which destroys the offset correspondence this shift relies
85
+ * on. Because the slice excludes the payload's quotes, no span recovered from it
86
+ * can reach one, so the masked payload stays quoted as it was written.
87
+ *
88
+ * `parser` is the narrow {@link BashReparser} rather than the warmed parser's own
89
+ * type, so the recursion structurally cannot `delete()` the process-wide parser
90
+ * out from under every later command — the same reason `unresolved-salvage.ts`
91
+ * takes that interface.
92
+ */
93
+ function collectSpansIn(
94
+ parser: BashReparser,
95
+ source: string,
96
+ offset: number,
97
+ depth: number,
98
+ ): MaskSpan[] {
99
+ const tree = parser.parse(source);
100
+ if (!tree) return [];
101
+ try {
102
+ const own: MaskSpan[] = [];
103
+ collectMaskSpans(tree.rootNode, own);
104
+ const spans = own.map((span) => ({
105
+ ...span,
106
+ start: span.start + offset,
107
+ end: span.end + offset,
108
+ }));
109
+ if (depth >= MAX_PAYLOAD_DEPTH) return spans;
110
+ for (const payload of inlineShellPayloads(tree.rootNode)) {
111
+ const sliced = payload.kind === "slice";
112
+ const inner = collectSpansIn(
113
+ parser,
114
+ payload.text,
115
+ sliced ? offset + payload.start : 0,
116
+ depth + 1,
117
+ );
118
+ if (sliced) spans.push(...inner);
119
+ else if (inner.length > 0) spans.push(wholeOf(payload, offset));
120
+ }
121
+ return spans;
122
+ } finally {
123
+ tree.delete();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * How many payload layers to descend.
129
+ *
130
+ * A payload is a strict sub-span of its own command, so the recursion terminates
131
+ * regardless; the bound is what makes its cost statable, and it matches the
132
+ * unwrap depth `wrapper-analysis.ts` already applies to nested wrappers.
133
+ */
134
+ const MAX_PAYLOAD_DEPTH = 4;
135
+
136
+ /**
137
+ * An inline-shell payload's program, and how a span found in it maps back onto
138
+ * the command being masked.
139
+ *
140
+ * `"slice"` — the program is a verbatim span of the command: a bare word, or
141
+ * literal text inside one pair of quotes. A span found in it shifts onto the
142
+ * command by the constant {@link PayloadSource.start}.
143
+ *
144
+ * `"stitched"` — the program is assembled across quote boundaries
145
+ * (`bash -c 'TOKEN='"$SECRET"`, one `concatenation` node), so no constant shift
146
+ * exists and an offset into the program names nothing in the command. The
147
+ * program still decides *whether* a secret is bound inside, so the whole
148
+ * argument is masked when one is — coarser than a slice, and the alternative is
149
+ * writing the secret.
150
+ */
151
+ interface PayloadSource {
152
+ readonly kind: "slice" | "stitched";
153
+ readonly text: string;
154
+ readonly start: number;
155
+ readonly end: number;
156
+ }
157
+
158
+ /** The span covering a stitched payload argument whole, quotes included. */
159
+ function wholeOf(payload: PayloadSource, offset: number): MaskSpan {
160
+ return {
161
+ start: payload.start + offset,
162
+ end: payload.end + offset,
163
+ replacement: REDACTED_PLACEHOLDER,
164
+ };
165
+ }
166
+
167
+ function inlineShellPayloads(root: TSNode): PayloadSource[] {
168
+ const payloads: PayloadSource[] = [];
169
+ collectInlineShellPayloads(root, payloads);
170
+ return payloads;
171
+ }
172
+
173
+ function collectInlineShellPayloads(
174
+ node: TSNode,
175
+ payloads: PayloadSource[],
176
+ ): void {
177
+ if (node.type === "command") {
178
+ const payload = inlineShellPayloadNode(node);
179
+ if (payload) payloads.push(payloadSourceOf(payload));
180
+ }
181
+ for (let i = 0; i < node.childCount; i++) {
182
+ const child = node.child(i);
183
+ if (child) collectInlineShellPayloads(child, payloads);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The program a payload node carries, sliced where the grammar spells it as one
189
+ * contiguous run of the command and stitched where it does not.
190
+ *
191
+ * A `word` payload is the program already. A `string`/`raw_string` wraps it in
192
+ * one quote pair and an `ansi_c_string` in a `$` plus one quote pair, so each is
193
+ * a slice at a known offset. Anything else — a `concatenation`, an expansion —
194
+ * is stitched: `resolveNodeText` knows how to read its shell value, and that
195
+ * value's own offsets describe no span of the command.
196
+ */
197
+ function payloadSourceOf(node: TSNode): PayloadSource {
198
+ const bounds = { start: node.startIndex, end: node.endIndex };
199
+ if (node.type === "word") {
200
+ return { kind: "slice", text: node.text, ...bounds };
201
+ }
202
+
203
+ const text = node.text;
204
+ const quoteAt = text.startsWith("$") ? 1 : 0;
205
+ const quote = text.at(quoteAt);
206
+ const singlyQuoted =
207
+ SINGLY_QUOTED_PAYLOAD_TYPES.has(node.type) &&
208
+ (quote === "'" || quote === '"') &&
209
+ text.length >= quoteAt + 2 &&
210
+ text.endsWith(quote);
211
+
212
+ return singlyQuoted
213
+ ? {
214
+ kind: "slice",
215
+ text: text.slice(quoteAt + 1, -1),
216
+ start: node.startIndex + quoteAt + 1,
217
+ end: node.endIndex - 1,
218
+ }
219
+ : { kind: "stitched", text: resolveNodeText(node), ...bounds };
220
+ }
221
+
222
+ /**
223
+ * Payload node types whose program sits inside exactly one pair of quotes, so
224
+ * removing them (and a leading `$`) leaves a verbatim span of the command.
225
+ */
226
+ const SINGLY_QUOTED_PAYLOAD_TYPES: ReadonlySet<string> = new Set([
227
+ "string",
228
+ "raw_string",
229
+ "ansi_c_string",
230
+ ]);
231
+
72
232
  /**
73
233
  * Apply {@link redactCommandSecrets} to every command-bearing key in a log
74
234
  * record.