@gotgenes/pi-permission-system 9.0.1 → 9.2.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,28 @@ 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
+ ## [9.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.1.0...pi-permission-system-v9.2.0) (2026-06-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * flag relative paths conservatively after a non-literal cd ([6e631a0](https://github.com/gotgenes/pi-packages/commit/6e631a0c62633e0be3a498752a3bf3614d357d65)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
14
+ * fold sequential current-shell cd into the bash effective directory ([7fd8e95](https://github.com/gotgenes/pi-packages/commit/7fd8e9525196ffa558a2b59ea9f4cf66943f9010)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
15
+ * scope cd inside subshells and persist it across brace groups ([37b948c](https://github.com/gotgenes/pi-packages/commit/37b948c7e9fd5d9ddac3aa8c6b456039132f7c4e)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
16
+
17
+ ## [9.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.1...pi-permission-system-v9.1.0) (2026-06-02)
18
+
19
+
20
+ ### Features
21
+
22
+ * evaluate nested bash command substitutions and subshells ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([0e52d64](https://github.com/gotgenes/pi-packages/commit/0e52d645bc2f604b1317781edf48578936e0ae34))
23
+ * surface nested execution context in bash deny and ask messages ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([9d88543](https://github.com/gotgenes/pi-packages/commit/9d88543c855d16910d71c7e783c451160753c52d))
24
+
25
+
26
+ ### Documentation
27
+
28
+ * document nested bash command evaluation ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([352e206](https://github.com/gotgenes/pi-packages/commit/352e206ce673ba73d57b1a4dbf24a409adf32e70))
29
+
8
30
  ## [9.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.0...pi-permission-system-v9.0.1) (2026-06-01)
9
31
 
10
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.0.1",
3
+ "version": "9.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { EXTENSION_ID } from "./extension-config";
2
- import type { PermissionCheckResult } from "./types";
2
+ import type { BashCommandContext, PermissionCheckResult } from "./types";
3
3
 
4
4
  // ── Extension attribution tag ──────────────────────────────────────────────
5
5
 
@@ -114,13 +114,54 @@ function buildToolDenyBody(
114
114
  parts.push(`command '${check.command}'`);
115
115
  }
116
116
 
117
- if (check.matchedPattern) {
118
- parts.push(`(matched '${check.matchedPattern}')`);
117
+ const qualifier = matchQualifier(check.matchedPattern, check.commandContext);
118
+ if (qualifier) {
119
+ parts.push(qualifier);
119
120
  }
120
121
 
121
122
  return `${parts.join(" ")}.`;
122
123
  }
123
124
 
125
+ /**
126
+ * Human-readable label for a nested bash execution context, or `undefined` for
127
+ * a current-shell (top-level) command.
128
+ */
129
+ export function describeBashCommandContext(
130
+ context?: BashCommandContext,
131
+ ): string | undefined {
132
+ switch (context) {
133
+ case "command_substitution":
134
+ return "command substitution";
135
+ case "process_substitution":
136
+ return "process substitution";
137
+ case "subshell":
138
+ return "subshell";
139
+ default:
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build the parenthetical qualifier for a bash decision, folding the matched
146
+ * rule and (for a nested command) its execution context into one clause, e.g.
147
+ * `(matched 'rm *', inside command substitution)`. Returns `""` when neither
148
+ * applies.
149
+ */
150
+ export function matchQualifier(
151
+ matchedPattern?: string,
152
+ context?: BashCommandContext,
153
+ ): string {
154
+ const parts: string[] = [];
155
+ if (matchedPattern) {
156
+ parts.push(`matched '${matchedPattern}'`);
157
+ }
158
+ const label = describeBashCommandContext(context);
159
+ if (label) {
160
+ parts.push(`inside ${label}`);
161
+ }
162
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
163
+ }
164
+
124
165
  function buildUnavailableBody(ctx: DenialContext): string {
125
166
  switch (ctx.kind) {
126
167
  case "tool": {
@@ -1,4 +1,4 @@
1
- import { BashProgram } from "#src/handlers/gates/bash-program";
1
+ import type { BashCommand } from "#src/handlers/gates/bash-program";
2
2
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
3
  import type { Rule } from "#src/rule";
4
4
  import type { PermissionCheckResult } from "#src/types";
@@ -11,43 +11,45 @@ type CheckPermissionFn = (
11
11
  sessionRules?: Rule[],
12
12
  ) => PermissionCheckResult;
13
13
 
14
- /** Decompose a bash command into its top-level simple-commands. */
15
- async function decomposeTopLevelCommands(command: string): Promise<string[]> {
16
- return (await BashProgram.parse(command)).topLevelCommands();
17
- }
18
-
19
14
  /**
20
15
  * Resolve the bash command-pattern decision for a (possibly chained) command.
21
16
  *
22
17
  * A bash invocation may be a shell program with several commands joined by
23
18
  * `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
24
19
  * bash patterns lets a denied command ride through on an allowed leading one
25
- * (issue #301). Instead, decompose the command into its top-level simple-commands
26
- * and evaluate each on the `bash` surface, then select the most restrictive
27
- * result (`deny > ask > allow`).
20
+ * (issue #301). Instead, the caller supplies the program's command units (from
21
+ * the shared `BashProgram.commands()` parse) including those nested inside
22
+ * substitutions and subshells (#306); each is evaluated on the `bash` surface
23
+ * and the most restrictive result wins (`deny > ask > allow`).
28
24
  *
29
- * The selected result carries the offending sub-command in `command` and its
30
- * rule in `matchedPattern`, so the prompt, session-approval suggestion, and
31
- * decision event scope to that command.
25
+ * The selected result carries the offending sub-command in `command`, its rule
26
+ * in `matchedPattern`, and the offending command's execution context in
27
+ * `commandContext` (set only for a nested command), so the prompt,
28
+ * session-approval suggestion, and decision event scope to that command.
32
29
  *
33
- * When decomposition yields no top-level commands (an empty command, a comment,
34
- * or a bare compound statement), the whole command is evaluated as before, so
35
- * the surface is never weaker than the previous behavior.
30
+ * When `commands` is empty (an empty command, a comment, or a bare compound
31
+ * statement), the whole `command` is evaluated as before, so the surface is
32
+ * never weaker than the previous behavior.
36
33
  *
37
- * `checkPermission` stays synchronous and single-command; only the decomposition
38
- * is async (tree-sitter). `decompose` is injectable for testing.
34
+ * Pure and synchronous: the (async, tree-sitter) parse happens once in the
35
+ * handler, which passes the decomposed `commands` here.
39
36
  */
40
- export async function resolveBashCommandCheck(
37
+ export function resolveBashCommandCheck(
41
38
  command: string,
39
+ commands: BashCommand[],
42
40
  agentName: string | undefined,
43
41
  sessionRules: Rule[],
44
42
  checkPermission: CheckPermissionFn,
45
- decompose: (command: string) => Promise<string[]> = decomposeTopLevelCommands,
46
- ): Promise<PermissionCheckResult> {
47
- const units = await decompose(command);
48
- const results = units.map((unit) =>
49
- checkPermission("bash", { command: unit }, agentName, sessionRules),
50
- );
43
+ ): PermissionCheckResult {
44
+ const results = commands.map((cmd) => {
45
+ const result = checkPermission(
46
+ "bash",
47
+ { command: cmd.text },
48
+ agentName,
49
+ sessionRules,
50
+ );
51
+ return cmd.context ? { ...result, commandContext: cmd.context } : result;
52
+ });
51
53
  return (
52
54
  pickMostRestrictive(results) ??
53
55
  checkPermission("bash", { command }, agentName, sessionRules)
@@ -3,7 +3,7 @@ import type { Rule } from "#src/rule";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
- import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
6
+ import type { BashProgram } from "./bash-program";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
@@ -20,26 +20,26 @@ type CheckPermissionFn = (
20
20
  /**
21
21
  * Build a pure descriptor for the bash external-directory permission gate.
22
22
  *
23
- * Extracts paths from a bash command and checks whether any reference
24
- * directories outside the working directory. Returns `null` when the gate
23
+ * Reads the external paths from the injected `BashProgram` and checks whether
24
+ * any reference directories outside the working directory. Returns `null` when the gate
25
25
  * does not apply (tool is not bash, no CWD, or no external paths found).
26
26
  * Returns a `GateBypass` when all paths are allowed (by config or session rule).
27
27
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
28
28
  */
29
- export async function describeBashExternalDirectoryGate(
29
+ export function describeBashExternalDirectoryGate(
30
30
  tcc: ToolCallContext,
31
+ bashProgram: BashProgram | null,
31
32
  checkPermission: CheckPermissionFn,
32
33
  getSessionRuleset: () => Rule[],
33
- ): Promise<GateResult> {
34
+ ): GateResult {
34
35
  if (tcc.toolName !== "bash" || !tcc.cwd) return null;
35
36
 
36
37
  const command = getNonEmptyString(toRecord(tcc.input).command);
37
38
  if (!command) return null;
38
39
 
39
- const externalPaths = await extractExternalPathsFromBashCommand(
40
- command,
41
- tcc.cwd,
42
- );
40
+ if (!bashProgram) return null;
41
+
42
+ const externalPaths = bashProgram.externalPaths(tcc.cwd);
43
43
  if (externalPaths.length === 0) return null;
44
44
 
45
45
  const bashSessionRules = getSessionRuleset();
@@ -3,7 +3,7 @@ import type { Rule } from "#src/rule";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
- import { extractTokensForPathRules } from "./bash-path-extractor";
6
+ import type { BashProgram } from "./bash-program";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatPathAskPrompt } from "./path";
@@ -20,8 +20,8 @@ type CheckPermissionFn = (
20
20
  /**
21
21
  * Build a pure descriptor for the cross-cutting path permission gate (bash).
22
22
  *
23
- * Extracts path-candidate tokens from a bash command using tree-sitter with
24
- * the broader filter (accepts dot-files, relative paths). Evaluates each
23
+ * Reads path-candidate tokens from the injected `BashProgram` (the broader
24
+ * `path`-rule filter, accepting dot-files and relative paths). Evaluates each
25
25
  * token against the `path` permission surface and returns the most
26
26
  * restrictive result.
27
27
  *
@@ -30,17 +30,20 @@ type CheckPermissionFn = (
30
30
  * Returns a `GateBypass` when all tokens are session-covered.
31
31
  * Returns a `GateDescriptor` for the most restrictive token needing a check.
32
32
  */
33
- export async function describeBashPathGate(
33
+ export function describeBashPathGate(
34
34
  tcc: ToolCallContext,
35
+ bashProgram: BashProgram | null,
35
36
  checkPermission: CheckPermissionFn,
36
37
  getSessionRuleset: () => Rule[],
37
- ): Promise<GateResult> {
38
+ ): GateResult {
38
39
  if (tcc.toolName !== "bash") return null;
39
40
 
40
41
  const command = getNonEmptyString(toRecord(tcc.input).command);
41
42
  if (!command) return null;
42
43
 
43
- const tokens = await extractTokensForPathRules(command);
44
+ if (!bashProgram) return null;
45
+
46
+ const tokens = bashProgram.pathTokens();
44
47
  if (tokens.length === 0) return null;
45
48
 
46
49
  // Check each token against path rules with session rules appended.