@gotgenes/pi-permission-system 35.0.1 → 35.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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
|
+
## [35.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.1...pi-permission-system-v35.0.2) (2026-09-28)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** deny a command written after a heredoc the grammar cannot parse ([8a01239](https://github.com/gotgenes/pi-packages/commit/8a012390b49605ec52ff4379a8f452d3ec48a69e)), closes [#985](https://github.com/gotgenes/pi-packages/issues/985)
|
|
14
|
+
|
|
8
15
|
## [35.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.0...pi-permission-system-v35.0.1) (2026-09-27)
|
|
9
16
|
|
|
10
17
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import { childrenOf } from "./parse-view";
|
|
3
|
+
import type { TSNode } from "./parser";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heredoc-free spelling of each line whose heredoc sits in a statement the
|
|
7
|
+
* primary parse could not resolve, in source order.
|
|
8
|
+
*
|
|
9
|
+
* `tree-sitter-bash` 0.25.1's `heredoc_redirect` accepts one tail form after
|
|
10
|
+
* the delimiter, so valid bash such as `cat <<EOF ; rm -rf x` or
|
|
11
|
+
* `cat <<EOF arg > /tmp/o` yields an `ERROR` directly under the redirect. The
|
|
12
|
+
* innermost unresolved node is then the redirect itself, whose own text fails
|
|
13
|
+
* to re-parse too, so the region salvage recovers nothing and a `deny` on the
|
|
14
|
+
* command after the heredoc never fires. The same line with its heredoc
|
|
15
|
+
* operators cut out (`cat ; rm -rf x`, `cat arg > /tmp/o`) is what bash runs
|
|
16
|
+
* on that line, and it parses.
|
|
17
|
+
*
|
|
18
|
+
* A heredoc's host is its nearest `redirected_statement`, or, when no
|
|
19
|
+
* statement holds it, the nearest enclosing `ERROR` (`cat 0<<EOF | …` lexes
|
|
20
|
+
* `0<<EOF` as one delimiter inside a top-level `ERROR`). Only a host the parse
|
|
21
|
+
* could not resolve is spelled, so a clean heredoc beside an unrelated failure
|
|
22
|
+
* yields nothing. The spelling runs from the host statement's start, or the
|
|
23
|
+
* heredoc's line start for an `ERROR` host, to the end of the heredoc's line;
|
|
24
|
+
* the statement anchor is what recovers a heredoc inside a compound opened
|
|
25
|
+
* earlier on its line (`if true; then cat <<EOF ; rm x`).
|
|
26
|
+
*
|
|
27
|
+
* Each cut removes an operator the scanner tokenized: the `<<` / `<<-` token,
|
|
28
|
+
* a `file_descriptor` before it, the delimiter, and the blanks before them,
|
|
29
|
+
* so the result reads as its heredoc-free spelling (`cat arg`, not `cat arg`)
|
|
30
|
+
* that a rule is written against. It only removes text, and the caller admits
|
|
31
|
+
* a spelling only when it re-parses cleanly (`unresolved-salvage.ts`).
|
|
32
|
+
*/
|
|
33
|
+
export function heredocFreeLinesWithin(root: TSNode): string[] {
|
|
34
|
+
const source = root.text;
|
|
35
|
+
const offset = root.startIndex;
|
|
36
|
+
const spans = new Map<number, HeredocLine>();
|
|
37
|
+
for (const heredoc of unresolvedHeredocsWithin(root)) {
|
|
38
|
+
const start =
|
|
39
|
+
heredoc.host.type === "redirected_statement"
|
|
40
|
+
? heredoc.host.startIndex
|
|
41
|
+
: lineStartOf(source, offset, heredoc.delimiter.startIndex);
|
|
42
|
+
const end = lineEndOf(source, offset, heredoc.delimiter.endIndex);
|
|
43
|
+
const line = spans.get(start) ?? { start, end, cuts: [] };
|
|
44
|
+
line.cuts.push(operatorSpanOf(heredoc, source, offset, start));
|
|
45
|
+
spans.set(start, line);
|
|
46
|
+
}
|
|
47
|
+
return [...spans.values()].map((line) => spell(line, source, offset));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface UnresolvedHeredoc {
|
|
51
|
+
/** The `heredoc_start` token. */
|
|
52
|
+
readonly delimiter: TSNode;
|
|
53
|
+
/** The delimiter's siblings, in order, so its operator can be found. */
|
|
54
|
+
readonly siblings: readonly TSNode[];
|
|
55
|
+
readonly index: number;
|
|
56
|
+
readonly host: TSNode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HeredocLine {
|
|
60
|
+
readonly start: number;
|
|
61
|
+
readonly end: number;
|
|
62
|
+
readonly cuts: [number, number][];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unresolvedHeredocsWithin(root: TSNode): UnresolvedHeredoc[] {
|
|
66
|
+
const found: UnresolvedHeredoc[] = [];
|
|
67
|
+
collectUnresolvedHeredocs(root, null, null, found);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Descends every node, `ERROR`s included: the `heredoc_start` token is the
|
|
73
|
+
* scanner's rather than recovery's invention, and it can sit inside one.
|
|
74
|
+
*/
|
|
75
|
+
function collectUnresolvedHeredocs(
|
|
76
|
+
node: TSNode,
|
|
77
|
+
statement: TSNode | null,
|
|
78
|
+
error: TSNode | null,
|
|
79
|
+
found: UnresolvedHeredoc[],
|
|
80
|
+
): void {
|
|
81
|
+
const siblings = childrenOf(node);
|
|
82
|
+
siblings.forEach((child, index) => {
|
|
83
|
+
const host = statement ?? error;
|
|
84
|
+
if (
|
|
85
|
+
child.type === "heredoc_start" &&
|
|
86
|
+
host !== null &&
|
|
87
|
+
parseUnresolvedWithin(host)
|
|
88
|
+
) {
|
|
89
|
+
found.push({ delimiter: child, siblings, index, host });
|
|
90
|
+
}
|
|
91
|
+
collectUnresolvedHeredocs(
|
|
92
|
+
child,
|
|
93
|
+
child.type === "redirected_statement" ? child : statement,
|
|
94
|
+
child.type === "ERROR" ? child : error,
|
|
95
|
+
found,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The span of a heredoc's operator: its delimiter, the `<<` / `<<-` token and
|
|
102
|
+
* a `file_descriptor` before it, and the blanks before those, but never
|
|
103
|
+
* reaching before the line's own start.
|
|
104
|
+
*/
|
|
105
|
+
function operatorSpanOf(
|
|
106
|
+
heredoc: UnresolvedHeredoc,
|
|
107
|
+
source: string,
|
|
108
|
+
offset: number,
|
|
109
|
+
lineStart: number,
|
|
110
|
+
): [number, number] {
|
|
111
|
+
const { delimiter, siblings, index } = heredoc;
|
|
112
|
+
let start = delimiter.startIndex;
|
|
113
|
+
let before = index - 1;
|
|
114
|
+
const operator = siblings.at(before);
|
|
115
|
+
if (
|
|
116
|
+
before >= 0 &&
|
|
117
|
+
operator &&
|
|
118
|
+
!operator.isNamed &&
|
|
119
|
+
operator.text.startsWith("<<")
|
|
120
|
+
) {
|
|
121
|
+
start = operator.startIndex;
|
|
122
|
+
before -= 1;
|
|
123
|
+
const descriptor = siblings.at(before);
|
|
124
|
+
if (before >= 0 && descriptor?.type === "file_descriptor") {
|
|
125
|
+
start = descriptor.startIndex;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
while (start > lineStart && isBlank(source[start - 1 - offset])) start -= 1;
|
|
129
|
+
return [start, delimiter.endIndex];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function spell(line: HeredocLine, source: string, offset: number): string {
|
|
133
|
+
let text = "";
|
|
134
|
+
let at = line.start;
|
|
135
|
+
for (const [cutStart, cutEnd] of line.cuts.toSorted(([a], [b]) => a - b)) {
|
|
136
|
+
text += source.slice(at - offset, cutStart - offset);
|
|
137
|
+
at = cutEnd;
|
|
138
|
+
}
|
|
139
|
+
return text + source.slice(at - offset, line.end - offset);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function lineStartOf(source: string, offset: number, index: number): number {
|
|
143
|
+
return source.lastIndexOf("\n", index - offset - 1) + 1 + offset;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineEndOf(source: string, offset: number, index: number): number {
|
|
147
|
+
const newline = source.indexOf("\n", index - offset);
|
|
148
|
+
return (newline === -1 ? source.length : newline) + offset;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isBlank(character: string | undefined): boolean {
|
|
152
|
+
return character === " " || character === "\t";
|
|
153
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { heredocFreeLinesWithin } from "./heredoc-free-lines";
|
|
1
2
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
3
|
import type { BashReparser, TSNode } from "./parser";
|
|
3
4
|
|
|
@@ -19,6 +20,12 @@ import type { BashReparser, TSNode } from "./parser";
|
|
|
19
20
|
* grammar gap is in the *combination* — `2>&1 | rm -rf /tmp/x` parses
|
|
20
21
|
* perfectly on its own.
|
|
21
22
|
*
|
|
23
|
+
* A heredoc tail the grammar cannot parse at all (`cat <<EOF ; rm -rf x`)
|
|
24
|
+
* leaves no region whose own text re-parses: the innermost unresolved node is
|
|
25
|
+
* the heredoc redirect, whose text fails the same way. So each such heredoc's
|
|
26
|
+
* line is offered too, spelled without its heredoc operators
|
|
27
|
+
* (`heredoc-free-lines.ts`), after the regions.
|
|
28
|
+
*
|
|
22
29
|
* The roots are handed to a callback rather than returned because each belongs
|
|
23
30
|
* to a tree that must outlive its use and be released afterwards, exactly as
|
|
24
31
|
* the primary parse's caller already does for its own tree.
|
|
@@ -33,14 +40,20 @@ export function withSalvagedRoots<T>(
|
|
|
33
40
|
): T {
|
|
34
41
|
const trees: { rootNode: TSNode; delete(): void }[] = [];
|
|
35
42
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const candidates = [
|
|
44
|
+
...unresolvedRegionsWithin(primary).map((region) => region.text),
|
|
45
|
+
...heredocFreeLinesWithin(primary),
|
|
46
|
+
];
|
|
47
|
+
for (const text of candidates) {
|
|
48
|
+
const tree = reparser.parse(text);
|
|
38
49
|
if (!tree) continue;
|
|
39
50
|
// The whole safety argument: tree-sitter's error recovery *invents* the
|
|
40
51
|
// structure inside an unresolved region (#742), and invented structure
|
|
41
52
|
// does not re-parse. Without this check `cat <> rw.txt` salvages a
|
|
42
53
|
// command unit whose text is `">"`, matched against the bash rules like
|
|
43
|
-
// any real command.
|
|
54
|
+
// any real command. A heredoc-free line is derived rather than sliced,
|
|
55
|
+
// but only by removing the operators the scanner tokenized, and it is
|
|
56
|
+
// held to the same condition.
|
|
44
57
|
if (parseUnresolvedWithin(tree.rootNode)) {
|
|
45
58
|
tree.delete();
|
|
46
59
|
continue;
|