@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { basename, resolve } from "node:path";
|
|
2
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
classifyTokenAsPathCandidate,
|
|
@@ -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,46 @@ 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
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The working directory in force where a path candidate appears.
|
|
80
|
+
*
|
|
81
|
+
* A `known` base carries an `offset` to be joined with `cwd` at resolution time
|
|
82
|
+
* (the parse-time walk never sees `cwd`): a relative-or-absolute path string
|
|
83
|
+
* built by folding the literal targets of current-shell `cd` commands (`""` =
|
|
84
|
+
* `cwd`); an absolute offset (from `cd /abs`) ignores `cwd` at resolution time.
|
|
85
|
+
* An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
|
|
86
|
+
* `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
|
|
87
|
+
*/
|
|
88
|
+
type EffectiveBase =
|
|
89
|
+
| { readonly kind: "known"; readonly offset: string }
|
|
90
|
+
| { readonly kind: "unknown" };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A path-candidate token paired with the effective working directory projected
|
|
94
|
+
* onto the point in the command stream where it appears.
|
|
95
|
+
*/
|
|
96
|
+
interface PathCandidate {
|
|
97
|
+
readonly token: string;
|
|
98
|
+
readonly base: EffectiveBase;
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
/**
|
|
59
102
|
* A bash command parsed once into a reusable representation.
|
|
60
103
|
*
|
|
@@ -67,29 +110,29 @@ function getParser(): Promise<TSParser> {
|
|
|
67
110
|
*/
|
|
68
111
|
export class BashProgram {
|
|
69
112
|
private constructor(
|
|
70
|
-
private readonly
|
|
71
|
-
private readonly
|
|
72
|
-
private readonly topLevelCommandTexts: readonly string[],
|
|
113
|
+
private readonly rawCandidates: readonly PathCandidate[],
|
|
114
|
+
private readonly commandUnits: readonly BashCommand[],
|
|
73
115
|
) {}
|
|
74
116
|
|
|
75
117
|
/**
|
|
76
118
|
* Parse a bash command into a `BashProgram`.
|
|
77
119
|
*
|
|
78
|
-
* Uses tree-sitter-bash to build the full AST
|
|
79
|
-
* redirect-destination nodes once into raw candidate tokens,
|
|
80
|
-
*
|
|
81
|
-
*
|
|
120
|
+
* Uses tree-sitter-bash to build the full AST and walks command-argument and
|
|
121
|
+
* redirect-destination nodes once into raw candidate tokens, each tagged with
|
|
122
|
+
* the effective working directory projected onto its position by folding
|
|
123
|
+
* current-shell `cd` commands. Heredoc bodies, comments, and other
|
|
124
|
+
* non-argument content are skipped. An unparseable command yields an empty
|
|
125
|
+
* program.
|
|
82
126
|
*/
|
|
83
127
|
static async parse(command: string): Promise<BashProgram> {
|
|
84
128
|
const parser = await getParser();
|
|
85
129
|
const tree = parser.parse(command);
|
|
86
|
-
if (!tree) return new BashProgram([],
|
|
130
|
+
if (!tree) return new BashProgram([], []);
|
|
87
131
|
|
|
88
132
|
try {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
return new BashProgram(rawTokens, leadingCdTarget, topLevelCommandTexts);
|
|
133
|
+
const rawCandidates = collectPathCandidates(tree.rootNode);
|
|
134
|
+
const commandUnits = collectCommands(tree.rootNode);
|
|
135
|
+
return new BashProgram(rawCandidates, commandUnits);
|
|
93
136
|
} finally {
|
|
94
137
|
tree.delete();
|
|
95
138
|
}
|
|
@@ -102,14 +145,10 @@ export class BashProgram {
|
|
|
102
145
|
* paths; does NOT filter by CWD. Returns deduplicated tokens for rule
|
|
103
146
|
* evaluation.
|
|
104
147
|
*/
|
|
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
148
|
pathTokens(): string[] {
|
|
110
149
|
const seen = new Set<string>();
|
|
111
150
|
const result: string[] = [];
|
|
112
|
-
for (const token of this.
|
|
151
|
+
for (const { token } of this.rawCandidates) {
|
|
113
152
|
const candidate = classifyTokenAsRuleCandidate(token);
|
|
114
153
|
if (!candidate) continue;
|
|
115
154
|
if (!seen.has(candidate)) {
|
|
@@ -121,17 +160,7 @@ export class BashProgram {
|
|
|
121
160
|
}
|
|
122
161
|
|
|
123
162
|
/**
|
|
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.
|
|
163
|
+
* The top-level command-pattern units of the chain, in source order.
|
|
135
164
|
*
|
|
136
165
|
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
|
|
137
166
|
* quotes, command substitution, and subshells are respected by the parser and
|
|
@@ -143,22 +172,49 @@ export class BashProgram {
|
|
|
143
172
|
// syntactic analysis cannot resolve the static-factory return type (private
|
|
144
173
|
// ctor), so it reports a false positive here.
|
|
145
174
|
// fallow-ignore-next-line unused-class-member
|
|
146
|
-
|
|
147
|
-
return [...this.
|
|
175
|
+
commands(): BashCommand[] {
|
|
176
|
+
return [...this.commandUnits];
|
|
148
177
|
}
|
|
149
178
|
|
|
150
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Deduplicated paths that resolve outside `cwd`.
|
|
181
|
+
*
|
|
182
|
+
* Each candidate is resolved against the effective working directory in force
|
|
183
|
+
* where it appears, projected by folding a sequence of current-shell `cd`
|
|
184
|
+
* commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
|
|
185
|
+
* pipeline or a backgrounded command runs in a subshell and does not update
|
|
186
|
+
* the running directory.
|
|
187
|
+
*/
|
|
151
188
|
externalPaths(cwd: string): string[] {
|
|
152
|
-
const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
|
|
153
189
|
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
154
190
|
|
|
155
191
|
const seen = new Set<string>();
|
|
156
192
|
const externalPaths: string[] = [];
|
|
157
193
|
|
|
158
|
-
for (const token of this.
|
|
194
|
+
for (const { token, base } of this.rawCandidates) {
|
|
159
195
|
const candidate = classifyTokenAsPathCandidate(token);
|
|
160
196
|
if (!candidate) continue;
|
|
161
197
|
|
|
198
|
+
// Unknown effective directory: a relative candidate could resolve
|
|
199
|
+
// anywhere, so flag it conservatively (resolving against `cwd` only for a
|
|
200
|
+
// display path). Absolute / `~` candidates are base-independent and
|
|
201
|
+
// resolve normally below.
|
|
202
|
+
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
203
|
+
const normalized = normalizePathForComparison(candidate, cwd);
|
|
204
|
+
if (
|
|
205
|
+
normalized &&
|
|
206
|
+
normalizedCwd !== "" &&
|
|
207
|
+
!isSafeSystemPath(normalized) &&
|
|
208
|
+
!seen.has(normalized)
|
|
209
|
+
) {
|
|
210
|
+
seen.add(normalized);
|
|
211
|
+
externalPaths.push(normalized);
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const resolveBase =
|
|
217
|
+
base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
162
218
|
const normalized = normalizePathForComparison(candidate, resolveBase);
|
|
163
219
|
if (!normalized) continue;
|
|
164
220
|
|
|
@@ -592,14 +648,12 @@ function collectPathCandidateTokens(node: TSNode): string[] {
|
|
|
592
648
|
// which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
|
|
593
649
|
// with a shared rejectNonPathToken predicate eliminating the prior clone.
|
|
594
650
|
|
|
595
|
-
// ──
|
|
651
|
+
// ── Command enumeration ──────────────────────────────────────────────────────
|
|
596
652
|
|
|
597
653
|
/**
|
|
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.
|
|
654
|
+
* Container node types descended into when enumerating command units.
|
|
601
655
|
*/
|
|
602
|
-
const
|
|
656
|
+
const COMMAND_ENUM_DESCEND = new Set([
|
|
603
657
|
"program",
|
|
604
658
|
"list",
|
|
605
659
|
"pipeline",
|
|
@@ -607,18 +661,12 @@ const TOP_LEVEL_COMMAND_DESCEND = new Set([
|
|
|
607
661
|
]);
|
|
608
662
|
|
|
609
663
|
/**
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
664
|
+
* Named node types skipped during command enumeration: redirect targets,
|
|
665
|
+
* comments, and heredoc bodies — none is a command to evaluate. Anonymous
|
|
666
|
+
* tokens (chain operators `&&`/`;`/`|`, substitution and subshell delimiters
|
|
667
|
+
* `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not listed here.
|
|
613
668
|
*/
|
|
614
|
-
const
|
|
615
|
-
"&&",
|
|
616
|
-
"||",
|
|
617
|
-
";",
|
|
618
|
-
"&",
|
|
619
|
-
"|",
|
|
620
|
-
"|&",
|
|
621
|
-
"\n",
|
|
669
|
+
const COMMAND_ENUM_SKIP = new Set([
|
|
622
670
|
"file_redirect",
|
|
623
671
|
"heredoc_redirect",
|
|
624
672
|
"herestring_redirect",
|
|
@@ -628,100 +676,300 @@ const TOP_LEVEL_COMMAND_SKIP = new Set([
|
|
|
628
676
|
]);
|
|
629
677
|
|
|
630
678
|
/**
|
|
631
|
-
*
|
|
679
|
+
* Nested execution contexts whose interior commands really execute and must be
|
|
680
|
+
* evaluated too: command substitution (`$(…)`, backticks) and process
|
|
681
|
+
* substitution (`<(…)`/`>(…)`). Subshells (`( … )`) are handled separately
|
|
682
|
+
* because they are also emitted whole.
|
|
683
|
+
*/
|
|
684
|
+
const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
|
|
685
|
+
["command_substitution", "command_substitution"],
|
|
686
|
+
["process_substitution", "process_substitution"],
|
|
687
|
+
]);
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Enumerate the command units of a bash program, in source order.
|
|
632
691
|
*
|
|
633
692
|
* Descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`)
|
|
634
|
-
* and emits each `command` node
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
693
|
+
* and emits each `command` node whole. Additionally descends into the three
|
|
694
|
+
* nested execution contexts — command substitution (`$(…)`, backticks), process
|
|
695
|
+
* substitution (`<(…)`/`>(…)`), and subshells (`( … )`) — emitting each inner
|
|
696
|
+
* command as its own unit *in addition to* the enclosing command, since those
|
|
697
|
+
* inner commands really execute (#306). Control-flow bodies and `{ … }` brace
|
|
698
|
+
* groups are emitted whole without descending (deferred).
|
|
699
|
+
*
|
|
700
|
+
* The enclosing command/subshell is always still emitted whole, so adding the
|
|
701
|
+
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
639
702
|
*/
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
703
|
+
function collectCommands(node: TSNode): BashCommand[] {
|
|
704
|
+
const out: BashCommand[] = [];
|
|
705
|
+
collectCommandsInto(node, undefined, out);
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function collectCommandsInto(
|
|
710
|
+
node: TSNode,
|
|
711
|
+
context: BashCommandContext | undefined,
|
|
712
|
+
out: BashCommand[],
|
|
713
|
+
): void {
|
|
714
|
+
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
|
|
715
|
+
// carry no command.
|
|
716
|
+
if (!node.isNamed) return;
|
|
717
|
+
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
718
|
+
|
|
719
|
+
if (node.type === "command") {
|
|
720
|
+
out.push(makeUnit(node.text, context));
|
|
721
|
+
// A command's text already contains any substitution; descend its subtree
|
|
722
|
+
// to ALSO emit the inner commands of command/process substitutions.
|
|
723
|
+
collectSubstitutionCommands(node, out);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (node.type === "subshell") {
|
|
728
|
+
out.push(makeUnit(node.text, context)); // never-weaker whole emit
|
|
729
|
+
descendCommandChildren(node, "subshell", out);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (COMMAND_ENUM_DESCEND.has(node.type)) {
|
|
734
|
+
descendCommandChildren(node, context, out);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
|
739
|
+
// function_definition): emit whole, do not descend — deferred (#306).
|
|
740
|
+
out.push(makeUnit(node.text, context));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function makeUnit(
|
|
744
|
+
text: string,
|
|
745
|
+
context: BashCommandContext | undefined,
|
|
746
|
+
): BashCommand {
|
|
747
|
+
return context ? { text, context } : { text };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function descendCommandChildren(
|
|
751
|
+
node: TSNode,
|
|
752
|
+
context: BashCommandContext | undefined,
|
|
753
|
+
out: BashCommand[],
|
|
754
|
+
): void {
|
|
755
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
756
|
+
const child = node.child(i);
|
|
757
|
+
if (child) collectCommandsInto(child, context, out);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Search a command's subtree for command/process substitutions and enumerate
|
|
763
|
+
* the commands inside them, tagged with the substitution's execution context.
|
|
764
|
+
* A substitution can nest under `command_name` (when the whole command is
|
|
765
|
+
* `$(…)`) or under an argument, so the entire subtree is searched.
|
|
766
|
+
*/
|
|
767
|
+
function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
|
|
768
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
769
|
+
const child = node.child(i);
|
|
770
|
+
if (!child) continue;
|
|
771
|
+
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
|
772
|
+
if (nestedContext) {
|
|
773
|
+
descendCommandChildren(child, nestedContext, out);
|
|
774
|
+
} else {
|
|
775
|
+
collectSubstitutionCommands(child, out);
|
|
648
776
|
}
|
|
649
|
-
return texts;
|
|
650
777
|
}
|
|
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
778
|
}
|
|
655
779
|
|
|
656
|
-
// ──
|
|
780
|
+
// ── Effective working directory projection ─────────────────────────────────
|
|
781
|
+
|
|
782
|
+
/** The working directory in force at the start of a program (`cwd`). */
|
|
783
|
+
const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
|
|
784
|
+
|
|
785
|
+
/** The effective directory after a non-literal or unresolvable `cd`. */
|
|
786
|
+
const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
657
787
|
|
|
658
788
|
/**
|
|
659
|
-
* Walk
|
|
789
|
+
* Walk the AST once, collecting every path-candidate token tagged with the
|
|
790
|
+
* effective working directory projected onto its position.
|
|
660
791
|
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
792
|
+
* The effective directory is stateful: it starts at `cwd` and each current-shell
|
|
793
|
+
* `cd <literal>` (joined by `&&`, `||`, `;`, or a newline) folds into it for
|
|
794
|
+
* subsequent commands. A `cd` inside a pipeline or a backgrounded command runs
|
|
795
|
+
* in a subshell and does not update the running directory; subshell and
|
|
796
|
+
* brace-group interiors inherit the enclosing base without folding their own
|
|
797
|
+
* `cd`s (a conservative first tier).
|
|
798
|
+
*/
|
|
799
|
+
function collectPathCandidates(rootNode: TSNode): PathCandidate[] {
|
|
800
|
+
const out: PathCandidate[] = [];
|
|
801
|
+
walkForCandidates(rootNode, CWD_BASE, out);
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Collect a single node's candidates tagged with `base`, returning the
|
|
807
|
+
* effective base in force *after* the node (the input base unless the node is a
|
|
808
|
+
* current-shell `cd <literal>` that folds the running directory).
|
|
664
809
|
*/
|
|
665
|
-
function
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
810
|
+
function walkForCandidates(
|
|
811
|
+
node: TSNode,
|
|
812
|
+
base: EffectiveBase,
|
|
813
|
+
out: PathCandidate[],
|
|
814
|
+
): EffectiveBase {
|
|
815
|
+
switch (node.type) {
|
|
816
|
+
case "program":
|
|
817
|
+
case "list":
|
|
818
|
+
case "redirected_statement":
|
|
819
|
+
return walkCurrentShellSequence(node, base, out);
|
|
820
|
+
case "command":
|
|
821
|
+
tagTokens(collectCommandTokens(node), base, out);
|
|
822
|
+
return foldCd(node, base);
|
|
823
|
+
case "subshell":
|
|
824
|
+
// A subshell runs in a child shell: its interior `cd`s fold within the
|
|
825
|
+
// subshell but reset on exit, so the folded base is discarded.
|
|
826
|
+
walkCurrentShellSequence(node, base, out);
|
|
827
|
+
return base;
|
|
828
|
+
case "compound_statement":
|
|
829
|
+
// A `{ … }` brace group runs in the current shell, so its `cd`s persist
|
|
830
|
+
// to following commands — thread and return the folded base.
|
|
831
|
+
return walkCurrentShellSequence(node, base, out);
|
|
832
|
+
default:
|
|
833
|
+
// Pipelines, control-flow bodies, redirect targets, and command/process
|
|
834
|
+
// substitution interiors: collect every candidate in the subtree tagged
|
|
835
|
+
// with the enclosing base and do not fold their internal `cd`s. (Folding
|
|
836
|
+
// inside substitutions is deferred — conservative, never under-flags.)
|
|
837
|
+
tagTokens(collectPathCandidateTokens(node), base, out);
|
|
838
|
+
return base;
|
|
670
839
|
}
|
|
671
|
-
return null;
|
|
672
840
|
}
|
|
673
841
|
|
|
674
842
|
/**
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* directory. The external-directory guard must do the same, otherwise a
|
|
680
|
-
* path that the shell keeps inside the working directory can appear to
|
|
681
|
-
* escape it and trigger a spurious permission prompt.
|
|
682
|
-
*
|
|
683
|
-
* Returns `undefined` when the first command is not `cd`, or when the
|
|
684
|
-
* target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
|
|
843
|
+
* Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
|
|
844
|
+
* thread the effective base left-to-right through the children so a `cd` updates
|
|
845
|
+
* the base for following siblings. A statement immediately followed by the
|
|
846
|
+
* background operator (`&`) runs in a subshell, so its folded base is discarded.
|
|
685
847
|
*/
|
|
686
|
-
function
|
|
687
|
-
|
|
688
|
-
|
|
848
|
+
function walkCurrentShellSequence(
|
|
849
|
+
seqNode: TSNode,
|
|
850
|
+
base: EffectiveBase,
|
|
851
|
+
out: PathCandidate[],
|
|
852
|
+
): EffectiveBase {
|
|
853
|
+
let current = base;
|
|
854
|
+
for (let i = 0; i < seqNode.childCount; i++) {
|
|
855
|
+
const child = seqNode.child(i);
|
|
856
|
+
if (!child?.isNamed) continue;
|
|
857
|
+
if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
|
|
858
|
+
const after = walkForCandidates(child, current, out);
|
|
859
|
+
current = isBackgrounded(seqNode, i) ? current : after;
|
|
860
|
+
}
|
|
861
|
+
return current;
|
|
862
|
+
}
|
|
689
863
|
|
|
690
|
-
|
|
691
|
-
|
|
864
|
+
/**
|
|
865
|
+
* True when the statement at `index` is immediately followed by the background
|
|
866
|
+
* operator (`&`) — distinct from the `&&` / `||` / `;` current-shell separators.
|
|
867
|
+
*/
|
|
868
|
+
function isBackgrounded(seqNode: TSNode, index: number): boolean {
|
|
869
|
+
const next = seqNode.child(index + 1);
|
|
870
|
+
if (!next || next.isNamed) return false;
|
|
871
|
+
return next.type === "&";
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function tagTokens(
|
|
875
|
+
tokens: readonly string[],
|
|
876
|
+
base: EffectiveBase,
|
|
877
|
+
out: PathCandidate[],
|
|
878
|
+
): void {
|
|
879
|
+
for (const token of tokens) out.push({ token, base });
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* True when a path candidate is relative (resolved against the effective
|
|
884
|
+
* directory) rather than absolute (`/…`) or home-relative (`~…`), which are
|
|
885
|
+
* base-independent. Used to decide which candidates an unknown base affects.
|
|
886
|
+
*/
|
|
887
|
+
function isRelativeCandidate(candidate: string): boolean {
|
|
888
|
+
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
889
|
+
}
|
|
692
890
|
|
|
693
|
-
|
|
694
|
-
|
|
891
|
+
/**
|
|
892
|
+
* Compute the effective base after a command runs. Returns `base` unchanged
|
|
893
|
+
* unless the command is `cd`:
|
|
894
|
+
*
|
|
895
|
+
* - `cd /abs` (absolute literal) → a fresh known base, recovering from an
|
|
896
|
+
* earlier unknown base.
|
|
897
|
+
* - `cd rel` (relative literal) → fold into a known base, or stay unknown if the
|
|
898
|
+
* base was already unknown.
|
|
899
|
+
* - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
|
|
900
|
+
* unknown.
|
|
901
|
+
*/
|
|
902
|
+
function foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
|
|
903
|
+
if (extractCommandName(commandNode) !== "cd") return base;
|
|
904
|
+
const target = cdLiteralTarget(commandNode);
|
|
905
|
+
if (target === null) return UNKNOWN_BASE;
|
|
906
|
+
if (isAbsolute(target)) return { kind: "known", offset: target };
|
|
907
|
+
if (base.kind === "unknown") return UNKNOWN_BASE;
|
|
908
|
+
return { kind: "known", offset: join(base.offset, target) };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Resolve the literal target of a `cd` command, or `null` when the first
|
|
913
|
+
* argument is not a static literal (contains an expansion or command
|
|
914
|
+
* substitution) or cannot be resolved against the working directory (`cd -`,
|
|
915
|
+
* `cd ~…`, bare `cd`).
|
|
916
|
+
*/
|
|
917
|
+
function cdLiteralTarget(commandNode: TSNode): string | null {
|
|
918
|
+
for (let i = 0; i < commandNode.childCount; i++) {
|
|
919
|
+
const child = commandNode.child(i);
|
|
695
920
|
if (!child) continue;
|
|
696
921
|
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
697
922
|
continue;
|
|
698
|
-
if (!
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
// `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
|
|
704
|
-
// resolved against the working directory.
|
|
705
|
-
if (text === "-" || text.startsWith("~")) return undefined;
|
|
706
|
-
return text;
|
|
923
|
+
if (!child.isNamed) continue;
|
|
924
|
+
// Skip the `--` end-of-flags marker; the next argument is the target.
|
|
925
|
+
if (child.type === "word" && child.text === "--") continue;
|
|
926
|
+
if (!ARG_NODE_TYPES.has(child.type)) return null;
|
|
927
|
+
return literalTextOf(child);
|
|
707
928
|
}
|
|
708
|
-
return
|
|
929
|
+
return null;
|
|
709
930
|
}
|
|
710
931
|
|
|
711
932
|
/**
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* relative paths should be resolved against it. An escaping target is itself
|
|
716
|
-
* an external access (reported via its own candidate token) and must never
|
|
717
|
-
* silence checks on subsequent paths, so the function falls back to `cwd`.
|
|
933
|
+
* The literal string value of an argument node, or `null` when it contains a
|
|
934
|
+
* variable expansion / command substitution or is a non-resolvable `cd`
|
|
935
|
+
* destination (`-`, `~…`).
|
|
718
936
|
*/
|
|
719
|
-
function
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
937
|
+
function literalTextOf(node: TSNode): string | null {
|
|
938
|
+
switch (node.type) {
|
|
939
|
+
case "word": {
|
|
940
|
+
const text = node.text;
|
|
941
|
+
if (text === "-" || text.startsWith("~")) return null;
|
|
942
|
+
return text;
|
|
943
|
+
}
|
|
944
|
+
case "raw_string": {
|
|
945
|
+
const text = node.text;
|
|
946
|
+
return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
|
|
947
|
+
? text.slice(1, -1)
|
|
948
|
+
: text;
|
|
949
|
+
}
|
|
950
|
+
case "concatenation": {
|
|
951
|
+
let result = "";
|
|
952
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
953
|
+
const child = node.child(i);
|
|
954
|
+
if (!child) continue;
|
|
955
|
+
const part = literalTextOf(child);
|
|
956
|
+
if (part === null) return null;
|
|
957
|
+
result += part;
|
|
958
|
+
}
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
case "string": {
|
|
962
|
+
let result = "";
|
|
963
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
964
|
+
const child = node.child(i);
|
|
965
|
+
if (!child) continue;
|
|
966
|
+
if (child.type === '"') continue;
|
|
967
|
+
if (child.type !== "string_content") return null;
|
|
968
|
+
result += child.text;
|
|
969
|
+
}
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
default:
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
727
975
|
}
|
|
@@ -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,
|