@gotgenes/pi-permission-system 9.1.0 → 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
CHANGED
|
@@ -5,6 +5,15 @@ 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
|
+
|
|
8
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)
|
|
9
18
|
|
|
10
19
|
|
package/package.json
CHANGED
|
@@ -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,
|
|
@@ -75,6 +75,29 @@ export interface BashCommand {
|
|
|
75
75
|
readonly context?: BashCommandContext;
|
|
76
76
|
}
|
|
77
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
|
+
|
|
78
101
|
/**
|
|
79
102
|
* A bash command parsed once into a reusable representation.
|
|
80
103
|
*
|
|
@@ -87,29 +110,29 @@ export interface BashCommand {
|
|
|
87
110
|
*/
|
|
88
111
|
export class BashProgram {
|
|
89
112
|
private constructor(
|
|
90
|
-
private readonly
|
|
91
|
-
private readonly leadingCdTarget: string | undefined,
|
|
113
|
+
private readonly rawCandidates: readonly PathCandidate[],
|
|
92
114
|
private readonly commandUnits: readonly BashCommand[],
|
|
93
115
|
) {}
|
|
94
116
|
|
|
95
117
|
/**
|
|
96
118
|
* Parse a bash command into a `BashProgram`.
|
|
97
119
|
*
|
|
98
|
-
* Uses tree-sitter-bash to build the full AST
|
|
99
|
-
* redirect-destination nodes once into raw candidate tokens,
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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.
|
|
102
126
|
*/
|
|
103
127
|
static async parse(command: string): Promise<BashProgram> {
|
|
104
128
|
const parser = await getParser();
|
|
105
129
|
const tree = parser.parse(command);
|
|
106
|
-
if (!tree) return new BashProgram([],
|
|
130
|
+
if (!tree) return new BashProgram([], []);
|
|
107
131
|
|
|
108
132
|
try {
|
|
109
|
-
const
|
|
110
|
-
const rawTokens = collectPathCandidateTokens(tree.rootNode);
|
|
133
|
+
const rawCandidates = collectPathCandidates(tree.rootNode);
|
|
111
134
|
const commandUnits = collectCommands(tree.rootNode);
|
|
112
|
-
return new BashProgram(
|
|
135
|
+
return new BashProgram(rawCandidates, commandUnits);
|
|
113
136
|
} finally {
|
|
114
137
|
tree.delete();
|
|
115
138
|
}
|
|
@@ -125,7 +148,7 @@ export class BashProgram {
|
|
|
125
148
|
pathTokens(): string[] {
|
|
126
149
|
const seen = new Set<string>();
|
|
127
150
|
const result: string[] = [];
|
|
128
|
-
for (const token of this.
|
|
151
|
+
for (const { token } of this.rawCandidates) {
|
|
129
152
|
const candidate = classifyTokenAsRuleCandidate(token);
|
|
130
153
|
if (!candidate) continue;
|
|
131
154
|
if (!seen.has(candidate)) {
|
|
@@ -156,21 +179,42 @@ export class BashProgram {
|
|
|
156
179
|
/**
|
|
157
180
|
* Deduplicated paths that resolve outside `cwd`.
|
|
158
181
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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.
|
|
162
187
|
*/
|
|
163
188
|
externalPaths(cwd: string): string[] {
|
|
164
|
-
const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
|
|
165
189
|
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
166
190
|
|
|
167
191
|
const seen = new Set<string>();
|
|
168
192
|
const externalPaths: string[] = [];
|
|
169
193
|
|
|
170
|
-
for (const token of this.
|
|
194
|
+
for (const { token, base } of this.rawCandidates) {
|
|
171
195
|
const candidate = classifyTokenAsPathCandidate(token);
|
|
172
196
|
if (!candidate) continue;
|
|
173
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;
|
|
174
218
|
const normalized = normalizePathForComparison(candidate, resolveBase);
|
|
175
219
|
if (!normalized) continue;
|
|
176
220
|
|
|
@@ -733,75 +777,199 @@ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
|
|
|
733
777
|
}
|
|
734
778
|
}
|
|
735
779
|
|
|
736
|
-
// ──
|
|
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" };
|
|
737
787
|
|
|
738
788
|
/**
|
|
739
|
-
* Walk
|
|
789
|
+
* Walk the AST once, collecting every path-candidate token tagged with the
|
|
790
|
+
* effective working directory projected onto its position.
|
|
740
791
|
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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).
|
|
744
798
|
*/
|
|
745
|
-
function
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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).
|
|
809
|
+
*/
|
|
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;
|
|
750
839
|
}
|
|
751
|
-
return null;
|
|
752
840
|
}
|
|
753
841
|
|
|
754
842
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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.
|
|
847
|
+
*/
|
|
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
|
+
}
|
|
863
|
+
|
|
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.
|
|
765
886
|
*/
|
|
766
|
-
function
|
|
767
|
-
|
|
768
|
-
|
|
887
|
+
function isRelativeCandidate(candidate: string): boolean {
|
|
888
|
+
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
889
|
+
}
|
|
769
890
|
|
|
770
|
-
|
|
771
|
-
|
|
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
|
+
}
|
|
772
910
|
|
|
773
|
-
|
|
774
|
-
|
|
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);
|
|
775
920
|
if (!child) continue;
|
|
776
921
|
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
777
922
|
continue;
|
|
778
|
-
if (!
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
// `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
|
|
784
|
-
// resolved against the working directory.
|
|
785
|
-
if (text === "-" || text.startsWith("~")) return undefined;
|
|
786
|
-
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);
|
|
787
928
|
}
|
|
788
|
-
return
|
|
929
|
+
return null;
|
|
789
930
|
}
|
|
790
931
|
|
|
791
932
|
/**
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
* relative paths should be resolved against it. An escaping target is itself
|
|
796
|
-
* an external access (reported via its own candidate token) and must never
|
|
797
|
-
* 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 (`-`, `~…`).
|
|
798
936
|
*/
|
|
799
|
-
function
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
}
|
|
807
975
|
}
|
|
@@ -852,15 +852,16 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
852
852
|
expect(result).toHaveLength(0);
|
|
853
853
|
});
|
|
854
854
|
|
|
855
|
-
test("cd to external dir: paths
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
855
|
+
test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => {
|
|
856
|
+
// The effective directory is tracked faithfully: `cd /tmp` makes /tmp the
|
|
857
|
+
// base, so the cd target itself is flagged AND ../etc/hosts resolves to
|
|
858
|
+
// /etc/hosts (both outside cwd).
|
|
859
859
|
const result = await extractExternalPathsFromBashCommand(
|
|
860
860
|
"cd /tmp && cat ../etc/hosts",
|
|
861
861
|
cwd,
|
|
862
862
|
);
|
|
863
|
-
expect(result
|
|
863
|
+
expect(result).toContain("/tmp");
|
|
864
|
+
expect(result).toContain("/etc/hosts");
|
|
864
865
|
});
|
|
865
866
|
|
|
866
867
|
test("cd with relative target: resolves inside cwd", async () => {
|
|
@@ -880,14 +881,14 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
880
881
|
expect(result.length).toBeGreaterThan(0);
|
|
881
882
|
});
|
|
882
883
|
|
|
883
|
-
test("cd is not first command
|
|
884
|
-
// cd
|
|
884
|
+
test("sequential fold: a cd that is not the first command still updates the base", async () => {
|
|
885
|
+
// The current-shell `cd` folds even though it is not the first command;
|
|
886
|
+
// ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt.
|
|
885
887
|
const result = await extractExternalPathsFromBashCommand(
|
|
886
888
|
"echo hello && cd /projects/my-app/src && cat ../../outside.txt",
|
|
887
889
|
cwd,
|
|
888
890
|
);
|
|
889
|
-
|
|
890
|
-
expect(result.length).toBeGreaterThan(0);
|
|
891
|
+
expect(result).toContain("/projects/outside.txt");
|
|
891
892
|
});
|
|
892
893
|
|
|
893
894
|
test("cd with semicolon separator", async () => {
|
|
@@ -33,6 +33,115 @@ describe("BashProgram", () => {
|
|
|
33
33
|
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
34
|
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
describe("effective working directory projection", () => {
|
|
38
|
+
it("folds a sequence of current-shell cd commands", async () => {
|
|
39
|
+
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
40
|
+
const program = await BashProgram.parse("cd a && cd b && cat ../c");
|
|
41
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
45
|
+
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
46
|
+
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
47
|
+
const program = await BashProgram.parse(
|
|
48
|
+
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
49
|
+
);
|
|
50
|
+
expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("folds a cd that is not the first command", async () => {
|
|
54
|
+
// The single-base model ignored a cd that was not first; now `cd a`
|
|
55
|
+
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
56
|
+
const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
|
|
57
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not fold a backgrounded cd", async () => {
|
|
61
|
+
// `cd a &` runs in a subshell, so it must not update the running
|
|
62
|
+
// directory; ../b resolves against cwd and escapes.
|
|
63
|
+
const program = await BashProgram.parse("cd a & cat ../b");
|
|
64
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not fold a cd inside a pipeline", async () => {
|
|
68
|
+
// Pipeline members run in subshells; the cd must not leak.
|
|
69
|
+
const program = await BashProgram.parse("cd nested | cat ../b");
|
|
70
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
74
|
+
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
75
|
+
const program = await BashProgram.parse("( cd sub && cat ../x )");
|
|
76
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not leak a subshell cd to following commands", async () => {
|
|
80
|
+
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
81
|
+
const program = await BashProgram.parse("( cd sub ) && cat ../y");
|
|
82
|
+
expect(program.externalPaths(cwd)).toContain("/projects/y");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
86
|
+
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
87
|
+
const program = await BashProgram.parse("{ cd sub; cat ../x; }");
|
|
88
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("persists a brace-group cd to following sibling commands", async () => {
|
|
92
|
+
const program = await BashProgram.parse("{ cd sub; } && cat ../x");
|
|
93
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
97
|
+
// Interior cd folding inside substitutions is deferred: the interior
|
|
98
|
+
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
99
|
+
// resolved against cwd/q. Conservative — never misses an escape.
|
|
100
|
+
const program = await BashProgram.parse("echo $(cd q && cat ../r)");
|
|
101
|
+
expect(program.externalPaths(cwd)).toContain("/projects/r");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
105
|
+
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
106
|
+
// so it is flagged (least-privilege).
|
|
107
|
+
const program = await BashProgram.parse('cd "$DIR" && cat ../x');
|
|
108
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
112
|
+
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
113
|
+
// flagged because the effective dir is unknown.
|
|
114
|
+
const program = await BashProgram.parse(
|
|
115
|
+
'cd "$DIR" && cat src/../within.txt',
|
|
116
|
+
);
|
|
117
|
+
expect(program.externalPaths(cwd)).toContain(
|
|
118
|
+
"/projects/my-app/within.txt",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
123
|
+
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
124
|
+
// even when the effective dir is unknown.
|
|
125
|
+
const program = await BashProgram.parse(
|
|
126
|
+
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
127
|
+
);
|
|
128
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("treats `cd -` as an unknown effective directory", async () => {
|
|
132
|
+
const program = await BashProgram.parse("cd - && cat ../x");
|
|
133
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("recovers a known base when a later cd is absolute", async () => {
|
|
137
|
+
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
138
|
+
// ../x resolves to cwd and is not flagged.
|
|
139
|
+
const program = await BashProgram.parse(
|
|
140
|
+
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
141
|
+
);
|
|
142
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
36
145
|
});
|
|
37
146
|
|
|
38
147
|
describe("commands", () => {
|