@gotgenes/pi-permission-system 17.0.0 → 17.1.1
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 +14 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +5 -5
- package/src/access-intent/bash/bash-path-resolver.ts +535 -0
- package/src/access-intent/bash/program.ts +21 -14
- package/src/access-intent/bash/token-classification.ts +24 -1
- package/src/canonicalize-path.ts +11 -5
- package/src/forwarded-permissions/permission-forwarder.ts +11 -1
- package/src/forwarding-manager.ts +7 -1
- package/src/handlers/before-agent-start.ts +1 -1
- package/src/handlers/gates/bash-path-extractor.ts +7 -5
- package/src/handlers/gates/external-directory.ts +7 -18
- package/src/handlers/gates/path.ts +3 -2
- package/src/handlers/gates/skill-read.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +20 -4
- package/src/handlers/gates/tool.ts +4 -2
- package/src/index.ts +12 -1
- package/src/input-normalizer.ts +9 -4
- package/src/path-normalizer.ts +100 -0
- package/src/path-utils.ts +39 -28
- package/src/permission-manager.ts +21 -7
- package/src/permission-session.ts +35 -2
- package/src/prompting-gateway.ts +3 -0
- package/src/rule.ts +8 -6
- package/src/skill-prompt-sanitizer.ts +8 -10
- package/src/subagent-context.ts +17 -9
- package/test/access-intent/access-path.test.ts +73 -24
- package/test/access-intent/bash/program.test.ts +131 -65
- package/test/access-intent/bash/token-classification.test.ts +75 -0
- package/test/bash-external-directory.test.ts +53 -1
- package/test/canonicalize-path.test.ts +34 -8
- package/test/forwarding-manager.test.ts +7 -1
- package/test/handlers/external-directory-symlink-acceptance.test.ts +23 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
- package/test/handlers/gates/bash-external-directory.test.ts +5 -1
- package/test/handlers/gates/bash-path.test.ts +6 -1
- package/test/handlers/gates/external-directory-policy.test.ts +26 -8
- package/test/handlers/gates/external-directory.test.ts +8 -1
- package/test/handlers/gates/path.test.ts +53 -14
- package/test/handlers/gates/skill-read.test.ts +26 -13
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
- package/test/handlers/gates/tool.test.ts +13 -1
- package/test/helpers/gate-fixtures.ts +10 -0
- package/test/helpers/session-fixtures.ts +3 -0
- package/test/input-normalizer.test.ts +104 -39
- package/test/path-normalizer.test.ts +166 -0
- package/test/path-utils.test.ts +130 -51
- package/test/permission-forwarder.test.ts +1 -0
- package/test/permission-manager-unified.test.ts +40 -0
- package/test/permission-resolver.test.ts +12 -3
- package/test/permission-session.test.ts +41 -0
- package/test/pi-infrastructure-read.test.ts +27 -4
- package/test/prompting-gateway.test.ts +1 -0
- package/test/rule.test.ts +88 -42
- package/test/session-rules.test.ts +21 -4
- package/test/skill-prompt-sanitizer.test.ts +37 -16
- package/test/subagent-context.test.ts +77 -31
- package/test/synthesize.test.ts +13 -11
- package/src/access-intent/bash/cwd-projection.ts +0 -500
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
## [17.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.0...pi-permission-system-v17.1.1) (2026-06-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** gate Windows drive-letter paths in bash external_directory ([#508](https://github.com/gotgenes/pi-packages/issues/508)) ([2d33183](https://github.com/gotgenes/pi-packages/commit/2d331834254f58bcb0782b3da353132821536296))
|
|
14
|
+
|
|
15
|
+
## [17.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.0.0...pi-permission-system-v17.1.0) (2026-06-28)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** add PathNormalizer collaborator ([#510](https://github.com/gotgenes/pi-packages/issues/510)) ([d016089](https://github.com/gotgenes/pi-packages/commit/d016089690aff3821eabd5bd5351bb3a0c3639d4))
|
|
21
|
+
|
|
8
22
|
## [17.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.1...pi-permission-system-v17.0.0) (2026-06-27)
|
|
9
23
|
|
|
10
24
|
|
package/package.json
CHANGED
|
@@ -87,13 +87,13 @@ export class AccessPath {
|
|
|
87
87
|
*/
|
|
88
88
|
static forPath(
|
|
89
89
|
pathValue: string,
|
|
90
|
-
options: { cwd: string; resolveBase?: string },
|
|
90
|
+
options: { cwd: string; resolveBase?: string; platform: NodeJS.Platform },
|
|
91
91
|
): AccessPath {
|
|
92
|
-
const { cwd, resolveBase = cwd } = options;
|
|
92
|
+
const { cwd, resolveBase = cwd, platform } = options;
|
|
93
93
|
return new AccessPath(
|
|
94
|
-
normalizePathForComparison(pathValue, resolveBase),
|
|
95
|
-
getPathPolicyValues(pathValue, { cwd, resolveBase }),
|
|
96
|
-
canonicalNormalizePathForComparison(pathValue, resolveBase),
|
|
94
|
+
normalizePathForComparison(pathValue, resolveBase, platform),
|
|
95
|
+
getPathPolicyValues(pathValue, { cwd, resolveBase }, platform),
|
|
96
|
+
canonicalNormalizePathForComparison(pathValue, resolveBase, platform),
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import {
|
|
3
|
+
ARG_NODE_TYPES,
|
|
4
|
+
SKIP_SUBTREE_TYPES,
|
|
5
|
+
} from "#src/access-intent/bash/node-text";
|
|
6
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
|
+
import {
|
|
8
|
+
classifyTokenAsPathCandidate,
|
|
9
|
+
classifyTokenAsRuleCandidate,
|
|
10
|
+
} from "#src/access-intent/bash/token-classification";
|
|
11
|
+
import {
|
|
12
|
+
collectCommandTokens,
|
|
13
|
+
collectPathCandidateTokens,
|
|
14
|
+
collectRedirectTokens,
|
|
15
|
+
extractCommandName,
|
|
16
|
+
} from "#src/access-intent/bash/token-collection";
|
|
17
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
18
|
+
import { isSafeSystemPath, normalizePathPolicyLiteral } from "#src/path-utils";
|
|
19
|
+
|
|
20
|
+
// ── Internal types ───────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The working directory in force where a path candidate appears.
|
|
24
|
+
*
|
|
25
|
+
* A `known` base carries an `offset` to be joined with `cwd` at resolution
|
|
26
|
+
* time: a relative-or-absolute path string built by folding the literal targets
|
|
27
|
+
* of current-shell `cd` commands (`""` = `cwd`); an absolute offset (from
|
|
28
|
+
* `cd /abs`) ignores `cwd` at resolution time.
|
|
29
|
+
* An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
|
|
30
|
+
* `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
|
|
31
|
+
*/
|
|
32
|
+
type EffectiveBase =
|
|
33
|
+
| { readonly kind: "known"; readonly offset: string }
|
|
34
|
+
| { readonly kind: "unknown" };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A path-candidate token paired with the effective working directory projected
|
|
38
|
+
* onto the point in the command stream where it appears.
|
|
39
|
+
*/
|
|
40
|
+
interface PathCandidate {
|
|
41
|
+
readonly token: string;
|
|
42
|
+
readonly base: EffectiveBase;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Public output types ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface BashPathRuleCandidate {
|
|
48
|
+
/** Raw path-like token shown in prompts, logs, and session approvals. */
|
|
49
|
+
readonly token: string;
|
|
50
|
+
/** The path's lexical and canonical forms for permission policy matching. */
|
|
51
|
+
readonly path: AccessPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The filesystem paths a bash program references, resolved against the working
|
|
56
|
+
* directory and platform — the two typed slices {@link BashProgram} exposes.
|
|
57
|
+
*/
|
|
58
|
+
export interface ResolvedBashPaths {
|
|
59
|
+
/** Deduplicated paths resolving outside the working directory (#418). */
|
|
60
|
+
readonly externalPaths: readonly AccessPath[];
|
|
61
|
+
/** Every path-rule token paired with its cd-aware policy values (#393). */
|
|
62
|
+
readonly ruleCandidates: readonly BashPathRuleCandidate[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Walk-time constants ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** The working directory in force at the start of a program (`cwd`). */
|
|
68
|
+
const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
|
|
69
|
+
|
|
70
|
+
/** The effective directory after a non-literal or unresolvable `cd`. */
|
|
71
|
+
const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves the filesystem paths a parsed bash program references.
|
|
75
|
+
*
|
|
76
|
+
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its collaborator
|
|
77
|
+
* and answers all platform/cwd-dependent questions through it — `cd`-base
|
|
78
|
+
* folding (`isAbsolute`/`joinBase`), per-candidate resolution (`forPath`/
|
|
79
|
+
* `forLiteral`/`resolveBase`), and the outside-cwd boundary decision — so no
|
|
80
|
+
* walk step re-reads the platform or threads the cwd.
|
|
81
|
+
*
|
|
82
|
+
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
|
83
|
+
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
|
84
|
+
* the `cd`-folding state, and the intermediate path candidates stay private.
|
|
85
|
+
* One instance per parse ({@link BashProgram.parse} constructs it with the
|
|
86
|
+
* session normalizer).
|
|
87
|
+
*/
|
|
88
|
+
export class BashPathResolver {
|
|
89
|
+
constructor(private readonly normalizer: PathNormalizer) {}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a parsed bash program's path references into its external-path and
|
|
93
|
+
* rule-candidate slices, walking the AST exactly once.
|
|
94
|
+
*/
|
|
95
|
+
resolve(rootNode: TSNode): ResolvedBashPaths {
|
|
96
|
+
const candidates = this.collectPathCandidates(rootNode);
|
|
97
|
+
return {
|
|
98
|
+
externalPaths: this.projectExternalPaths(candidates),
|
|
99
|
+
ruleCandidates: this.projectRuleCandidates(candidates),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── AST walk — collect PathCandidates ──────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Walk the AST once, collecting every path-candidate token tagged with the
|
|
107
|
+
* effective working directory projected onto its position.
|
|
108
|
+
*
|
|
109
|
+
* The effective directory is stateful: it starts at `cwd` and each
|
|
110
|
+
* current-shell `cd <literal>` (joined by `&&`, `||`, `;`, or a newline)
|
|
111
|
+
* folds into it for subsequent commands.
|
|
112
|
+
* A `cd` inside a pipeline or a backgrounded command runs in a subshell and
|
|
113
|
+
* does not update the running directory; subshell and brace-group interiors
|
|
114
|
+
* inherit the enclosing base without folding their own `cd`s (a conservative
|
|
115
|
+
* first tier).
|
|
116
|
+
*/
|
|
117
|
+
private collectPathCandidates(rootNode: TSNode): PathCandidate[] {
|
|
118
|
+
const out: PathCandidate[] = [];
|
|
119
|
+
this.walkForCandidates(rootNode, CWD_BASE, out);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Collect a single node's candidates tagged with `base`, returning the
|
|
125
|
+
* effective base in force *after* the node (the input base unless the node is
|
|
126
|
+
* a current-shell `cd <literal>` that folds the running directory).
|
|
127
|
+
*/
|
|
128
|
+
private walkForCandidates(
|
|
129
|
+
node: TSNode,
|
|
130
|
+
base: EffectiveBase,
|
|
131
|
+
out: PathCandidate[],
|
|
132
|
+
): EffectiveBase {
|
|
133
|
+
switch (node.type) {
|
|
134
|
+
case "program":
|
|
135
|
+
case "list":
|
|
136
|
+
case "redirected_statement":
|
|
137
|
+
return this.walkCurrentShellSequence(node, base, out);
|
|
138
|
+
case "command":
|
|
139
|
+
tagTokens(collectCommandTokens(node), base, out);
|
|
140
|
+
return this.foldCd(node, base);
|
|
141
|
+
case "pipeline":
|
|
142
|
+
// tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
|
|
143
|
+
// first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
|
|
144
|
+
// `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside
|
|
145
|
+
// a node the `default` case treats as non-folding. Recover bash operator
|
|
146
|
+
// precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first
|
|
147
|
+
// stage's leading current-shell commands while keeping its terminal
|
|
148
|
+
// command and every downstream stage as non-folding subshells (#454).
|
|
149
|
+
return this.walkPipeline(node, base, out);
|
|
150
|
+
case "subshell":
|
|
151
|
+
// A subshell runs in a child shell: its interior `cd`s fold within the
|
|
152
|
+
// subshell but reset on exit, so the folded base is discarded.
|
|
153
|
+
this.walkCurrentShellSequence(node, base, out);
|
|
154
|
+
return base;
|
|
155
|
+
case "compound_statement":
|
|
156
|
+
// A `{ … }` brace group runs in the current shell, so its `cd`s persist
|
|
157
|
+
// to following commands — thread and return the folded base.
|
|
158
|
+
return this.walkCurrentShellSequence(node, base, out);
|
|
159
|
+
default:
|
|
160
|
+
// Pipelines, control-flow bodies, redirect targets, and command/process
|
|
161
|
+
// substitution interiors: collect every candidate in the subtree tagged
|
|
162
|
+
// with the enclosing base and do not fold their internal `cd`s. (Folding
|
|
163
|
+
// inside substitutions is deferred — conservative, never under-flags.)
|
|
164
|
+
tagTokens(collectPathCandidateTokens(node), base, out);
|
|
165
|
+
return base;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
|
|
171
|
+
* thread the effective base left-to-right through the children so a `cd`
|
|
172
|
+
* updates the base for following siblings.
|
|
173
|
+
* A statement immediately followed by the background operator (`&`) runs in a
|
|
174
|
+
* subshell, so its folded base is discarded.
|
|
175
|
+
*/
|
|
176
|
+
private walkCurrentShellSequence(
|
|
177
|
+
seqNode: TSNode,
|
|
178
|
+
base: EffectiveBase,
|
|
179
|
+
out: PathCandidate[],
|
|
180
|
+
): EffectiveBase {
|
|
181
|
+
let current = base;
|
|
182
|
+
for (let i = 0; i < seqNode.childCount; i++) {
|
|
183
|
+
const child = seqNode.child(i);
|
|
184
|
+
if (!child?.isNamed) continue;
|
|
185
|
+
if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
|
|
186
|
+
const after = this.walkForCandidates(child, current, out);
|
|
187
|
+
current = isBackgrounded(seqNode, i) ? current : after;
|
|
188
|
+
}
|
|
189
|
+
return current;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Walk a `pipeline` node, returning the effective base in force after it.
|
|
194
|
+
*
|
|
195
|
+
* Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd`
|
|
196
|
+
* inside any stage must not leak — the base normally passes through unchanged.
|
|
197
|
+
* The exception is the first stage: tree-sitter-bash wraps a redirect-bearing
|
|
198
|
+
* current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage,
|
|
199
|
+
* and bash precedence makes the list's leading commands current-shell, so they
|
|
200
|
+
* fold and the folded base persists past the pipeline to following siblings.
|
|
201
|
+
*
|
|
202
|
+
* The terminal command of the first stage is the real pipe stage (a subshell)
|
|
203
|
+
* and must not fold; every stage after a `|` is a downstream subshell stage
|
|
204
|
+
* and collects tokens against the folded base without folding (#454).
|
|
205
|
+
*/
|
|
206
|
+
private walkPipeline(
|
|
207
|
+
node: TSNode,
|
|
208
|
+
base: EffectiveBase,
|
|
209
|
+
out: PathCandidate[],
|
|
210
|
+
): EffectiveBase {
|
|
211
|
+
let current = base;
|
|
212
|
+
let first = true;
|
|
213
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
214
|
+
const child = node.child(i);
|
|
215
|
+
if (!child?.isNamed) continue;
|
|
216
|
+
if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
|
|
217
|
+
if (first) {
|
|
218
|
+
current = this.foldPipelineFirstStage(child, current, out);
|
|
219
|
+
first = false;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Downstream stage (after a `|`): subshell — collect against the folded
|
|
223
|
+
// base, do not fold.
|
|
224
|
+
tagTokens(collectPathCandidateTokens(child), current, out);
|
|
225
|
+
}
|
|
226
|
+
return current;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Collect the first pipe stage's candidates, folding its leading current-shell
|
|
231
|
+
* `cd` commands when tree-sitter wrapped a `list` or `redirected_statement`
|
|
232
|
+
* around them.
|
|
233
|
+
* The terminal command of that container is the real pipe stage (a subshell)
|
|
234
|
+
* and is collected without folding.
|
|
235
|
+
* A bare `command` first stage (a true pipeline first stage such as
|
|
236
|
+
* `cd nested | cat ../b`) is a subshell: it collects against the input base
|
|
237
|
+
* and does not fold.
|
|
238
|
+
*/
|
|
239
|
+
private foldPipelineFirstStage(
|
|
240
|
+
node: TSNode,
|
|
241
|
+
base: EffectiveBase,
|
|
242
|
+
out: PathCandidate[],
|
|
243
|
+
): EffectiveBase {
|
|
244
|
+
if (node.type === "list")
|
|
245
|
+
return this.foldListExceptTerminal(node, base, out);
|
|
246
|
+
if (node.type === "redirected_statement") {
|
|
247
|
+
let current = base;
|
|
248
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
249
|
+
const child = node.child(i);
|
|
250
|
+
if (!child?.isNamed) continue;
|
|
251
|
+
if (child.type === "file_redirect") {
|
|
252
|
+
// Redirect destinations are part of the piped stage; collect them
|
|
253
|
+
// against the folded base without folding.
|
|
254
|
+
tagTokens(collectRedirectTokens(child), current, out);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// The inner statement is the `list`/`command` being redirected; fold its
|
|
258
|
+
// leading current-shell commands via the terminal-excluding walk.
|
|
259
|
+
current = this.foldPipelineFirstStage(child, current, out);
|
|
260
|
+
}
|
|
261
|
+
return current;
|
|
262
|
+
}
|
|
263
|
+
// Bare `command` or any other shape: a true subshell first stage.
|
|
264
|
+
tagTokens(collectPathCandidateTokens(node), base, out);
|
|
265
|
+
return base;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fold every named, non-skip child of a `list` except the last, threading the
|
|
270
|
+
* effective base left-to-right through the leading current-shell commands; the
|
|
271
|
+
* terminal child is the real pipe stage and is collected without folding.
|
|
272
|
+
*/
|
|
273
|
+
private foldListExceptTerminal(
|
|
274
|
+
node: TSNode,
|
|
275
|
+
base: EffectiveBase,
|
|
276
|
+
out: PathCandidate[],
|
|
277
|
+
): EffectiveBase {
|
|
278
|
+
const namedChildren: TSNode[] = [];
|
|
279
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
280
|
+
const child = node.child(i);
|
|
281
|
+
if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) {
|
|
282
|
+
namedChildren.push(child);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let current = base;
|
|
286
|
+
for (let i = 0; i < namedChildren.length; i++) {
|
|
287
|
+
const child = namedChildren[i];
|
|
288
|
+
if (i < namedChildren.length - 1) {
|
|
289
|
+
current = this.walkForCandidates(child, current, out);
|
|
290
|
+
} else {
|
|
291
|
+
// Terminal child = the real pipe stage; collect without folding.
|
|
292
|
+
tagTokens(collectPathCandidateTokens(child), current, out);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return current;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compute the effective base after a command runs.
|
|
300
|
+
* Returns `base` unchanged unless the command is `cd`:
|
|
301
|
+
*
|
|
302
|
+
* - `cd /abs` (absolute literal) → a fresh known base, recovering from an
|
|
303
|
+
* earlier unknown base.
|
|
304
|
+
* - `cd rel` (relative literal) → fold into a known base, or stay unknown if
|
|
305
|
+
* the base was already unknown.
|
|
306
|
+
* - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
|
|
307
|
+
* unknown.
|
|
308
|
+
*/
|
|
309
|
+
private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
|
|
310
|
+
if (extractCommandName(commandNode) !== "cd") return base;
|
|
311
|
+
const target = cdLiteralTarget(commandNode);
|
|
312
|
+
if (target === null) return UNKNOWN_BASE;
|
|
313
|
+
if (this.normalizer.isAbsolute(target))
|
|
314
|
+
return { kind: "known", offset: target };
|
|
315
|
+
if (base.kind === "unknown") return UNKNOWN_BASE;
|
|
316
|
+
return {
|
|
317
|
+
kind: "known",
|
|
318
|
+
offset: this.normalizer.joinBase(base.offset, target),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Projection ─────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Project the collected candidates into deduplicated external paths.
|
|
326
|
+
*
|
|
327
|
+
* Filters candidates through the strict path classifier
|
|
328
|
+
* (`classifyTokenAsPathCandidate`), resolves each against its effective working
|
|
329
|
+
* directory base, and returns only paths that resolve outside the baked cwd in
|
|
330
|
+
* their lexical (as-typed, normalized but not symlink-resolved) form.
|
|
331
|
+
*
|
|
332
|
+
* The outside-cwd decision and the dedup identity use the canonical
|
|
333
|
+
* (symlink-resolved) form so `external_directory` config patterns match the
|
|
334
|
+
* path as the user typed it (#418).
|
|
335
|
+
*/
|
|
336
|
+
private projectExternalPaths(
|
|
337
|
+
candidates: readonly PathCandidate[],
|
|
338
|
+
): AccessPath[] {
|
|
339
|
+
const seen = new Set<string>();
|
|
340
|
+
const externalPaths: AccessPath[] = [];
|
|
341
|
+
|
|
342
|
+
for (const { token, base } of candidates) {
|
|
343
|
+
const candidate = classifyTokenAsPathCandidate(token);
|
|
344
|
+
if (!candidate) continue;
|
|
345
|
+
|
|
346
|
+
// Unknown effective directory: a relative candidate could resolve
|
|
347
|
+
// anywhere, so flag it conservatively (resolved against the baked cwd
|
|
348
|
+
// only for a display path). Absolute / `~` candidates are base-independent
|
|
349
|
+
// below.
|
|
350
|
+
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
|
351
|
+
const accessPath = this.normalizer.forPath(candidate);
|
|
352
|
+
const canonical = accessPath.boundaryValue();
|
|
353
|
+
if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
|
|
354
|
+
seen.add(canonical);
|
|
355
|
+
externalPaths.push(accessPath);
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const resolveBase =
|
|
361
|
+
base.kind === "known"
|
|
362
|
+
? this.normalizer.resolveBase(base.offset)
|
|
363
|
+
: undefined;
|
|
364
|
+
const accessPath = this.normalizer.forPath(candidate, { resolveBase });
|
|
365
|
+
const lexical = accessPath.value();
|
|
366
|
+
if (!lexical) continue;
|
|
367
|
+
// The boundary decision and dedup identity use the canonical
|
|
368
|
+
// (symlink-resolved) form, but the returned value is the lexical form so
|
|
369
|
+
// config patterns match the path as the user typed it (#418).
|
|
370
|
+
const canonical = accessPath.boundaryValue();
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
this.normalizer.isOutsideWorkingDirectory(lexical) &&
|
|
374
|
+
!seen.has(canonical)
|
|
375
|
+
) {
|
|
376
|
+
seen.add(canonical);
|
|
377
|
+
externalPaths.push(accessPath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return externalPaths;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Project the collected candidates into rule candidates with their cd-aware
|
|
386
|
+
* policy lookup values.
|
|
387
|
+
*
|
|
388
|
+
* Filters candidates through the broad path classifier
|
|
389
|
+
* (`classifyTokenAsRuleCandidate`) and pairs each qualifying token with its
|
|
390
|
+
* set of policy values (absolute + project-relative + raw).
|
|
391
|
+
* A token after a non-literal `cd` keeps only its literal value so no
|
|
392
|
+
* spurious absolute rule can match (#393).
|
|
393
|
+
*/
|
|
394
|
+
private projectRuleCandidates(
|
|
395
|
+
candidates: readonly PathCandidate[],
|
|
396
|
+
): BashPathRuleCandidate[] {
|
|
397
|
+
const seen = new Set<string>();
|
|
398
|
+
const result: BashPathRuleCandidate[] = [];
|
|
399
|
+
|
|
400
|
+
for (const { token, base } of candidates) {
|
|
401
|
+
const candidate = classifyTokenAsRuleCandidate(token);
|
|
402
|
+
if (!candidate) continue;
|
|
403
|
+
|
|
404
|
+
const path = this.buildRuleCandidatePath(candidate, base);
|
|
405
|
+
const matchValues = path.matchValues();
|
|
406
|
+
if (matchValues.length === 0) continue;
|
|
407
|
+
|
|
408
|
+
const key = matchValues.join("\0");
|
|
409
|
+
if (seen.has(key)) continue;
|
|
410
|
+
seen.add(key);
|
|
411
|
+
result.push({ token: candidate, path });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private buildRuleCandidatePath(
|
|
418
|
+
candidate: string,
|
|
419
|
+
base: EffectiveBase,
|
|
420
|
+
): AccessPath {
|
|
421
|
+
// An unknown base + relative candidate stays literal-only: a resolved
|
|
422
|
+
// absolute or canonical alias would resolve against the wrong directory and
|
|
423
|
+
// could spuriously match a rule (#393).
|
|
424
|
+
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
|
425
|
+
return this.normalizer.forLiteral(normalizePathPolicyLiteral(candidate));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const resolveBase =
|
|
429
|
+
base.kind === "known"
|
|
430
|
+
? this.normalizer.resolveBase(base.offset)
|
|
431
|
+
: undefined;
|
|
432
|
+
return this.normalizer.forPath(candidate, { resolveBase });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* True when a path candidate is relative (resolved against the effective
|
|
437
|
+
* directory) rather than absolute or home-relative (`~…`), which are
|
|
438
|
+
* base-independent.
|
|
439
|
+
*
|
|
440
|
+
* Delegates the absoluteness decision to the platform-aware `PathNormalizer`
|
|
441
|
+
* rather than a POSIX-only `startsWith("/")` check, so Windows drive-letter
|
|
442
|
+
* paths (`C:/…`, `C:\…`) are correctly treated as absolute on win32 and as
|
|
443
|
+
* relative on POSIX (where they denote an in-CWD path).
|
|
444
|
+
*/
|
|
445
|
+
private isRelativeCandidate(candidate: string): boolean {
|
|
446
|
+
return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Pure AST/string helpers ──────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* True when the statement at `index` is immediately followed by the background
|
|
454
|
+
* operator (`&`) — distinct from the `&&` / `||` / `;` current-shell
|
|
455
|
+
* separators.
|
|
456
|
+
*/
|
|
457
|
+
function isBackgrounded(seqNode: TSNode, index: number): boolean {
|
|
458
|
+
const next = seqNode.child(index + 1);
|
|
459
|
+
if (!next || next.isNamed) return false;
|
|
460
|
+
return next.type === "&";
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function tagTokens(
|
|
464
|
+
tokens: readonly string[],
|
|
465
|
+
base: EffectiveBase,
|
|
466
|
+
out: PathCandidate[],
|
|
467
|
+
): void {
|
|
468
|
+
for (const token of tokens) out.push({ token, base });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resolve the literal target of a `cd` command, or `null` when the first
|
|
473
|
+
* argument is not a static literal (contains an expansion or command
|
|
474
|
+
* substitution) or cannot be resolved against the working directory (`cd -`,
|
|
475
|
+
* `cd ~…`, bare `cd`).
|
|
476
|
+
*/
|
|
477
|
+
function cdLiteralTarget(commandNode: TSNode): string | null {
|
|
478
|
+
for (let i = 0; i < commandNode.childCount; i++) {
|
|
479
|
+
const child = commandNode.child(i);
|
|
480
|
+
if (!child) continue;
|
|
481
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
482
|
+
continue;
|
|
483
|
+
if (!child.isNamed) continue;
|
|
484
|
+
// Skip the `--` end-of-flags marker; the next argument is the target.
|
|
485
|
+
if (child.type === "word" && child.text === "--") continue;
|
|
486
|
+
if (!ARG_NODE_TYPES.has(child.type)) return null;
|
|
487
|
+
return literalTextOf(child);
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The literal string value of an argument node, or `null` when it contains a
|
|
494
|
+
* variable expansion / command substitution or is a non-resolvable `cd`
|
|
495
|
+
* destination (`-`, `~…`).
|
|
496
|
+
*/
|
|
497
|
+
function literalTextOf(node: TSNode): string | null {
|
|
498
|
+
switch (node.type) {
|
|
499
|
+
case "word": {
|
|
500
|
+
const text = node.text;
|
|
501
|
+
if (text === "-" || text.startsWith("~")) return null;
|
|
502
|
+
return text;
|
|
503
|
+
}
|
|
504
|
+
case "raw_string": {
|
|
505
|
+
const text = node.text;
|
|
506
|
+
return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
|
|
507
|
+
? text.slice(1, -1)
|
|
508
|
+
: text;
|
|
509
|
+
}
|
|
510
|
+
case "concatenation": {
|
|
511
|
+
let result = "";
|
|
512
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
513
|
+
const child = node.child(i);
|
|
514
|
+
if (!child) continue;
|
|
515
|
+
const part = literalTextOf(child);
|
|
516
|
+
if (part === null) return null;
|
|
517
|
+
result += part;
|
|
518
|
+
}
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
case "string": {
|
|
522
|
+
let result = "";
|
|
523
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
524
|
+
const child = node.child(i);
|
|
525
|
+
if (!child) continue;
|
|
526
|
+
if (child.type === '"') continue;
|
|
527
|
+
if (child.type !== "string_content") return null;
|
|
528
|
+
result += child.text;
|
|
529
|
+
}
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
default:
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import {
|
|
3
|
+
BashPathResolver,
|
|
4
|
+
type BashPathRuleCandidate,
|
|
5
|
+
} from "#src/access-intent/bash/bash-path-resolver";
|
|
2
6
|
import {
|
|
3
7
|
type BashCommand,
|
|
4
8
|
collectCommands,
|
|
5
9
|
} from "#src/access-intent/bash/command-enumeration";
|
|
6
|
-
import {
|
|
7
|
-
type BashPathRuleCandidate,
|
|
8
|
-
collectPathCandidates,
|
|
9
|
-
projectExternalPaths,
|
|
10
|
-
projectRuleCandidates,
|
|
11
|
-
} from "#src/access-intent/bash/cwd-projection";
|
|
12
10
|
import { getParser } from "#src/access-intent/bash/parser";
|
|
11
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
13
12
|
|
|
14
13
|
export type { BashCommand, BashPathRuleCandidate };
|
|
15
14
|
|
|
@@ -35,20 +34,26 @@ export class BashProgram {
|
|
|
35
34
|
*
|
|
36
35
|
* Uses tree-sitter-bash to build the full AST, enumerates command units and
|
|
37
36
|
* walks path-candidate tokens once, then eagerly resolves all three slices
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* through the injected {@link PathNormalizer} (platform + cwd baked in).
|
|
38
|
+
* Heredoc bodies, comments, and other non-argument content are skipped. An
|
|
39
|
+
* unparseable command yields an empty program.
|
|
40
40
|
*/
|
|
41
|
-
static async parse(
|
|
41
|
+
static async parse(
|
|
42
|
+
command: string,
|
|
43
|
+
normalizer: PathNormalizer,
|
|
44
|
+
): Promise<BashProgram> {
|
|
42
45
|
const parser = await getParser();
|
|
43
46
|
const tree = parser.parse(command);
|
|
44
47
|
if (!tree) return new BashProgram([], [], []);
|
|
45
48
|
|
|
46
49
|
try {
|
|
47
|
-
const
|
|
50
|
+
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
|
51
|
+
normalizer,
|
|
52
|
+
).resolve(tree.rootNode);
|
|
48
53
|
return new BashProgram(
|
|
49
54
|
collectCommands(tree.rootNode),
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
externalPaths,
|
|
56
|
+
ruleCandidates,
|
|
52
57
|
);
|
|
53
58
|
} finally {
|
|
54
59
|
tree.delete();
|
|
@@ -81,7 +86,8 @@ export class BashProgram {
|
|
|
81
86
|
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
|
|
82
87
|
* forms behind distinct accessors.
|
|
83
88
|
*
|
|
84
|
-
* Resolved eagerly at parse time
|
|
89
|
+
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
|
90
|
+
* `parse()` (platform + cwd baked in).
|
|
85
91
|
* Use `.matchValues()` for `external_directory` pattern matching and
|
|
86
92
|
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
|
|
87
93
|
*/
|
|
@@ -92,7 +98,8 @@ export class BashProgram {
|
|
|
92
98
|
/**
|
|
93
99
|
* Path-rule candidates paired with their policy lookup values.
|
|
94
100
|
*
|
|
95
|
-
* Resolved eagerly at parse time
|
|
101
|
+
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
|
102
|
+
* `parse()` (platform + cwd baked in).
|
|
96
103
|
* Each token is resolved against the effective working directory in force at
|
|
97
104
|
* the token's position (folding literal current-shell `cd` commands), while
|
|
98
105
|
* raw and project-relative aliases are retained for backward-compatible
|