@gotgenes/pi-permission-system 24.0.0 → 25.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,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
+ ## [25.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v24.0.0...pi-permission-system-v25.0.0) (2026-08-11)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** a bash command referencing `$HOME` or `${HOME}` now reaches the `external_directory` gate whether or not the target exists, so a policy with `external_directory: {"*": "ask"}` prompts for commands that previously ran silently. Allow the directory explicitly to restore the old behavior — e.g. `"external_directory": {"~/.cargo/registry/*": "allow"}`. The token shown in prompts, review-log entries, and derived session-approval patterns is now the expanded path rather than the `$HOME/...` spelling; this makes the prompt agree with the approval pattern, which was already derived from the expanded form.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-system:** expand ${HOME} alongside $HOME in path patterns ([3ebbd41](https://github.com/gotgenes/pi-packages/commit/3ebbd418e5b85a6dfbc1865c69f62c1f9a3cb144)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **pi-permission-system:** resolve $HOME and $PWD expansions in bash path tokens ([8cbc7ee](https://github.com/gotgenes/pi-packages/commit/8cbc7ee011a3e06dcf047bfe8a467db3aa487721)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **pi-permission-system:** record resolved shell expansions in ADR 0009 and user docs ([caaf5ec](https://github.com/gotgenes/pi-packages/commit/caaf5ec02ef2f7df1e2a922dba0a9afdc121b191)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
28
+
8
29
  ## [24.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.3...pi-permission-system-v24.0.0) (2026-07-26)
9
30
 
10
31
 
@@ -106,7 +106,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
106
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
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
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 (`*`, `?`). |
109
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
110
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/`.
@@ -136,7 +136,7 @@ Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt
136
136
  ### `piInfrastructureReadPaths` patterns
137
137
 
138
138
  Each entry is either a plain directory prefix or a wildcard pattern.
139
- Plain entries match any path that starts with the given directory (after `~`/`$HOME` expansion).
139
+ Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
140
140
  Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
141
141
  `*` and `**` are equivalent — both cross directory boundaries.
142
142
 
@@ -587,9 +587,22 @@ The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs a
587
587
  For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
588
588
  Use `external_directory` when the allowance must apply to every tool.
589
589
 
590
- Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`.
591
- Quoted strings are stripped first to reduce false positives.
592
- This is a best-effort heuristic — variable expansion and escaped quotes are not parsed, and relative paths inside subshells are not yet resolved against a per-subshell working directory. (The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
590
+ Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
591
+ Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
592
+
593
+ What the bash projection resolves:
594
+
595
+ - Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
596
+ - 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.
597
+ - Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
598
+ - A bare token (`cat id_rsa`) when it names an existing filesystem entry.
599
+
600
+ 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"`).
601
+ A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
602
+ Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
603
+ 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.
604
+
605
+ (The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
593
606
 
594
607
  #### Symlinked paths
595
608
 
@@ -644,8 +657,9 @@ To allow-list such a path, write the rule using the path as typed — for exampl
644
657
 
645
658
  ### Home Directory Expansion in Patterns
646
659
 
647
- Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
660
+ Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
648
661
  They are expanded to the OS home directory at match time, so configs are portable across machines and users.
662
+ A prefix is recognized only when it stands alone or precedes a separator, so a longer name (`~username`, `$HOMEDIR`) and a braced expansion carrying an operator (`${HOME:-/tmp}`) are left alone.
649
663
 
650
664
  ```jsonc
651
665
  {
@@ -661,7 +675,7 @@ They are expanded to the OS home directory at match time, so configs are portabl
661
675
  The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
662
676
 
663
677
  Path **values** supplied by tool calls and bash commands are expanded the same way.
664
- This means `~/...`, `$HOME/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
678
+ This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
665
679
 
666
680
  ---
667
681
 
@@ -20,7 +20,7 @@ The following concepts are shared between OpenCode and this extension:
20
20
  | Last-match-wins | When multiple patterns match, the last one in config order wins |
21
21
  | `*` wildcard | Matches zero or more of any character (including path separators) |
22
22
  | `?` wildcard | Matches exactly one character |
23
- | Home directory expansion | `~/` and `$HOME/` expand to the OS home directory in patterns |
23
+ | Home directory expansion | `~/`, `$HOME/`, and `${HOME}/` expand to the OS home directory in patterns |
24
24
  | `external_directory` surface | Gates access to paths outside the working directory |
25
25
  | `bash` surface | Command patterns matched against shell commands |
26
26
  | `skill` surface | Skill name patterns matched against skill invocations |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "24.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import type { TSNode } from "#src/access-intent/bash/parser";
2
+ import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
2
3
 
3
4
  /**
4
5
  * Node types whose subtrees must never be descended into for
@@ -29,6 +30,8 @@ export const ARG_NODE_TYPES = new Set([
29
30
  * - `raw_string` → strip surrounding single quotes
30
31
  * - `string` → strip surrounding double quotes, concatenate children text
31
32
  * - `concatenation` → concatenate resolved children
33
+ * - expansions → the resolved value of a plain `$HOME`/`$PWD` reference,
34
+ * else `.text` (see `shell-variable-expansion.ts`)
32
35
  * - other → `.text` as fallback
33
36
  */
34
37
  export function resolveNodeText(node: TSNode): string {
@@ -57,9 +60,10 @@ export function resolveNodeText(node: TSNode): string {
57
60
  return result;
58
61
  }
59
62
  case "string_content":
63
+ return node.text;
60
64
  case "simple_expansion":
61
65
  case "expansion":
62
- return node.text;
66
+ return resolvePlainVariableExpansion(node) ?? node.text;
63
67
  case "concatenation": {
64
68
  let result = "";
65
69
  for (let i = 0; i < node.childCount; i++) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resolution of the shell variable references the bash path projection can
3
+ * settle statically.
4
+ *
5
+ * Runs at token collection, upstream of classification: by the time a token
6
+ * reaches `classifyTokenAsPathCandidate` it already carries the expanded path,
7
+ * so `$HOME/x` is accepted by the ordinary absolute-shape branch and needs no
8
+ * per-variable knowledge in the classifiers (#694). Keeping the vocabulary here
9
+ * — rather than teaching each classifier a `$HOME` prefix — is what stops the
10
+ * two from drifting apart, which is the defect this module closes.
11
+ *
12
+ * The resolvable set is deliberately tiny and closed. `HOME` is the spelling
13
+ * `expandHomePath` already resolves for config patterns and path literals, so
14
+ * resolving it here removes an inconsistency rather than widening the
15
+ * determinism boundary; `PWD` reads no environment at all. Every other name
16
+ * keeps its literal text, so ADR 0003's exclusion of ambient host state stands.
17
+ * See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
18
+ */
19
+ import { homedir } from "node:os";
20
+
21
+ import type { TSNode } from "#src/access-intent/bash/parser";
22
+
23
+ /**
24
+ * The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
25
+ * is not a plain reference or names a variable outside the resolvable set.
26
+ *
27
+ * Plainness is decided structurally, not by matching the node's text: a plain
28
+ * reference carries exactly one `variable_name` child and nothing else but
29
+ * delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
30
+ * carries additional children and is therefore rejected without this module
31
+ * needing to enumerate bash's expansion operators.
32
+ */
33
+ export function resolvePlainVariableExpansion(node: TSNode): string | null {
34
+ const name = plainVariableName(node);
35
+ return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
36
+ }
37
+
38
+ /**
39
+ * How each resolvable variable is spelled as a path.
40
+ *
41
+ * `PWD` resolves to the base-relative marker rather than a directory: the
42
+ * shell's working directory at a given point *is* the projection's effective
43
+ * base, which the resolver already applies via `resolveBase`. Handing back `.`
44
+ * therefore lands `$PWD/x` on the same footing as `./x` — correct after any
45
+ * `cd` folding, conservative under an unknown base (#393), and free of both a
46
+ * threaded base parameter and a platform branch.
47
+ */
48
+ const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
49
+ ["HOME", homedir],
50
+ ["PWD", () => "."],
51
+ ]);
52
+
53
+ /** Node types that delimit an expansion without altering what it evaluates to. */
54
+ const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
55
+
56
+ /**
57
+ * The variable a node plainly references, or `null` when it references none —
58
+ * because it has no `variable_name` child, has more than one, or carries a
59
+ * child that is neither the name nor a delimiter (an expansion operator and its
60
+ * operand, or an assignment's `=` and value).
61
+ */
62
+ function plainVariableName(node: TSNode): string | null {
63
+ let name: string | null = null;
64
+
65
+ for (let i = 0; i < node.childCount; i++) {
66
+ const child = node.child(i);
67
+ if (!child) continue;
68
+ if (child.type === "variable_name") {
69
+ if (name !== null) return null;
70
+ name = child.text;
71
+ continue;
72
+ }
73
+ if (!EXPANSION_DELIMITERS.has(child.type)) return null;
74
+ }
75
+
76
+ return name;
77
+ }
@@ -2,27 +2,41 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
 
4
4
  /**
5
- * Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
5
+ * The spellings of the home directory this package resolves, in every pattern
6
+ * and path literal.
6
7
  *
7
- * Supported forms:
8
- * - `~` → `homedir()`
9
- * - `~/path` → `homedir()/path`
10
- * - `~\path` → `homedir()\path` (Windows)
11
- * - `$HOME` → `homedir()`
12
- * - `$HOME/path` `homedir()/path`
13
- * - `$HOME\path` → `homedir()\path` (Windows)
8
+ * `$HOME` and `${HOME}` are the two spellings of the same shell variable and
9
+ * must stay interchangeable: a rule keyed on one form has to match a path
10
+ * written in the other, and the bash path projection classifies a token by the
11
+ * shape it has *after* this expansion (#694).
12
+ */
13
+ const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
14
+
15
+ /**
16
+ * Expand a home-directory prefix in a pattern or path value to the OS home
17
+ * directory.
18
+ *
19
+ * A prefix is recognized only when it stands alone or is followed by a path
20
+ * separator, so a longer name (`~username`, `$HOMEDIR`, `${HOMEDIR}`) and a
21
+ * braced parameter expansion carrying an operator (`${HOME:-/tmp}`,
22
+ * `${HOME%/*}`) are both left untouched.
23
+ *
24
+ * Supported forms, for each prefix in {@link HOME_PREFIXES}:
25
+ * - `<prefix>` → `homedir()`
26
+ * - `<prefix>/path` → `homedir()/path`
27
+ * - `<prefix>\path` → `homedir()\path` (Windows)
14
28
  *
15
29
  * All other patterns are returned unchanged.
16
30
  */
17
31
  export function expandHomePath(pattern: string): string {
18
- if (pattern === "~" || pattern === "$HOME") {
19
- return homedir();
20
- }
21
- if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
22
- return join(homedir(), pattern.slice(2));
23
- }
24
- if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
25
- return join(homedir(), pattern.slice(6));
32
+ for (const prefix of HOME_PREFIXES) {
33
+ if (pattern === prefix) return homedir();
34
+ if (!pattern.startsWith(prefix)) continue;
35
+
36
+ const rest = pattern.slice(prefix.length);
37
+ if (rest.startsWith("/") || rest.startsWith("\\")) {
38
+ return join(homedir(), rest.slice(1));
39
+ }
26
40
  }
27
41
  return pattern;
28
42
  }