@gotgenes/pi-permission-system 36.2.0 → 37.0.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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
+ ## [37.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.1...pi-permission-system-v37.0.0) (2026-10-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** **breaking:** require Pi 1.0.0 or later ([9c76ae3](https://github.com/gotgenes/pi-packages/commit/9c76ae30dd58b0a94524f753f7c72ff356231294)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** keep prompt sections other extensions add, such as <mcp_servers> ([1abdfe4](https://github.com/gotgenes/pi-packages/commit/1abdfe4901a27cb8ff3d85a965fc07b3be874624)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
18
+ * **pi-permission-system:** keep later prompt sections when a skill is denied ([dafd83b](https://github.com/gotgenes/pi-packages/commit/dafd83b075b01ef39335822f8b5122c56ee00b97)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** note the Pi 1.0.0 requirement under Upgrading ([5bc932c](https://github.com/gotgenes/pi-packages/commit/5bc932cf9936728c0e3c551fdbea6a7009108958)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
23
+ * **pi-permission-system:** record prompt changes through systemPromptOptions ([b11051f](https://github.com/gotgenes/pi-packages/commit/b11051f1365a635b67e70dea126c8a49ff9ef31b)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
24
+
25
+ ## [36.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.0...pi-permission-system-v36.2.1) (2026-10-01)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **pi-permission-system:** a reassigned $HOME or $PWD is no longer resolved to its startup value ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([956fa32](https://github.com/gotgenes/pi-packages/commit/956fa32ecbe6ee3b2954cebbfed8d9a44857c617)), closes [#995](https://github.com/gotgenes/pi-packages/issues/995)
31
+ * **pi-permission-system:** a name read, printf -v, nameref, eval, or source counts as reassigning $HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([a05a6d3](https://github.com/gotgenes/pi-packages/commit/a05a6d391eb0281d5977a2264aa69958ce8d7549))
32
+ * **pi-permission-system:** ~ is no longer read as the home directory after the command reassigns HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([998d51b](https://github.com/gotgenes/pi-packages/commit/998d51b36989bf0397beb4028d76e75d3e37666d))
33
+ * **pi-permission-system:** ~ may spell an option when the inherited HOME begins with a dash ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([f1ee69a](https://github.com/gotgenes/pi-packages/commit/f1ee69a19b3f6880228da88ad2683f9b1f79a980))
34
+ * **pi-permission-system:** a quoted export, let, trap, or quoted eval counts as reassigning $HOME ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([4406ef4](https://github.com/gotgenes/pi-packages/commit/4406ef429706aa0b7cd77ba9608d0bcb1c2982bc))
35
+
36
+ ### Documentation
37
+
38
+ * **pi-permission-system:** record that a reassigned $HOME or $PWD is not resolved ([#995](https://github.com/gotgenes/pi-packages/issues/995)) ([e7c8b88](https://github.com/gotgenes/pi-packages/commit/e7c8b88167f05531507cdfaf584dd9e4905a0387))
39
+
8
40
  ## [36.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.1.0...pi-permission-system-v36.2.0) (2026-09-30)
9
41
 
10
42
 
package/README.md CHANGED
@@ -155,6 +155,12 @@ Third-party packages are maintained by their authors; review one before granting
155
155
 
156
156
  ## Upgrading
157
157
 
158
+ ### The major after 36.x — requires Pi 1.0.0
159
+
160
+ The extension now requires `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` 1.0.0 or later.
161
+ Upgrade Pi to 1.0.0 before upgrading this package; on an older Pi, stay on the 36.x line.
162
+ No configuration change is needed.
163
+
158
164
  ### 22.0.0 — project config requires project trust
159
165
 
160
166
  Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
@@ -785,11 +785,11 @@ Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
785
785
  What the bash projection resolves:
786
786
 
787
787
  - Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`, including a file the redirect creates) and values embedded in long options (`--file=/tmp/patterns`).
788
- - The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
788
+ - The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists, as long as the command does not reassign them.
789
789
  - Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
790
790
  - A bare token (`cat id_rsa`) when it names an existing filesystem entry.
791
791
 
792
- What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
792
+ What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`), and `$HOME`, `$PWD`, or a leading `~` in a command that reassigns the variable anywhere (`HOME=/etc; cat "$HOME/shadow"`), including through `read`, `unset`, `eval`, or `source`.
793
793
  A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
794
794
  Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
795
795
  The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
@@ -1314,11 +1314,11 @@ permission:
1314
1314
 
1315
1315
  The extension integrates via Pi's lifecycle hooks:
1316
1316
 
1317
- | Hook | Behavior |
1318
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1319
- | `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match (except under a custom system prompt outside a subagent), and hides denied skills |
1320
- | `tool_call` | Enforces permissions for every tool invocation |
1321
- | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1317
+ | Hook | Behavior |
1318
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1319
+ | `before_agent_start` | Filters the active tool set (restrict-only), states a subagent child's own tool list and rules, and hides denied skills, all through pi's prompt options rather than a rewritten prompt |
1320
+ | `tool_call` | Enforces permissions for every tool invocation |
1321
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1322
1322
 
1323
1323
  Additional behaviors:
1324
1324
 
@@ -1326,16 +1326,18 @@ Additional behaviors:
1326
1326
  - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
1327
1327
  - Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
1328
1328
  A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1329
- - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
1330
1329
  - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1331
- - The tool list and guidelines are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after the working directory pi states last.
1332
- They take the shape pi writes them in: `Available tools:` and `Guidelines:` sections after a `Current working directory:` footer through pi 0.85, and `<tools>` and `<rules>` sections after a `<cwd>` section from pi 0.86.
1333
- Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session on pi's default prompt, whether or not anything is denied.
1334
- A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1330
+ - Every change this extension makes to the system prompt goes through pi's `systemPromptOptions`; it never returns a rewritten prompt.
1331
+ A returned prompt is sent as the whole prompt, which would drop the sections other extensions add after this one runs, such as pi's `<mcp_servers>` list of `codemode` and `deferred` MCP servers (see [ADR 0015](decisions/0015-prompt-changes-through-system-prompt-options.md)).
1332
+ - On pi's default prompt, the tool list and guidelines stay where pi writes them, as its `<tools>` and `<rules>` sections, and pi renders them from the filtered active set, so a denied tool and its guidelines are absent and a restored tool is listed on the turn it returns.
1333
+ - A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1335
1334
  Tool filtering and enforcement still apply, and the model still receives only the allowed tools in the request's tool list.
1336
- A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it still gets its own tool list and rules at the end, while any custom text it inherited stays untouched.
1337
- - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones
1338
- - The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1335
+ A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it gets its own `<tools>` and `<rules>` sections, placed after the working directory, while any custom text it inherited stays untouched.
1336
+ The child's sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones.
1337
+ - A subagent child running with `@gotgenes/pi-subagents` needs a release that drops the parent's `<tools>` and `<rules>` from the prompt the child inherits; with an older one, the child shows the parent's tool list as well as its own.
1338
+ - A denied skill is removed from the skills catalogue pi renders.
1339
+ A skill listed in text pi did not render, such as your own `SYSTEM.md`, is not edited out; using it is still gated.
1340
+ - The prompt options are recomputed on every turn but are stable across turns for a stable policy/agent, so the provider's prompt cache is preserved rather than rewritten each turn.
1339
1341
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1340
1342
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1341
1343
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "36.2.0",
3
+ "version": "37.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,13 +63,13 @@
63
63
  ]
64
64
  },
65
65
  "peerDependencies": {
66
- "@earendil-works/pi-coding-agent": ">=0.79.0",
67
- "@earendil-works/pi-tui": ">=0.79.0"
66
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
67
+ "@earendil-works/pi-tui": ">=1.0.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.5.11",
71
- "@earendil-works/pi-coding-agent": "0.79.1",
72
- "@earendil-works/pi-tui": "0.79.1",
71
+ "@earendil-works/pi-coding-agent": "1.0.0",
72
+ "@earendil-works/pi-tui": "1.0.0",
73
73
  "@types/node": "^22.15.3",
74
74
  "rollup": "^4.63.1",
75
75
  "rollup-plugin-dts": "^6.5.1",
@@ -7,7 +7,11 @@ import {
7
7
  import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
8
8
  import type { PathNormalizer } from "#src/path/path-normalizer";
9
9
  import { isSafeSystemPath } from "#src/path/safe-system-paths";
10
- import { ARG_NODE_TYPES, SKIP_SUBTREE_TYPES } from "./node-text";
10
+ import {
11
+ ARG_NODE_TYPES,
12
+ SKIP_SUBTREE_TYPES,
13
+ type WordReader,
14
+ } from "./node-text";
11
15
  import type { TSNode } from "./parser";
12
16
  import { REDIRECT_NODE_TYPES } from "./redirect-analysis";
13
17
  import {
@@ -121,6 +125,7 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
121
125
  export class BashPathResolver {
122
126
  constructor(
123
127
  private readonly normalizer: PathNormalizer,
128
+ private readonly words: WordReader,
124
129
  private readonly workdir?: string,
125
130
  ) {}
126
131
 
@@ -146,6 +151,10 @@ export class BashPathResolver {
146
151
  * could then allow this access. #393's unknown base declines the claim
147
152
  * instead, keeping an absolute token literal-only and unconditionally
148
153
  * external while a relative one is not projected at all.
154
+ *
155
+ * A token spelled from a `HOME` the program rebinds leaves both slices before
156
+ * projection: path normalization would otherwise expand its `~`/`$HOME` prefix
157
+ * to the startup home, naming a directory the shell never touches.
149
158
  */
150
159
  resolve(
151
160
  rootNode: TSNode,
@@ -155,10 +164,13 @@ export class BashPathResolver {
155
164
  this.workdir === undefined
156
165
  ? CWD_BASE
157
166
  : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
158
- const candidates = this.collectPathCandidates(rootNode, initialBase);
167
+ const collected = this.collectPathCandidates(rootNode, initialBase);
159
168
  for (const salvaged of salvagedRoots) {
160
- this.walkForCandidates(salvaged, UNKNOWN_BASE, candidates);
169
+ this.walkForCandidates(salvaged, UNKNOWN_BASE, collected);
161
170
  }
171
+ const candidates = collected.filter(
172
+ ({ token }) => !this.words.spellsReboundHome(token),
173
+ );
162
174
  return {
163
175
  externalAccesses: this.withWorkdirExternal(
164
176
  this.projectExternalPaths(candidates),
@@ -236,7 +248,7 @@ export class BashPathResolver {
236
248
  case "redirected_statement":
237
249
  return this.walkCurrentShellSequence(node, base, out);
238
250
  case "command":
239
- tagTokens(collectCommandTokens(node), base, out);
251
+ tagTokens(collectCommandTokens(node, this.words), base, out);
240
252
  return this.foldCd(node, base);
241
253
  case "pipeline":
242
254
  // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
@@ -261,7 +273,7 @@ export class BashPathResolver {
261
273
  // substitution interiors: collect every candidate in the subtree tagged
262
274
  // with the enclosing base and do not fold their internal `cd`s. (Folding
263
275
  // inside substitutions is deferred — conservative, never under-flags.)
264
- tagTokens(collectPathCandidateTokens(node), base, out);
276
+ tagTokens(collectPathCandidateTokens(node, this.words), base, out);
265
277
  return base;
266
278
  }
267
279
  }
@@ -321,7 +333,7 @@ export class BashPathResolver {
321
333
  }
322
334
  // Downstream stage (after a `|`): subshell — collect against the folded
323
335
  // base, do not fold.
324
- tagTokens(collectPathCandidateTokens(child), current, out);
336
+ tagTokens(collectPathCandidateTokens(child, this.words), current, out);
325
337
  }
326
338
  return current;
327
339
  }
@@ -351,7 +363,7 @@ export class BashPathResolver {
351
363
  if (child.type === "file_redirect") {
352
364
  // Redirect destinations are part of the piped stage; collect them
353
365
  // against the folded base without folding.
354
- tagTokens(collectRedirectTokens(child), current, out);
366
+ tagTokens(collectRedirectTokens(child, this.words), current, out);
355
367
  continue;
356
368
  }
357
369
  // The inner statement is the `list`/`command` being redirected; fold its
@@ -361,7 +373,7 @@ export class BashPathResolver {
361
373
  return current;
362
374
  }
363
375
  // Bare `command` or any other shape: a true subshell first stage.
364
- tagTokens(collectPathCandidateTokens(node), base, out);
376
+ tagTokens(collectPathCandidateTokens(node, this.words), base, out);
365
377
  return base;
366
378
  }
367
379
 
@@ -389,7 +401,7 @@ export class BashPathResolver {
389
401
  current = this.walkForCandidates(child, current, out);
390
402
  } else {
391
403
  // Terminal child = the real pipe stage; collect without folding.
392
- tagTokens(collectPathCandidateTokens(child), current, out);
404
+ tagTokens(collectPathCandidateTokens(child, this.words), current, out);
393
405
  }
394
406
  }
395
407
  return current;
@@ -412,7 +424,7 @@ export class BashPathResolver {
412
424
  * {@link PathNormalizer}; this method owns only the base-folding state.
413
425
  */
414
426
  private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
415
- if (extractCommandName(commandNode) !== "cd") return base;
427
+ if (extractCommandName(commandNode, this.words) !== "cd") return base;
416
428
  const target = cdLiteralTarget(commandNode);
417
429
  if (target === null) return UNKNOWN_BASE;
418
430
  return this.deriveBaseFromCdTarget(base, target);
@@ -1,6 +1,6 @@
1
1
  import type { BashCommandContext, FloorExemption } from "#src/types";
2
2
  import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
3
- import { readArgWord } from "./node-text";
3
+ import type { WordReader } from "./node-text";
4
4
  import { parseUnresolvedWithin } from "./parse-health";
5
5
  import type { TSNode } from "./parser";
6
6
  import { REDIRECT_NODE_TYPES, redirectMayWriteFile } from "./redirect-analysis";
@@ -105,14 +105,22 @@ interface UnitScope {
105
105
  * everything found inside a salvaged region is salvaged.
106
106
  */
107
107
  readonly salvaged: boolean;
108
+ /**
109
+ * How the program's shell expands an argument word. Relayed unchanged: a
110
+ * program's variables are the same wherever in it a word sits.
111
+ */
112
+ readonly words: WordReader;
108
113
  }
109
114
 
110
115
  /** A top-level command in the current shell, writing no file, fully parsed. */
111
- const TOP_LEVEL_SCOPE: UnitScope = {
112
- writesViaRedirect: false,
113
- parseUnresolved: false,
114
- salvaged: false,
115
- };
116
+ function topLevelScope(words: WordReader): UnitScope {
117
+ return {
118
+ writesViaRedirect: false,
119
+ parseUnresolved: false,
120
+ salvaged: false,
121
+ words,
122
+ };
123
+ }
116
124
 
117
125
  /**
118
126
  * The scope a salvaged region's own units run under.
@@ -123,11 +131,14 @@ const TOP_LEVEL_SCOPE: UnitScope = {
123
131
  * {@link collectHostedCommands} resets it: a redirect established outside the
124
132
  * region is the enclosing statement's, not the region's.
125
133
  */
126
- const SALVAGED_SCOPE: UnitScope = {
127
- writesViaRedirect: false,
128
- parseUnresolved: true,
129
- salvaged: true,
130
- };
134
+ function salvagedScope(words: WordReader): UnitScope {
135
+ return {
136
+ writesViaRedirect: false,
137
+ parseUnresolved: true,
138
+ salvaged: true,
139
+ words,
140
+ };
141
+ }
131
142
 
132
143
  // ── Node-type vocabulary ─────────────────────────────────────────────────────
133
144
 
@@ -244,9 +255,12 @@ const STATEMENT_TYPES = new Set([
244
255
  * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
245
256
  * tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
246
257
  */
247
- export function collectCommands(node: TSNode): BashCommand[] {
258
+ export function collectCommands(
259
+ node: TSNode,
260
+ words: WordReader,
261
+ ): BashCommand[] {
248
262
  const out: BashCommand[] = [];
249
- collectCommandsInto(node, TOP_LEVEL_SCOPE, out);
263
+ collectCommandsInto(node, topLevelScope(words), out);
250
264
  return out;
251
265
  }
252
266
 
@@ -260,9 +274,12 @@ export function collectCommands(node: TSNode): BashCommand[] {
260
274
  * explicit `deny` fires — while its `allow` is still floored to `ask` by the
261
275
  * verdict fold (#840).
262
276
  */
263
- export function collectSalvagedCommands(node: TSNode): BashCommand[] {
277
+ export function collectSalvagedCommands(
278
+ node: TSNode,
279
+ words: WordReader,
280
+ ): BashCommand[] {
264
281
  const out: BashCommand[] = [];
265
- collectCommandsInto(node, SALVAGED_SCOPE, out);
282
+ collectCommandsInto(node, salvagedScope(words), out);
266
283
  return out;
267
284
  }
268
285
 
@@ -282,11 +299,14 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
282
299
  * (`python3 -c`, `node -e`) out: its payload is another language, so re-parsing
283
300
  * it as bash would read a secret out of embedded Python.
284
301
  */
285
- export function inlineShellPayloadNode(command: TSNode): TSNode | null {
302
+ export function inlineShellPayloadNode(
303
+ command: TSNode,
304
+ words: WordReader,
305
+ ): TSNode | null {
286
306
  const nodes = commandWordNodes(command);
287
307
  const index = inlineShellPayloadIndex(
288
308
  nodes.map((node) => ({
289
- ...readArgWord(node),
309
+ ...words.argWord(node),
290
310
  text: node.text,
291
311
  offset: node.startIndex,
292
312
  })),
@@ -426,7 +446,7 @@ function makeUnit(
426
446
  * surely as one on the enclosing statement.
427
447
  */
428
448
  function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
429
- const { text, words } = readCommandUnit(node);
449
+ const { text, words } = readCommandUnit(node, scope.words);
430
450
  return makeUnit(text, scope, {
431
451
  wrapperKind: classifyWrapperWords(words),
432
452
  executedUnit: executedUnitOf(text, words) ?? undefined,
@@ -482,7 +502,10 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
482
502
  * A pure assignment (`FOO=bar`, no `command_name`) runs no command, has no
483
503
  * words, and keeps its whole text.
484
504
  */
485
- function readCommandUnit(node: TSNode): {
505
+ function readCommandUnit(
506
+ node: TSNode,
507
+ reader: WordReader,
508
+ ): {
486
509
  text: string;
487
510
  words: CommandWord[];
488
511
  } {
@@ -495,7 +518,11 @@ function readCommandUnit(node: TSNode): {
495
518
  let previous: TSNode | undefined;
496
519
  for (const word of nodes) {
497
520
  if (previous) text += gapBetween(node, previous, word, redirects);
498
- words.push({ ...readArgWord(word), text: word.text, offset: text.length });
521
+ words.push({
522
+ ...reader.argWord(word),
523
+ text: word.text,
524
+ offset: text.length,
525
+ });
499
526
  text += word.text;
500
527
  previous = word;
501
528
  }
@@ -626,6 +653,7 @@ function collectHostedCommands(
626
653
  writesViaRedirect: false,
627
654
  parseUnresolved: false,
628
655
  salvaged: scope.salvaged,
656
+ words: scope.words,
629
657
  },
630
658
  out,
631
659
  );
@@ -1,5 +1,5 @@
1
1
  import type { TSNode } from "./parser";
2
- import { resolvePlainVariableExpansion } from "./shell-variable-expansion";
2
+ import type { ShellVariables } from "./shell-variable-expansion";
3
3
 
4
4
  /**
5
5
  * Node types whose text content is never a command argument, so no path
@@ -35,13 +35,13 @@ export const ARG_NODE_TYPES = new Set([
35
35
  * since a word only the shell decides can spell any option at all.
36
36
  */
37
37
  export interface ArgWord {
38
- /** The string the shell passes after quote removal ({@link resolveNodeText}). */
38
+ /** The string the shell passes after quote removal ({@link resolveText}). */
39
39
  readonly value: string;
40
40
  /**
41
41
  * Whether `value` may differ from what the program receives: a part only
42
- * running the command decides ({@link hasComputedPart}), or a spelling the
42
+ * running the command decides ({@link computedPart}), or a spelling the
43
43
  * shell rewrites before the program sees it — an escape, a glob, a brace
44
- * expansion, an ANSI-C string — which {@link resolveNodeText} passes through as written.
44
+ * expansion, an ANSI-C string — which {@link resolveText} passes through as written.
45
45
  */
46
46
  readonly computed: boolean;
47
47
  /**
@@ -53,17 +53,67 @@ export interface ArgWord {
53
53
  readonly mayLeadWithDash: boolean;
54
54
  }
55
55
 
56
- /** Read an argument node into the word the program receives. */
57
- export function readArgWord(node: TSNode): ArgWord {
58
- const value = resolveNodeText(node);
59
- const computed = hasComputedPart(node) || !isSpelledExactly(node);
60
- return {
61
- value,
62
- computed,
63
- mayLeadWithDash: computed
64
- ? mayExpandToDashWord(node)
65
- : value.startsWith("-"),
66
- };
56
+ /**
57
+ * Reads argument nodes as one program's shell expands them: the node-text
58
+ * reads bound to the {@link ShellVariables} that program rebinds.
59
+ */
60
+ export class WordReader {
61
+ constructor(private readonly variables: ShellVariables) {}
62
+
63
+ /** Read an argument node into the word the program receives. */
64
+ argWord(node: TSNode): ArgWord {
65
+ const value = this.text(node);
66
+ const tilde = this.variables.readTilde(leadingUnquotedText(node));
67
+ if (tilde?.known === false) {
68
+ return { value, computed: true, mayLeadWithDash: true };
69
+ }
70
+ const computed =
71
+ this.isComputed(node) || !isSpelledExactly(node, this.variables);
72
+ if (tilde) {
73
+ // The spelling `~` is not what the program receives; it is exact enough
74
+ // to prove with only while the home it stands for cannot be an option.
75
+ return {
76
+ value,
77
+ computed: computed || tilde.leadsWithDash,
78
+ mayLeadWithDash:
79
+ tilde.leadsWithDash || (computed && maySplitIntoWords(node, false)),
80
+ };
81
+ }
82
+ return {
83
+ value,
84
+ computed,
85
+ mayLeadWithDash: computed
86
+ ? mayExpandToDashWord(node)
87
+ : value.startsWith("-"),
88
+ };
89
+ }
90
+
91
+ /** The string the shell passes after quote removal ({@link resolveText}). */
92
+ text(node: TSNode): string {
93
+ return resolveText(node, this.variables);
94
+ }
95
+
96
+ /** Whether the node's value is decided at run time ({@link computedPart}). */
97
+ isComputed(node: TSNode): boolean {
98
+ return computedPart(node, this.variables);
99
+ }
100
+
101
+ /** Whether a collected token is spelled from a rebound `HOME` ({@link ShellVariables.spellsReboundHome}). */
102
+ spellsReboundHome(token: string): boolean {
103
+ return this.variables.spellsReboundHome(token);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The unquoted literal an argument opens with, where bash expands a tilde
109
+ * prefix: the word itself, or a concatenation's first part when that is a
110
+ * word. A tilde after any other part, or inside quotes, stays literal.
111
+ */
112
+ function leadingUnquotedText(node: TSNode): string {
113
+ if (node.type === "word") return node.text;
114
+ if (node.type !== "concatenation") return "";
115
+ const first = node.child(0);
116
+ return first?.type === "word" ? first.text : "";
67
117
  }
68
118
 
69
119
  /**
@@ -182,12 +232,12 @@ function quotedLiteralMayLeadWithDash(text: string): boolean | undefined {
182
232
  }
183
233
 
184
234
  /**
185
- * Whether {@link resolveNodeText} returns exactly the string the shell passes.
235
+ * Whether {@link resolveText} returns exactly the string the shell passes.
186
236
  *
187
237
  * Answers `false` for any node type it does not know, which is the
188
238
  * fail-closed direction for a caller proving what a word cannot be.
189
239
  */
190
- function isSpelledExactly(node: TSNode): boolean {
240
+ function isSpelledExactly(node: TSNode, variables: ShellVariables): boolean {
191
241
  switch (node.type) {
192
242
  case "raw_string":
193
243
  return true;
@@ -197,27 +247,33 @@ function isSpelledExactly(node: TSNode): boolean {
197
247
  return !node.text.includes("\\");
198
248
  case "simple_expansion":
199
249
  case "expansion":
200
- return resolvePlainVariableExpansion(node) !== null;
250
+ return variables.resolveReference(node) !== null;
201
251
  case "string":
202
252
  // A digit run is an ordinary word to the shell; the grammar's `10#$x` form
203
253
  // carries an expansion child, which answers for itself.
204
254
  case "number":
205
- return childrenSpelledExactly(node);
255
+ return childrenSpelledExactly(node, variables);
206
256
  case "concatenation":
207
257
  // The grammar splits `{-i,-n}` into plain words, so the expansion is
208
258
  // visible only across the whole concatenation's text.
209
- return !BRACE_EXPANSION.test(node.text) && childrenSpelledExactly(node);
259
+ return (
260
+ !BRACE_EXPANSION.test(node.text) &&
261
+ childrenSpelledExactly(node, variables)
262
+ );
210
263
  default:
211
264
  return false;
212
265
  }
213
266
  }
214
267
 
215
268
  /** A `"` delimiter is spelled exactly; every named child must be too. */
216
- function childrenSpelledExactly(node: TSNode): boolean {
269
+ function childrenSpelledExactly(
270
+ node: TSNode,
271
+ variables: ShellVariables,
272
+ ): boolean {
217
273
  for (let i = 0; i < node.childCount; i++) {
218
274
  const child = node.child(i);
219
275
  if (!child || child.type === '"') continue;
220
- if (!isSpelledExactly(child)) return false;
276
+ if (!isSpelledExactly(child, variables)) return false;
221
277
  }
222
278
  return true;
223
279
  }
@@ -236,22 +292,22 @@ const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/;
236
292
  /**
237
293
  * Whether an argument node's value is decided at run time: it contains a
238
294
  * command or process substitution, an arithmetic expansion, or a variable
239
- * expansion {@link resolvePlainVariableExpansion} cannot resolve.
295
+ * expansion {@link ShellVariables.resolveReference} cannot resolve.
240
296
  *
241
- * The complement of what {@link resolveNodeText} can spell exactly. A plain
297
+ * The complement of what {@link resolveText} can spell exactly. A plain
242
298
  * `$HOME` / `$PWD` reference resolves, so `"$HOME/out"` is not computed; any
243
299
  * other expansion falls back to its own source text there, which names a file
244
300
  * that is not the one the shell will touch (ADR 0009's computed-path residual).
245
301
  * A single-quoted `'$x'` is a literal.
246
302
  */
247
- export function hasComputedPart(node: TSNode): boolean {
303
+ function computedPart(node: TSNode, variables: ShellVariables): boolean {
248
304
  if (COMPUTED_NODE_TYPES.has(node.type)) return true;
249
305
  if (VARIABLE_EXPANSION_TYPES.has(node.type)) {
250
- return resolvePlainVariableExpansion(node) === null;
306
+ return variables.resolveReference(node) === null;
251
307
  }
252
308
  for (let i = 0; i < node.childCount; i++) {
253
309
  const child = node.child(i);
254
- if (child && hasComputedPart(child)) return true;
310
+ if (child && computedPart(child, variables)) return true;
255
311
  }
256
312
  return false;
257
313
  }
@@ -281,7 +337,7 @@ const VARIABLE_EXPANSION_TYPES: ReadonlySet<string> = new Set([
281
337
  * else `.text` (see `shell-variable-expansion.ts`)
282
338
  * - other → `.text` as fallback
283
339
  */
284
- export function resolveNodeText(node: TSNode): string {
340
+ function resolveText(node: TSNode, variables: ShellVariables): string {
285
341
  switch (node.type) {
286
342
  case "word":
287
343
  return node.text;
@@ -302,7 +358,7 @@ export function resolveNodeText(node: TSNode): string {
302
358
  if (!child) continue;
303
359
  // Skip the literal `"` delimiters
304
360
  if (child.type === '"') continue;
305
- result += resolveNodeText(child);
361
+ result += resolveText(child, variables);
306
362
  }
307
363
  return result;
308
364
  }
@@ -310,13 +366,13 @@ export function resolveNodeText(node: TSNode): string {
310
366
  return node.text;
311
367
  case "simple_expansion":
312
368
  case "expansion":
313
- return resolvePlainVariableExpansion(node) ?? node.text;
369
+ return variables.resolveReference(node) ?? node.text;
314
370
  case "concatenation": {
315
371
  let result = "";
316
372
  for (let i = 0; i < node.childCount; i++) {
317
373
  const child = node.child(i);
318
374
  if (!child) continue;
319
- result += resolveNodeText(child);
375
+ result += resolveText(child, variables);
320
376
  }
321
377
  return result;
322
378
  }
@@ -9,7 +9,9 @@ import {
9
9
  collectCommands,
10
10
  collectSalvagedCommands,
11
11
  } from "./command-enumeration";
12
+ import { WordReader } from "./node-text";
12
13
  import { getParser } from "./parser";
14
+ import { ShellVariables } from "./shell-variable-expansion";
13
15
  import { withSalvagedRoots } from "./unresolved-salvage";
14
16
 
15
17
  export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
@@ -62,15 +64,19 @@ export class BashProgram {
62
64
 
63
65
  try {
64
66
  return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
67
+ const words = new WordReader(
68
+ ShellVariables.scan([tree.rootNode, ...salvaged]),
69
+ );
65
70
  const { externalAccesses, ruleCandidates } = new BashPathResolver(
66
71
  normalizer,
72
+ words,
67
73
  options?.workdir,
68
74
  ).resolve(tree.rootNode, salvaged);
69
75
  return new BashProgram(
70
76
  command,
71
77
  [
72
- ...collectCommands(tree.rootNode),
73
- ...salvaged.flatMap(collectSalvagedCommands),
78
+ ...collectCommands(tree.rootNode, words),
79
+ ...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
74
80
  ],
75
81
  externalAccesses,
76
82
  ruleCandidates,