@erichll/pi-auto-review 0.22.1 → 0.24.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 +53 -0
- package/README.md +55 -12
- package/package.json +8 -8
- package/src/broker/grants.ts +3 -0
- package/src/broker/types.ts +11 -0
- package/src/index.ts +4 -1
- package/src/policy.ts +114 -14
- package/src/review/complete.ts +35 -2
- package/src/review/config.ts +1 -1
- package/src/review/input.ts +46 -2
- package/src/review/prompts.ts +6 -0
- package/src/review/provider.ts +22 -7
- package/src/review/shell-text.ts +496 -0
- package/src/review/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0 - 2026-10-03
|
|
4
|
+
|
|
5
|
+
- Raise the `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent`
|
|
6
|
+
peer floors to `^1.0.0` and the development baselines to `^1.0.0`, plus
|
|
7
|
+
`@types/node` to `^26.6.4`. Raise the
|
|
8
|
+
`@gotgenes/pi-permission-system` development baseline to `^38.0.2`; the peer
|
|
9
|
+
floor stays `>=36.0.0`. Validated against Pi 1.0.0 with
|
|
10
|
+
pi-permission-system 38.0.2 via `tsc --noEmit` and the full test suite: no
|
|
11
|
+
source changes were required, and the authorizer seam this package consumes
|
|
12
|
+
is unchanged.
|
|
13
|
+
- Move the example's Pi MCP tool rules under `mcp` (`mcp__atlassian__*`,
|
|
14
|
+
`mcp__pencil__*`) and update the README's MCP guidance to match, because
|
|
15
|
+
pi-permission-system 38.0.0 gates Pi's built-in MCP tools on the `mcp`
|
|
16
|
+
surface instead of their top-level names.
|
|
17
|
+
|
|
18
|
+
## 0.23.0 - 2026-10-02
|
|
19
|
+
|
|
20
|
+
- Add opt-in `model: "current"` to review approval requests with the active Pi
|
|
21
|
+
session model and provider, reflecting model changes on subsequent reviews.
|
|
22
|
+
- Decide, authorize, and hard-deny on the command that will actually execute,
|
|
23
|
+
not only the rule-matched unit. The permission-system ask payload is now
|
|
24
|
+
normalized into `executedUnit` (what a sudo/env/xargs/inline-shell wrapper
|
|
25
|
+
runs), `fullCommand` (the whole program), and `matchedPattern` (the rule or a
|
|
26
|
+
sentinel such as `<opaque-bash-wrapper>`), and those facts flow into the
|
|
27
|
+
reviewer context and the public `BoundaryRequest`.
|
|
28
|
+
- Split deterministic hard denies by family. Structural rules (recursive forced
|
|
29
|
+
wipes of `/`, `~`, or `$HOME`, and TLS/certificate weakening) scan a
|
|
30
|
+
skeleton with quoted literals masked, plus nested shell payloads (`bash -c`,
|
|
31
|
+
`eval`), including shells with options before `-c`. Literal path components
|
|
32
|
+
retain placeholders so named cleanup targets cannot become root/home wipes.
|
|
33
|
+
Other interpreter payloads (`python -c`, `node -e`, `perl -e`, `ruby -e`) and
|
|
34
|
+
heredoc bodies stay raw because shell quoting cannot establish whether their
|
|
35
|
+
strings execute. Inert mentions in shell commit messages and `echo` arguments
|
|
36
|
+
remain excluded. Hard denies scan the full program, outer unit, and executed
|
|
37
|
+
unit independently, preserving wrapper environment settings. Credential and
|
|
38
|
+
redirect rules keep scanning raw text, including quoted credential operands.
|
|
39
|
+
- Bound nested interpreter expansion to 32 payloads and 32 KiB of cumulative
|
|
40
|
+
payload text per command fact, shared across recursion levels. Lazy
|
|
41
|
+
extraction stops repeated `eval` chains from producing megabytes of
|
|
42
|
+
overlapping suffixes. On exhaustion, raw-command hard denies retain priority;
|
|
43
|
+
otherwise the request defers to a human without calling the model, including
|
|
44
|
+
on a trusted reviewer retry.
|
|
45
|
+
- Bind grants and the exact-retry match to the execution text, so two different
|
|
46
|
+
heredoc bodies gated by the same wrapper unit no longer share one request
|
|
47
|
+
hash. Denial labels and surface inference (`git-push`, `delete`) use the same
|
|
48
|
+
text.
|
|
49
|
+
- Bound the reviewer view: `command`, `executedUnit`, and `fullCommand` are each
|
|
50
|
+
cut to 10 KiB, and any cut sets `fullCommandTruncated`. An incomplete program
|
|
51
|
+
now deterministically defers to a human before the model is called; a trusted
|
|
52
|
+
exact-retry override still reaches the reviewer, and local hard denies keep
|
|
53
|
+
priority. Hashing, hard denies, retry matching, and denial labels always use
|
|
54
|
+
the untruncated text.
|
|
55
|
+
|
|
3
56
|
## 0.22.1 - 2026-10-01
|
|
4
57
|
|
|
5
58
|
- Fix permission-policy auditing being disabled on Bun with
|
package/README.md
CHANGED
|
@@ -31,8 +31,9 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
|
|
|
31
31
|
> **Prerequisite:** pi-auto-review is an authorizer inside
|
|
32
32
|
> `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
|
|
33
33
|
> install the permission system separately (once per machine) before this
|
|
34
|
-
> extension. This release line requires Pi 0.
|
|
35
|
-
> permission-system 36.0.0 and later
|
|
34
|
+
> extension. This release line requires Pi 1.0.0 or later and supports
|
|
35
|
+
> permission-system 36.0.0 and later (development baseline validated against
|
|
36
|
+
> permission-system 38.0.2):
|
|
36
37
|
|
|
37
38
|
Node-based Pi requires Node.js 22.13.0 or newer. Permission auditing uses the
|
|
38
39
|
host runtime's built-in SQLite: `node:sqlite` on Node and `bun:sqlite` on Bun,
|
|
@@ -95,6 +96,24 @@ Deterministic hard denies cover recursive forced wipes of `/`, `~`, and
|
|
|
95
96
|
`$HOME`. A named path under `/home/...` is reviewed by the model as high-risk,
|
|
96
97
|
not treated as a home-directory wipe.
|
|
97
98
|
|
|
99
|
+
Review and authorization use the command that will actually execute, not only
|
|
100
|
+
the unit the matched rule names. When a wrapper (`sudo`, `env`, `xargs`,
|
|
101
|
+
`bash -c`, `eval`, or an interpreter payload) hides the real program, the
|
|
102
|
+
permission-system ask payload supplies the executed unit, the complete command,
|
|
103
|
+
and the matched rule, and those facts are what get hashed, reviewed, and
|
|
104
|
+
hard-denied. Hard denies check the complete command, outer unit, and executed
|
|
105
|
+
unit independently. Structural rules mask inert shell literals without turning
|
|
106
|
+
named paths into root/home targets, and follow nested shell payloads. Other
|
|
107
|
+
interpreter payloads and heredoc bodies are conservatively scanned as raw text;
|
|
108
|
+
credential rules also keep scanning raw text. When a command fact exceeds the
|
|
109
|
+
reviewer bound (10 KiB), the ask defers to a human instead of being reviewed from
|
|
110
|
+
a truncated program.
|
|
111
|
+
|
|
112
|
+
Nested interpreter expansion is limited to 32 payloads and 32 KiB of cumulative
|
|
113
|
+
payload text per command fact, across all recursion levels. Exceeding either
|
|
114
|
+
budget keeps the raw-command hard-deny checks and otherwise defers to a human
|
|
115
|
+
without calling the model. A reviewer-retry override does not bypass this limit.
|
|
116
|
+
|
|
98
117
|
## Configuration
|
|
99
118
|
|
|
100
119
|
Configuration is resolved in this order:
|
|
@@ -113,21 +132,45 @@ Use the user-global file for normal customization. It may set any legal key:
|
|
|
113
132
|
}
|
|
114
133
|
```
|
|
115
134
|
|
|
135
|
+
To use the model currently selected in the Pi session, set the trusted
|
|
136
|
+
user-global config to:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"model": "current"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Each review reads the active session model, including its provider, so changing
|
|
145
|
+
models during a session affects subsequent approval reviews. Retries within
|
|
146
|
+
one review use the same selected model. Reviewer reasoning, token limits, and
|
|
147
|
+
timeouts still come from this extension's configuration.
|
|
148
|
+
|
|
149
|
+
The reviewer makes a separate provider request without tools and uses Pi's
|
|
150
|
+
authentication resolution. If no session model is selected, authentication
|
|
151
|
+
fails, or the provider cannot serve this auxiliary request, the existing
|
|
152
|
+
`failureMode` applies (`"deny"` by default, or `"defer"` to the human terminal).
|
|
153
|
+
The default remains `"codex-auto-review"`; explicit model IDs and
|
|
154
|
+
`"provider/reviewer-model"` values continue to select a dedicated reviewer.
|
|
155
|
+
The bare value `"current"` is reserved for this mode; use `"provider/current"`
|
|
156
|
+
to select a model whose ID is literally `current`.
|
|
157
|
+
|
|
116
158
|
For a complete `@gotgenes/pi-permission-system` config that wires
|
|
117
159
|
`pi-auto-review` into the authorizer chain — a copyable baseline covering
|
|
118
|
-
read/write/edit, a read-only bash allowlist,
|
|
160
|
+
read/write/edit, a read-only bash allowlist, MCP tool access, and a
|
|
119
161
|
`path` deny block for secret and credential files — see
|
|
120
162
|
[`examples/pi-permission-system.config.example.json`](examples/pi-permission-system.config.example.json).
|
|
121
163
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
164
|
+
Permission-system 38.0.0 gates Pi's built-in MCP tools on the `mcp` surface, so
|
|
165
|
+
the example writes its `mcp__<server>__*` rules inside a `mcp` block instead of
|
|
166
|
+
as top-level tool names. MCP rules are evaluated last-match-wins across the
|
|
167
|
+
candidates derived for one call: the tool name, its bare server, and `mcp_call`.
|
|
168
|
+
A call the `mcp` block does not match falls through to the universal
|
|
169
|
+
`"*": "ask"`, and any broad catch-all must be written before a narrower
|
|
170
|
+
override or last-match-wins shadows it. A bare server entry such as
|
|
171
|
+
`"atlassian": "allow"` also matches the `mcp connect <server>` candidate list,
|
|
172
|
+
so it auto-allows starting that server — prefer the `server_*` and `server:*`
|
|
173
|
+
forms for tool calls.
|
|
131
174
|
|
|
132
175
|
Package defaults:
|
|
133
176
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,16 +47,16 @@
|
|
|
47
47
|
"test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --import ./test/register-external-ts.mjs --test test/*.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "^0.
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
50
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
52
52
|
"@gotgenes/pi-permission-system": ">=36.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
57
|
-
"@earendil-works/pi-server": "^0.
|
|
58
|
-
"@gotgenes/pi-permission-system": "^
|
|
59
|
-
"@types/node": "^26.6.
|
|
55
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
57
|
+
"@earendil-works/pi-server": "^1.0.0",
|
|
58
|
+
"@gotgenes/pi-permission-system": "^38.0.2",
|
|
59
|
+
"@types/node": "^26.6.4",
|
|
60
60
|
"typescript": "^7.0.2"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/broker/grants.ts
CHANGED
|
@@ -22,6 +22,9 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
|
|
|
22
22
|
operation: request.operation,
|
|
23
23
|
cwd: request.cwd,
|
|
24
24
|
command: request.command,
|
|
25
|
+
executedUnit: request.executedUnit,
|
|
26
|
+
fullCommand: request.fullCommand,
|
|
27
|
+
matchedPattern: request.matchedPattern,
|
|
25
28
|
path: request.path,
|
|
26
29
|
resolvedPath: request.resolvedPath,
|
|
27
30
|
destination: request.destination,
|
package/src/broker/types.ts
CHANGED
|
@@ -19,6 +19,17 @@ export type BoundaryRequest = {
|
|
|
19
19
|
operation: string;
|
|
20
20
|
cwd: string;
|
|
21
21
|
command?: string;
|
|
22
|
+
/** Command the rule-matched unit actually runs after unwrapping a wrapper. */
|
|
23
|
+
executedUnit?: string;
|
|
24
|
+
/** The complete program that will execute, when it differs from `command`. */
|
|
25
|
+
fullCommand?: string;
|
|
26
|
+
/** Rule (or synthetic sentinel) that produced this ask. */
|
|
27
|
+
matchedPattern?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Set only on the bounded reviewer view when a command fact was truncated;
|
|
30
|
+
* never set on the stored request used for hashing or hard denies.
|
|
31
|
+
*/
|
|
32
|
+
fullCommandTruncated?: boolean;
|
|
22
33
|
path?: string;
|
|
23
34
|
resolvedPath?: string;
|
|
24
35
|
destination?: string;
|
package/src/index.ts
CHANGED
|
@@ -41,7 +41,7 @@ export {
|
|
|
41
41
|
buildUserReviewStatus,
|
|
42
42
|
} from "./user-feedback.ts";
|
|
43
43
|
|
|
44
|
-
export { parseDecision } from "./policy.ts";
|
|
44
|
+
export { executionText, parseDecision } from "./policy.ts";
|
|
45
45
|
export * from "./broker/index.ts";
|
|
46
46
|
export {
|
|
47
47
|
sandboxTrapToBoundaryRequest,
|
|
@@ -353,6 +353,9 @@ export function createPiAutoReviewExtension(
|
|
|
353
353
|
deterministicHardDeny({
|
|
354
354
|
surface: "bash_escalated",
|
|
355
355
|
command: request.command,
|
|
356
|
+
executedUnit: request.executedUnit,
|
|
357
|
+
fullCommand: request.fullCommand,
|
|
358
|
+
matchedPattern: request.matchedPattern,
|
|
356
359
|
path: request.path,
|
|
357
360
|
target: request.destination,
|
|
358
361
|
toolName: request.toolName,
|
package/src/policy.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { isPathSurface } from "./path-surfaces.ts";
|
|
2
|
+
import {
|
|
3
|
+
shellExpansionLimitExceeded,
|
|
4
|
+
structuralScanText,
|
|
5
|
+
} from "./review/shell-text.ts";
|
|
2
6
|
|
|
3
7
|
export type RiskLevel = "low" | "medium" | "high" | "critical";
|
|
4
8
|
|
|
@@ -21,12 +25,21 @@ export type PermissionDetailsLike = {
|
|
|
21
25
|
toolInputPreview?: unknown;
|
|
22
26
|
accessIntent?: unknown;
|
|
23
27
|
forwarding?: unknown;
|
|
28
|
+
/** Structured permission-system ask; the only source of the execution facts. */
|
|
29
|
+
payload?: unknown;
|
|
30
|
+
/** Pre-projected execution facts (broker hard-deny entry). */
|
|
31
|
+
executedUnit?: unknown;
|
|
32
|
+
fullCommand?: unknown;
|
|
33
|
+
matchedPattern?: unknown;
|
|
24
34
|
};
|
|
25
35
|
|
|
26
36
|
export type NormalizedPermissionEvidence = {
|
|
27
37
|
surface: string;
|
|
28
38
|
value?: string;
|
|
29
39
|
command?: string;
|
|
40
|
+
executedUnit?: string;
|
|
41
|
+
fullCommand?: string;
|
|
42
|
+
matchedPattern?: string;
|
|
30
43
|
path?: string;
|
|
31
44
|
resolvedPath?: string;
|
|
32
45
|
destination?: string;
|
|
@@ -155,6 +168,9 @@ export type RelevantBoundaryRequest = {
|
|
|
155
168
|
operation?: string;
|
|
156
169
|
cwd?: string;
|
|
157
170
|
command?: string;
|
|
171
|
+
executedUnit?: string;
|
|
172
|
+
fullCommand?: string;
|
|
173
|
+
matchedPattern?: string;
|
|
158
174
|
path?: string;
|
|
159
175
|
resolvedPath?: string;
|
|
160
176
|
destination?: string;
|
|
@@ -310,6 +326,20 @@ function parsedPreviewCommand(value: unknown): string | undefined {
|
|
|
310
326
|
}
|
|
311
327
|
}
|
|
312
328
|
|
|
329
|
+
function payloadFullCommand(
|
|
330
|
+
payload: Record<string, unknown> | undefined,
|
|
331
|
+
): string | undefined {
|
|
332
|
+
const evidence = payload?.evidence;
|
|
333
|
+
if (!Array.isArray(evidence)) return undefined;
|
|
334
|
+
for (const entry of evidence) {
|
|
335
|
+
const candidate = record(entry);
|
|
336
|
+
if (candidate?.label !== "full command") continue;
|
|
337
|
+
const text = nonEmptyString(candidate.text);
|
|
338
|
+
if (text) return text;
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
313
343
|
/** Normalize direct and forwarded permission-system evidence without trusting it. */
|
|
314
344
|
export function normalizePermissionEvidence(
|
|
315
345
|
details: PermissionDetailsLike,
|
|
@@ -355,10 +385,33 @@ export function normalizePermissionEvidence(
|
|
|
355
385
|
nonEmptyString(details.agentName) ??
|
|
356
386
|
nonEmptyString(forwarding?.requesterAgentName);
|
|
357
387
|
const sessionId = nonEmptyString(forwarding?.requesterSessionId);
|
|
388
|
+
const payload = record(details.payload);
|
|
389
|
+
const payloadRequest = record(payload?.request);
|
|
390
|
+
const matchedPattern =
|
|
391
|
+
nonEmptyString(payloadRequest?.matchedPattern) ??
|
|
392
|
+
nonEmptyString(details.matchedPattern);
|
|
393
|
+
const executedUnitCandidate =
|
|
394
|
+
nonEmptyString(payloadRequest?.executedUnit) ??
|
|
395
|
+
nonEmptyString(details.executedUnit);
|
|
396
|
+
const fullCommandCandidate =
|
|
397
|
+
payloadFullCommand(payload) ?? nonEmptyString(details.fullCommand);
|
|
398
|
+
// Suppress facts that add nothing over the gated unit: duplicating the same
|
|
399
|
+
// text would only widen the reviewer view and add hash noise.
|
|
400
|
+
const executedUnit =
|
|
401
|
+
executedUnitCandidate && executedUnitCandidate !== value
|
|
402
|
+
? executedUnitCandidate
|
|
403
|
+
: undefined;
|
|
404
|
+
const fullCommand =
|
|
405
|
+
fullCommandCandidate && fullCommandCandidate !== command
|
|
406
|
+
? fullCommandCandidate
|
|
407
|
+
: undefined;
|
|
358
408
|
return {
|
|
359
409
|
surface,
|
|
360
410
|
value,
|
|
361
411
|
command,
|
|
412
|
+
...(executedUnit ? { executedUnit } : {}),
|
|
413
|
+
...(fullCommand ? { fullCommand } : {}),
|
|
414
|
+
...(matchedPattern ? { matchedPattern } : {}),
|
|
362
415
|
path,
|
|
363
416
|
resolvedPath: accessIntent?.boundaryValue,
|
|
364
417
|
destination,
|
|
@@ -368,6 +421,22 @@ export function normalizePermissionEvidence(
|
|
|
368
421
|
};
|
|
369
422
|
}
|
|
370
423
|
|
|
424
|
+
/**
|
|
425
|
+
* The text that will actually execute, most complete fact first.
|
|
426
|
+
*
|
|
427
|
+
* Used for transcript matching, denial labels, and surface inference. Keep
|
|
428
|
+
* the outer command ahead of its extracted payload: wrapper options and
|
|
429
|
+
* environment assignments are part of execution too. Hard denies scan all
|
|
430
|
+
* three facts independently, and grant hashes bind all three.
|
|
431
|
+
*/
|
|
432
|
+
export function executionText(source: {
|
|
433
|
+
command?: string;
|
|
434
|
+
executedUnit?: string;
|
|
435
|
+
fullCommand?: string;
|
|
436
|
+
}): string | undefined {
|
|
437
|
+
return source.fullCommand ?? source.command ?? source.executedUnit;
|
|
438
|
+
}
|
|
439
|
+
|
|
371
440
|
export function effectiveCommand(details: PermissionDetailsLike): string | undefined {
|
|
372
441
|
return normalizePermissionEvidence(details).command;
|
|
373
442
|
}
|
|
@@ -377,6 +446,20 @@ export type HardDeny = {
|
|
|
377
446
|
reason: string;
|
|
378
447
|
};
|
|
379
448
|
|
|
449
|
+
function executionCommands(details: PermissionDetailsLike): string[] {
|
|
450
|
+
const evidence = normalizePermissionEvidence(details);
|
|
451
|
+
return [...new Set([
|
|
452
|
+
evidence.fullCommand,
|
|
453
|
+
evidence.command,
|
|
454
|
+
evidence.executedUnit,
|
|
455
|
+
].flatMap((command) => command?.trim() ? [command.trim()] : []))];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Hard denies run first; incomplete expansion then requires a human decision. */
|
|
459
|
+
export function commandExpansionLimitExceeded(details: PermissionDetailsLike): boolean {
|
|
460
|
+
return executionCommands(details).some(shellExpansionLimitExceeded);
|
|
461
|
+
}
|
|
462
|
+
|
|
380
463
|
/**
|
|
381
464
|
* Keep this list narrow: these checks are terminal and cannot be overridden by
|
|
382
465
|
* the model or the user prompt. Ambiguous or merely high-risk actions belong in
|
|
@@ -385,10 +468,26 @@ export type HardDeny = {
|
|
|
385
468
|
export function deterministicHardDeny(
|
|
386
469
|
details: PermissionDetailsLike,
|
|
387
470
|
): HardDeny | undefined {
|
|
388
|
-
|
|
389
|
-
|
|
471
|
+
// These facts are complementary. Unwrapping can omit an env assignment,
|
|
472
|
+
// while the full program can contain syntax our shell scanner cannot peel.
|
|
473
|
+
// Scan independently so unrelated projections cannot form a synthetic pipe
|
|
474
|
+
// or staged credential upload when concatenated.
|
|
475
|
+
for (const command of executionCommands(details)) {
|
|
476
|
+
const denial = hardDenyCommand(command);
|
|
477
|
+
if (denial) return denial;
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
390
481
|
|
|
391
|
-
|
|
482
|
+
function hardDenyCommand(command: string): HardDeny | undefined {
|
|
483
|
+
// Structural rules run on the literal-masked skeleton so a literal mention
|
|
484
|
+
// inside a commit message or an `echo` argument is not a wipe, while nested
|
|
485
|
+
// interpreter payloads are still scanned. Credential/data rules below stay
|
|
486
|
+
// on the raw text: a quoted `"$HOME/.aws/credentials"` is still the operand
|
|
487
|
+
// of a real upload.
|
|
488
|
+
const structural = structuralScanText(command);
|
|
489
|
+
|
|
490
|
+
for (const segment of structural.split(/&&|\|\||;|\n/)) {
|
|
392
491
|
const isRm = /(?:^|\s)(?:\/[^\s/]+)*\/?rm(?:\s|$)/i.test(segment);
|
|
393
492
|
const recursive =
|
|
394
493
|
/(?:^|\s)--recursive(?:\s|$)/i.test(segment) ||
|
|
@@ -411,12 +510,12 @@ export function deterministicHardDeny(
|
|
|
411
510
|
|
|
412
511
|
if (
|
|
413
512
|
/\bcurl\b[^;\n]*(?:--insecure\b|-[A-Za-z]*k[A-Za-z]*(?:\s|$))/i.test(
|
|
414
|
-
|
|
513
|
+
structural,
|
|
415
514
|
) ||
|
|
416
|
-
/\bwget\b[^;\n]*--no-check-certificate\b/i.test(
|
|
417
|
-
/\bgit\s+config\b[^;\n]*http\.sslverify\s+false\b/i.test(
|
|
418
|
-
/\bnpm\s+config\s+set\s+strict-ssl\s+false\b/i.test(
|
|
419
|
-
/\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0\b/i.test(
|
|
515
|
+
/\bwget\b[^;\n]*--no-check-certificate\b/i.test(structural) ||
|
|
516
|
+
/\bgit\s+config\b[^;\n]*http\.sslverify\s+false\b/i.test(structural) ||
|
|
517
|
+
/\bnpm\s+config\s+set\s+strict-ssl\s+false\b/i.test(structural) ||
|
|
518
|
+
/\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0\b/i.test(structural)
|
|
420
519
|
) {
|
|
421
520
|
return {
|
|
422
521
|
rule: "transport-security-weakening",
|
|
@@ -840,7 +939,7 @@ function relevanceReason(
|
|
|
840
939
|
| "provider-branch-protection"
|
|
841
940
|
| undefined {
|
|
842
941
|
if (request.toolCallId && call.id === request.toolCallId) return "same-tool";
|
|
843
|
-
const currentCommand = request
|
|
942
|
+
const currentCommand = executionText(request) || "";
|
|
844
943
|
const priorCommand = commandArgument(call);
|
|
845
944
|
const pushedBranch = explicitPushBranch(currentCommand);
|
|
846
945
|
const providerQuery = providerBranchQuery(priorCommand);
|
|
@@ -997,8 +1096,9 @@ function surfaceProfile(
|
|
|
997
1096
|
): EvidenceSurfaceProfile {
|
|
998
1097
|
if (request.agentName || request.requesterSessionId) return "forwarded";
|
|
999
1098
|
if (request.surface === "network" || request.destination) return "network";
|
|
1000
|
-
|
|
1001
|
-
if (/\b
|
|
1099
|
+
const command = executionText(request) ?? "";
|
|
1100
|
+
if (/\bgit\b[\s\S]*\bpush\b/i.test(command)) return "git-push";
|
|
1101
|
+
if (/\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(command)) {
|
|
1002
1102
|
return "delete";
|
|
1003
1103
|
}
|
|
1004
1104
|
return "generic";
|
|
@@ -1043,7 +1143,7 @@ function exactStructuredMatch(
|
|
|
1043
1143
|
}
|
|
1044
1144
|
const args = call.arguments as Record<string, unknown>;
|
|
1045
1145
|
const pairs: Array<[unknown, unknown]> = [
|
|
1046
|
-
[args.command, request
|
|
1146
|
+
[args.command, executionText(request)],
|
|
1047
1147
|
[args.path, request.path],
|
|
1048
1148
|
[args.resolvedPath, request.resolvedPath],
|
|
1049
1149
|
[args.destination, request.destination],
|
|
@@ -1100,7 +1200,7 @@ function toolArgumentCoveredByRequest(
|
|
|
1100
1200
|
return sameReviewerField(actual, request.destination ?? request.path);
|
|
1101
1201
|
}
|
|
1102
1202
|
if (key === "value") {
|
|
1103
|
-
return [request
|
|
1203
|
+
return [executionText(request), request.path, request.destination].some(
|
|
1104
1204
|
(expected) => sameReviewerField(actual, expected),
|
|
1105
1205
|
);
|
|
1106
1206
|
}
|
|
@@ -1112,7 +1212,7 @@ function toolArgumentCoveredByRequest(
|
|
|
1112
1212
|
});
|
|
1113
1213
|
}
|
|
1114
1214
|
const expected = ({
|
|
1115
|
-
command: request
|
|
1215
|
+
command: executionText(request),
|
|
1116
1216
|
path: request.path,
|
|
1117
1217
|
resolvedPath: request.resolvedPath,
|
|
1118
1218
|
destination: request.destination,
|
package/src/review/complete.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import {
|
|
6
6
|
buildClassifierTranscript,
|
|
7
|
+
commandExpansionLimitExceeded,
|
|
8
|
+
executionText,
|
|
7
9
|
parseDecision,
|
|
8
10
|
type ModelDecision,
|
|
9
11
|
} from "../policy.ts";
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
applyReviewerInputBudget,
|
|
30
32
|
preflightPart,
|
|
31
33
|
reviewPreflight,
|
|
34
|
+
reviewerRequestView,
|
|
32
35
|
sharedReviewContext,
|
|
33
36
|
textFromAssistant,
|
|
34
37
|
} from "./input.ts";
|
|
@@ -87,6 +90,37 @@ export async function complete(
|
|
|
87
90
|
sharedContext,
|
|
88
91
|
config.maxReviewerInputTokens,
|
|
89
92
|
);
|
|
93
|
+
// Hard denies already ran on the raw text plus bounded payloads. An
|
|
94
|
+
// incomplete expansion must reach the human terminal, even with a trusted
|
|
95
|
+
// reviewer-retry override. The older display-truncation fallback can still
|
|
96
|
+
// be lifted by that override.
|
|
97
|
+
const deferralReason = commandExpansionLimitExceeded(request)
|
|
98
|
+
? "Nested command expansion exceeds the local scan budget; a human must decide."
|
|
99
|
+
: reviewerRequestView(request).fullCommandTruncated && !reviewerContext?.userOverride
|
|
100
|
+
? "The complete command exceeds the reviewer input bound and was truncated; a human must decide."
|
|
101
|
+
: undefined;
|
|
102
|
+
if (deferralReason) {
|
|
103
|
+
const deferredSummary: ReviewExecutionSummary = {
|
|
104
|
+
attempts: [],
|
|
105
|
+
errorCounts: {},
|
|
106
|
+
durationMs: Date.now() - started,
|
|
107
|
+
transcript,
|
|
108
|
+
preflight,
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
decision: {
|
|
112
|
+
outcome: "defer",
|
|
113
|
+
risk_level: "high",
|
|
114
|
+
user_authorization: "unknown",
|
|
115
|
+
rationale: deferralReason,
|
|
116
|
+
},
|
|
117
|
+
attempts: 0,
|
|
118
|
+
retryErrors: [],
|
|
119
|
+
durationMs: deferredSummary.durationMs,
|
|
120
|
+
transcript,
|
|
121
|
+
summary: deferredSummary,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
90
124
|
const deadlineAt = started + config.timeoutMs;
|
|
91
125
|
const controller = new AbortController();
|
|
92
126
|
const onSessionAbort = () => controller.abort();
|
|
@@ -338,10 +372,9 @@ export function denialLabel(
|
|
|
338
372
|
denial.request.resolvedPath ??
|
|
339
373
|
denial.request.path ??
|
|
340
374
|
denial.request.destination ??
|
|
341
|
-
denial.request
|
|
375
|
+
executionText(denial.request) ??
|
|
342
376
|
denial.request.toolName ??
|
|
343
377
|
denial.request.operation;
|
|
344
378
|
const compact = String(target).replace(/\s+/g, " ").slice(0, 90);
|
|
345
379
|
return `${index + 1}. ${denial.request.surface}: ${compact} — ${denial.review.rationale.slice(0, 70)}`;
|
|
346
380
|
}
|
|
347
|
-
|
package/src/review/config.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function validateConfig(value: unknown, source: string): Config {
|
|
|
70
70
|
config.model.split("/").some((segment) => !segment.trim())
|
|
71
71
|
) {
|
|
72
72
|
throw new Error(
|
|
73
|
-
`${EXTENSION_NAME}: model must be a model id or provider/model`,
|
|
73
|
+
`${EXTENSION_NAME}: model must be current, a model id, or provider/model`,
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
if (
|
package/src/review/input.ts
CHANGED
|
@@ -27,6 +27,10 @@ import {
|
|
|
27
27
|
REVIEWER_FRAMING_RESERVE_TOKENS,
|
|
28
28
|
} from "./consts.ts";
|
|
29
29
|
import { REVIEWER_SYSTEM_PROMPT } from "./prompts.ts";
|
|
30
|
+
import {
|
|
31
|
+
MAX_REVIEWER_COMMAND_BYTES,
|
|
32
|
+
truncateUtf8,
|
|
33
|
+
} from "./shell-text.ts";
|
|
30
34
|
import { applyProjectConfig } from "./config.ts";
|
|
31
35
|
import { assertTrustedInstallation } from "./guards.ts";
|
|
32
36
|
|
|
@@ -110,6 +114,11 @@ export function boundaryRequest(
|
|
|
110
114
|
operation: details.source || surface,
|
|
111
115
|
cwd: ctx.cwd,
|
|
112
116
|
command: evidence.command,
|
|
117
|
+
...(evidence.executedUnit ? { executedUnit: evidence.executedUnit } : {}),
|
|
118
|
+
...(evidence.fullCommand ? { fullCommand: evidence.fullCommand } : {}),
|
|
119
|
+
...(evidence.matchedPattern
|
|
120
|
+
? { matchedPattern: evidence.matchedPattern }
|
|
121
|
+
: {}),
|
|
113
122
|
path: evidence.path,
|
|
114
123
|
resolvedPath: evidence.resolvedPath,
|
|
115
124
|
destination: evidence.destination,
|
|
@@ -184,10 +193,43 @@ export function sharedReviewContext(
|
|
|
184
193
|
}
|
|
185
194
|
: {}),
|
|
186
195
|
profile: transcript.surfaceProfile,
|
|
187
|
-
request,
|
|
196
|
+
request: reviewerRequestView(request),
|
|
188
197
|
});
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The bounded projection of a request that the reviewer model sees.
|
|
202
|
+
*
|
|
203
|
+
* `command`, `executedUnit`, and `fullCommand` are each cut to
|
|
204
|
+
* {@link MAX_REVIEWER_COMMAND_BYTES}; any cut sets `fullCommandTruncated` so the
|
|
205
|
+
* caller can fail closed instead of reviewing an incomplete program. Hashing,
|
|
206
|
+
* hard denies, retry matching, and denial labels keep using the untruncated
|
|
207
|
+
* request.
|
|
208
|
+
*/
|
|
209
|
+
export function reviewerRequestView(request: BoundaryRequest): BoundaryRequest {
|
|
210
|
+
let truncated = false;
|
|
211
|
+
const cut = (value: string | undefined): string | undefined => {
|
|
212
|
+
if (value === undefined) return undefined;
|
|
213
|
+
if (Buffer.byteLength(value, "utf8") <= MAX_REVIEWER_COMMAND_BYTES) {
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
truncated = true;
|
|
217
|
+
return truncateUtf8(value, MAX_REVIEWER_COMMAND_BYTES);
|
|
218
|
+
};
|
|
219
|
+
const view: BoundaryRequest = { ...request };
|
|
220
|
+
const command = cut(request.command);
|
|
221
|
+
const executedUnit = cut(request.executedUnit);
|
|
222
|
+
const fullCommand = cut(request.fullCommand);
|
|
223
|
+
if (command === undefined) delete view.command;
|
|
224
|
+
else view.command = command;
|
|
225
|
+
if (executedUnit === undefined) delete view.executedUnit;
|
|
226
|
+
else view.executedUnit = executedUnit;
|
|
227
|
+
if (fullCommand === undefined) delete view.fullCommand;
|
|
228
|
+
else view.fullCommand = fullCommand;
|
|
229
|
+
if (truncated) view.fullCommandTruncated = true;
|
|
230
|
+
return view;
|
|
231
|
+
}
|
|
232
|
+
|
|
191
233
|
/**
|
|
192
234
|
* Conservative token estimate for reviewer prompt sizing.
|
|
193
235
|
*
|
|
@@ -255,7 +297,9 @@ export function reviewPreflight(
|
|
|
255
297
|
maxReviewerInputTokens: number,
|
|
256
298
|
): ReviewPreflight {
|
|
257
299
|
const fixedPrompt = preflightPart(REVIEWER_SYSTEM_PROMPT);
|
|
258
|
-
const canonicalRequest = preflightPart(
|
|
300
|
+
const canonicalRequest = preflightPart(
|
|
301
|
+
canonicalReviewerJson(reviewerRequestView(request)),
|
|
302
|
+
);
|
|
259
303
|
const override = preflightPart(
|
|
260
304
|
reviewerContext?.userOverride
|
|
261
305
|
? canonicalReviewerJson(reviewerContext.userOverride)
|
package/src/review/prompts.ts
CHANGED
|
@@ -19,6 +19,12 @@ Policy:
|
|
|
19
19
|
a sandbox boundary.
|
|
20
20
|
- Defer when a human must decide or evidence is missing, conflicting,
|
|
21
21
|
truncated, or materially uncertain. User intent cannot override a hard deny.
|
|
22
|
+
- Request facts: command is the rule-matched unit; executedUnit is what that
|
|
23
|
+
unit runs after unwrapping sudo/env/xargs/inline shells; fullCommand is the
|
|
24
|
+
whole program; matchedPattern names the rule or a sentinel such as
|
|
25
|
+
<opaque-bash-wrapper>, <indirection-bash-wrapper>, or <unparsed-bash-subtree>.
|
|
26
|
+
When fullCommandTruncated is true or a wrapper payload is unresolved, defer;
|
|
27
|
+
never assume unseen text is safe.
|
|
22
28
|
|
|
23
29
|
Return exactly one JSON object and no markdown:
|
|
24
30
|
{"outcome":"allow|deny|defer","risk_level":"low|medium|high|critical","user_authorization":"unknown|low|medium|high","rationale":"short concrete reason"}
|
package/src/review/provider.ts
CHANGED
|
@@ -469,11 +469,11 @@ export function reviewerSessionId(
|
|
|
469
469
|
return `pi-auto-review-${fingerprint}`;
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
-
|
|
472
|
+
function resolveConfiguredModel(
|
|
473
473
|
ctx: ExtensionContext,
|
|
474
|
-
|
|
475
|
-
):
|
|
476
|
-
const { provider, modelId } = parseModelRef(
|
|
474
|
+
modelRef: string,
|
|
475
|
+
): ReviewerMeta["model"] {
|
|
476
|
+
const { provider, modelId } = parseModelRef(modelRef);
|
|
477
477
|
const available = ctx.modelRegistry.getAvailable();
|
|
478
478
|
const registeredModel = provider
|
|
479
479
|
? ctx.modelRegistry.find(provider, modelId)
|
|
@@ -492,10 +492,23 @@ export async function resolveReviewerMeta(
|
|
|
492
492
|
if (!model) {
|
|
493
493
|
throw new Error(
|
|
494
494
|
provider
|
|
495
|
-
? `provider ${provider} is unavailable for custom model ${
|
|
496
|
-
: `model ${
|
|
495
|
+
? `provider ${provider} is unavailable for custom model ${modelRef}`
|
|
496
|
+
: `model ${modelRef} is unavailable`,
|
|
497
497
|
);
|
|
498
498
|
}
|
|
499
|
+
return model;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function resolveReviewerMeta(
|
|
503
|
+
ctx: ExtensionContext,
|
|
504
|
+
config: Config,
|
|
505
|
+
): Promise<ReviewerMeta> {
|
|
506
|
+
// Pi exposes model as a live getter, including on the retained session
|
|
507
|
+
// context. Read it once per review and keep retries on that same model.
|
|
508
|
+
const model = config.model === "current"
|
|
509
|
+
? ctx.model
|
|
510
|
+
: resolveConfiguredModel(ctx, config.model);
|
|
511
|
+
if (!model) throw new Error("current Pi session model is unavailable");
|
|
499
512
|
|
|
500
513
|
const registered = (
|
|
501
514
|
ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
|
|
@@ -513,7 +526,7 @@ export async function resolveReviewerMeta(
|
|
|
513
526
|
return {
|
|
514
527
|
model,
|
|
515
528
|
streamSimple:
|
|
516
|
-
registered
|
|
529
|
+
registered && registered.api === model.api ? registered.streamSimple : undefined,
|
|
517
530
|
};
|
|
518
531
|
}
|
|
519
532
|
|
|
@@ -572,6 +585,8 @@ export async function modelCall(
|
|
|
572
585
|
metadata.retryAfterMs = parseRetryAfterMs(response.headers);
|
|
573
586
|
},
|
|
574
587
|
};
|
|
588
|
+
// Call the provider directly with no tools, outside Pi's agent/tool loop,
|
|
589
|
+
// so using the session model cannot re-enter permission review.
|
|
575
590
|
// 0.86 moved the provider-facing request to a normalized transcript: the
|
|
576
591
|
// system prompt and tool declarations now travel as the transcript's leading
|
|
577
592
|
// system message. Providers registered by extensions receive that shape, so
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-text helpers for the deterministic hard-deny scan.
|
|
3
|
+
*
|
|
4
|
+
* `@gotgenes/pi-permission-system` decides one *command unit* at a time, so a
|
|
5
|
+
* wrapper such as `bash -c "…"`, `sudo …`, or `python3 - <<'PY'` reports only
|
|
6
|
+
* the wrapper as the gated command while the payload carries the program that
|
|
7
|
+
* actually runs. The reviewer and the deterministic rules need a bounded view
|
|
8
|
+
* of that real execution text.
|
|
9
|
+
*
|
|
10
|
+
* `structuralScanText` builds the "skeleton" used by structural rules
|
|
11
|
+
* (destructive deletion, transport weakening): quoted literals that merely
|
|
12
|
+
* *mention* a dangerous command are inert data and are masked, while shell
|
|
13
|
+
* expansions (`$HOME`, `$(…)`, backticks) and target-significant characters
|
|
14
|
+
* (`/`, `*`, `~`) are preserved because a quoted value can still be the target
|
|
15
|
+
* of a real command. Literal placeholders keep named paths from collapsing
|
|
16
|
+
* into root/home targets. Nested shell payloads are scanned the same way;
|
|
17
|
+
* other interpreter payloads stay raw because shell quoting rules cannot
|
|
18
|
+
* establish which of their strings execute.
|
|
19
|
+
* Nested payloads are generated lazily and share count and byte budgets. If
|
|
20
|
+
* either budget is exhausted, retain the original text for hard denies and
|
|
21
|
+
* require human review instead of treating the partial expansion as safe.
|
|
22
|
+
*
|
|
23
|
+
* Fail direction: an unterminated quote, an unparsable payload, or a heredoc
|
|
24
|
+
* we cannot delimit all fall back to scanning the original text, which can
|
|
25
|
+
* only add denials.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Reviewer-visible bound for one command string. */
|
|
29
|
+
export const MAX_REVIEWER_COMMAND_BYTES = 10_240;
|
|
30
|
+
|
|
31
|
+
/** Shared by all nested payloads of one command, across both recursion levels. */
|
|
32
|
+
export const MAX_NESTED_PAYLOAD_COUNT = 32;
|
|
33
|
+
export const MAX_NESTED_PAYLOAD_BYTES = 32_768;
|
|
34
|
+
|
|
35
|
+
const TARGET_CHARACTERS = "/*~";
|
|
36
|
+
|
|
37
|
+
/** Truncate to a UTF-8 byte budget without splitting a code point. */
|
|
38
|
+
export function truncateUtf8(value: string, maxBytes: number): string {
|
|
39
|
+
if (maxBytes <= 0) return "";
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
let result = "";
|
|
42
|
+
for (const character of value) {
|
|
43
|
+
const size = Buffer.byteLength(character, "utf8");
|
|
44
|
+
if (bytes + size > maxBytes) break;
|
|
45
|
+
result += character;
|
|
46
|
+
bytes += size;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type QuotedBody = { body: string; end: number };
|
|
52
|
+
|
|
53
|
+
function readPlainQuote(
|
|
54
|
+
text: string,
|
|
55
|
+
start: number,
|
|
56
|
+
quote: "'" | '"',
|
|
57
|
+
): QuotedBody | undefined {
|
|
58
|
+
let index = start + 1;
|
|
59
|
+
let body = "";
|
|
60
|
+
while (index < text.length) {
|
|
61
|
+
const character = text[index]!;
|
|
62
|
+
if (quote === '"' && character === "\\") {
|
|
63
|
+
const next = text[index + 1];
|
|
64
|
+
if (next === undefined) return undefined;
|
|
65
|
+
body += text.slice(index, index + 2);
|
|
66
|
+
index += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (character === quote) return { body, end: index + 1 };
|
|
70
|
+
body += character;
|
|
71
|
+
index++;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readAnsiCQuote(text: string, start: number): QuotedBody | undefined {
|
|
77
|
+
// `start` points at the `$` of `$'…'`.
|
|
78
|
+
let index = start + 2;
|
|
79
|
+
let body = "";
|
|
80
|
+
while (index < text.length) {
|
|
81
|
+
const character = text[index]!;
|
|
82
|
+
if (character === "\\") {
|
|
83
|
+
const next = text[index + 1];
|
|
84
|
+
if (next === undefined) return undefined;
|
|
85
|
+
body += text.slice(index, index + 2);
|
|
86
|
+
index += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (character === "'") return { body, end: index + 1 };
|
|
90
|
+
body += character;
|
|
91
|
+
index++;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readExpansion(text: string, start: number): string | undefined {
|
|
97
|
+
const next = text[start + 1];
|
|
98
|
+
if (next === undefined) return undefined;
|
|
99
|
+
if (next === "{") {
|
|
100
|
+
const close = text.indexOf("}", start + 2);
|
|
101
|
+
return close < 0 ? undefined : text.slice(start, close + 1);
|
|
102
|
+
}
|
|
103
|
+
if (next === "(") {
|
|
104
|
+
let depth = 1;
|
|
105
|
+
let index = start + 2;
|
|
106
|
+
while (index < text.length) {
|
|
107
|
+
const character = text[index]!;
|
|
108
|
+
if (character === "(") depth++;
|
|
109
|
+
else if (character === ")") {
|
|
110
|
+
depth--;
|
|
111
|
+
if (depth === 0) return text.slice(start, index + 1);
|
|
112
|
+
}
|
|
113
|
+
index++;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (/[A-Za-z_]/.test(next)) {
|
|
118
|
+
const match = /^\$[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(start));
|
|
119
|
+
return match?.[0];
|
|
120
|
+
}
|
|
121
|
+
if (/[0-9@*#?$!\-]/.test(next)) return text.slice(start, start + 2);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Mask literal text without erasing components of a path or shell word. */
|
|
126
|
+
function dynamicQuotedBody(body: string): string {
|
|
127
|
+
let result = "";
|
|
128
|
+
let index = 0;
|
|
129
|
+
while (index < body.length) {
|
|
130
|
+
const character = body[index]!;
|
|
131
|
+
if (character === "\\") {
|
|
132
|
+
// An escaped expansion/quote is literal text, but still part of the word.
|
|
133
|
+
result += "x";
|
|
134
|
+
index += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (character === "`") {
|
|
138
|
+
const close = body.indexOf("`", index + 1);
|
|
139
|
+
if (close < 0) {
|
|
140
|
+
result += body.slice(index);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
result += body.slice(index, close + 1);
|
|
144
|
+
index = close + 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (character === "$") {
|
|
148
|
+
const expansion = readExpansion(body, index);
|
|
149
|
+
if (expansion) {
|
|
150
|
+
result += expansion;
|
|
151
|
+
index += expansion.length;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
result += "x";
|
|
155
|
+
index++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
result += TARGET_CHARACTERS.includes(character) ? character : "x";
|
|
159
|
+
index++;
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function literalQuotedBody(body: string): string {
|
|
165
|
+
let result = "";
|
|
166
|
+
for (const character of body) {
|
|
167
|
+
result += TARGET_CHARACTERS.includes(character) ? character : "x";
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readHeredoc(
|
|
173
|
+
text: string,
|
|
174
|
+
start: number,
|
|
175
|
+
): { end: number } | undefined {
|
|
176
|
+
if (text.startsWith("<<<", start)) return undefined;
|
|
177
|
+
const match = /^<<(-?)(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(
|
|
178
|
+
text.slice(start),
|
|
179
|
+
);
|
|
180
|
+
if (!match) return undefined;
|
|
181
|
+
const stripTabs = match[1] === "-";
|
|
182
|
+
const delimiter = match[3]!;
|
|
183
|
+
const headerEnd = start + match[0].length;
|
|
184
|
+
let lineEnd = text.indexOf("\n", headerEnd);
|
|
185
|
+
if (lineEnd < 0) return { end: text.length };
|
|
186
|
+
let position = lineEnd + 1;
|
|
187
|
+
while (position <= text.length) {
|
|
188
|
+
const next = text.indexOf("\n", position);
|
|
189
|
+
const line = text.slice(
|
|
190
|
+
position,
|
|
191
|
+
next < 0 ? text.length : next,
|
|
192
|
+
);
|
|
193
|
+
const compare = stripTabs ? line.replace(/^\t+/, "") : line;
|
|
194
|
+
if (compare === delimiter) {
|
|
195
|
+
return { end: next < 0 ? text.length : next };
|
|
196
|
+
}
|
|
197
|
+
if (next < 0) return { end: text.length };
|
|
198
|
+
position = next + 1;
|
|
199
|
+
}
|
|
200
|
+
return { end: text.length };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Replace quoted literal text with its structural skeleton.
|
|
205
|
+
*
|
|
206
|
+
* Quoted literals are inert data for structural rules, so their text is
|
|
207
|
+
* masked — except shell expansions and path/glob/tilde characters, which can
|
|
208
|
+
* still be the target of a real command. Masking must not erase named path
|
|
209
|
+
* components: `"./dist"` must never become `"/"`. Heredoc bodies and command
|
|
210
|
+
* substitutions are preserved verbatim. An unterminated quote returns the
|
|
211
|
+
* original text unchanged (fail-closed).
|
|
212
|
+
*/
|
|
213
|
+
export function stripQuotedLiterals(text: string): string {
|
|
214
|
+
let result = "";
|
|
215
|
+
let index = 0;
|
|
216
|
+
while (index < text.length) {
|
|
217
|
+
const character = text[index]!;
|
|
218
|
+
if (character === "'" || character === '"') {
|
|
219
|
+
const quoted = readPlainQuote(text, index, character);
|
|
220
|
+
if (!quoted) return text;
|
|
221
|
+
result +=
|
|
222
|
+
character +
|
|
223
|
+
(character === "'"
|
|
224
|
+
? literalQuotedBody(quoted.body)
|
|
225
|
+
: dynamicQuotedBody(quoted.body)) +
|
|
226
|
+
character;
|
|
227
|
+
index = quoted.end;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (character === "$" && text[index + 1] === "'") {
|
|
231
|
+
const quoted = readAnsiCQuote(text, index);
|
|
232
|
+
if (!quoted) return text;
|
|
233
|
+
result += `$'${literalQuotedBody(quoted.body)}'`;
|
|
234
|
+
index = quoted.end;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (character === "`") {
|
|
238
|
+
const close = text.indexOf("`", index + 1);
|
|
239
|
+
if (close < 0) return text;
|
|
240
|
+
result += text.slice(index, close + 1);
|
|
241
|
+
index = close + 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (character === "<" && text[index + 1] === "<") {
|
|
245
|
+
const heredoc = readHeredoc(text, index);
|
|
246
|
+
if (heredoc) {
|
|
247
|
+
result += text.slice(index, heredoc.end);
|
|
248
|
+
index = heredoc.end;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
result += character;
|
|
253
|
+
index++;
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type ShellToken = {
|
|
259
|
+
value: string;
|
|
260
|
+
start: number;
|
|
261
|
+
end: number;
|
|
262
|
+
quoted: boolean;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const WORD_SEPARATORS = /[\s;&|()<>`]/;
|
|
266
|
+
|
|
267
|
+
function tokenizeShell(text: string): ShellToken[] {
|
|
268
|
+
const tokens: ShellToken[] = [];
|
|
269
|
+
let index = 0;
|
|
270
|
+
while (index < text.length) {
|
|
271
|
+
const character = text[index]!;
|
|
272
|
+
if (/\s/.test(character)) {
|
|
273
|
+
index++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (character === "#") {
|
|
277
|
+
const newline = text.indexOf("\n", index);
|
|
278
|
+
index = newline < 0 ? text.length : newline;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (character === "'" || character === '"') {
|
|
282
|
+
const quoted = readPlainQuote(text, index, character);
|
|
283
|
+
if (!quoted) {
|
|
284
|
+
tokens.push({
|
|
285
|
+
value: text.slice(index + 1),
|
|
286
|
+
start: index,
|
|
287
|
+
end: text.length,
|
|
288
|
+
quoted: true,
|
|
289
|
+
});
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
tokens.push({
|
|
293
|
+
value: quoted.body,
|
|
294
|
+
start: index,
|
|
295
|
+
end: quoted.end,
|
|
296
|
+
quoted: true,
|
|
297
|
+
});
|
|
298
|
+
index = quoted.end;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (character === "$" && text[index + 1] === "'") {
|
|
302
|
+
const quoted = readAnsiCQuote(text, index);
|
|
303
|
+
if (!quoted) break;
|
|
304
|
+
tokens.push({
|
|
305
|
+
value: quoted.body,
|
|
306
|
+
start: index,
|
|
307
|
+
end: quoted.end,
|
|
308
|
+
quoted: true,
|
|
309
|
+
});
|
|
310
|
+
index = quoted.end;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let end = index;
|
|
314
|
+
while (end < text.length && !WORD_SEPARATORS.test(text[end]!)) end++;
|
|
315
|
+
if (end === index) {
|
|
316
|
+
index++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
tokens.push({
|
|
320
|
+
value: text.slice(index, end),
|
|
321
|
+
start: index,
|
|
322
|
+
end,
|
|
323
|
+
quoted: false,
|
|
324
|
+
});
|
|
325
|
+
index = end;
|
|
326
|
+
}
|
|
327
|
+
return tokens;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function commandEnd(text: string, start: number): number {
|
|
331
|
+
const match = /[;&|\n]/.exec(text.slice(start));
|
|
332
|
+
return match ? start + (match.index ?? 0) : text.length;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function baseName(value: string): string {
|
|
336
|
+
const slash = value.lastIndexOf("/");
|
|
337
|
+
return slash < 0 ? value : value.slice(slash + 1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
341
|
+
const SHORT_COMMAND_OPTION = /^-[A-Za-z]*c[A-Za-z]*$/;
|
|
342
|
+
|
|
343
|
+
type InterpreterPayload = { text: string; kind: "shell" | "opaque" };
|
|
344
|
+
|
|
345
|
+
// Shell options that consume a separate operand before a later -c option.
|
|
346
|
+
const SHELL_VALUE_OPTIONS = new Set([
|
|
347
|
+
"-o", "+o", "-O", "+O", "--rcfile", "--init-file",
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
function nextCommandToken(
|
|
351
|
+
text: string,
|
|
352
|
+
tokens: ShellToken[],
|
|
353
|
+
index: number,
|
|
354
|
+
): ShellToken | undefined {
|
|
355
|
+
const current = tokens[index];
|
|
356
|
+
const next = tokens[index + 1];
|
|
357
|
+
if (!current || !next) return undefined;
|
|
358
|
+
// The tokenizer skips operators. Never treat a token in a subsequent
|
|
359
|
+
// command as this interpreter's option or payload.
|
|
360
|
+
return /[;&|\n()<>`]/.test(text.slice(current.end, next.start))
|
|
361
|
+
? undefined
|
|
362
|
+
: next;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function shellPayloadToken(
|
|
366
|
+
text: string,
|
|
367
|
+
tokens: ShellToken[],
|
|
368
|
+
interpreterIndex: number,
|
|
369
|
+
): ShellToken | undefined {
|
|
370
|
+
for (let index = interpreterIndex; index < tokens.length; index++) {
|
|
371
|
+
const option = nextCommandToken(text, tokens, index);
|
|
372
|
+
if (!option) return undefined;
|
|
373
|
+
if (
|
|
374
|
+
SHORT_COMMAND_OPTION.test(option.value) || option.value === "--command"
|
|
375
|
+
) {
|
|
376
|
+
return nextCommandToken(text, tokens, index + 1);
|
|
377
|
+
}
|
|
378
|
+
// Stop at a script operand or the end-of-options marker.
|
|
379
|
+
if (option.value === "--" || !/^[-+][A-Za-z-]/.test(option.value)) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (SHELL_VALUE_OPTIONS.has(option.value)) {
|
|
383
|
+
if (!nextCommandToken(text, tokens, index + 1)) return undefined;
|
|
384
|
+
index++;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function* directPayloads(text: string): Generator<InterpreterPayload> {
|
|
391
|
+
const tokens = tokenizeShell(text);
|
|
392
|
+
const payloadValue = (token: ShellToken | undefined): string | undefined => {
|
|
393
|
+
if (!token) return undefined;
|
|
394
|
+
return token.quoted
|
|
395
|
+
? token.value
|
|
396
|
+
: text.slice(token.start, commandEnd(text, token.start));
|
|
397
|
+
};
|
|
398
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
399
|
+
const token = tokens[index]!;
|
|
400
|
+
const name = baseName(token.value);
|
|
401
|
+
let payload: string | undefined;
|
|
402
|
+
let kind: InterpreterPayload["kind"] = "opaque";
|
|
403
|
+
if (SHELL_INTERPRETERS.has(name)) {
|
|
404
|
+
payload = payloadValue(shellPayloadToken(text, tokens, index));
|
|
405
|
+
kind = "shell";
|
|
406
|
+
} else if (name === "eval") {
|
|
407
|
+
payload = payloadValue(tokens[index + 1]);
|
|
408
|
+
kind = "shell";
|
|
409
|
+
} else if (/^python[0-9.]*$/.test(name)) {
|
|
410
|
+
if (tokens[index + 1]?.value === "-c") {
|
|
411
|
+
payload = payloadValue(tokens[index + 2]);
|
|
412
|
+
}
|
|
413
|
+
} else if (name === "perl" || name === "ruby") {
|
|
414
|
+
if (tokens[index + 1]?.value === "-e") {
|
|
415
|
+
payload = payloadValue(tokens[index + 2]);
|
|
416
|
+
}
|
|
417
|
+
} else if (name === "node") {
|
|
418
|
+
const option = tokens[index + 1]?.value;
|
|
419
|
+
if (option === "-e" || option === "--eval") {
|
|
420
|
+
payload = payloadValue(tokens[index + 2]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Yield lazily: eagerly collecting all suffixes of `eval eval ...` would
|
|
424
|
+
// already allocate quadratic data before the caller can enforce a budget.
|
|
425
|
+
if (payload && payload.trim()) yield { text: payload, kind };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Nested interpreter payloads within the shared count/byte budget, to a depth
|
|
431
|
+
* of two. Extraction understands `bash|sh|zsh|dash|ksh -c`, `eval`, `python* -c`,
|
|
432
|
+
* `perl -e`, `node -e`, and `ruby -e`. Safety callers must also check
|
|
433
|
+
* `shellExpansionLimitExceeded`; a bounded prefix is not a complete program.
|
|
434
|
+
*/
|
|
435
|
+
export function nestedShellPayloads(text: string): string[] {
|
|
436
|
+
return nestedInterpreterPayloads(text).payloads.map((payload) => payload.text);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function nestedInterpreterPayloads(text: string): {
|
|
440
|
+
payloads: InterpreterPayload[];
|
|
441
|
+
limitExceeded: boolean;
|
|
442
|
+
} {
|
|
443
|
+
const payloads: InterpreterPayload[] = [];
|
|
444
|
+
let payloadBytes = 0;
|
|
445
|
+
let limitExceeded = false;
|
|
446
|
+
const visit = (value: string, depth: number): void => {
|
|
447
|
+
if (depth <= 0 || limitExceeded) return;
|
|
448
|
+
for (const payload of directPayloads(value)) {
|
|
449
|
+
const remainingBytes = MAX_NESTED_PAYLOAD_BYTES - payloadBytes;
|
|
450
|
+
// UTF-8 is at least as long as UTF-16 in code units. Reject an obviously
|
|
451
|
+
// oversized suffix before measuring/flattening its entire string.
|
|
452
|
+
if (
|
|
453
|
+
payloads.length >= MAX_NESTED_PAYLOAD_COUNT ||
|
|
454
|
+
payload.text.length > remainingBytes
|
|
455
|
+
) {
|
|
456
|
+
limitExceeded = true;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const bytes = Buffer.byteLength(payload.text, "utf8");
|
|
460
|
+
if (bytes > remainingBytes) {
|
|
461
|
+
limitExceeded = true;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
payloadBytes += bytes;
|
|
465
|
+
payloads.push(payload);
|
|
466
|
+
if (payload.kind === "shell") visit(payload.text, depth - 1);
|
|
467
|
+
if (limitExceeded) return;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
visit(text, 2);
|
|
471
|
+
return { payloads, limitExceeded };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Whether nested execution could not be fully expanded within its budget. */
|
|
475
|
+
export function shellExpansionLimitExceeded(text: string): boolean {
|
|
476
|
+
return nestedInterpreterPayloads(text).limitExceeded;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Structural scan text: the literal-masked skeleton of `text` plus the
|
|
481
|
+
* skeleton of nested shell payloads. Other languages remain raw: a string
|
|
482
|
+
* passed to subprocess.run/execSync/system is not an inert shell literal.
|
|
483
|
+
* On budget exhaustion retain the complete original text for hard denies;
|
|
484
|
+
* callers must defer if that scan finds no deny, rather than trusting the
|
|
485
|
+
* partially expanded program. Extra payload text never exceeds the budget.
|
|
486
|
+
*/
|
|
487
|
+
export function structuralScanText(text: string): string {
|
|
488
|
+
const { payloads, limitExceeded } = nestedInterpreterPayloads(text);
|
|
489
|
+
const parts = [limitExceeded ? text : stripQuotedLiterals(text)];
|
|
490
|
+
for (const payload of payloads) {
|
|
491
|
+
parts.push(
|
|
492
|
+
payload.kind === "shell" ? stripQuotedLiterals(payload.text) : payload.text,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return parts.join("\n");
|
|
496
|
+
}
|
package/src/review/types.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type ReasoningLevel =
|
|
|
17
17
|
export type BoundedSurface = "external_directory" | "path";
|
|
18
18
|
|
|
19
19
|
export type Config = {
|
|
20
|
+
/** Model id, provider/model, or "current" for the live Pi session model. */
|
|
20
21
|
model: string;
|
|
21
22
|
reasoning: ReasoningLevel;
|
|
22
23
|
timeoutMs: number;
|