@gotgenes/pi-permission-system 39.1.1 → 40.0.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 +28 -0
- package/dist/public.d.ts +36 -0
- package/docs/configuration.md +36 -0
- package/docs/cross-extension-api.md +11 -10
- package/docs/subagent-integration.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +85 -16
- package/src/access-intent/bash/command-enumeration.ts +77 -17
- package/src/access-intent/bash/program.ts +34 -8
- package/src/access-intent/bash/token-collection.ts +47 -10
- package/src/authority/forwarded-request-server.ts +6 -1
- package/src/authority/forwarding-io.ts +44 -0
- package/src/authority/permission-forwarding.ts +9 -0
- package/src/handlers/gates/bash-command.ts +28 -3
- package/src/handlers/gates/tool.ts +15 -1
- package/src/logging/command-redaction.ts +1 -0
- package/src/policy/permission-manager.ts +44 -11
- package/src/policy/serving-policy.ts +40 -1
- package/src/presentation/dialog-renderer.ts +3 -0
- package/src/presentation/forwarded-ask-payload.ts +1 -0
- package/src/presentation/path-ask-payload.ts +2 -0
- package/src/presentation/prompt-payload.ts +11 -0
- package/src/presentation/review-log-renderer.ts +1 -0
- package/src/presentation/skill-ask-payload.ts +1 -0
- package/src/presentation/tool-ask-payload.ts +1 -0
- package/src/service/bash-advisory-check.ts +16 -5
- package/src/service/permissions-service.ts +6 -1
- package/src/types.ts +23 -0
- package/src/access-intent/bash/sync-commands.ts +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [40.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.2...pi-permission-system-v40.0.0) (2026-10-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** report which spelling of a bash command a rule matched ([9c1a9da](https://github.com/gotgenes/pi-packages/commit/9c1a9da4ee19e25aa6ea8366f3d6a5062e2d3c2c)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
|
|
14
|
+
* **pi-permission-system:** show the matching bash spelling in the ask dialog and the review log ([09c1ef6](https://github.com/gotgenes/pi-packages/commit/09c1ef62e9b1fdc6dc4beabc7193ac89ef9496ce)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** **breaking:** match a bash rule written with an absolute path against the relative spelling of that path ([8883d96](https://github.com/gotgenes/pi-packages/commit/8883d960f4ccbe6131e22959e3acbc35abe2558b)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** document the absolute argument spelling of a bash command ([29816e7](https://github.com/gotgenes/pi-packages/commit/29816e7664cb2a813f042fe1b41d7b46ce5c870e)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
|
|
23
|
+
* **pi-permission-system:** note that a slash-bearing non-path argument is spelled too ([8a21456](https://github.com/gotgenes/pi-packages/commit/8a21456faa43eb86ac0c0cb790cff3e170b73ee2)), closes [#910](https://github.com/gotgenes/pi-packages/issues/910)
|
|
24
|
+
|
|
25
|
+
## [39.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.1...pi-permission-system-v39.1.2) (2026-10-06)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** a subagent's forwarded bash line is judged command by command on the parent ([0990e92](https://github.com/gotgenes/pi-packages/commit/0990e921868d077868b007682ffbaacc3a00cbc8)), closes [#1030](https://github.com/gotgenes/pi-packages/issues/1030)
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** a forwarded bash line carries every asking command ([5cb6696](https://github.com/gotgenes/pi-packages/commit/5cb6696feb8572247dd6d09d4eb980292c0510f1))
|
|
35
|
+
|
|
8
36
|
## [39.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.0...pi-permission-system-v39.1.1) (2026-10-06)
|
|
9
37
|
|
|
10
38
|
|
package/dist/public.d.ts
CHANGED
|
@@ -63,6 +63,13 @@ interface PermissionCheckResult {
|
|
|
63
63
|
* {@link floorExemption} says the wrapper's floor has no reason to hold.
|
|
64
64
|
*/
|
|
65
65
|
executedUnit?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The spelling of the bash unit the winning rule matched, when the rule did
|
|
68
|
+
* not also match the unit as typed — the absolute spelling of a relative
|
|
69
|
+
* path argument, say (#910). Display-only: absent whenever the typed text
|
|
70
|
+
* decided, and dropped by a floor that replaces {@link matchedPattern}.
|
|
71
|
+
*/
|
|
72
|
+
matchedSpelling?: string;
|
|
66
73
|
/**
|
|
67
74
|
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
68
75
|
* naming why (#803, #963). Recorded in the review log so an allow the floor would
|
|
@@ -76,6 +83,21 @@ interface PermissionCheckResult {
|
|
|
76
83
|
* node to clamp its own `allow` with.
|
|
77
84
|
*/
|
|
78
85
|
floor?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Every unit of the bash chain that resolved to `ask` and was not
|
|
88
|
+
* session-granted, in chain order, each with its own floor. Set only on an
|
|
89
|
+
* asking chain winner: the serving node of a forwarded ask judges each one,
|
|
90
|
+
* since the winner alone would approve commands it never saw.
|
|
91
|
+
*/
|
|
92
|
+
askingUnits?: readonly AskingBashUnit[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One unit of a bash chain the gate left asking, as the serving node of a
|
|
96
|
+
* forwarded ask needs it: the unit as typed, and the floor that raised it.
|
|
97
|
+
*/
|
|
98
|
+
interface AskingBashUnit {
|
|
99
|
+
command: string;
|
|
100
|
+
floor?: string;
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/**
|
|
@@ -130,6 +152,12 @@ interface PromptRequestFacts {
|
|
|
130
152
|
readonly value: string;
|
|
131
153
|
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
|
|
132
154
|
readonly matchedPattern: string | null;
|
|
155
|
+
/**
|
|
156
|
+
* For bash, the spelling of the unit the matched rule matched when it did not
|
|
157
|
+
* match {@link value} as typed — the absolute spelling of a relative path
|
|
158
|
+
* argument, say. `null` when the typed text decided.
|
|
159
|
+
*/
|
|
160
|
+
readonly matchedSpelling: string | null;
|
|
133
161
|
/**
|
|
134
162
|
* Where the offending bash unit runs, when it came from a substitution or a
|
|
135
163
|
* subshell. A fact rather than a rendered clause: it is what makes the
|
|
@@ -385,6 +413,14 @@ interface ForwardedAccessFacts {
|
|
|
385
413
|
* than recomputing it. Absent when no floor was raised.
|
|
386
414
|
*/
|
|
387
415
|
floor?: string;
|
|
416
|
+
/**
|
|
417
|
+
* Every unit of a bash chain the child left asking, each with its own floor.
|
|
418
|
+
* `matchValues` names only the chain's winner, and the serving node's answer
|
|
419
|
+
* approves the whole tool call, so it judges each of these instead. Absent
|
|
420
|
+
* for a non-bash ask and for a bash ask raised without a chain (the empty
|
|
421
|
+
* parse), where `matchValues` is the whole command.
|
|
422
|
+
*/
|
|
423
|
+
askingUnits?: AskingBashUnit[];
|
|
388
424
|
}
|
|
389
425
|
|
|
390
426
|
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
package/docs/configuration.md
CHANGED
|
@@ -453,6 +453,41 @@ Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are n
|
|
|
453
453
|
|
|
454
454
|
A command that starts with `~`, `$HOME`, or `${HOME}` also matches a home-anchored pattern spelled any of those ways, or with the absolute home directory (see [Home Directory Expansion in Patterns](#home-directory-expansion-in-patterns)).
|
|
455
455
|
|
|
456
|
+
A path argument also matches in its absolute spelling.
|
|
457
|
+
Each command is matched as typed and with every path argument replaced by the absolute path it resolves to, against the working directory a literal `cd` earlier in the chain moved to.
|
|
458
|
+
The last rule matching either text wins, as for any other pattern.
|
|
459
|
+
|
|
460
|
+
```jsonc
|
|
461
|
+
{
|
|
462
|
+
"permission": {
|
|
463
|
+
"bash": {
|
|
464
|
+
"*": "allow",
|
|
465
|
+
"rm *": "ask",
|
|
466
|
+
"rm /tmp/agent-builds/*": "allow"
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
With this policy, `cd /tmp && rm agent-builds/x` runs, because `rm agent-builds/x` is also matched as `rm /tmp/agent-builds/x`.
|
|
473
|
+
An absolute `deny` reaches the relative spelling the same way.
|
|
474
|
+
An absolute `allow` written *before* the broader `ask` still loses to it.
|
|
475
|
+
|
|
476
|
+
Some arguments get no absolute spelling and are matched as typed only:
|
|
477
|
+
|
|
478
|
+
- an argument after a `cd` whose target is not literal (`cd "$DIR"`, `cd -`, a bare `cd`), since the directory is not known;
|
|
479
|
+
- an argument holding a glob (`*`, `?`, `[`), quoted or not;
|
|
480
|
+
- an argument a variable or substitution computes (`$DIR/x`, `$(pwd)/x`);
|
|
481
|
+
- a bare name such as `config.json` that names nothing on disk;
|
|
482
|
+
- a POSIX absolute path on Windows (`/tmp/x`), which is kept as typed.
|
|
483
|
+
|
|
484
|
+
The absolute spelling replaces every path argument of a command at once, and it is separate from the home spelling above.
|
|
485
|
+
A rule naming one argument absolute and another relative, or naming both the expanded home and an absolute argument, matches neither text.
|
|
486
|
+
A quoted argument is spelled without its quotes (`rm "a b/c"` is also matched as `rm /tmp/a b/c`).
|
|
487
|
+
Whether an argument is a path is decided by its shape, so a slash-bearing word that is not one is spelled too: `git push origin feature/x` is also matched as `git push origin <cwd>/feature/x`, which only a rule naming that absolute form would match.
|
|
488
|
+
When the spelling is what a rule matched, the prompt shows it on a `matched as` line beside the rule, and the review log records it as `matchedSpelling`.
|
|
489
|
+
A session approval still records the command as typed, so approving `rm agent-builds/x` does not cover a later `rm /tmp/agent-builds/x`.
|
|
490
|
+
|
|
456
491
|
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
|
|
457
492
|
So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
|
|
458
493
|
Prefixes like `PGPASSWORD=` and `KUBECONFIG=` are handled the same way.
|
|
@@ -541,6 +576,7 @@ Approving one for the session works normally: the floors clamp the decision and
|
|
|
541
576
|
A subagent's floored ask keeps its floor when it is forwarded to the parent session.
|
|
542
577
|
The parent's `allow` rule does not answer it, so `sudo rm x` from a subagent prompts in the parent even under a parent `bash: *` allow.
|
|
543
578
|
The parent's `deny` still denies, and a grant for the whole parent session or the parent's `yoloMode` still approves.
|
|
579
|
+
A subagent's chained command (`ls && rm -rf /tmp/x`) is judged by the parent command by command: every command the subagent's own policy left asking is forwarded with its floor, and the parent's `deny` or `ask` on any one of them decides, not only on the first.
|
|
544
580
|
|
|
545
581
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
546
582
|
A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
|
|
@@ -428,16 +428,17 @@ Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its origin
|
|
|
428
428
|
The facts every render of the ask shows, that no renderer's budget may elide.
|
|
429
429
|
Nested rather than flattened so the event and the prompt payload share one shape: a fact added here reaches the bus without a second hand-maintained declaration.
|
|
430
430
|
|
|
431
|
-
| Field | Type | Description
|
|
432
|
-
| ----------------- | ---------------------------- |
|
|
433
|
-
| `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent
|
|
434
|
-
| `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name
|
|
435
|
-
| `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped
|
|
436
|
-
| `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name
|
|
437
|
-
| `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name
|
|
438
|
-
| `matchedPattern` | `string \| null` | The matched rule, including a sentinel such as `<indirection-bash-wrapper>`
|
|
439
|
-
| `
|
|
440
|
-
| `
|
|
431
|
+
| Field | Type | Description |
|
|
432
|
+
| ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
433
|
+
| `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent |
|
|
434
|
+
| `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name |
|
|
435
|
+
| `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped |
|
|
436
|
+
| `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name |
|
|
437
|
+
| `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name |
|
|
438
|
+
| `matchedPattern` | `string \| null` | The matched rule, including a sentinel such as `<indirection-bash-wrapper>` |
|
|
439
|
+
| `matchedSpelling` | `string \| null` | For bash, the spelling of the command the rule matched, when it did not match the command as typed |
|
|
440
|
+
| `commandContext` | `BashCommandContext \| null` | Where the offending bash unit runs, when it came from a substitution or subshell |
|
|
441
|
+
| `executedUnit` | `string \| null` | For bash, the unit that will actually run, including inside an unstrippable wrapper |
|
|
441
442
|
|
|
442
443
|
`PromptRequester` carries `agentName` (`string | null`), `forwarded` (`boolean`), and `sessionId` (`string | null`, the requesting session for a forwarded ask).
|
|
443
444
|
|
|
@@ -148,6 +148,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
|
|
|
148
148
|
When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
|
|
149
149
|
The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
150
150
|
A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
|
|
151
|
+
For a chained bash command, the parent judges every command the child left asking, not only the first, and the most restrictive answer decides.
|
|
151
152
|
The exception is a bash ask the child raised through a [synthetic floor](configuration.md#fail-closed-behavior) (a wrapper such as `sudo` or `bash -c`, or a command the parser could not resolve): the child forwards the floor with the ask, and a parent `allow` rule escalates it to the parent's prompt instead of approving it.
|
|
152
153
|
|
|
153
154
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
8
8
|
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
9
9
|
import { isSafeSystemPath } from "#src/path/safe-system-paths";
|
|
10
|
+
import type { ArgumentSpeller } from "./command-enumeration";
|
|
10
11
|
import {
|
|
11
12
|
ARG_NODE_TYPES,
|
|
12
13
|
SKIP_SUBTREE_TYPES,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
collectRedirectTokens,
|
|
27
28
|
extractCommandName,
|
|
28
29
|
type PathToken,
|
|
30
|
+
type SourceSpan,
|
|
29
31
|
type TokenRole,
|
|
30
32
|
} from "./token-collection";
|
|
31
33
|
|
|
@@ -46,15 +48,13 @@ type EffectiveBase =
|
|
|
46
48
|
| { readonly kind: "unknown" };
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
* A
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* A collected {@link PathToken} — its text, the effect its position proved,
|
|
52
|
+
* the role its collector gave it, and its source span when it is a whole
|
|
53
|
+
* argument — paired with the effective working directory projected onto the
|
|
54
|
+
* point in the command stream where it appears.
|
|
52
55
|
*/
|
|
53
|
-
interface PathCandidate {
|
|
54
|
-
readonly token: string;
|
|
56
|
+
interface PathCandidate extends PathToken {
|
|
55
57
|
readonly base: EffectiveBase;
|
|
56
|
-
readonly effect: TokenEffect;
|
|
57
|
-
readonly role: TokenRole;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** A promoted bare token and its resolved path, before an effect is attached. */
|
|
@@ -89,6 +89,12 @@ export interface ResolvedBashPaths {
|
|
|
89
89
|
readonly externalAccesses: readonly BashExternalPath[];
|
|
90
90
|
/** Every path-rule token paired with its cd-aware policy values (#393). */
|
|
91
91
|
readonly ruleCandidates: readonly BashPathRuleCandidate[];
|
|
92
|
+
/**
|
|
93
|
+
* The absolute spelling of each primary-tree argument node a rule candidate
|
|
94
|
+
* resolved to an absolute path, for the command enumerator to spell a unit
|
|
95
|
+
* with (#910). See {@link BashPathResolver.resolve} for what is left out.
|
|
96
|
+
*/
|
|
97
|
+
readonly argumentSpellings: ArgumentSpeller;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
// ── Walk-time constants ──────────────────────────────────────────────────────
|
|
@@ -155,6 +161,11 @@ export class BashPathResolver {
|
|
|
155
161
|
* A token spelled from a `HOME` the program rebinds leaves both slices before
|
|
156
162
|
* projection: path normalization would otherwise expand its `~`/`$HOME` prefix
|
|
157
163
|
* to the startup home, naming a directory the shell never touches.
|
|
164
|
+
*
|
|
165
|
+
* The argument spellings come from the same projection as the rule
|
|
166
|
+
* candidates, keyed by the source span of a whole-argument token. A salvaged
|
|
167
|
+
* fragment's tokens carry no span, because its indices are its own re-parse's
|
|
168
|
+
* and would collide with the primary tree's.
|
|
158
169
|
*/
|
|
159
170
|
resolve(
|
|
160
171
|
rootNode: TSNode,
|
|
@@ -166,16 +177,21 @@ export class BashPathResolver {
|
|
|
166
177
|
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
|
167
178
|
const collected = this.collectPathCandidates(rootNode, initialBase);
|
|
168
179
|
for (const salvaged of salvagedRoots) {
|
|
169
|
-
|
|
180
|
+
const fragment: PathCandidate[] = [];
|
|
181
|
+
this.walkForCandidates(salvaged, UNKNOWN_BASE, fragment);
|
|
182
|
+
collected.push(...fragment.map(withoutSpan));
|
|
170
183
|
}
|
|
171
184
|
const candidates = collected.filter(
|
|
172
185
|
({ token }) => !this.words.spellsReboundHome(token),
|
|
173
186
|
);
|
|
187
|
+
const { ruleCandidates, argumentSpellings } =
|
|
188
|
+
this.projectRuleCandidates(candidates);
|
|
174
189
|
return {
|
|
175
190
|
externalAccesses: this.withWorkdirExternal(
|
|
176
191
|
this.projectExternalPaths(candidates),
|
|
177
192
|
),
|
|
178
|
-
ruleCandidates
|
|
193
|
+
ruleCandidates,
|
|
194
|
+
argumentSpellings,
|
|
179
195
|
};
|
|
180
196
|
}
|
|
181
197
|
|
|
@@ -575,13 +591,15 @@ export class BashPathResolver {
|
|
|
575
591
|
* A token after a non-literal `cd` keeps only its literal value so no
|
|
576
592
|
* spurious absolute rule can match (#393).
|
|
577
593
|
*/
|
|
578
|
-
private projectRuleCandidates(
|
|
579
|
-
|
|
580
|
-
|
|
594
|
+
private projectRuleCandidates(candidates: readonly PathCandidate[]): {
|
|
595
|
+
ruleCandidates: BashPathRuleCandidate[];
|
|
596
|
+
argumentSpellings: ArgumentSpeller;
|
|
597
|
+
} {
|
|
581
598
|
const seen = new Map<string, number>();
|
|
582
599
|
const result: BashPathRuleCandidate[] = [];
|
|
600
|
+
const spellings = new Map<string, string>();
|
|
583
601
|
|
|
584
|
-
for (const { token, base, effect, role } of candidates) {
|
|
602
|
+
for (const { token, base, effect, role, span } of candidates) {
|
|
585
603
|
const shaped = admittedByRole(role)
|
|
586
604
|
? token
|
|
587
605
|
: classifyTokenAsRuleCandidate(token, this.normalizer.flavor);
|
|
@@ -594,6 +612,11 @@ export class BashPathResolver {
|
|
|
594
612
|
const matchValues = probed.path.matchValues();
|
|
595
613
|
if (matchValues.length === 0) continue;
|
|
596
614
|
|
|
615
|
+
// Recorded per occurrence, ahead of the fold below, so `cp a/x a/x`
|
|
616
|
+
// spells both words rather than the first.
|
|
617
|
+
const spelling = span && this.absoluteSpellingOf(token, probed.path);
|
|
618
|
+
if (spelling) spellings.set(spanKey(span), spelling);
|
|
619
|
+
|
|
597
620
|
const key = matchValues.join("\0");
|
|
598
621
|
const index = seen.get(key);
|
|
599
622
|
if (index !== undefined) {
|
|
@@ -611,7 +634,35 @@ export class BashPathResolver {
|
|
|
611
634
|
result.push({ ...probed, effect });
|
|
612
635
|
}
|
|
613
636
|
|
|
614
|
-
return
|
|
637
|
+
return {
|
|
638
|
+
ruleCandidates: result,
|
|
639
|
+
argumentSpellings: {
|
|
640
|
+
absoluteSpellingOf: (node) =>
|
|
641
|
+
spellings.get(
|
|
642
|
+
spanKey({ start: node.startIndex, end: node.endIndex }),
|
|
643
|
+
),
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The absolute spelling of a whole-argument token, or `undefined` when giving
|
|
650
|
+
* one would invent rather than resolve.
|
|
651
|
+
*
|
|
652
|
+
* Absolute only: a token after a non-literal `cd` resolves to its literal,
|
|
653
|
+
* which is not absolute, so the check that keeps the spelling absolute is
|
|
654
|
+
* also what keeps that token unspelled (#393). A glob is never spelled,
|
|
655
|
+
* quoted or not — what it names is decided by expanding it (#822) — and a
|
|
656
|
+
* token already spelled as it resolves needs no second spelling.
|
|
657
|
+
*/
|
|
658
|
+
private absoluteSpellingOf(
|
|
659
|
+
token: string,
|
|
660
|
+
path: AccessPath,
|
|
661
|
+
): string | undefined {
|
|
662
|
+
if (GLOB_CHARACTERS.test(token)) return undefined;
|
|
663
|
+
const spelling = path.value();
|
|
664
|
+
if (!spelling || spelling === token) return undefined;
|
|
665
|
+
return this.normalizer.isAbsolute(spelling) ? spelling : undefined;
|
|
615
666
|
}
|
|
616
667
|
|
|
617
668
|
/**
|
|
@@ -683,6 +734,24 @@ export class BashPathResolver {
|
|
|
683
734
|
|
|
684
735
|
// ── Pure AST/string helpers ──────────────────────────────────────────────────
|
|
685
736
|
|
|
737
|
+
/** A character that makes the shell expand a word against the filesystem. */
|
|
738
|
+
const GLOB_CHARACTERS = /[*?[]/;
|
|
739
|
+
|
|
740
|
+
/** The key an argument spelling is filed under: its node's source span. */
|
|
741
|
+
function spanKey({ start, end }: SourceSpan): string {
|
|
742
|
+
return `${start}:${end}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** A salvaged candidate, which carries no span in the primary tree's coordinates. */
|
|
746
|
+
function withoutSpan({
|
|
747
|
+
token,
|
|
748
|
+
effect,
|
|
749
|
+
role,
|
|
750
|
+
base,
|
|
751
|
+
}: PathCandidate): PathCandidate {
|
|
752
|
+
return { token, effect, role, base };
|
|
753
|
+
}
|
|
754
|
+
|
|
686
755
|
/**
|
|
687
756
|
* True when the statement at `index` is immediately followed by the background
|
|
688
757
|
* operator (`&`) — distinct from the `&&` / `||` / `;` current-shell
|
|
@@ -699,8 +768,8 @@ function tagTokens(
|
|
|
699
768
|
base: EffectiveBase,
|
|
700
769
|
out: PathCandidate[],
|
|
701
770
|
): void {
|
|
702
|
-
for (const
|
|
703
|
-
out.push({ token, base
|
|
771
|
+
for (const token of tokens) {
|
|
772
|
+
out.push({ ...token, base });
|
|
704
773
|
}
|
|
705
774
|
}
|
|
706
775
|
|
|
@@ -77,13 +77,28 @@ export interface BashCommand {
|
|
|
77
77
|
* Other spellings of {@link text} the shell runs identically, matched with
|
|
78
78
|
* it as aliases of one invocation. Absent when there are none.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
* opens with `~`, `$HOME`, or
|
|
82
|
-
*
|
|
80
|
+
* Two producers supply them, each spelling the text on its own: the
|
|
81
|
+
* home-expanded spelling of a unit whose text opens with `~`, `$HOME`, or
|
|
82
|
+
* `${HOME}` in a program that leaves `HOME` alone
|
|
83
|
+
* ({@link WordReader.spellHomeAtStart}), and the argument spelling, in which
|
|
84
|
+
* every argument word the {@link ArgumentSpeller} resolved to an absolute
|
|
85
|
+
* path is replaced by that path (#910).
|
|
83
86
|
*/
|
|
84
87
|
readonly spellings?: readonly string[];
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* What the enumerator asks about one argument word of a unit: the absolute
|
|
92
|
+
* spelling of the path it names, as the party that resolved the program's
|
|
93
|
+
* paths knows it, or `undefined` when there is none to give.
|
|
94
|
+
*
|
|
95
|
+
* Asked by node rather than by text, because only the node says which
|
|
96
|
+
* occurrence of a token a unit's word is.
|
|
97
|
+
*/
|
|
98
|
+
export interface ArgumentSpeller {
|
|
99
|
+
absoluteSpellingOf(node: TSNode): string | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
/**
|
|
88
103
|
* What the statement enclosing a command unit establishes about it.
|
|
89
104
|
*
|
|
@@ -119,16 +134,27 @@ interface UnitScope {
|
|
|
119
134
|
* program's variables are the same wherever in it a word sits.
|
|
120
135
|
*/
|
|
121
136
|
readonly words: WordReader;
|
|
137
|
+
/**
|
|
138
|
+
* What an argument word's absolute spelling is, from the party that resolved
|
|
139
|
+
* the program's paths. Relayed unchanged, including into nested executions:
|
|
140
|
+
* it answers by node, so each unit asks about its own words. Absent for a
|
|
141
|
+
* salvaged region, whose nodes are not the primary tree's.
|
|
142
|
+
*/
|
|
143
|
+
readonly speller?: ArgumentSpeller;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
/** A top-level command in the current shell, writing no file, fully parsed. */
|
|
125
|
-
function topLevelScope(
|
|
126
|
-
|
|
147
|
+
function topLevelScope(
|
|
148
|
+
words: WordReader,
|
|
149
|
+
speller: ArgumentSpeller | undefined,
|
|
150
|
+
): UnitScope {
|
|
151
|
+
const scope: UnitScope = {
|
|
127
152
|
writesViaRedirect: false,
|
|
128
153
|
parseUnresolved: false,
|
|
129
154
|
salvaged: false,
|
|
130
155
|
words,
|
|
131
156
|
};
|
|
157
|
+
return speller === undefined ? scope : { ...scope, speller };
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
/**
|
|
@@ -267,9 +293,10 @@ const STATEMENT_TYPES = new Set([
|
|
|
267
293
|
export function collectCommands(
|
|
268
294
|
node: TSNode,
|
|
269
295
|
words: WordReader,
|
|
296
|
+
speller?: ArgumentSpeller,
|
|
270
297
|
): BashCommand[] {
|
|
271
298
|
const out: BashCommand[] = [];
|
|
272
|
-
collectCommandsInto(node, topLevelScope(words), out);
|
|
299
|
+
collectCommandsInto(node, topLevelScope(words, speller), out);
|
|
273
300
|
return out;
|
|
274
301
|
}
|
|
275
302
|
|
|
@@ -462,10 +489,12 @@ function makeUnit(
|
|
|
462
489
|
* surely as one on the enclosing statement.
|
|
463
490
|
*/
|
|
464
491
|
function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
465
|
-
const { text, words } = readCommandUnit(node, scope
|
|
466
|
-
const homeSpelling = scope.words.spellHomeAtStart(text);
|
|
492
|
+
const { text, words, argumentSpelling } = readCommandUnit(node, scope);
|
|
467
493
|
return makeUnit(text, scope, {
|
|
468
|
-
spellings:
|
|
494
|
+
spellings: distinctSpellings(text, [
|
|
495
|
+
scope.words.spellHomeAtStart(text),
|
|
496
|
+
argumentSpelling,
|
|
497
|
+
]),
|
|
469
498
|
wrapperKind: classifyWrapperWords(words),
|
|
470
499
|
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
471
500
|
floorExemption: floorExemptionOf(words, redirectedScope(node, scope)),
|
|
@@ -520,10 +549,11 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
|
520
549
|
*/
|
|
521
550
|
function readCommandUnit(
|
|
522
551
|
node: TSNode,
|
|
523
|
-
|
|
552
|
+
scope: UnitScope,
|
|
524
553
|
): {
|
|
525
554
|
text: string;
|
|
526
555
|
words: CommandWord[];
|
|
556
|
+
argumentSpelling?: string;
|
|
527
557
|
} {
|
|
528
558
|
const nodes = commandWordNodes(node);
|
|
529
559
|
if (nodes.length === 0) return { text: node.text, words: [] };
|
|
@@ -531,18 +561,47 @@ function readCommandUnit(
|
|
|
531
561
|
const redirects = hostedRedirects(node);
|
|
532
562
|
const words: CommandWord[] = [];
|
|
533
563
|
let text = "";
|
|
564
|
+
let spelled = "";
|
|
565
|
+
let respelled = false;
|
|
534
566
|
let previous: TSNode | undefined;
|
|
535
567
|
for (const word of nodes) {
|
|
536
|
-
if (previous)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
568
|
+
if (previous) {
|
|
569
|
+
const gap = gapBetween(node, previous, word, redirects);
|
|
570
|
+
text += gap;
|
|
571
|
+
spelled += gap;
|
|
572
|
+
}
|
|
573
|
+
const argWord = scope.words.argWord(word);
|
|
574
|
+
words.push({ ...argWord, text: word.text, offset: text.length });
|
|
542
575
|
text += word.text;
|
|
576
|
+
const spelling = argWord.computed
|
|
577
|
+
? undefined
|
|
578
|
+
: scope.speller?.absoluteSpellingOf(word);
|
|
579
|
+
respelled ||= spelling !== undefined;
|
|
580
|
+
spelled += spelling ?? word.text;
|
|
543
581
|
previous = word;
|
|
544
582
|
}
|
|
545
|
-
return
|
|
583
|
+
return respelled
|
|
584
|
+
? { text, words, argumentSpelling: spelled }
|
|
585
|
+
: { text, words };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The spellings that differ from `text`, each once, or `undefined` when none
|
|
590
|
+
* does — so a unit with nothing to respell keeps the shape it had.
|
|
591
|
+
*/
|
|
592
|
+
function distinctSpellings(
|
|
593
|
+
text: string,
|
|
594
|
+
candidates: readonly (string | undefined)[],
|
|
595
|
+
): readonly string[] | undefined {
|
|
596
|
+
const spellings = [
|
|
597
|
+
...new Set(
|
|
598
|
+
candidates.filter(
|
|
599
|
+
(spelling): spelling is string =>
|
|
600
|
+
spelling !== undefined && spelling !== text,
|
|
601
|
+
),
|
|
602
|
+
),
|
|
603
|
+
];
|
|
604
|
+
return spellings.length === 0 ? undefined : spellings;
|
|
546
605
|
}
|
|
547
606
|
|
|
548
607
|
/**
|
|
@@ -670,6 +729,7 @@ function collectHostedCommands(
|
|
|
670
729
|
parseUnresolved: false,
|
|
671
730
|
salvaged: scope.salvaged,
|
|
672
731
|
words: scope.words,
|
|
732
|
+
speller: scope.speller,
|
|
673
733
|
},
|
|
674
734
|
out,
|
|
675
735
|
);
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
collectSalvagedCommands,
|
|
11
11
|
} from "./command-enumeration";
|
|
12
12
|
import { WordReader } from "./node-text";
|
|
13
|
-
import { getParser } from "./parser";
|
|
13
|
+
import { type BashReparser, getParser, getWarmBashParser } from "./parser";
|
|
14
14
|
import { ShellVariables } from "./shell-variable-expansion";
|
|
15
15
|
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
16
16
|
|
|
@@ -58,7 +58,33 @@ export class BashProgram {
|
|
|
58
58
|
normalizer: PathNormalizer,
|
|
59
59
|
options?: { workdir?: string },
|
|
60
60
|
): Promise<BashProgram> {
|
|
61
|
-
|
|
61
|
+
return BashProgram.build(await getParser(), command, normalizer, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse a bash command synchronously over the warmed parser, or answer `null`
|
|
66
|
+
* in the pre-warm window so the caller can fall back rather than block.
|
|
67
|
+
*
|
|
68
|
+
* Builds exactly the program {@link parse} builds; the service's advisory
|
|
69
|
+
* query uses it so its answer resolves the same slices the gate does (#309).
|
|
70
|
+
*/
|
|
71
|
+
static parseSync(
|
|
72
|
+
command: string,
|
|
73
|
+
normalizer: PathNormalizer,
|
|
74
|
+
options?: { workdir?: string },
|
|
75
|
+
): BashProgram | null {
|
|
76
|
+
const parser = getWarmBashParser();
|
|
77
|
+
if (!parser) return null;
|
|
78
|
+
return BashProgram.build(parser, command, normalizer, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse `command` with `parser` and resolve every slice synchronously. */
|
|
82
|
+
private static build(
|
|
83
|
+
parser: BashReparser,
|
|
84
|
+
command: string,
|
|
85
|
+
normalizer: PathNormalizer,
|
|
86
|
+
options?: { workdir?: string },
|
|
87
|
+
): BashProgram {
|
|
62
88
|
const tree = parser.parse(command);
|
|
63
89
|
if (!tree) return new BashProgram(command, [], [], []);
|
|
64
90
|
|
|
@@ -67,15 +93,15 @@ export class BashProgram {
|
|
|
67
93
|
const words = new WordReader(
|
|
68
94
|
ShellVariables.scan([tree.rootNode, ...salvaged]),
|
|
69
95
|
);
|
|
70
|
-
const { externalAccesses, ruleCandidates } =
|
|
71
|
-
normalizer,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
const { externalAccesses, ruleCandidates, argumentSpellings } =
|
|
97
|
+
new BashPathResolver(normalizer, words, options?.workdir).resolve(
|
|
98
|
+
tree.rootNode,
|
|
99
|
+
salvaged,
|
|
100
|
+
);
|
|
75
101
|
return new BashProgram(
|
|
76
102
|
command,
|
|
77
103
|
[
|
|
78
|
-
...collectCommands(tree.rootNode, words),
|
|
104
|
+
...collectCommands(tree.rootNode, words, argumentSpellings),
|
|
79
105
|
...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
|
|
80
106
|
],
|
|
81
107
|
externalAccesses,
|