@gotgenes/pi-permission-system 34.0.1 → 35.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/docs/configuration.md +9 -7
- package/package.json +1 -1
- package/src/access-intent/bash/heredoc-tails.ts +277 -0
- package/src/access-intent/bash/parse-view.ts +180 -0
- package/src/access-intent/bash/parser.ts +15 -6
- package/src/access-intent/bash/redirect-analysis.ts +34 -3
- package/src/access-intent/bash/redirect-arguments.ts +39 -153
- package/src/handlers/before-agent-start.ts +41 -10
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.0...pi-permission-system-v35.0.1) (2026-09-27)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** check the words after a heredoc against the command's own rules ([0a88264](https://github.com/gotgenes/pi-packages/commit/0a882643bb5b1be1f546842e75ca3fac5cff1930)), closes [#979](https://github.com/gotgenes/pi-packages/issues/979)
|
|
14
|
+
* **pi-permission-system:** gate the commands and redirects written after a heredoc ([b7b2709](https://github.com/gotgenes/pi-packages/commit/b7b27095606c07f684eca353c253b59ebdec4cb4)), closes [#979](https://github.com/gotgenes/pi-packages/issues/979)
|
|
15
|
+
|
|
16
|
+
## [35.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v34.0.1...pi-permission-system-v35.0.0) (2026-09-26)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** **breaking:** stop appending a tool list and rules to an operator's custom system prompt ([672a64c](https://github.com/gotgenes/pi-packages/commit/672a64c0844ec2fff8e113130109919623158107)), closes [#980](https://github.com/gotgenes/pi-packages/issues/980)
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** record that a custom system prompt gets no appended tool surface ([7e0816d](https://github.com/gotgenes/pi-packages/commit/7e0816d28ab5377de011b5558b034c5b2bdaca4d))
|
|
26
|
+
|
|
8
27
|
## [34.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v34.0.0...pi-permission-system-v34.0.1) (2026-09-26)
|
|
9
28
|
|
|
10
29
|
|
package/docs/configuration.md
CHANGED
|
@@ -1246,11 +1246,11 @@ permission:
|
|
|
1246
1246
|
|
|
1247
1247
|
The extension integrates via Pi's lifecycle hooks:
|
|
1248
1248
|
|
|
1249
|
-
| Hook | Behavior
|
|
1250
|
-
| -------------------- |
|
|
1251
|
-
| `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match, and hides denied skills |
|
|
1252
|
-
| `tool_call` | Enforces permissions for every tool invocation
|
|
1253
|
-
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy
|
|
1249
|
+
| Hook | Behavior |
|
|
1250
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1251
|
+
| `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match (except under a custom system prompt outside a subagent), and hides denied skills |
|
|
1252
|
+
| `tool_call` | Enforces permissions for every tool invocation |
|
|
1253
|
+
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
|
|
1254
1254
|
|
|
1255
1255
|
Additional behaviors:
|
|
1256
1256
|
|
|
@@ -1262,8 +1262,10 @@ Additional behaviors:
|
|
|
1262
1262
|
- A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
|
|
1263
1263
|
- The tool list and guidelines are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after the working directory pi states last.
|
|
1264
1264
|
They take the shape pi writes them in: `Available tools:` and `Guidelines:` sections after a `Current working directory:` footer through pi 0.85, and `<tools>` and `<rules>` sections after a `<cwd>` section from pi 0.86.
|
|
1265
|
-
Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied.
|
|
1266
|
-
|
|
1265
|
+
Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session on pi's default prompt, whether or not anything is denied.
|
|
1266
|
+
A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
|
|
1267
|
+
Tool filtering and enforcement still apply, and the model still receives only the allowed tools in the request's tool list.
|
|
1268
|
+
A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it still gets its own tool list and rules at the end, while any custom text it inherited stays untouched.
|
|
1267
1269
|
- The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones
|
|
1268
1270
|
- The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
|
|
1269
1271
|
A policy change is an intentional cache transition, as a mid-session agent switch already is.
|
package/package.json
CHANGED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
childrenOf,
|
|
3
|
+
rewriteStatements,
|
|
4
|
+
rewrittenNode,
|
|
5
|
+
type Source,
|
|
6
|
+
sourceOf,
|
|
7
|
+
} from "./parse-view";
|
|
8
|
+
import type { TSNode } from "./parser";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `tree-sitter-bash`'s parse with the rest of each heredoc's line moved out of
|
|
12
|
+
* the heredoc, to where the grammar puts it when the heredoc is written as a
|
|
13
|
+
* plain input redirect.
|
|
14
|
+
*
|
|
15
|
+
* The grammar (0.25.1) lets a `heredoc_redirect` carry whatever follows its
|
|
16
|
+
* delimiter on the same line: further redirects (`cat <<EOF > /tmp/o`), a
|
|
17
|
+
* `| …` statement, or an `&& …` / `|| …` statement. They parse as children of
|
|
18
|
+
* the heredoc, which every walker reads only for the substitutions it hosts, so
|
|
19
|
+
* `cat <<EOF | rm -rf /tmp/x` would enumerate as `cat` alone and
|
|
20
|
+
* `cat <<EOF > /tmp/o` would project no path (#979).
|
|
21
|
+
*
|
|
22
|
+
* The corrected tree is the grammar's own parse of the same line with `< in` in
|
|
23
|
+
* place of `<<EOF`: a redirect becomes a sibling after the heredoc, and a
|
|
24
|
+
* `| …` or `&& …` statement is joined to the redirected statement the way the
|
|
25
|
+
* grammar joins it to `cat < in`. That reproduces the grammar's groupings
|
|
26
|
+
* rather than bash's where the two differ (a redirect on a list's last command
|
|
27
|
+
* hangs off the whole list), because every walker is already built to read
|
|
28
|
+
* those groupings.
|
|
29
|
+
*
|
|
30
|
+
* Words after the delimiter (`git <<EOF push --force`) stay in the heredoc:
|
|
31
|
+
* `reattachRedirectArguments` hands them to the command, exactly as it does for
|
|
32
|
+
* the words after a file redirect's target. The heredoc keeps its body, which
|
|
33
|
+
* is written after the rest of the line, so it ends after the nodes moved out
|
|
34
|
+
* of it.
|
|
35
|
+
*
|
|
36
|
+
* Returns `root` itself when no heredoc carries such a tail.
|
|
37
|
+
*/
|
|
38
|
+
export function hoistHeredocTails(root: TSNode): TSNode {
|
|
39
|
+
return rewriteStatements(root, hoistStatement);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `statement` with its heredoc's tail moved out, or `undefined` when no
|
|
44
|
+
* heredoc among `children` carries one.
|
|
45
|
+
*/
|
|
46
|
+
function hoistStatement(
|
|
47
|
+
statement: TSNode,
|
|
48
|
+
children: readonly TSNode[],
|
|
49
|
+
): TSNode | undefined {
|
|
50
|
+
const heredocIndex = children.findIndex(
|
|
51
|
+
(child) => child.type === "heredoc_redirect" && tailOf(child) !== undefined,
|
|
52
|
+
);
|
|
53
|
+
const heredoc = children.at(heredocIndex);
|
|
54
|
+
const tail = heredoc && tailOf(heredoc);
|
|
55
|
+
if (!tail) return undefined;
|
|
56
|
+
|
|
57
|
+
const after = children.slice(heredocIndex + 1);
|
|
58
|
+
// A statement tail runs to the end of the line, so nothing of the statement
|
|
59
|
+
// can follow it; anything that does is a shape this correction does not know.
|
|
60
|
+
if (tail.joined && after.some((child) => child.isNamed)) return undefined;
|
|
61
|
+
|
|
62
|
+
const source = sourceOf(statement);
|
|
63
|
+
const redirected = rewrittenNode(
|
|
64
|
+
statement,
|
|
65
|
+
[
|
|
66
|
+
...children.slice(0, heredocIndex),
|
|
67
|
+
rewrittenNode(
|
|
68
|
+
heredoc,
|
|
69
|
+
tail.kept,
|
|
70
|
+
heredoc.startIndex,
|
|
71
|
+
heredoc.endIndex,
|
|
72
|
+
source,
|
|
73
|
+
),
|
|
74
|
+
...tail.redirects,
|
|
75
|
+
...after,
|
|
76
|
+
],
|
|
77
|
+
statement.startIndex,
|
|
78
|
+
statement.endIndex,
|
|
79
|
+
source,
|
|
80
|
+
);
|
|
81
|
+
if (!tail.joined) return redirected;
|
|
82
|
+
return join(
|
|
83
|
+
redirected,
|
|
84
|
+
tail.joined.operator,
|
|
85
|
+
withTrailingRedirectsOutermost(tail.joined.statement, source),
|
|
86
|
+
source,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `statement` with a redirect written at its end hung off all of it, as the
|
|
92
|
+
* grammar hangs it when the same text is not a heredoc's tail.
|
|
93
|
+
*
|
|
94
|
+
* At the top level `a | b | c > o` parses as `(a | b | c) > o`, but in a
|
|
95
|
+
* heredoc's tail the grammar parses it as `a | ((b | c) > o)`. Left that way,
|
|
96
|
+
* the joined write would not reach the heredoc's own command, so
|
|
97
|
+
* `xargs grep foo <<EOF && a | b | c > o` would keep a floor exemption that
|
|
98
|
+
* `xargs grep foo < in && a | b | c > o` withholds.
|
|
99
|
+
*/
|
|
100
|
+
function withTrailingRedirectsOutermost(
|
|
101
|
+
statement: TSNode,
|
|
102
|
+
source: Source,
|
|
103
|
+
): TSNode {
|
|
104
|
+
if (statement.type === "redirected_statement") return statement;
|
|
105
|
+
const detached = detachTrailingRedirects(statement, source);
|
|
106
|
+
if (!detached) return statement;
|
|
107
|
+
return rewrittenNode(
|
|
108
|
+
{ type: "redirected_statement", isNamed: true },
|
|
109
|
+
[detached.body, ...detached.redirects],
|
|
110
|
+
statement.startIndex,
|
|
111
|
+
statement.endIndex,
|
|
112
|
+
source,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `node` without the `redirected_statement` that ends it, plus that
|
|
118
|
+
* statement's redirects, or `undefined` when `node` does not end in one.
|
|
119
|
+
*
|
|
120
|
+
* The walk follows the last element of each `list` and `pipeline`, and a
|
|
121
|
+
* pipeline left holding a pipeline is flattened into one, as the grammar
|
|
122
|
+
* parses `a | b | c`.
|
|
123
|
+
*/
|
|
124
|
+
function detachTrailingRedirects(
|
|
125
|
+
node: TSNode,
|
|
126
|
+
source: Source,
|
|
127
|
+
): { body: TSNode; redirects: TSNode[] } | undefined {
|
|
128
|
+
const children = childrenOf(node);
|
|
129
|
+
if (node.type === "redirected_statement") {
|
|
130
|
+
const bodyIndex = children.findIndex((child) => child.isNamed);
|
|
131
|
+
return {
|
|
132
|
+
body: children[bodyIndex],
|
|
133
|
+
redirects: children.slice(bodyIndex + 1),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!GROUPING_TYPES.has(node.type)) return undefined;
|
|
137
|
+
const lastIndex = children.findLastIndex((child) => child.isNamed);
|
|
138
|
+
const inner = detachTrailingRedirects(children[lastIndex], source);
|
|
139
|
+
if (!inner) return undefined;
|
|
140
|
+
const replaced =
|
|
141
|
+
node.type === "pipeline" && inner.body.type === "pipeline"
|
|
142
|
+
? [...children.slice(0, lastIndex), ...childrenOf(inner.body)]
|
|
143
|
+
: children.with(lastIndex, inner.body);
|
|
144
|
+
return {
|
|
145
|
+
body: rewrittenNode(
|
|
146
|
+
node,
|
|
147
|
+
replaced,
|
|
148
|
+
node.startIndex,
|
|
149
|
+
inner.body.endIndex,
|
|
150
|
+
source,
|
|
151
|
+
),
|
|
152
|
+
redirects: inner.redirects,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The groupings whose last element a trailing redirect is written after. */
|
|
157
|
+
const GROUPING_TYPES: ReadonlySet<string> = new Set(["list", "pipeline"]);
|
|
158
|
+
|
|
159
|
+
/** What a heredoc carries after its delimiter, split by where each part goes. */
|
|
160
|
+
interface HeredocTail {
|
|
161
|
+
/** The heredoc's own children: operator, delimiter, any words, and body. */
|
|
162
|
+
readonly kept: readonly TSNode[];
|
|
163
|
+
/** Redirects written after the delimiter. */
|
|
164
|
+
readonly redirects: readonly TSNode[];
|
|
165
|
+
/** A `| …`, `|& …`, `&& …`, or `|| …` statement after them, if any. */
|
|
166
|
+
readonly joined?: {
|
|
167
|
+
readonly operator: TSNode;
|
|
168
|
+
readonly statement: TSNode;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `heredoc`'s tail, or `undefined` when it carries neither a redirect nor a
|
|
174
|
+
* statement after its delimiter.
|
|
175
|
+
*
|
|
176
|
+
* The grammar spells a `| …` tail as one `pipeline` child holding the operator
|
|
177
|
+
* and the statement, and an `&& …` / `|| …` tail as the operator token and the
|
|
178
|
+
* statement as two children of the heredoc itself. That statement can be a
|
|
179
|
+
* pipeline too (`cat <<EOF && ls | rm x`), so a `pipeline` child is the `| …`
|
|
180
|
+
* form only when no operator came before it.
|
|
181
|
+
*/
|
|
182
|
+
function tailOf(heredoc: TSNode): HeredocTail | undefined {
|
|
183
|
+
const kept: TSNode[] = [];
|
|
184
|
+
const redirects: TSNode[] = [];
|
|
185
|
+
let operator: TSNode | undefined;
|
|
186
|
+
let statement: TSNode | undefined;
|
|
187
|
+
for (const child of childrenOf(heredoc)) {
|
|
188
|
+
if (TAIL_REDIRECT_TYPES.has(child.type)) {
|
|
189
|
+
redirects.push(child);
|
|
190
|
+
} else if (child.type === "pipeline" && !operator) {
|
|
191
|
+
const [pipe] = childrenOf(child);
|
|
192
|
+
operator = pipe;
|
|
193
|
+
statement = childrenOf(child).find((node) => node.isNamed);
|
|
194
|
+
} else if (LIST_OPERATORS.has(child.type) && !child.isNamed) {
|
|
195
|
+
operator = child;
|
|
196
|
+
} else if (operator && !statement && child.isNamed) {
|
|
197
|
+
statement = child;
|
|
198
|
+
} else {
|
|
199
|
+
kept.push(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (operator && !statement) return undefined;
|
|
203
|
+
const joined = operator && statement ? { operator, statement } : undefined;
|
|
204
|
+
if (redirects.length === 0 && !joined) return undefined;
|
|
205
|
+
return joined ? { kept, redirects, joined } : { kept, redirects };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The redirects a heredoc can carry after its delimiter. */
|
|
209
|
+
const TAIL_REDIRECT_TYPES: ReadonlySet<string> = new Set([
|
|
210
|
+
"file_redirect",
|
|
211
|
+
"herestring_redirect",
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
/** The operators that join an `&& …` / `|| …` tail. */
|
|
215
|
+
const LIST_OPERATORS: ReadonlySet<string> = new Set(["&&", "||"]);
|
|
216
|
+
|
|
217
|
+
/** The operators that join a `| …` tail. */
|
|
218
|
+
const PIPE_OPERATORS: ReadonlySet<string> = new Set(["|", "|&"]);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `redirected` joined to `statement` by `operator`, grouped as the grammar
|
|
222
|
+
* groups `cat < in <operator> <statement>`.
|
|
223
|
+
*
|
|
224
|
+
* The grammar hangs a redirect off everything before it, so a joined
|
|
225
|
+
* `redirected_statement` keeps its redirects outermost. That holds inside a
|
|
226
|
+
* pipeline too: `cat < in && a > o | b` parses as `(cat < in && a > o) | b`,
|
|
227
|
+
* so a pipeline whose first stage is redirected is joined at that stage. A
|
|
228
|
+
* `list` is left-associative and binds looser than a pipe, so the join reaches
|
|
229
|
+
* its first element. A pipe tail onto any other pipeline extends it. Anything
|
|
230
|
+
* else becomes the operator's own two-element node.
|
|
231
|
+
*
|
|
232
|
+
* Each rebuilt node spans from `redirected`'s start to the furthest end beneath
|
|
233
|
+
* it, which is the heredoc's, since its body is written after the tail.
|
|
234
|
+
*/
|
|
235
|
+
function join(
|
|
236
|
+
redirected: TSNode,
|
|
237
|
+
operator: TSNode,
|
|
238
|
+
statement: TSNode,
|
|
239
|
+
source: Source,
|
|
240
|
+
): TSNode {
|
|
241
|
+
const isPipe = PIPE_OPERATORS.has(operator.type);
|
|
242
|
+
const children = childrenOf(statement);
|
|
243
|
+
const end = Math.max(redirected.endIndex, statement.endIndex);
|
|
244
|
+
const rebuild = (type: Pick<TSNode, "type" | "isNamed">, kids: TSNode[]) =>
|
|
245
|
+
rewrittenNode(type, kids, redirected.startIndex, end, source);
|
|
246
|
+
|
|
247
|
+
const first = children.findIndex((child) => child.isNamed);
|
|
248
|
+
if (joinsAtFirstElement(statement, children[first])) {
|
|
249
|
+
return rebuild(
|
|
250
|
+
statement,
|
|
251
|
+
children.with(first, join(redirected, operator, children[first], source)),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (isPipe && statement.type === "pipeline") {
|
|
255
|
+
return rebuild(statement, [redirected, operator, ...children]);
|
|
256
|
+
}
|
|
257
|
+
return rebuild({ type: isPipe ? "pipeline" : "list", isNamed: true }, [
|
|
258
|
+
redirected,
|
|
259
|
+
operator,
|
|
260
|
+
statement,
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether the grammar joins what precedes `statement` to its first element
|
|
266
|
+
* rather than to `statement` as a whole.
|
|
267
|
+
*/
|
|
268
|
+
function joinsAtFirstElement(
|
|
269
|
+
statement: TSNode,
|
|
270
|
+
first: TSNode | undefined,
|
|
271
|
+
): boolean {
|
|
272
|
+
if (statement.type === "redirected_statement") return true;
|
|
273
|
+
if (statement.type === "list") return true;
|
|
274
|
+
return (
|
|
275
|
+
statement.type === "pipeline" && first?.type === "redirected_statement"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import type { TSNode } from "./parser";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A view of `tree-sitter-bash`'s parse tree that a correction pass can rewrite.
|
|
6
|
+
*
|
|
7
|
+
* The grammar hangs command-line material on the wrong node in more than one
|
|
8
|
+
* production (#977, #979), and each correction is a pass that rewrites the
|
|
9
|
+
* `redirected_statement`s it recognizes. Each pass builds its nodes from the
|
|
10
|
+
* primitives here, so a later pass reads an earlier one's output as the same
|
|
11
|
+
* kind of view and can re-parent its nodes in turn.
|
|
12
|
+
*
|
|
13
|
+
* A view reads its text from the source by absolute offsets, so every caller's
|
|
14
|
+
* `startIndex`/`endIndex` slicing still holds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `root` with each `redirected_statement` whose parse resolved replaced by what
|
|
19
|
+
* `rewrite` returns for it, or `root` itself when nothing beneath it changed.
|
|
20
|
+
*
|
|
21
|
+
* The walk is bottom-up: `rewrite` receives the statement's children already
|
|
22
|
+
* rewritten, and returns `undefined` to leave the statement as it is.
|
|
23
|
+
*
|
|
24
|
+
* A statement whose parse failed is never handed to `rewrite`. Its units are
|
|
25
|
+
* floored already, and moving a node out of an unresolvable region would hand it
|
|
26
|
+
* a proof in place of that region's refusal to prove one (#814).
|
|
27
|
+
*/
|
|
28
|
+
export function rewriteStatements(
|
|
29
|
+
root: TSNode,
|
|
30
|
+
rewrite: (
|
|
31
|
+
statement: TSNode,
|
|
32
|
+
children: readonly TSNode[],
|
|
33
|
+
) => TSNode | undefined,
|
|
34
|
+
): TSNode {
|
|
35
|
+
return rewriteBeneath(root, rewrite) ?? root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The rewritten node, or `undefined` when nothing beneath `node` changed. */
|
|
39
|
+
function rewriteBeneath(
|
|
40
|
+
node: TSNode,
|
|
41
|
+
rewrite: (
|
|
42
|
+
statement: TSNode,
|
|
43
|
+
children: readonly TSNode[],
|
|
44
|
+
) => TSNode | undefined,
|
|
45
|
+
): TSNode | undefined {
|
|
46
|
+
const children: TSNode[] = [];
|
|
47
|
+
let changed = false;
|
|
48
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
49
|
+
const child = node.child(i);
|
|
50
|
+
if (!child) continue;
|
|
51
|
+
const rewritten = rewriteBeneath(child, rewrite);
|
|
52
|
+
if (rewritten) changed = true;
|
|
53
|
+
children.push(rewritten ?? child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (node.type === "redirected_statement" && !parseUnresolvedWithin(node)) {
|
|
57
|
+
const rewritten = rewrite(node, children);
|
|
58
|
+
if (rewritten) return rewritten;
|
|
59
|
+
}
|
|
60
|
+
return changed
|
|
61
|
+
? adoptingView(node, children, parseUnresolvedWithin(node))
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A node a correction built, reading its text from the statement's source. */
|
|
66
|
+
export function rewrittenNode(
|
|
67
|
+
original: Pick<TSNode, "type" | "isNamed">,
|
|
68
|
+
children: readonly TSNode[],
|
|
69
|
+
startIndex: number,
|
|
70
|
+
endIndex: number,
|
|
71
|
+
source: Source,
|
|
72
|
+
): TSNode {
|
|
73
|
+
return adoptingView(
|
|
74
|
+
{
|
|
75
|
+
type: original.type,
|
|
76
|
+
isNamed: original.isNamed,
|
|
77
|
+
startIndex,
|
|
78
|
+
endIndex,
|
|
79
|
+
text: source(startIndex, endIndex),
|
|
80
|
+
},
|
|
81
|
+
children,
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A slice of the command by absolute offsets. */
|
|
87
|
+
export type Source = (startIndex: number, endIndex: number) => string;
|
|
88
|
+
|
|
89
|
+
/** The source slicer for every node within `statement`. */
|
|
90
|
+
export function sourceOf(statement: TSNode): Source {
|
|
91
|
+
return (startIndex, endIndex) =>
|
|
92
|
+
statement.text.slice(
|
|
93
|
+
startIndex - statement.startIndex,
|
|
94
|
+
endIndex - statement.startIndex,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function childrenOf(node: TSNode): TSNode[] {
|
|
99
|
+
const children: TSNode[] = [];
|
|
100
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
101
|
+
const child = node.child(i);
|
|
102
|
+
if (child) children.push(child);
|
|
103
|
+
}
|
|
104
|
+
return children;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function lastNamedChild(node: TSNode): TSNode | undefined {
|
|
108
|
+
return childrenOf(node).findLast((child) => child.isNamed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A view whose children are views too, so each one's `previousSibling` is its
|
|
113
|
+
* neighbor in the rewritten tree rather than the one the grammar gave it.
|
|
114
|
+
*/
|
|
115
|
+
function adoptingView(
|
|
116
|
+
fields: NodeFields,
|
|
117
|
+
children: readonly TSNode[],
|
|
118
|
+
hasError: boolean,
|
|
119
|
+
): TSNode {
|
|
120
|
+
return new NodeView(fields, children.map(asView), hasError);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `node` as a view, so a new parent can give it a new previous sibling.
|
|
125
|
+
*
|
|
126
|
+
* Its own children stay the grammar's nodes: nothing beneath it moved, so their
|
|
127
|
+
* siblings are still the grammar's too.
|
|
128
|
+
*/
|
|
129
|
+
function asView(node: TSNode): TSNode {
|
|
130
|
+
return node instanceof NodeView
|
|
131
|
+
? node
|
|
132
|
+
: new NodeView(node, childrenOf(node), parseUnresolvedWithin(node));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The fields a {@link NodeView} copies from the node it stands for. */
|
|
136
|
+
interface NodeFields {
|
|
137
|
+
readonly type: string;
|
|
138
|
+
readonly isNamed: boolean;
|
|
139
|
+
readonly startIndex: number;
|
|
140
|
+
readonly endIndex: number;
|
|
141
|
+
readonly text: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A parse-tree node a correction presents in place of the grammar's.
|
|
146
|
+
*
|
|
147
|
+
* Adopting its children sets each view child's `previousSibling` to the child
|
|
148
|
+
* before it, which is what lets a redirect moved into a command ask
|
|
149
|
+
* `parseUnresolvedAt` about its new neighbor.
|
|
150
|
+
*/
|
|
151
|
+
class NodeView implements TSNode {
|
|
152
|
+
readonly type: string;
|
|
153
|
+
readonly isNamed: boolean;
|
|
154
|
+
readonly startIndex: number;
|
|
155
|
+
readonly endIndex: number;
|
|
156
|
+
readonly text: string;
|
|
157
|
+
readonly childCount: number;
|
|
158
|
+
previousSibling: TSNode | null = null;
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
fields: NodeFields,
|
|
162
|
+
private readonly children: readonly TSNode[],
|
|
163
|
+
readonly hasError: boolean,
|
|
164
|
+
) {
|
|
165
|
+
this.type = fields.type;
|
|
166
|
+
this.isNamed = fields.isNamed;
|
|
167
|
+
this.startIndex = fields.startIndex;
|
|
168
|
+
this.endIndex = fields.endIndex;
|
|
169
|
+
this.text = fields.text;
|
|
170
|
+
this.childCount = children.length;
|
|
171
|
+
children.forEach((child, i) => {
|
|
172
|
+
if (child instanceof NodeView)
|
|
173
|
+
child.previousSibling = children[i - 1] ?? null;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
child(index: number): TSNode | null {
|
|
178
|
+
return this.children[index] ?? null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { memoizeAsyncWithRetry } from "./async-cache";
|
|
3
|
+
import { hoistHeredocTails } from "./heredoc-tails";
|
|
3
4
|
import { reattachRedirectArguments } from "./redirect-arguments";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -69,11 +70,19 @@ async function initParser(): Promise<TSParser> {
|
|
|
69
70
|
/**
|
|
70
71
|
* The parser every consumer reads the bash grammar through.
|
|
71
72
|
*
|
|
72
|
-
* Its trees are the grammar's with
|
|
73
|
-
* package
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
73
|
+
* Its trees are the grammar's with two corrections applied where they enter the
|
|
74
|
+
* package, each moving command-line material `tree-sitter-bash` hangs on a
|
|
75
|
+
* redirect to where bash gives it:
|
|
76
|
+
*
|
|
77
|
+
* 1. `hoistHeredocTails` moves the redirects and the `| …` / `&& …` statement
|
|
78
|
+
* written after a heredoc's delimiter out of the heredoc (#979).
|
|
79
|
+
* 2. `reattachRedirectArguments` hands the words a redirect carries, a file
|
|
80
|
+
* redirect's or a heredoc's, back to the command (#977).
|
|
81
|
+
*
|
|
82
|
+
* The order matters: a redirect hoisted out of a heredoc can itself carry the
|
|
83
|
+
* command's words (`cat <<EOF 2>/dev/null arg`), which the second pass then
|
|
84
|
+
* hands back. Every walker, the salvage re-parse, and the log masker read the
|
|
85
|
+
* corrected tree, so none of them has to learn the grammar's quirks on its own.
|
|
77
86
|
*/
|
|
78
87
|
export const getParser = memoizeAsyncWithRetry(async () =>
|
|
79
88
|
correctingParser(await getGrammarParser()),
|
|
@@ -85,7 +94,7 @@ function correctingParser(grammar: TSParser): TSParser {
|
|
|
85
94
|
const tree = grammar.parse(input);
|
|
86
95
|
if (!tree) return null;
|
|
87
96
|
return {
|
|
88
|
-
rootNode: reattachRedirectArguments(tree.rootNode),
|
|
97
|
+
rootNode: reattachRedirectArguments(hoistHeredocTails(tree.rootNode)),
|
|
89
98
|
delete: () => {
|
|
90
99
|
tree.delete();
|
|
91
100
|
},
|
|
@@ -138,18 +138,49 @@ export function redirectTargetIndex(redirect: TSNode): number | undefined {
|
|
|
138
138
|
* The child index of the first word the grammar appended after `redirect`'s
|
|
139
139
|
* own target, or `undefined` when none follows.
|
|
140
140
|
*
|
|
141
|
-
* Every
|
|
142
|
-
* command rather than a destination of the redirect: `f.txt` in
|
|
141
|
+
* Every {@link LITERAL_NODE_TYPES} child from there on is a word bash passes to
|
|
142
|
+
* the redirected command rather than a destination of the redirect: `f.txt` in
|
|
143
143
|
* `grep pat 2>/dev/null f.txt`, and `arg` in `cmd >&- arg`, where the close
|
|
144
144
|
* operator has no target at all (#977).
|
|
145
|
+
*
|
|
146
|
+
* A heredoc carries them the same way: `git <<EOF push --force` hangs `push`
|
|
147
|
+
* and `--force` after its delimiter, where its own target would be. Its body
|
|
148
|
+
* follows them and is not a word, and neither is any other tail the grammar
|
|
149
|
+
* lets a heredoc carry (a redirect, a `| …` or `&& …` statement), so the first
|
|
150
|
+
* child after the target counts only when it is a literal (#979).
|
|
145
151
|
*/
|
|
146
152
|
export function trailingArgumentIndex(redirect: TSNode): number | undefined {
|
|
147
153
|
const operator = redirectOperatorIndex(redirect);
|
|
148
154
|
if (operator === undefined) return undefined;
|
|
149
155
|
const target = redirectTargetIndex(redirect);
|
|
150
|
-
|
|
156
|
+
const index = namedChildIndexAfter(redirect, target ?? operator);
|
|
157
|
+
if (index === undefined) return undefined;
|
|
158
|
+
return LITERAL_NODE_TYPES.has(redirect.child(index)?.type ?? "")
|
|
159
|
+
? index
|
|
160
|
+
: undefined;
|
|
151
161
|
}
|
|
152
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The node types `tree-sitter-bash` 0.25.1 parses a literal word as: its
|
|
165
|
+
* `_literal` rule, which a file redirect's destinations and a heredoc's
|
|
166
|
+
* trailing words are both declared as.
|
|
167
|
+
*/
|
|
168
|
+
export const LITERAL_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
169
|
+
"concatenation",
|
|
170
|
+
"word",
|
|
171
|
+
"string",
|
|
172
|
+
"raw_string",
|
|
173
|
+
"translated_string",
|
|
174
|
+
"ansi_c_string",
|
|
175
|
+
"number",
|
|
176
|
+
"expansion",
|
|
177
|
+
"simple_expansion",
|
|
178
|
+
"command_substitution",
|
|
179
|
+
"process_substitution",
|
|
180
|
+
"arithmetic_expansion",
|
|
181
|
+
"brace_expression",
|
|
182
|
+
]);
|
|
183
|
+
|
|
153
184
|
/** Operators that close a descriptor, naming no file (`>&-`, `<&-`). */
|
|
154
185
|
const CLOSE_OPERATORS: ReadonlySet<string> = new Set([">&-", "<&-"]);
|
|
155
186
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
childrenOf,
|
|
3
|
+
lastNamedChild,
|
|
4
|
+
rewriteStatements,
|
|
5
|
+
rewrittenNode,
|
|
6
|
+
type Source,
|
|
7
|
+
sourceOf,
|
|
8
|
+
} from "./parse-view";
|
|
2
9
|
import type { TSNode } from "./parser";
|
|
3
|
-
import { trailingArgumentIndex } from "./redirect-analysis";
|
|
10
|
+
import { LITERAL_NODE_TYPES, trailingArgumentIndex } from "./redirect-analysis";
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
13
|
* `tree-sitter-bash`'s parse with each word it hung on a redirect handed back
|
|
@@ -21,6 +28,11 @@ import { trailingArgumentIndex } from "./redirect-analysis";
|
|
|
21
28
|
* followed by its words; a redirect after the last word stays at the
|
|
22
29
|
* statement, and a statement left with no redirect gives way to its body.
|
|
23
30
|
*
|
|
31
|
+
* A heredoc carries words the same way (`git <<EOF push --force`), and they
|
|
32
|
+
* move into the command after it; its body stays in the heredoc, which is
|
|
33
|
+
* written after the words and so ends after them (#979). Every other tail a
|
|
34
|
+
* heredoc can carry is `heredoc-tails.ts`'s to move.
|
|
35
|
+
*
|
|
24
36
|
* Three kinds of statement are left exactly as the grammar produced them:
|
|
25
37
|
*
|
|
26
38
|
* - One whose parse failed. Its units are floored already, and moving a word
|
|
@@ -35,28 +47,7 @@ import { trailingArgumentIndex } from "./redirect-analysis";
|
|
|
35
47
|
* stay positions in the command string every caller already slices.
|
|
36
48
|
*/
|
|
37
49
|
export function reattachRedirectArguments(root: TSNode): TSNode {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The corrected node, or `undefined` when nothing beneath `node` changed. */
|
|
42
|
-
function correct(node: TSNode): TSNode | undefined {
|
|
43
|
-
const children: TSNode[] = [];
|
|
44
|
-
let changed = false;
|
|
45
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
46
|
-
const child = node.child(i);
|
|
47
|
-
if (!child) continue;
|
|
48
|
-
const corrected = correct(child);
|
|
49
|
-
if (corrected) changed = true;
|
|
50
|
-
children.push(corrected ?? child);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (node.type === "redirected_statement" && !parseUnresolvedWithin(node)) {
|
|
54
|
-
const reattached = reattachStatement(node, children);
|
|
55
|
-
if (reattached) return reattached;
|
|
56
|
-
}
|
|
57
|
-
return changed
|
|
58
|
-
? adoptingView(node, children, parseUnresolvedWithin(node))
|
|
59
|
-
: undefined;
|
|
50
|
+
return rewriteStatements(root, reattachStatement);
|
|
60
51
|
}
|
|
61
52
|
|
|
62
53
|
/**
|
|
@@ -74,7 +65,7 @@ function reattachStatement(
|
|
|
74
65
|
|
|
75
66
|
const lastCarrier = children.findLastIndex(
|
|
76
67
|
(child) =>
|
|
77
|
-
child.type
|
|
68
|
+
WORD_CARRYING_TYPES.has(child.type) &&
|
|
78
69
|
trailingArgumentIndex(child) !== undefined,
|
|
79
70
|
);
|
|
80
71
|
if (lastCarrier === -1) return undefined;
|
|
@@ -112,23 +103,34 @@ function reachesCommand(body: TSNode): boolean {
|
|
|
112
103
|
/** The bodies whose last element a statement-level redirect belongs to. */
|
|
113
104
|
const GROUPING_TYPES: ReadonlySet<string> = new Set(["list", "pipeline"]);
|
|
114
105
|
|
|
106
|
+
/** The redirects the grammar can hang a command's words on. */
|
|
107
|
+
const WORD_CARRYING_TYPES: ReadonlySet<string> = new Set([
|
|
108
|
+
"file_redirect",
|
|
109
|
+
"heredoc_redirect",
|
|
110
|
+
]);
|
|
111
|
+
|
|
115
112
|
/**
|
|
116
|
-
* `redirect` as it belongs in the command:
|
|
117
|
-
*
|
|
118
|
-
*
|
|
113
|
+
* `redirect` as it belongs in the command: without the words the grammar
|
|
114
|
+
* appended to it, and followed by them. A redirect carrying no words, or a node
|
|
115
|
+
* that cannot carry any, moves as it is.
|
|
116
|
+
*
|
|
117
|
+
* A file redirect's words are the rest of its children, so it is truncated
|
|
118
|
+
* after its target. A heredoc keeps its body and delimiter, which follow the
|
|
119
|
+
* words in the source, so it keeps its own end.
|
|
119
120
|
*/
|
|
120
121
|
function splitRedirect(redirect: TSNode, source: Source): TSNode[] {
|
|
121
|
-
const trailing =
|
|
122
|
-
redirect
|
|
123
|
-
|
|
124
|
-
: undefined;
|
|
122
|
+
const trailing = WORD_CARRYING_TYPES.has(redirect.type)
|
|
123
|
+
? trailingArgumentIndex(redirect)
|
|
124
|
+
: undefined;
|
|
125
125
|
if (trailing === undefined) return [redirect];
|
|
126
126
|
|
|
127
127
|
const kept: TSNode[] = [];
|
|
128
128
|
const words: TSNode[] = [];
|
|
129
129
|
for (let i = 0; i < redirect.childCount; i++) {
|
|
130
130
|
const child = redirect.child(i);
|
|
131
|
-
if (child)
|
|
131
|
+
if (!child) continue;
|
|
132
|
+
const isWord = i >= trailing && LITERAL_NODE_TYPES.has(child.type);
|
|
133
|
+
(isWord ? words : kept).push(child);
|
|
132
134
|
}
|
|
133
135
|
const end = kept.at(-1)?.endIndex ?? redirect.startIndex;
|
|
134
136
|
return [
|
|
@@ -139,8 +141,9 @@ function splitRedirect(redirect: TSNode, source: Source): TSNode[] {
|
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
143
|
* `body` with `moved` appended to its rightmost command: the command itself,
|
|
142
|
-
* or the last element of a `list` or `pipeline`, recursively. Each node on the
|
|
143
|
-
* the end
|
|
144
|
+
* or the last element of a `list` or `pipeline`, recursively. Each node on the
|
|
145
|
+
* way grows to the furthest end among the moved nodes, which is a heredoc's
|
|
146
|
+
* when one moved, since its body is written after the words that follow it.
|
|
144
147
|
*/
|
|
145
148
|
function appendToRightmostCommand(
|
|
146
149
|
body: TSNode,
|
|
@@ -148,7 +151,7 @@ function appendToRightmostCommand(
|
|
|
148
151
|
source: Source,
|
|
149
152
|
): TSNode {
|
|
150
153
|
const children = childrenOf(body);
|
|
151
|
-
const end = moved.
|
|
154
|
+
const end = Math.max(body.endIndex, ...moved.map((node) => node.endIndex));
|
|
152
155
|
if (body.type === "command") {
|
|
153
156
|
return rewrittenNode(
|
|
154
157
|
body,
|
|
@@ -166,120 +169,3 @@ function appendToRightmostCommand(
|
|
|
166
169
|
);
|
|
167
170
|
return rewrittenNode(body, replaced, body.startIndex, end, source);
|
|
168
171
|
}
|
|
169
|
-
|
|
170
|
-
/** A node the correction built, reading its text from the statement's source. */
|
|
171
|
-
function rewrittenNode(
|
|
172
|
-
original: TSNode,
|
|
173
|
-
children: readonly TSNode[],
|
|
174
|
-
startIndex: number,
|
|
175
|
-
endIndex: number,
|
|
176
|
-
source: Source,
|
|
177
|
-
): TSNode {
|
|
178
|
-
return adoptingView(
|
|
179
|
-
{
|
|
180
|
-
type: original.type,
|
|
181
|
-
isNamed: original.isNamed,
|
|
182
|
-
startIndex,
|
|
183
|
-
endIndex,
|
|
184
|
-
text: source(startIndex, endIndex),
|
|
185
|
-
},
|
|
186
|
-
children,
|
|
187
|
-
false,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* A view whose children are views too, so each one's `previousSibling` is its
|
|
193
|
-
* neighbor in the corrected tree rather than the one the grammar gave it.
|
|
194
|
-
*/
|
|
195
|
-
function adoptingView(
|
|
196
|
-
fields: NodeFields,
|
|
197
|
-
children: readonly TSNode[],
|
|
198
|
-
hasError: boolean,
|
|
199
|
-
): TSNode {
|
|
200
|
-
return new NodeView(fields, children.map(asView), hasError);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** A slice of the command by absolute offsets. */
|
|
204
|
-
type Source = (startIndex: number, endIndex: number) => string;
|
|
205
|
-
|
|
206
|
-
/** The source slicer for every node within `statement`. */
|
|
207
|
-
function sourceOf(statement: TSNode): Source {
|
|
208
|
-
return (startIndex, endIndex) =>
|
|
209
|
-
statement.text.slice(
|
|
210
|
-
startIndex - statement.startIndex,
|
|
211
|
-
endIndex - statement.startIndex,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function childrenOf(node: TSNode): TSNode[] {
|
|
216
|
-
const children: TSNode[] = [];
|
|
217
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
218
|
-
const child = node.child(i);
|
|
219
|
-
if (child) children.push(child);
|
|
220
|
-
}
|
|
221
|
-
return children;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function lastNamedChild(node: TSNode): TSNode | undefined {
|
|
225
|
-
return childrenOf(node).findLast((child) => child.isNamed);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* `node` as a view, so a new parent can give it a new previous sibling.
|
|
230
|
-
*
|
|
231
|
-
* Its own children stay the grammar's nodes: nothing beneath it moved, so their
|
|
232
|
-
* siblings are still the grammar's too.
|
|
233
|
-
*/
|
|
234
|
-
function asView(node: TSNode): TSNode {
|
|
235
|
-
return node instanceof NodeView
|
|
236
|
-
? node
|
|
237
|
-
: new NodeView(node, childrenOf(node), parseUnresolvedWithin(node));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The fields a {@link NodeView} copies from the node it stands for. */
|
|
241
|
-
interface NodeFields {
|
|
242
|
-
readonly type: string;
|
|
243
|
-
readonly isNamed: boolean;
|
|
244
|
-
readonly startIndex: number;
|
|
245
|
-
readonly endIndex: number;
|
|
246
|
-
readonly text: string;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* A parse-tree node the correction presents in place of the grammar's.
|
|
251
|
-
*
|
|
252
|
-
* Adopting its children sets each view child's `previousSibling` to the child
|
|
253
|
-
* before it, which is what lets a redirect moved into a command ask
|
|
254
|
-
* `parseUnresolvedAt` about its new neighbor.
|
|
255
|
-
*/
|
|
256
|
-
class NodeView implements TSNode {
|
|
257
|
-
readonly type: string;
|
|
258
|
-
readonly isNamed: boolean;
|
|
259
|
-
readonly startIndex: number;
|
|
260
|
-
readonly endIndex: number;
|
|
261
|
-
readonly text: string;
|
|
262
|
-
readonly childCount: number;
|
|
263
|
-
previousSibling: TSNode | null = null;
|
|
264
|
-
|
|
265
|
-
constructor(
|
|
266
|
-
fields: NodeFields,
|
|
267
|
-
private readonly children: readonly TSNode[],
|
|
268
|
-
readonly hasError: boolean,
|
|
269
|
-
) {
|
|
270
|
-
this.type = fields.type;
|
|
271
|
-
this.isNamed = fields.isNamed;
|
|
272
|
-
this.startIndex = fields.startIndex;
|
|
273
|
-
this.endIndex = fields.endIndex;
|
|
274
|
-
this.text = fields.text;
|
|
275
|
-
this.childCount = children.length;
|
|
276
|
-
children.forEach((child, i) => {
|
|
277
|
-
if (child instanceof NodeView)
|
|
278
|
-
child.previousSibling = children[i - 1] ?? null;
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
child(index: number): TSNode | null {
|
|
283
|
-
return this.children[index] ?? null;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { SubagentDetector } from "#src/authority/subagent-detection";
|
|
5
6
|
import { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
|
|
6
7
|
import {
|
|
7
8
|
type RegisteredTools,
|
|
@@ -23,7 +24,8 @@ interface BeforeAgentStartPayload {
|
|
|
23
24
|
* handler render the session's own tool list instead of editing the one Pi
|
|
24
25
|
* wrote — including in a child, whose inherited identity carries none.
|
|
25
26
|
* `customPrompt` says whether Pi wrote a preamble at all: under one, it
|
|
26
|
-
* writes no tool surface, so there is nothing of Pi's to remove
|
|
27
|
+
* writes no tool surface, so there is nothing of Pi's to remove, and a root
|
|
28
|
+
* node adds none either.
|
|
27
29
|
* `promptGuidelines` carries rules other extensions added, which removing
|
|
28
30
|
* Pi's own rules section would otherwise drop.
|
|
29
31
|
*/
|
|
@@ -60,6 +62,9 @@ export function shouldExposeTool(
|
|
|
60
62
|
*
|
|
61
63
|
* The tool surface is relocated rather than edited in place, so a subagent
|
|
62
64
|
* child's inherited identity stays byte-identical to its parent's (#890).
|
|
65
|
+
* A root node whose prompt Pi built from a custom one states no tool surface,
|
|
66
|
+
* matching Pi, which writes none there; a subagent child's prompt is always a
|
|
67
|
+
* custom one, and it still states its own tools.
|
|
63
68
|
*
|
|
64
69
|
* Constructor deps:
|
|
65
70
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
@@ -68,6 +73,8 @@ export function shouldExposeTool(
|
|
|
68
73
|
* - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
|
|
69
74
|
* - `toolRegistry` — Pi tool API subset (getAll + getActive + setActive)
|
|
70
75
|
* - `logger` — records each change to the effective tool surface
|
|
76
|
+
* - `detector` — tells a subagent child from a root, which decides whether a
|
|
77
|
+
* custom prompt gets this node's tool surface
|
|
71
78
|
*
|
|
72
79
|
* The active set is recomputed from the session's pre-filter tool surface
|
|
73
80
|
* every turn, so relaxing a rule restores the tool it had withheld (#873).
|
|
@@ -79,6 +86,7 @@ export class AgentPrepHandler {
|
|
|
79
86
|
private readonly resolver: PermissionResolver,
|
|
80
87
|
private readonly toolRegistry: ToolRegistry,
|
|
81
88
|
private readonly logger: DebugLogger,
|
|
89
|
+
private readonly detector: SubagentDetector,
|
|
82
90
|
) {}
|
|
83
91
|
|
|
84
92
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
@@ -108,15 +116,15 @@ export class AgentPrepHandler {
|
|
|
108
116
|
});
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
const toolSurfacePrompt =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
const toolSurfacePrompt = this.statesOwnToolSurface(event, ctx)
|
|
120
|
+
? renderToolSurface(event.systemPrompt, {
|
|
121
|
+
allowedTools,
|
|
122
|
+
toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
|
|
123
|
+
guidelinesByTool: registered.guidelinesByTool,
|
|
124
|
+
promptGuidelines: event.systemPromptOptions?.promptGuidelines ?? [],
|
|
125
|
+
piAuthoredPreamble: !hasCustomPrompt(event),
|
|
126
|
+
})
|
|
127
|
+
: event.systemPrompt;
|
|
120
128
|
const skillPromptResult = resolveSkillPromptEntries(
|
|
121
129
|
toolSurfacePrompt,
|
|
122
130
|
this.resolver,
|
|
@@ -129,6 +137,21 @@ export class AgentPrepHandler {
|
|
|
129
137
|
: {};
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Whether this node renders its own tool surface into the prompt.
|
|
142
|
+
*
|
|
143
|
+
* Every node does except a root whose prompt Pi built from a custom one:
|
|
144
|
+
* there Pi writes no tool list or rules, and the operator's prompt is left
|
|
145
|
+
* as Pi built it. A subagent child's prompt is always a custom one, and its
|
|
146
|
+
* inherited identity carries no tool list, so it still states its own.
|
|
147
|
+
*/
|
|
148
|
+
private statesOwnToolSurface(
|
|
149
|
+
event: BeforeAgentStartPayload,
|
|
150
|
+
ctx: ExtensionContext,
|
|
151
|
+
): boolean {
|
|
152
|
+
return !hasCustomPrompt(event) || this.detector.isSubagent(ctx);
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
private observeToolSurface(
|
|
133
156
|
registered: RegisteredTools,
|
|
134
157
|
): ToolSurfaceObservation {
|
|
@@ -138,3 +161,11 @@ export class AgentPrepHandler {
|
|
|
138
161
|
};
|
|
139
162
|
}
|
|
140
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether Pi built the prompt from a custom one, by Pi's own `if (customPrompt)`
|
|
167
|
+
* test, so an empty string reads here the way it reads there: as none.
|
|
168
|
+
*/
|
|
169
|
+
function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
|
|
170
|
+
return Boolean(event.systemPromptOptions?.customPrompt);
|
|
171
|
+
}
|