@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 +27 -0
- package/package.json +1 -1
- package/src/denial-messages.ts +44 -3
- package/src/handlers/gates/bash-command.ts +57 -0
- package/src/handlers/gates/bash-external-directory.ts +11 -10
- package/src/handlers/gates/bash-path-extractor.ts +9 -618
- package/src/handlers/gates/bash-path.ts +22 -13
- package/src/handlers/gates/bash-program.ts +807 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/permission-gate-handler.ts +38 -8
- package/src/permission-prompts.ts +7 -4
- package/src/types.ts +15 -0
- package/test/denial-messages.test.ts +16 -0
- package/test/handlers/gates/bash-command.test.ts +205 -0
- 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 +179 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/tool-call.test.ts +111 -0
- package/test/permission-prompts.test.ts +16 -0
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
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": {
|
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
|
29
|
+
export function describeBashExternalDirectoryGate(
|
|
29
30
|
tcc: ToolCallContext,
|
|
31
|
+
bashProgram: BashProgram | null,
|
|
30
32
|
checkPermission: CheckPermissionFn,
|
|
31
33
|
getSessionRuleset: () => Rule[],
|
|
32
|
-
):
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
89
|
+
pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
|
|
89
90
|
uncoveredEntries[0].check;
|
|
90
91
|
|
|
91
92
|
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|