@gotgenes/pi-permission-system 9.0.0 → 9.1.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,33 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.1...pi-permission-system-v9.1.0) (2026-06-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+ * 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))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * document nested bash command evaluation ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([352e206](https://github.com/gotgenes/pi-packages/commit/352e206ce673ba73d57b1a4dbf24a409adf32e70))
20
+
21
+ ## [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)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * enumerate top-level bash commands in BashProgram ([cdb41e1](https://github.com/gotgenes/pi-packages/commit/cdb41e1ed03ad2219f5ba0a3ec79130bd39f3686))
27
+ * evaluate each bash sub-command with most-restrictive precedence ([85e48b2](https://github.com/gotgenes/pi-packages/commit/85e48b258dad84756a05fe11615d6d5de68a8659))
28
+ * gate bash command chains per sub-command ([#301](https://github.com/gotgenes/pi-packages/issues/301)) ([3f80097](https://github.com/gotgenes/pi-packages/commit/3f800977a909b2efc2a21ffefe933804b1c0eafd))
29
+
30
+
31
+ ### Documentation
32
+
33
+ * document per-sub-command bash chain evaluation ([#301](https://github.com/gotgenes/pi-packages/issues/301)) ([e195a70](https://github.com/gotgenes/pi-packages/commit/e195a706d192f3acaeb232c6ed580890ac3c0652))
34
+
8
35
  ## [9.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.2...pi-permission-system-v9.0.0) (2026-06-01)
9
36
 
10
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.0.0",
3
+ "version": "9.1.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": {
@@ -0,0 +1,57 @@
1
+ import type { BashCommand } from "#src/handlers/gates/bash-program";
2
+ import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
+ import type { Rule } from "#src/rule";
4
+ import type { PermissionCheckResult } from "#src/types";
5
+
6
+ /** Function type for checkPermission used by the resolver. */
7
+ type CheckPermissionFn = (
8
+ surface: string,
9
+ input: unknown,
10
+ agentName?: string,
11
+ sessionRules?: Rule[],
12
+ ) => PermissionCheckResult;
13
+
14
+ /**
15
+ * Resolve the bash command-pattern decision for a (possibly chained) command.
16
+ *
17
+ * A bash invocation may be a shell program with several commands joined by
18
+ * `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
19
+ * bash patterns lets a denied command ride through on an allowed leading one
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`).
24
+ *
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.
29
+ *
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.
33
+ *
34
+ * Pure and synchronous: the (async, tree-sitter) parse happens once in the
35
+ * handler, which passes the decomposed `commands` here.
36
+ */
37
+ export function resolveBashCommandCheck(
38
+ command: string,
39
+ commands: BashCommand[],
40
+ agentName: string | undefined,
41
+ sessionRules: Rule[],
42
+ checkPermission: CheckPermissionFn,
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
+ });
53
+ return (
54
+ pickMostRestrictive(results) ??
55
+ checkPermission("bash", { command }, agentName, sessionRules)
56
+ );
57
+ }
@@ -3,7 +3,8 @@ 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
+ import { pickMostRestrictive } from "./candidate-check";
7
8
  import type { GateResult } from "./descriptor";
8
9
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
9
10
  import type { ToolCallContext } from "./types";
@@ -19,26 +20,26 @@ type CheckPermissionFn = (
19
20
  /**
20
21
  * Build a pure descriptor for the bash external-directory permission gate.
21
22
  *
22
- * Extracts paths from a bash command and checks whether any reference
23
- * 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
24
25
  * does not apply (tool is not bash, no CWD, or no external paths found).
25
26
  * Returns a `GateBypass` when all paths are allowed (by config or session rule).
26
27
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
27
28
  */
28
- export async function describeBashExternalDirectoryGate(
29
+ export function describeBashExternalDirectoryGate(
29
30
  tcc: ToolCallContext,
31
+ bashProgram: BashProgram | null,
30
32
  checkPermission: CheckPermissionFn,
31
33
  getSessionRuleset: () => Rule[],
32
- ): Promise<GateResult> {
34
+ ): GateResult {
33
35
  if (tcc.toolName !== "bash" || !tcc.cwd) return null;
34
36
 
35
37
  const command = getNonEmptyString(toRecord(tcc.input).command);
36
38
  if (!command) return null;
37
39
 
38
- const externalPaths = await extractExternalPathsFromBashCommand(
39
- command,
40
- tcc.cwd,
41
- );
40
+ if (!bashProgram) return null;
41
+
42
+ const externalPaths = bashProgram.externalPaths(tcc.cwd);
42
43
  if (externalPaths.length === 0) return null;
43
44
 
44
45
  const bashSessionRules = getSessionRuleset();
@@ -85,7 +86,7 @@ export async function describeBashExternalDirectoryGate(
85
86
  // This ensures a config-level "deny" rule is not downgraded to "ask" by the
86
87
  // generic "*" catch-all that the old path-less checkPermission call returned.
87
88
  const worstCheck =
88
- uncoveredEntries.find(({ check }) => check.state === "deny")?.check ??
89
+ pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
89
90
  uncoveredEntries[0].check;
90
91
 
91
92
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(