@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 +22 -0
- package/package.json +1 -1
- package/src/denial-messages.ts +44 -3
- package/src/handlers/gates/bash-command.ts +26 -24
- package/src/handlers/gates/bash-external-directory.ts +9 -9
- package/src/handlers/gates/bash-path.ts +9 -6
- package/src/handlers/gates/bash-program.ts +368 -120
- package/src/handlers/permission-gate-handler.ts +25 -8
- package/src/permission-prompts.ts +7 -4
- package/src/types.ts +15 -0
- package/test/bash-external-directory.test.ts +10 -9
- package/test/denial-messages.test.ts +16 -0
- package/test/handlers/gates/bash-command.test.ts +62 -24
- package/test/handlers/gates/bash-external-directory.test.ts +40 -14
- package/test/handlers/gates/bash-path.test.ts +41 -14
- package/test/handlers/gates/bash-program.test.ts +204 -23
- package/test/handlers/tool-call.test.ts +38 -0
- package/test/permission-prompts.test.ts +16 -0
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
package/src/denial-messages.ts
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
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 {
|
|
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,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
29
|
+
export function describeBashExternalDirectoryGate(
|
|
30
30
|
tcc: ToolCallContext,
|
|
31
|
+
bashProgram: BashProgram | null,
|
|
31
32
|
checkPermission: CheckPermissionFn,
|
|
32
33
|
getSessionRuleset: () => Rule[],
|
|
33
|
-
):
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 {
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
33
|
+
export function describeBashPathGate(
|
|
34
34
|
tcc: ToolCallContext,
|
|
35
|
+
bashProgram: BashProgram | null,
|
|
35
36
|
checkPermission: CheckPermissionFn,
|
|
36
37
|
getSessionRuleset: () => Rule[],
|
|
37
|
-
):
|
|
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
|
-
|
|
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.
|