@gotgenes/pi-permission-system 39.1.2 → 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 +17 -0
- package/dist/public.d.ts +13 -0
- package/docs/configuration.md +35 -0
- package/docs/cross-extension-api.md +11 -10
- 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/handlers/gates/bash-command.ts +4 -2
- package/src/logging/command-redaction.ts +1 -0
- package/src/policy/permission-manager.ts +44 -11
- 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 +7 -0
- package/src/access-intent/bash/sync-commands.ts +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
|
|
8
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)
|
|
9
26
|
|
|
10
27
|
|
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
|
|
@@ -145,6 +152,12 @@ interface PromptRequestFacts {
|
|
|
145
152
|
readonly value: string;
|
|
146
153
|
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
|
|
147
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;
|
|
148
161
|
/**
|
|
149
162
|
* Where the offending bash unit runs, when it came from a substitution or a
|
|
150
163
|
* subshell. A fact rather than a rendered clause: it is what makes the
|
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.
|
|
@@ -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
|
|
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,
|
|
@@ -39,6 +39,19 @@ export interface PathToken {
|
|
|
39
39
|
readonly token: string;
|
|
40
40
|
readonly effect: TokenEffect;
|
|
41
41
|
readonly role: TokenRole;
|
|
42
|
+
/**
|
|
43
|
+
* The source span of the argument node, set only when {@link token} is that
|
|
44
|
+
* whole node's resolved text. A token read from part of an argument (an
|
|
45
|
+
* option's embedded value, a script file glued to its flag) carries none, so
|
|
46
|
+
* nothing keyed on the span can mistake it for the whole word.
|
|
47
|
+
*/
|
|
48
|
+
readonly span?: SourceSpan;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a node sits in the parsed source: `startIndex` to `endIndex`. */
|
|
52
|
+
export interface SourceSpan {
|
|
53
|
+
readonly start: number;
|
|
54
|
+
readonly end: number;
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
@@ -160,7 +173,7 @@ export function collectRedirectTokens(
|
|
|
160
173
|
i === target && provesTarget(effect, child, token, words)
|
|
161
174
|
? "redirect-destination"
|
|
162
175
|
: "operand";
|
|
163
|
-
tokens.push(
|
|
176
|
+
tokens.push(wholeArgumentToken(child, token, effect, role));
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
@@ -271,11 +284,9 @@ function collectStatementOperandTokens(
|
|
|
271
284
|
tokens.push(...collectPathCandidateTokens(child, words));
|
|
272
285
|
continue;
|
|
273
286
|
}
|
|
274
|
-
tokens.push(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
role: "operand",
|
|
278
|
-
});
|
|
287
|
+
tokens.push(
|
|
288
|
+
wholeArgumentToken(child, words.text(child), UNPROVEN_EFFECT, "operand"),
|
|
289
|
+
);
|
|
279
290
|
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
280
291
|
}
|
|
281
292
|
return tokens;
|
|
@@ -833,7 +844,12 @@ function collectPatternCommandTokens(
|
|
|
833
844
|
tokens.push(...collectPathCandidateTokens(child, words));
|
|
834
845
|
continue;
|
|
835
846
|
}
|
|
836
|
-
const discharge = dischargePendingConsumption(
|
|
847
|
+
const discharge = dischargePendingConsumption(
|
|
848
|
+
consumption,
|
|
849
|
+
child,
|
|
850
|
+
text,
|
|
851
|
+
effect,
|
|
852
|
+
);
|
|
837
853
|
if (discharge.token) tokens.push(discharge.token);
|
|
838
854
|
if (discharge.consumed) continue;
|
|
839
855
|
}
|
|
@@ -907,7 +923,7 @@ function collectPatternCommandTokens(
|
|
|
907
923
|
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
908
924
|
positionalsSeen++; // Skip: this is an inline pattern/script.
|
|
909
925
|
} else {
|
|
910
|
-
tokens.push(
|
|
926
|
+
tokens.push(wholeArgumentToken(child, text, effect, "operand"));
|
|
911
927
|
}
|
|
912
928
|
// A quoted token that did not act as a flag above has its embedded value
|
|
913
929
|
// split here instead.
|
|
@@ -957,6 +973,7 @@ interface ConsumptionDischarge {
|
|
|
957
973
|
/** Apply a pending consumption to the argument text that follows its flag. */
|
|
958
974
|
function dischargePendingConsumption(
|
|
959
975
|
role: PatternFlagRole,
|
|
976
|
+
argument: TSNode,
|
|
960
977
|
text: string,
|
|
961
978
|
effect: TokenEffect,
|
|
962
979
|
): ConsumptionDischarge {
|
|
@@ -964,7 +981,7 @@ function dischargePendingConsumption(
|
|
|
964
981
|
case "script-file":
|
|
965
982
|
return {
|
|
966
983
|
consumed: true,
|
|
967
|
-
token:
|
|
984
|
+
token: wholeArgumentToken(argument, text, effect, "operand"),
|
|
968
985
|
};
|
|
969
986
|
case "script":
|
|
970
987
|
case "value":
|
|
@@ -1015,7 +1032,9 @@ function collectGenericCommandTokens(
|
|
|
1015
1032
|
|
|
1016
1033
|
// Argument nodes: resolve their text and collect.
|
|
1017
1034
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
1018
|
-
tokens.push(
|
|
1035
|
+
tokens.push(
|
|
1036
|
+
wholeArgumentToken(child, words.text(child), effect, "operand"),
|
|
1037
|
+
);
|
|
1019
1038
|
continue;
|
|
1020
1039
|
}
|
|
1021
1040
|
|
|
@@ -1025,3 +1044,21 @@ function collectGenericCommandTokens(
|
|
|
1025
1044
|
|
|
1026
1045
|
return tokens;
|
|
1027
1046
|
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* A token that is `node`'s whole resolved text, carrying the node's source span
|
|
1050
|
+
* so a reader of the span knows the token stands for the entire word.
|
|
1051
|
+
*/
|
|
1052
|
+
function wholeArgumentToken(
|
|
1053
|
+
node: TSNode,
|
|
1054
|
+
token: string,
|
|
1055
|
+
effect: TokenEffect,
|
|
1056
|
+
role: TokenRole,
|
|
1057
|
+
): PathToken {
|
|
1058
|
+
return {
|
|
1059
|
+
token,
|
|
1060
|
+
effect,
|
|
1061
|
+
role,
|
|
1062
|
+
span: { start: node.startIndex, end: node.endIndex },
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
@@ -228,14 +228,16 @@ function floorUnparsedUnit(
|
|
|
228
228
|
* it.
|
|
229
229
|
*
|
|
230
230
|
* Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
|
|
231
|
-
* session fast path.
|
|
231
|
+
* session fast path. Drops `matchedSpelling`: it names what the replaced rule
|
|
232
|
+
* matched, and beside the sentinel it would name a match that did not decide.
|
|
232
233
|
*/
|
|
233
234
|
function floorToAsk(
|
|
234
235
|
resolved: PermissionCheckResult,
|
|
235
236
|
sentinel: string,
|
|
236
237
|
): PermissionCheckResult {
|
|
238
|
+
const { matchedSpelling: _replaced, ...unspelled } = resolved;
|
|
237
239
|
return {
|
|
238
|
-
...
|
|
240
|
+
...unspelled,
|
|
239
241
|
state: "ask",
|
|
240
242
|
matchedPattern: sentinel,
|
|
241
243
|
floor: sentinel,
|
|
@@ -42,6 +42,7 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
|
42
42
|
export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
|
|
43
43
|
"command",
|
|
44
44
|
"executedUnit",
|
|
45
|
+
"matchedSpelling",
|
|
45
46
|
]);
|
|
46
47
|
|
|
47
48
|
/** A range of the command to replace, and what to put in its place. */
|
|
@@ -376,7 +376,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
376
376
|
intent.command,
|
|
377
377
|
intent.spellings,
|
|
378
378
|
);
|
|
379
|
-
|
|
379
|
+
const { result, matchedValue } = evaluateCheck(
|
|
380
380
|
surface,
|
|
381
381
|
values,
|
|
382
382
|
resultExtras,
|
|
@@ -385,6 +385,12 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
385
385
|
fullRules,
|
|
386
386
|
this.flavor,
|
|
387
387
|
);
|
|
388
|
+
// `values[0]` is the unit as typed, and the evaluator reports the first
|
|
389
|
+
// value the winning rule matches, so any other value is a spelling the
|
|
390
|
+
// typed text did not match.
|
|
391
|
+
return matchedValue === values[0]
|
|
392
|
+
? result
|
|
393
|
+
: { ...result, matchedSpelling: matchedValue };
|
|
388
394
|
}
|
|
389
395
|
|
|
390
396
|
// kind === "tool"
|
|
@@ -423,6 +429,30 @@ function buildCheckResult(
|
|
|
423
429
|
fullRules: Ruleset,
|
|
424
430
|
flavor: PathFlavor,
|
|
425
431
|
): PermissionCheckResult {
|
|
432
|
+
return evaluateCheck(
|
|
433
|
+
surface,
|
|
434
|
+
values,
|
|
435
|
+
resultExtras,
|
|
436
|
+
normalizedToolName,
|
|
437
|
+
toolName,
|
|
438
|
+
fullRules,
|
|
439
|
+
flavor,
|
|
440
|
+
).result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* {@link buildCheckResult}, plus the candidate value the decision was reported
|
|
445
|
+
* under — for a caller that knows what that value means on its own surface.
|
|
446
|
+
*/
|
|
447
|
+
function evaluateCheck(
|
|
448
|
+
surface: string,
|
|
449
|
+
values: string[],
|
|
450
|
+
resultExtras: Record<string, unknown>,
|
|
451
|
+
normalizedToolName: string,
|
|
452
|
+
toolName: string,
|
|
453
|
+
fullRules: Ruleset,
|
|
454
|
+
flavor: PathFlavor,
|
|
455
|
+
): { result: PermissionCheckResult; matchedValue: string } {
|
|
426
456
|
const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
|
|
427
457
|
|
|
428
458
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
@@ -433,16 +463,19 @@ function buildCheckResult(
|
|
|
433
463
|
: resultExtras;
|
|
434
464
|
|
|
435
465
|
return {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
466
|
+
result: {
|
|
467
|
+
toolName,
|
|
468
|
+
state: rule.action,
|
|
469
|
+
reason: rule.reason,
|
|
470
|
+
matchedPattern:
|
|
471
|
+
rule.layer === "config" || rule.layer === "session"
|
|
472
|
+
? rule.pattern
|
|
473
|
+
: undefined,
|
|
474
|
+
source: deriveSource(rule, normalizedToolName),
|
|
475
|
+
origin: rule.origin,
|
|
476
|
+
...extras,
|
|
477
|
+
},
|
|
478
|
+
matchedValue: value,
|
|
446
479
|
};
|
|
447
480
|
}
|
|
448
481
|
|
|
@@ -222,6 +222,9 @@ function coreFacts(payload: PromptPayload): Fact[] {
|
|
|
222
222
|
if (request.matchedPattern !== null) {
|
|
223
223
|
facts.push({ label: "rule", text: request.matchedPattern });
|
|
224
224
|
}
|
|
225
|
+
if (request.matchedSpelling !== null) {
|
|
226
|
+
facts.push({ label: "matched as", text: request.matchedSpelling });
|
|
227
|
+
}
|
|
225
228
|
if (request.value !== "" && request.value !== request.toolName) {
|
|
226
229
|
facts.push({ label, text: request.value });
|
|
227
230
|
}
|
|
@@ -82,6 +82,7 @@ export function buildBashExternalDirectoryAskPayload(
|
|
|
82
82
|
value: facts.command,
|
|
83
83
|
matchedPattern: facts.matchedPattern ?? null,
|
|
84
84
|
commandContext: null,
|
|
85
|
+
matchedSpelling: null,
|
|
85
86
|
executedUnit: null,
|
|
86
87
|
},
|
|
87
88
|
evidence: [
|
|
@@ -113,6 +114,7 @@ function pathPayload(
|
|
|
113
114
|
value: facts.pathValue,
|
|
114
115
|
matchedPattern: facts.matchedPattern ?? null,
|
|
115
116
|
commandContext: null,
|
|
117
|
+
matchedSpelling: null,
|
|
116
118
|
executedUnit: null,
|
|
117
119
|
},
|
|
118
120
|
evidence,
|
|
@@ -63,6 +63,12 @@ export interface PromptRequestFacts {
|
|
|
63
63
|
readonly value: string;
|
|
64
64
|
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
|
|
65
65
|
readonly matchedPattern: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* For bash, the spelling of the unit the matched rule matched when it did not
|
|
68
|
+
* match {@link value} as typed — the absolute spelling of a relative path
|
|
69
|
+
* argument, say. `null` when the typed text decided.
|
|
70
|
+
*/
|
|
71
|
+
readonly matchedSpelling: string | null;
|
|
66
72
|
/**
|
|
67
73
|
* Where the offending bash unit runs, when it came from a substitution or a
|
|
68
74
|
* subshell. A fact rather than a rendered clause: it is what makes the
|
|
@@ -172,6 +178,9 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
172
178
|
candidate.commandContext,
|
|
173
179
|
BASH_COMMAND_CONTEXTS,
|
|
174
180
|
);
|
|
181
|
+
// Absent from a request a node built before the fact existed, which states
|
|
182
|
+
// no spelling rather than a malformed one.
|
|
183
|
+
const matchedSpelling = candidate.matchedSpelling ?? null;
|
|
175
184
|
if (
|
|
176
185
|
!requester ||
|
|
177
186
|
commandContext === undefined ||
|
|
@@ -180,6 +189,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
180
189
|
!isNullableString(candidate.toolName) ||
|
|
181
190
|
!isNullableString(candidate.invokedToolName) ||
|
|
182
191
|
!isNullableString(candidate.matchedPattern) ||
|
|
192
|
+
!isNullableString(matchedSpelling) ||
|
|
183
193
|
!isNullableString(candidate.executedUnit)
|
|
184
194
|
) {
|
|
185
195
|
return undefined;
|
|
@@ -192,6 +202,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
192
202
|
invokedToolName: candidate.invokedToolName,
|
|
193
203
|
value: candidate.value,
|
|
194
204
|
matchedPattern: candidate.matchedPattern,
|
|
205
|
+
matchedSpelling,
|
|
195
206
|
commandContext: commandContext.value,
|
|
196
207
|
executedUnit: candidate.executedUnit,
|
|
197
208
|
};
|
|
@@ -25,6 +25,7 @@ export function renderReviewLogFacts(
|
|
|
25
25
|
return {
|
|
26
26
|
surface: request.surface,
|
|
27
27
|
...present("matchedPattern", request.matchedPattern),
|
|
28
|
+
...present("matchedSpelling", request.matchedSpelling),
|
|
28
29
|
...present("executedUnit", request.executedUnit),
|
|
29
30
|
...present("commandContext", request.commandContext),
|
|
30
31
|
...present("invokedToolName", request.invokedToolName),
|
|
@@ -44,6 +44,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
|
|
|
44
44
|
invokedToolName: distinctInvokedName(facts),
|
|
45
45
|
value: askValue(check, bash, mcp),
|
|
46
46
|
matchedPattern: check.matchedPattern ?? null,
|
|
47
|
+
matchedSpelling: check.matchedSpelling ?? null,
|
|
47
48
|
commandContext: check.commandContext ?? null,
|
|
48
49
|
executedUnit: check.executedUnit ?? null,
|
|
49
50
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
2
|
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
3
|
+
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
3
4
|
import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
|
|
4
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
6
|
|
|
@@ -14,7 +15,11 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
14
15
|
* sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
|
|
15
16
|
* (#840) — at parity with the gate.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
+
* The command is parsed by `BashProgram.parseSync` over the session's
|
|
19
|
+
* `normalizer`, the same builder the gate's program comes from, so every slice
|
|
20
|
+
* the gate resolves a unit with is resolved here too.
|
|
21
|
+
*
|
|
22
|
+
* In the pre-warm window (`parseSync` returns `null`) it falls back
|
|
18
23
|
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
|
19
24
|
* than before — only strengthened once warm.
|
|
20
25
|
*
|
|
@@ -25,9 +30,10 @@ export function resolveBashAdvisoryCheck(
|
|
|
25
30
|
command: string,
|
|
26
31
|
agentName: string | undefined,
|
|
27
32
|
resolver: ScopedPermissionResolver,
|
|
33
|
+
normalizer: PathNormalizer,
|
|
28
34
|
): PermissionCheckResult {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
35
|
+
const program = BashProgram.parseSync(command, normalizer);
|
|
36
|
+
if (program === null) {
|
|
31
37
|
return resolver.resolve({
|
|
32
38
|
kind: "tool",
|
|
33
39
|
surface: "bash",
|
|
@@ -35,5 +41,10 @@ export function resolveBashAdvisoryCheck(
|
|
|
35
41
|
agentName,
|
|
36
42
|
});
|
|
37
43
|
}
|
|
38
|
-
return resolveBashCommandCheck(
|
|
44
|
+
return resolveBashCommandCheck(
|
|
45
|
+
command,
|
|
46
|
+
program.commands(),
|
|
47
|
+
agentName,
|
|
48
|
+
resolver,
|
|
49
|
+
);
|
|
39
50
|
}
|
|
@@ -67,7 +67,12 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
67
67
|
// the enforcement gate enforces (#309). A cold parser falls back to the
|
|
68
68
|
// whole-string match inside resolveBashAdvisoryCheck.
|
|
69
69
|
if (surface === "bash") {
|
|
70
|
-
return resolveBashAdvisoryCheck(
|
|
70
|
+
return resolveBashAdvisoryCheck(
|
|
71
|
+
value ?? "",
|
|
72
|
+
agentName,
|
|
73
|
+
this.resolver,
|
|
74
|
+
this.session.getPathNormalizer(),
|
|
75
|
+
);
|
|
71
76
|
}
|
|
72
77
|
const intent = buildAccessIntentForSurface(
|
|
73
78
|
surface,
|
package/src/types.ts
CHANGED
|
@@ -83,6 +83,13 @@ export interface PermissionCheckResult {
|
|
|
83
83
|
* {@link floorExemption} says the wrapper's floor has no reason to hold.
|
|
84
84
|
*/
|
|
85
85
|
executedUnit?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The spelling of the bash unit the winning rule matched, when the rule did
|
|
88
|
+
* not also match the unit as typed — the absolute spelling of a relative
|
|
89
|
+
* path argument, say (#910). Display-only: absent whenever the typed text
|
|
90
|
+
* decided, and dropped by a floor that replaces {@link matchedPattern}.
|
|
91
|
+
*/
|
|
92
|
+
matchedSpelling?: string;
|
|
86
93
|
/**
|
|
87
94
|
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
88
95
|
* naming why (#803, #963). Recorded in the review log so an allow the floor would
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type BashCommand,
|
|
3
|
-
collectCommands,
|
|
4
|
-
collectSalvagedCommands,
|
|
5
|
-
} from "./command-enumeration";
|
|
6
|
-
import { WordReader } from "./node-text";
|
|
7
|
-
import { getWarmBashParser } from "./parser";
|
|
8
|
-
import { ShellVariables } from "./shell-variable-expansion";
|
|
9
|
-
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Synchronously enumerate the command-pattern units of a bash command using the
|
|
13
|
-
* warmed tree-sitter parser.
|
|
14
|
-
*
|
|
15
|
-
* Returns `null` when the parser has not been warmed yet (the pre-warm window),
|
|
16
|
-
* so the caller can fall back to whole-string matching rather than block. Once
|
|
17
|
-
* warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
|
|
18
|
-
* chains split, nested substitutions/subshells descend, opaque wrappers flagged
|
|
19
|
-
* (#306). Only the command-pattern surface is produced — no path slices, so no
|
|
20
|
-
* `PathNormalizer` is needed.
|
|
21
|
-
*
|
|
22
|
-
* An unparseable command yields an empty array (the caller's decompose path
|
|
23
|
-
* fails it closed via `resolveBashCommandCheck`, #452).
|
|
24
|
-
*/
|
|
25
|
-
export function parseBashCommandsSync(command: string): BashCommand[] | null {
|
|
26
|
-
const parser = getWarmBashParser();
|
|
27
|
-
if (!parser) return null;
|
|
28
|
-
const tree = parser.parse(command);
|
|
29
|
-
if (!tree) return [];
|
|
30
|
-
try {
|
|
31
|
-
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
|
|
32
|
-
const words = new WordReader(
|
|
33
|
-
ShellVariables.scan([tree.rootNode, ...salvaged]),
|
|
34
|
-
);
|
|
35
|
-
return [
|
|
36
|
-
...collectCommands(tree.rootNode, words),
|
|
37
|
-
...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
|
|
38
|
-
];
|
|
39
|
-
});
|
|
40
|
-
} finally {
|
|
41
|
-
tree.delete();
|
|
42
|
-
}
|
|
43
|
-
}
|