@gotgenes/pi-permission-system 9.0.1 → 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 +13 -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 +135 -55
- package/src/handlers/permission-gate-handler.ts +25 -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 +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 +95 -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,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
|
+
## [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
|
+
|
|
8
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)
|
|
9
22
|
|
|
10
23
|
|
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.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isSafeSystemPath,
|
|
11
11
|
normalizePathForComparison,
|
|
12
12
|
} from "#src/path-utils";
|
|
13
|
+
import type { BashCommandContext } from "#src/types";
|
|
13
14
|
|
|
14
15
|
// ── tree-sitter-bash lazy parser ───────────────────────────────────────────
|
|
15
16
|
|
|
@@ -21,6 +22,8 @@ interface TSNode {
|
|
|
21
22
|
readonly type: string;
|
|
22
23
|
readonly text: string;
|
|
23
24
|
readonly childCount: number;
|
|
25
|
+
/** False for anonymous tokens (operators, delimiters); true for named nodes. */
|
|
26
|
+
readonly isNamed: boolean;
|
|
24
27
|
child(index: number): TSNode | null;
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -55,6 +58,23 @@ function getParser(): Promise<TSParser> {
|
|
|
55
58
|
|
|
56
59
|
// ── Parsed bash command representation ───────────────────────────────────────
|
|
57
60
|
|
|
61
|
+
/**
|
|
62
|
+
* One command-pattern unit of a parsed bash program.
|
|
63
|
+
*
|
|
64
|
+
* Minimal by design — `text` is the simple-command (or whole compound
|
|
65
|
+
* statement) string matched against the bash rules. The type is the stable
|
|
66
|
+
* extension point: #306 adds an execution `context`, #307 adds per-command
|
|
67
|
+
* path candidates and an effective working directory.
|
|
68
|
+
*/
|
|
69
|
+
export interface BashCommand {
|
|
70
|
+
readonly text: string;
|
|
71
|
+
/**
|
|
72
|
+
* Execution context for a nested command (substitution or subshell); absent
|
|
73
|
+
* for a current-shell (top-level) command.
|
|
74
|
+
*/
|
|
75
|
+
readonly context?: BashCommandContext;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/**
|
|
59
79
|
* A bash command parsed once into a reusable representation.
|
|
60
80
|
*
|
|
@@ -69,7 +89,7 @@ export class BashProgram {
|
|
|
69
89
|
private constructor(
|
|
70
90
|
private readonly rawTokens: readonly string[],
|
|
71
91
|
private readonly leadingCdTarget: string | undefined,
|
|
72
|
-
private readonly
|
|
92
|
+
private readonly commandUnits: readonly BashCommand[],
|
|
73
93
|
) {}
|
|
74
94
|
|
|
75
95
|
/**
|
|
@@ -88,8 +108,8 @@ export class BashProgram {
|
|
|
88
108
|
try {
|
|
89
109
|
const leadingCdTarget = extractLeadingCdTarget(tree.rootNode);
|
|
90
110
|
const rawTokens = collectPathCandidateTokens(tree.rootNode);
|
|
91
|
-
const
|
|
92
|
-
return new BashProgram(rawTokens, leadingCdTarget,
|
|
111
|
+
const commandUnits = collectCommands(tree.rootNode);
|
|
112
|
+
return new BashProgram(rawTokens, leadingCdTarget, commandUnits);
|
|
93
113
|
} finally {
|
|
94
114
|
tree.delete();
|
|
95
115
|
}
|
|
@@ -102,10 +122,6 @@ export class BashProgram {
|
|
|
102
122
|
* paths; does NOT filter by CWD. Returns deduplicated tokens for rule
|
|
103
123
|
* evaluation.
|
|
104
124
|
*/
|
|
105
|
-
// Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
|
|
106
|
-
// analysis cannot resolve the static-factory return type (private ctor), so
|
|
107
|
-
// it reports a false positive here.
|
|
108
|
-
// fallow-ignore-next-line unused-class-member
|
|
109
125
|
pathTokens(): string[] {
|
|
110
126
|
const seen = new Set<string>();
|
|
111
127
|
const result: string[] = [];
|
|
@@ -121,17 +137,7 @@ export class BashProgram {
|
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* When the command begins with `cd <dir> && …`, relative candidate paths are
|
|
127
|
-
* resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
|
|
128
|
-
* mirroring how the shell would resolve them.
|
|
129
|
-
*/
|
|
130
|
-
// Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
|
|
131
|
-
// analysis cannot resolve the static-factory return type (private ctor), so
|
|
132
|
-
// it reports a false positive here.
|
|
133
|
-
/**
|
|
134
|
-
* The top-level simple-commands of the chain, in source order.
|
|
140
|
+
* The top-level command-pattern units of the chain, in source order.
|
|
135
141
|
*
|
|
136
142
|
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
|
|
137
143
|
* quotes, command substitution, and subshells are respected by the parser and
|
|
@@ -143,11 +149,17 @@ export class BashProgram {
|
|
|
143
149
|
// syntactic analysis cannot resolve the static-factory return type (private
|
|
144
150
|
// ctor), so it reports a false positive here.
|
|
145
151
|
// fallow-ignore-next-line unused-class-member
|
|
146
|
-
|
|
147
|
-
return [...this.
|
|
152
|
+
commands(): BashCommand[] {
|
|
153
|
+
return [...this.commandUnits];
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Deduplicated paths that resolve outside `cwd`.
|
|
158
|
+
*
|
|
159
|
+
* When the command begins with `cd <dir> && …`, relative candidate paths are
|
|
160
|
+
* resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
|
|
161
|
+
* mirroring how the shell would resolve them.
|
|
162
|
+
*/
|
|
151
163
|
externalPaths(cwd: string): string[] {
|
|
152
164
|
const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
|
|
153
165
|
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
@@ -592,14 +604,12 @@ function collectPathCandidateTokens(node: TSNode): string[] {
|
|
|
592
604
|
// which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
|
|
593
605
|
// with a shared rejectNonPathToken predicate eliminating the prior clone.
|
|
594
606
|
|
|
595
|
-
// ──
|
|
607
|
+
// ── Command enumeration ──────────────────────────────────────────────────────
|
|
596
608
|
|
|
597
609
|
/**
|
|
598
|
-
* Container node types descended into when enumerating
|
|
599
|
-
* A `cd` or `rm` inside a subshell or compound statement is NOT a top-level
|
|
600
|
-
* command, so those node types are deliberately absent.
|
|
610
|
+
* Container node types descended into when enumerating command units.
|
|
601
611
|
*/
|
|
602
|
-
const
|
|
612
|
+
const COMMAND_ENUM_DESCEND = new Set([
|
|
603
613
|
"program",
|
|
604
614
|
"list",
|
|
605
615
|
"pipeline",
|
|
@@ -607,18 +617,12 @@ const TOP_LEVEL_COMMAND_DESCEND = new Set([
|
|
|
607
617
|
]);
|
|
608
618
|
|
|
609
619
|
/**
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
620
|
+
* Named node types skipped during command enumeration: redirect targets,
|
|
621
|
+
* comments, and heredoc bodies — none is a command to evaluate. Anonymous
|
|
622
|
+
* tokens (chain operators `&&`/`;`/`|`, substitution and subshell delimiters
|
|
623
|
+
* `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not listed here.
|
|
613
624
|
*/
|
|
614
|
-
const
|
|
615
|
-
"&&",
|
|
616
|
-
"||",
|
|
617
|
-
";",
|
|
618
|
-
"&",
|
|
619
|
-
"|",
|
|
620
|
-
"|&",
|
|
621
|
-
"\n",
|
|
625
|
+
const COMMAND_ENUM_SKIP = new Set([
|
|
622
626
|
"file_redirect",
|
|
623
627
|
"heredoc_redirect",
|
|
624
628
|
"herestring_redirect",
|
|
@@ -628,29 +632,105 @@ const TOP_LEVEL_COMMAND_SKIP = new Set([
|
|
|
628
632
|
]);
|
|
629
633
|
|
|
630
634
|
/**
|
|
631
|
-
*
|
|
635
|
+
* Nested execution contexts whose interior commands really execute and must be
|
|
636
|
+
* evaluated too: command substitution (`$(…)`, backticks) and process
|
|
637
|
+
* substitution (`<(…)`/`>(…)`). Subshells (`( … )`) are handled separately
|
|
638
|
+
* because they are also emitted whole.
|
|
639
|
+
*/
|
|
640
|
+
const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
|
|
641
|
+
["command_substitution", "command_substitution"],
|
|
642
|
+
["process_substitution", "process_substitution"],
|
|
643
|
+
]);
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Enumerate the command units of a bash program, in source order.
|
|
632
647
|
*
|
|
633
648
|
* Descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`)
|
|
634
|
-
* and emits each `command` node
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
649
|
+
* and emits each `command` node whole. Additionally descends into the three
|
|
650
|
+
* nested execution contexts — command substitution (`$(…)`, backticks), process
|
|
651
|
+
* substitution (`<(…)`/`>(…)`), and subshells (`( … )`) — emitting each inner
|
|
652
|
+
* command as its own unit *in addition to* the enclosing command, since those
|
|
653
|
+
* inner commands really execute (#306). Control-flow bodies and `{ … }` brace
|
|
654
|
+
* groups are emitted whole without descending (deferred).
|
|
655
|
+
*
|
|
656
|
+
* The enclosing command/subshell is always still emitted whole, so adding the
|
|
657
|
+
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
639
658
|
*/
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
659
|
+
function collectCommands(node: TSNode): BashCommand[] {
|
|
660
|
+
const out: BashCommand[] = [];
|
|
661
|
+
collectCommandsInto(node, undefined, out);
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function collectCommandsInto(
|
|
666
|
+
node: TSNode,
|
|
667
|
+
context: BashCommandContext | undefined,
|
|
668
|
+
out: BashCommand[],
|
|
669
|
+
): void {
|
|
670
|
+
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
|
|
671
|
+
// carry no command.
|
|
672
|
+
if (!node.isNamed) return;
|
|
673
|
+
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
674
|
+
|
|
675
|
+
if (node.type === "command") {
|
|
676
|
+
out.push(makeUnit(node.text, context));
|
|
677
|
+
// A command's text already contains any substitution; descend its subtree
|
|
678
|
+
// to ALSO emit the inner commands of command/process substitutions.
|
|
679
|
+
collectSubstitutionCommands(node, out);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (node.type === "subshell") {
|
|
684
|
+
out.push(makeUnit(node.text, context)); // never-weaker whole emit
|
|
685
|
+
descendCommandChildren(node, "subshell", out);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (COMMAND_ENUM_DESCEND.has(node.type)) {
|
|
690
|
+
descendCommandChildren(node, context, out);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
|
695
|
+
// function_definition): emit whole, do not descend — deferred (#306).
|
|
696
|
+
out.push(makeUnit(node.text, context));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function makeUnit(
|
|
700
|
+
text: string,
|
|
701
|
+
context: BashCommandContext | undefined,
|
|
702
|
+
): BashCommand {
|
|
703
|
+
return context ? { text, context } : { text };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function descendCommandChildren(
|
|
707
|
+
node: TSNode,
|
|
708
|
+
context: BashCommandContext | undefined,
|
|
709
|
+
out: BashCommand[],
|
|
710
|
+
): void {
|
|
711
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
712
|
+
const child = node.child(i);
|
|
713
|
+
if (child) collectCommandsInto(child, context, out);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Search a command's subtree for command/process substitutions and enumerate
|
|
719
|
+
* the commands inside them, tagged with the substitution's execution context.
|
|
720
|
+
* A substitution can nest under `command_name` (when the whole command is
|
|
721
|
+
* `$(…)`) or under an argument, so the entire subtree is searched.
|
|
722
|
+
*/
|
|
723
|
+
function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
|
|
724
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
725
|
+
const child = node.child(i);
|
|
726
|
+
if (!child) continue;
|
|
727
|
+
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
|
728
|
+
if (nestedContext) {
|
|
729
|
+
descendCommandChildren(child, nestedContext, out);
|
|
730
|
+
} else {
|
|
731
|
+
collectSubstitutionCommands(child, out);
|
|
648
732
|
}
|
|
649
|
-
return texts;
|
|
650
733
|
}
|
|
651
|
-
// Any other named statement node (subshell, compound_statement, if/while/for,
|
|
652
|
-
// function_definition, …): emit whole, do not descend.
|
|
653
|
-
return [node.text];
|
|
654
734
|
}
|
|
655
735
|
|
|
656
736
|
// ── Leading cd detection ───────────────────────────────────────────────────
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { resolveBashCommandCheck } from "./gates/bash-command";
|
|
30
30
|
import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
|
|
31
31
|
import { describeBashPathGate } from "./gates/bash-path";
|
|
32
|
+
import { BashProgram } from "./gates/bash-program";
|
|
32
33
|
import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
|
|
33
34
|
import { isGateBypass } from "./gates/descriptor";
|
|
34
35
|
import { describeExternalDirectoryGate } from "./gates/external-directory";
|
|
@@ -87,6 +88,14 @@ export class PermissionGateHandler {
|
|
|
87
88
|
cwd: ctx.cwd,
|
|
88
89
|
};
|
|
89
90
|
|
|
91
|
+
// Parse the bash command exactly once per tool_call; the three bash gates
|
|
92
|
+
// share this single BashProgram instead of each re-parsing (#308).
|
|
93
|
+
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
94
|
+
const bashProgram =
|
|
95
|
+
tcc.toolName === "bash" && command
|
|
96
|
+
? await BashProgram.parse(command)
|
|
97
|
+
: null;
|
|
98
|
+
|
|
90
99
|
// ── Shared gate adapter closures ─────────────────────────────────────
|
|
91
100
|
const canConfirm = () => this.session.canPrompt(ctx);
|
|
92
101
|
const promptPermission = (details: PromptPermissionDetails) =>
|
|
@@ -166,19 +175,27 @@ export class PermissionGateHandler {
|
|
|
166
175
|
() =>
|
|
167
176
|
describeBashExternalDirectoryGate(
|
|
168
177
|
tcc,
|
|
178
|
+
bashProgram,
|
|
179
|
+
checkPermission,
|
|
180
|
+
getSessionRuleset,
|
|
181
|
+
),
|
|
182
|
+
() =>
|
|
183
|
+
describeBashPathGate(
|
|
184
|
+
tcc,
|
|
185
|
+
bashProgram,
|
|
169
186
|
checkPermission,
|
|
170
187
|
getSessionRuleset,
|
|
171
188
|
),
|
|
172
|
-
() =>
|
|
173
|
-
async () => {
|
|
189
|
+
() => {
|
|
174
190
|
// Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
|
|
175
|
-
// evaluate each on the bash surface and
|
|
176
|
-
// rather than matching the whole program
|
|
177
|
-
// evaluate their single input directly.
|
|
191
|
+
// evaluate each unit from the shared parse on the bash surface and
|
|
192
|
+
// select the most restrictive, rather than matching the whole program
|
|
193
|
+
// string (#301). Other tools evaluate their single input directly.
|
|
178
194
|
const toolCheck =
|
|
179
|
-
tcc.toolName === "bash"
|
|
180
|
-
?
|
|
181
|
-
|
|
195
|
+
tcc.toolName === "bash" && bashProgram
|
|
196
|
+
? resolveBashCommandCheck(
|
|
197
|
+
command ?? "",
|
|
198
|
+
bashProgram.commands(),
|
|
182
199
|
tcc.agentName ?? undefined,
|
|
183
200
|
getSessionRuleset(),
|
|
184
201
|
checkPermission,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchQualifier } from "./denial-messages";
|
|
1
2
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
2
3
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
3
4
|
import type { PermissionCheckResult } from "./types";
|
|
@@ -36,10 +37,12 @@ export function formatAskPrompt(
|
|
|
36
37
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
37
38
|
|
|
38
39
|
if (result.toolName === "bash") {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const qualifier = matchQualifier(
|
|
41
|
+
result.matchedPattern,
|
|
42
|
+
result.commandContext,
|
|
43
|
+
);
|
|
44
|
+
const qualifierInfo = qualifier ? ` ${qualifier}` : "";
|
|
45
|
+
return `${subject} requested bash command '${result.command ?? ""}'${qualifierInfo}. Allow this command?`;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,15 @@ export interface ScopeConfig {
|
|
|
22
22
|
permission?: FlatPermissionConfig;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Execution context of a bash command nested inside a substitution or subshell.
|
|
27
|
+
* Absent for current-shell (top-level) commands.
|
|
28
|
+
*/
|
|
29
|
+
export type BashCommandContext =
|
|
30
|
+
| "command_substitution"
|
|
31
|
+
| "process_substitution"
|
|
32
|
+
| "subshell";
|
|
33
|
+
|
|
25
34
|
export interface PermissionCheckResult {
|
|
26
35
|
toolName: string;
|
|
27
36
|
state: PermissionState;
|
|
@@ -31,4 +40,10 @@ export interface PermissionCheckResult {
|
|
|
31
40
|
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
32
41
|
/** Which source contributed the winning rule. */
|
|
33
42
|
origin: RuleOrigin;
|
|
43
|
+
/**
|
|
44
|
+
* Execution context of the offending nested command, when the winning bash
|
|
45
|
+
* unit came from a substitution or subshell. Absent for current-shell
|
|
46
|
+
* (top-level) commands.
|
|
47
|
+
*/
|
|
48
|
+
commandContext?: BashCommandContext;
|
|
34
49
|
}
|