@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "35.0.1",
3
+ "version": "35.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- for (const candidate of unresolvedRegionsWithin(primary)) {
37
- const tree = reparser.parse(candidate.text);
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;