@erichll/pi-auto-review 0.3.1 → 0.3.3
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 +21 -0
- package/README.md +55 -17
- package/package.json +3 -4
- package/src/config.json +3 -3
- package/src/index.ts +231 -65
- package/src/user-feedback.ts +140 -0
- package/config/config.example.json +0 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 - 2026-07-30
|
|
4
|
+
|
|
5
|
+
- Fix config validation rejecting multi-segment model ids (`provider/group/model`).
|
|
6
|
+
`validateConfig` no longer restricts a model to exactly one `/`; it only rejects
|
|
7
|
+
ids with empty segments. Segments are resolved as before: the first segment is
|
|
8
|
+
the provider and the rest is the model id.
|
|
9
|
+
|
|
10
|
+
## 0.3.2 - 2026-07-29
|
|
11
|
+
|
|
12
|
+
- Load an optional user-global trusted config from
|
|
13
|
+
`~/.pi/agent/extensions/pi-auto-review/config.json` as a full overlay on the
|
|
14
|
+
package defaults (model, `autoConfirmBoundedAllows`, and other legal keys).
|
|
15
|
+
- Keep project `.pi/pi-auto-review.json` tighten-only on top of that trusted
|
|
16
|
+
layer.
|
|
17
|
+
- Deterministically deny writes to the user-global reviewer config directory.
|
|
18
|
+
- Default reviewer model is the bare id `codex-auto-review`; `provider/model`
|
|
19
|
+
remains supported. Bare ids resolve against registered available models.
|
|
20
|
+
- Show user-facing review feedback in interactive UI: a footer status while the
|
|
21
|
+
model is reviewing, then a short toast for allow / deny / defer / auto-confirm
|
|
22
|
+
/ circuit-breaker outcomes (separate from agent denial reasons).
|
|
23
|
+
|
|
3
24
|
## 0.3.1 - 2026-07-29
|
|
4
25
|
|
|
5
26
|
- Bind one-shot approval grants to matched policy evidence as well as the
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ The package name and model name are intentionally separate:
|
|
|
12
12
|
|
|
13
13
|
- npm package: `@erichll/pi-auto-review`
|
|
14
14
|
- Authorizer: `pi-auto-review`
|
|
15
|
-
- Default reviewer model: `
|
|
15
|
+
- Default reviewer model: `codex-auto-review`
|
|
16
16
|
|
|
17
17
|
## Security model
|
|
18
18
|
|
|
@@ -44,6 +44,24 @@ or ten denials in the rolling last fifty reviews. An explicit denial is
|
|
|
44
44
|
returned to the agent with an instruction not to pursue the same outcome
|
|
45
45
|
through a workaround.
|
|
46
46
|
|
|
47
|
+
## User-facing feedback
|
|
48
|
+
|
|
49
|
+
In interactive UI sessions, the extension shows short operator feedback that is
|
|
50
|
+
separate from agent denial reasons:
|
|
51
|
+
|
|
52
|
+
1. Footer status while review is in flight:
|
|
53
|
+
`auto-review: reviewing <surface> · <target>`
|
|
54
|
+
2. A toast when the decision lands:
|
|
55
|
+
- allowed
|
|
56
|
+
- allowed + auto-confirming the local dialog
|
|
57
|
+
- allowed but local confirmation still required
|
|
58
|
+
- deferred to you
|
|
59
|
+
- denied
|
|
60
|
+
- circuit-breaker stopped after repeated denials
|
|
61
|
+
|
|
62
|
+
These notices are best-effort UX only; missing or broken UI delivery never
|
|
63
|
+
changes the security decision.
|
|
64
|
+
|
|
47
65
|
## Exact human retry override
|
|
48
66
|
|
|
49
67
|
In interactive TUI mode, `/approve` shows up to ten recent model denials from
|
|
@@ -64,12 +82,30 @@ cannot be issued again for the same request semantics in that session.
|
|
|
64
82
|
|
|
65
83
|
## Configure
|
|
66
84
|
|
|
67
|
-
|
|
85
|
+
Trusted configuration is resolved in this order:
|
|
86
|
+
|
|
87
|
+
1. Package defaults from the installed `src/config.json`
|
|
88
|
+
2. Optional user-global overlay at
|
|
89
|
+
`~/.pi/agent/extensions/pi-auto-review/config.json`
|
|
90
|
+
3. Optional project tighten-only file at `.pi/pi-auto-review.json`
|
|
91
|
+
|
|
92
|
+
Prefer the user-global file for day-to-day overrides (same location style as
|
|
93
|
+
`pi-permission-system`). It may set any legal key, including `model` and
|
|
94
|
+
`autoConfirmBoundedAllows`. A partial file overlays the package defaults.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
// ~/.pi/agent/extensions/pi-auto-review/config.json
|
|
98
|
+
{
|
|
99
|
+
"autoConfirmBoundedAllows": ["external_directory", "path"]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Package-shipped defaults live in `src/config.json`:
|
|
68
104
|
|
|
69
105
|
```json
|
|
70
106
|
{
|
|
71
|
-
"model": "
|
|
72
|
-
"reasoning": "
|
|
107
|
+
"model": "codex-auto-review",
|
|
108
|
+
"reasoning": "low",
|
|
73
109
|
"timeoutMs": 45000,
|
|
74
110
|
"maxTokens": 1600,
|
|
75
111
|
"retries": 1,
|
|
@@ -78,7 +114,7 @@ Edit `src/config.json`, using `config/config.example.json` as the reference:
|
|
|
78
114
|
"maxRelevantResultTokens": 800,
|
|
79
115
|
"failureMode": "deny",
|
|
80
116
|
"grantTtlMs": 60000,
|
|
81
|
-
"autoConfirmBoundedAllows": ["external_directory"]
|
|
117
|
+
"autoConfirmBoundedAllows": ["external_directory", "path"]
|
|
82
118
|
}
|
|
83
119
|
```
|
|
84
120
|
|
|
@@ -199,17 +235,19 @@ Pi places user npm packages under `~/.pi/agent/npm/` and Git packages under
|
|
|
199
235
|
PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
|
|
200
236
|
```
|
|
201
237
|
|
|
202
|
-
The trusted
|
|
203
|
-
|
|
204
|
-
timeouts, token/evidence limits,
|
|
205
|
-
to `deny`. It may remove entries
|
|
206
|
-
add a surface not enabled by the
|
|
207
|
-
select a model, weaken fail-closed behavior,
|
|
208
|
-
|
|
238
|
+
The trusted configuration (package defaults plus optional user-global overlay)
|
|
239
|
+
is copied and frozen at `session_start`. An optional workspace file at
|
|
240
|
+
`.pi/pi-auto-review.json` may only lower timeouts, token/evidence limits,
|
|
241
|
+
retries, and grant TTL, or set `failureMode` to `deny`. It may remove entries
|
|
242
|
+
from `autoConfirmBoundedAllows`, but cannot add a surface not enabled by the
|
|
243
|
+
trusted configuration. It cannot select a model, weaken fail-closed behavior,
|
|
244
|
+
or raise a trusted limit. Invalid user-global or project configuration disables
|
|
245
|
+
the reviewer for that session.
|
|
209
246
|
|
|
210
|
-
Writes to the installed reviewer package,
|
|
211
|
-
|
|
212
|
-
|
|
247
|
+
Writes to the installed reviewer package, the user-global reviewer config
|
|
248
|
+
directory (`~/.pi/agent/extensions/pi-auto-review/`), project security
|
|
249
|
+
configuration, global Pi security configuration, and the global audit directory
|
|
250
|
+
are deterministically denied.
|
|
213
251
|
|
|
214
252
|
## Real-model smoke test
|
|
215
253
|
|
|
@@ -221,11 +259,11 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 \
|
|
|
221
259
|
PI_AUTO_REVIEW_SMOKE_AUDIT_PATH=/tmp/pi-auto-review-smoke-audit.jsonl \
|
|
222
260
|
pi --no-extensions --no-skills --no-prompt-templates --no-context-files \
|
|
223
261
|
--no-builtin-tools --no-session --print \
|
|
224
|
-
--extension /trusted/path/to/
|
|
262
|
+
--extension /trusted/path/to/your-model-provider/extensions/index.ts \
|
|
225
263
|
--extension ./packages/pi-auto-review/src/index.ts \
|
|
226
264
|
--extension ./packages/pi-sandbox/src/index.ts \
|
|
227
265
|
--extension ./scripts/real-model-smoke-audit.ts \
|
|
228
|
-
--model
|
|
266
|
+
--model provider/your-available-model \
|
|
229
267
|
"Use bash once to write a fixed marker outside the workspace."
|
|
230
268
|
```
|
|
231
269
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Model-backed boundary approval broker for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"src",
|
|
31
|
-
"config/config.example.json",
|
|
32
31
|
"CHANGELOG.md",
|
|
33
32
|
"README.md",
|
|
34
33
|
"LICENSE"
|
|
@@ -53,8 +52,8 @@
|
|
|
53
52
|
"@gotgenes/pi-permission-system": ">=23.0.0"
|
|
54
53
|
},
|
|
55
54
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
55
|
+
"@earendil-works/pi-ai": "0.83.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
58
57
|
"@gotgenes/pi-permission-system": "24.0.0",
|
|
59
58
|
"@types/node": "^22.15.3",
|
|
60
59
|
"typescript": "^6.0.3"
|
package/src/config.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"model": "
|
|
3
|
-
"reasoning": "
|
|
2
|
+
"model": "codex-auto-review",
|
|
3
|
+
"reasoning": "low",
|
|
4
4
|
"timeoutMs": 45000,
|
|
5
5
|
"maxTokens": 1600,
|
|
6
6
|
"retries": 1,
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
"maxRelevantResultTokens": 800,
|
|
10
10
|
"failureMode": "deny",
|
|
11
11
|
"grantTtlMs": 60000,
|
|
12
|
-
"autoConfirmBoundedAllows": ["external_directory"]
|
|
12
|
+
"autoConfirmBoundedAllows": ["external_directory", "path"]
|
|
13
13
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,19 @@ import {
|
|
|
34
34
|
type BoundaryReviewerContext,
|
|
35
35
|
} from "./broker/index.ts";
|
|
36
36
|
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
37
|
+
import {
|
|
38
|
+
buildUserReviewNotice,
|
|
39
|
+
buildUserReviewStatus,
|
|
40
|
+
notifyUserReview,
|
|
41
|
+
reviewTargetFromRequest,
|
|
42
|
+
setUserReviewStatus,
|
|
43
|
+
type UserReviewOutcome,
|
|
44
|
+
} from "./user-feedback.ts";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
buildUserReviewNotice,
|
|
48
|
+
buildUserReviewStatus,
|
|
49
|
+
} from "./user-feedback.ts";
|
|
37
50
|
|
|
38
51
|
export { parseDecision } from "./policy.ts";
|
|
39
52
|
export * from "./broker/index.ts";
|
|
@@ -98,13 +111,20 @@ type ReviewResult = {
|
|
|
98
111
|
const EXTENSION_NAME = "pi-auto-review";
|
|
99
112
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
100
113
|
const PROJECT_CONFIG_PATH = join(".pi", "pi-auto-review.json");
|
|
114
|
+
const USER_CONFIG_RELATIVE_PATH = join(
|
|
115
|
+
".pi",
|
|
116
|
+
"agent",
|
|
117
|
+
"extensions",
|
|
118
|
+
"pi-auto-review",
|
|
119
|
+
"config.json",
|
|
120
|
+
);
|
|
101
121
|
const PERMISSIONS_SERVICE_KEY = Symbol.for(
|
|
102
122
|
"@gotgenes/pi-permission-system:service",
|
|
103
123
|
);
|
|
104
124
|
const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
|
|
105
125
|
const DEFAULT_CONFIG: Config = {
|
|
106
|
-
model: "
|
|
107
|
-
reasoning: "
|
|
126
|
+
model: "codex-auto-review",
|
|
127
|
+
reasoning: "low",
|
|
108
128
|
timeoutMs: 45_000,
|
|
109
129
|
maxTokens: 1_600,
|
|
110
130
|
retries: 1,
|
|
@@ -113,7 +133,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
113
133
|
maxRelevantResultTokens: 800,
|
|
114
134
|
failureMode: "deny",
|
|
115
135
|
grantTtlMs: 60_000,
|
|
116
|
-
autoConfirmBoundedAllows: Object.freeze(["external_directory"]),
|
|
136
|
+
autoConfirmBoundedAllows: Object.freeze(["external_directory", "path"]),
|
|
117
137
|
};
|
|
118
138
|
|
|
119
139
|
const REVIEWER_SYSTEM_PROMPT = `You are a fail-closed permission reviewer.
|
|
@@ -177,8 +197,15 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
177
197
|
);
|
|
178
198
|
}
|
|
179
199
|
const config = { ...DEFAULT_CONFIG, ...raw };
|
|
180
|
-
if (
|
|
181
|
-
|
|
200
|
+
if (
|
|
201
|
+
typeof config.model !== "string" ||
|
|
202
|
+
!config.model.trim() ||
|
|
203
|
+
/\s/.test(config.model) ||
|
|
204
|
+
config.model.split("/").some((segment) => !segment.trim())
|
|
205
|
+
) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`${EXTENSION_NAME}: model must be a model id or provider/model`,
|
|
208
|
+
);
|
|
182
209
|
}
|
|
183
210
|
if (
|
|
184
211
|
!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(
|
|
@@ -247,19 +274,88 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
247
274
|
};
|
|
248
275
|
}
|
|
249
276
|
|
|
277
|
+
export function packageConfigPath(): string {
|
|
278
|
+
return join(dirname(fileURLToPath(import.meta.url)), "config.json");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function userConfigPath(home = homedir()): string {
|
|
282
|
+
return join(home, USER_CONFIG_RELATIVE_PATH);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readJsonConfig(path: string): unknown {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`${EXTENSION_NAME}: cannot load ${path}: ${
|
|
291
|
+
error instanceof Error ? error.message : String(error)
|
|
292
|
+
}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Package-shipped defaults from `src/config.json`. */
|
|
250
298
|
export function loadConfig(): Config {
|
|
251
|
-
const path =
|
|
299
|
+
const path = packageConfigPath();
|
|
300
|
+
return validateConfig(readJsonConfig(path), path);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* User-global trusted overlay at
|
|
305
|
+
* `~/.pi/agent/extensions/pi-auto-review/config.json`.
|
|
306
|
+
* May set any legal config key, including model and autoConfirmBoundedAllows.
|
|
307
|
+
*/
|
|
308
|
+
export function applyUserConfig(
|
|
309
|
+
packageConfig: Config,
|
|
310
|
+
value: unknown,
|
|
311
|
+
source = "user config",
|
|
312
|
+
): Config {
|
|
313
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
314
|
+
throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
|
|
315
|
+
}
|
|
316
|
+
return validateConfig(
|
|
317
|
+
{
|
|
318
|
+
...packageConfig,
|
|
319
|
+
autoConfirmBoundedAllows: [...packageConfig.autoConfirmBoundedAllows],
|
|
320
|
+
...(value as Record<string, unknown>),
|
|
321
|
+
},
|
|
322
|
+
source,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type LoadTrustedConfigOptions = {
|
|
327
|
+
packageConfig?: Config;
|
|
328
|
+
userConfigPath?: string;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Trusted config = package defaults, optionally fully overlaid by the user
|
|
333
|
+
* global file. Project config is applied later and may only tighten.
|
|
334
|
+
*/
|
|
335
|
+
export function loadTrustedConfig(
|
|
336
|
+
options: LoadTrustedConfigOptions = {},
|
|
337
|
+
): Config {
|
|
338
|
+
const packageConfig = options.packageConfig ?? loadConfig();
|
|
339
|
+
const path = options.userConfigPath ?? userConfigPath();
|
|
252
340
|
let value: unknown;
|
|
253
341
|
try {
|
|
254
342
|
value = JSON.parse(readFileSync(path, "utf8"));
|
|
255
343
|
} catch (error) {
|
|
344
|
+
if (
|
|
345
|
+
error &&
|
|
346
|
+
typeof error === "object" &&
|
|
347
|
+
"code" in error &&
|
|
348
|
+
error.code === "ENOENT"
|
|
349
|
+
) {
|
|
350
|
+
return packageConfig;
|
|
351
|
+
}
|
|
256
352
|
throw new Error(
|
|
257
353
|
`${EXTENSION_NAME}: cannot load ${path}: ${
|
|
258
354
|
error instanceof Error ? error.message : String(error)
|
|
259
355
|
}`,
|
|
260
356
|
);
|
|
261
357
|
}
|
|
262
|
-
return
|
|
358
|
+
return applyUserConfig(packageConfig, value, path);
|
|
263
359
|
}
|
|
264
360
|
|
|
265
361
|
const TIGHTENABLE_NUMBER_KEYS = [
|
|
@@ -356,6 +452,7 @@ function protectedWriteHardDeny(
|
|
|
356
452
|
const protectedDirectories = [
|
|
357
453
|
PACKAGE_ROOT,
|
|
358
454
|
join(agentDir, "logs"),
|
|
455
|
+
join(agentDir, "extensions", "pi-auto-review"),
|
|
359
456
|
];
|
|
360
457
|
const protectedFiles = [
|
|
361
458
|
join(request.cwd, ".pi", "settings.json"),
|
|
@@ -364,6 +461,7 @@ function protectedWriteHardDeny(
|
|
|
364
461
|
join(agentDir, "settings.json"),
|
|
365
462
|
join(agentDir, "permissions.json"),
|
|
366
463
|
join(agentDir, "sandbox.json"),
|
|
464
|
+
userConfigPath(),
|
|
367
465
|
];
|
|
368
466
|
if (
|
|
369
467
|
protectedDirectories.some((path) => isWithin(path, resolvedTarget)) ||
|
|
@@ -538,16 +636,32 @@ ${transcript.text}
|
|
|
538
636
|
</selected-transcript>`;
|
|
539
637
|
}
|
|
540
638
|
|
|
639
|
+
function parseModelRef(modelRef: string): {
|
|
640
|
+
provider?: string;
|
|
641
|
+
modelId: string;
|
|
642
|
+
} {
|
|
643
|
+
if (!modelRef.includes("/")) {
|
|
644
|
+
return { modelId: modelRef };
|
|
645
|
+
}
|
|
646
|
+
const [provider, ...idParts] = modelRef.split("/");
|
|
647
|
+
return { provider, modelId: idParts.join("/") };
|
|
648
|
+
}
|
|
649
|
+
|
|
541
650
|
async function resolveReviewer(
|
|
542
651
|
ctx: ExtensionContext,
|
|
543
652
|
config: Config,
|
|
544
653
|
): Promise<ReviewerRuntime> {
|
|
545
|
-
const
|
|
546
|
-
const
|
|
547
|
-
const registeredModel =
|
|
548
|
-
|
|
549
|
-
.
|
|
550
|
-
|
|
654
|
+
const { provider, modelId } = parseModelRef(config.model);
|
|
655
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
656
|
+
const registeredModel = provider
|
|
657
|
+
? ctx.modelRegistry.find(provider, modelId)
|
|
658
|
+
: available.find(
|
|
659
|
+
(candidate) =>
|
|
660
|
+
candidate.id === modelId || candidate.name === modelId,
|
|
661
|
+
);
|
|
662
|
+
const providerFallback = provider
|
|
663
|
+
? available.find((candidate) => candidate.provider === provider)
|
|
664
|
+
: undefined;
|
|
551
665
|
const model =
|
|
552
666
|
registeredModel ||
|
|
553
667
|
(providerFallback
|
|
@@ -555,7 +669,9 @@ async function resolveReviewer(
|
|
|
555
669
|
: undefined);
|
|
556
670
|
if (!model) {
|
|
557
671
|
throw new Error(
|
|
558
|
-
|
|
672
|
+
provider
|
|
673
|
+
? `provider ${provider} is unavailable for custom model ${config.model}`
|
|
674
|
+
: `model ${config.model} is unavailable`,
|
|
559
675
|
);
|
|
560
676
|
}
|
|
561
677
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
@@ -757,7 +873,9 @@ export function createPiAutoReviewExtension(
|
|
|
757
873
|
options: PiAutoReviewExtensionOptions = {},
|
|
758
874
|
): (pi: ExtensionAPI) => void {
|
|
759
875
|
const trustedConfig = Object.freeze(
|
|
760
|
-
|
|
876
|
+
options.config !== undefined
|
|
877
|
+
? validateConfig(options.config, "trusted config")
|
|
878
|
+
: loadTrustedConfig(),
|
|
761
879
|
);
|
|
762
880
|
const allowUntrustedWorkspace =
|
|
763
881
|
options.allowUntrustedWorkspace === true ||
|
|
@@ -897,11 +1015,14 @@ export function createPiAutoReviewExtension(
|
|
|
897
1015
|
context = undefined;
|
|
898
1016
|
broker = undefined;
|
|
899
1017
|
disposeBrokerService = undefined;
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1018
|
+
const message = `${EXTENSION_NAME}: session disabled: ${
|
|
1019
|
+
error instanceof Error ? error.message : String(error)
|
|
1020
|
+
}`;
|
|
1021
|
+
console.error(message);
|
|
1022
|
+
notifyUserReview(ctx, {
|
|
1023
|
+
type: "error",
|
|
1024
|
+
message,
|
|
1025
|
+
});
|
|
905
1026
|
}
|
|
906
1027
|
});
|
|
907
1028
|
|
|
@@ -925,56 +1046,100 @@ export function createPiAutoReviewExtension(
|
|
|
925
1046
|
surface,
|
|
926
1047
|
reason,
|
|
927
1048
|
});
|
|
1049
|
+
notifyUserReview(
|
|
1050
|
+
context,
|
|
1051
|
+
buildUserReviewNotice({
|
|
1052
|
+
outcome: "unavailable",
|
|
1053
|
+
surface,
|
|
1054
|
+
rationale: reason,
|
|
1055
|
+
}),
|
|
1056
|
+
);
|
|
928
1057
|
return { kind: "deny", reason };
|
|
929
1058
|
}
|
|
930
1059
|
|
|
931
1060
|
const request = boundaryRequest(context, details, query);
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1061
|
+
const target = reviewTargetFromRequest(request);
|
|
1062
|
+
setUserReviewStatus(
|
|
1063
|
+
context,
|
|
1064
|
+
buildUserReviewStatus(surface, target),
|
|
1065
|
+
);
|
|
1066
|
+
try {
|
|
1067
|
+
const decision = await broker.review(request, {
|
|
1068
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
1069
|
+
scopeKey: currentTurnScope(context),
|
|
1070
|
+
issueGrant: false,
|
|
1071
|
+
});
|
|
1072
|
+
const result = reviewResults.get(request.id);
|
|
1073
|
+
reviewResults.delete(request.id);
|
|
1074
|
+
const allowCapped =
|
|
1075
|
+
decision.kind === "allow" && boundedRequest(details);
|
|
1076
|
+
const autoConfirmQueued =
|
|
1077
|
+
allowCapped &&
|
|
1078
|
+
context.mode === "tui" &&
|
|
1079
|
+
context.hasUI &&
|
|
1080
|
+
uiAutoConfirmer.stage(request.id, surface);
|
|
1081
|
+
|
|
1082
|
+
let userOutcome: UserReviewOutcome;
|
|
1083
|
+
if (decision.kind === "deny" && decision.circuitBreakerTripped) {
|
|
1084
|
+
userOutcome = "circuit_breaker";
|
|
1085
|
+
} else if (allowCapped && autoConfirmQueued) {
|
|
1086
|
+
userOutcome = "auto_confirm";
|
|
1087
|
+
} else if (allowCapped) {
|
|
1088
|
+
userOutcome = "needs_confirmation";
|
|
1089
|
+
} else if (decision.kind === "allow") {
|
|
1090
|
+
userOutcome = "allow";
|
|
1091
|
+
} else if (decision.kind === "defer") {
|
|
1092
|
+
userOutcome = "defer";
|
|
1093
|
+
} else {
|
|
1094
|
+
userOutcome = "deny";
|
|
1095
|
+
}
|
|
1096
|
+
notifyUserReview(
|
|
1097
|
+
context,
|
|
1098
|
+
buildUserReviewNotice({
|
|
1099
|
+
outcome: userOutcome,
|
|
1100
|
+
surface,
|
|
1101
|
+
target,
|
|
1102
|
+
rationale: decision.review.rationale,
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
log.review("pi_auto_review_decision", {
|
|
1107
|
+
requestId: request.id,
|
|
1108
|
+
surface,
|
|
1109
|
+
model: config.model,
|
|
1110
|
+
outcome: allowCapped ? "defer" : decision.kind,
|
|
1111
|
+
reviewerOutcome: decision.review.outcome,
|
|
1112
|
+
riskLevel: decision.review.riskLevel,
|
|
1113
|
+
userAuthorization: decision.review.userAuthorization,
|
|
1114
|
+
rationale: decision.review.rationale,
|
|
1115
|
+
allowCapped,
|
|
1116
|
+
autoConfirmQueued,
|
|
1117
|
+
userOutcome,
|
|
1118
|
+
circuitBreakerTripped:
|
|
1119
|
+
decision.kind === "deny"
|
|
1120
|
+
? decision.circuitBreakerTripped
|
|
1121
|
+
: false,
|
|
1122
|
+
attempts: result?.attempts ?? 0,
|
|
1123
|
+
retryErrors: result?.retryErrors ?? [],
|
|
1124
|
+
durationMs: result?.durationMs,
|
|
1125
|
+
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
1126
|
+
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
1127
|
+
transcriptRelevantResultCharacters:
|
|
1128
|
+
result?.transcript.relevantResultCharacters,
|
|
1129
|
+
transcriptTruncated: result?.transcript.truncated,
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
if (allowCapped || decision.kind === "defer") {
|
|
1133
|
+
return { kind: "defer" };
|
|
1134
|
+
}
|
|
1135
|
+
if (decision.kind === "allow") return { kind: "allow" };
|
|
1136
|
+
return {
|
|
1137
|
+
kind: "deny",
|
|
1138
|
+
reason: `${decision.review.rationale} Do not retry this outcome through a workaround or policy circumvention; use a materially safer alternative or ask the user.`,
|
|
1139
|
+
};
|
|
1140
|
+
} finally {
|
|
1141
|
+
setUserReviewStatus(context, undefined);
|
|
972
1142
|
}
|
|
973
|
-
if (decision.kind === "allow") return { kind: "allow" };
|
|
974
|
-
return {
|
|
975
|
-
kind: "deny",
|
|
976
|
-
reason: `${decision.review.rationale} Do not retry this outcome through a workaround or policy circumvention; use a materially safer alternative or ask the user.`,
|
|
977
|
-
};
|
|
978
1143
|
},
|
|
979
1144
|
);
|
|
980
1145
|
} catch (error) {
|
|
@@ -987,6 +1152,7 @@ export function createPiAutoReviewExtension(
|
|
|
987
1152
|
});
|
|
988
1153
|
|
|
989
1154
|
pi.on("session_shutdown", () => {
|
|
1155
|
+
setUserReviewStatus(context, undefined);
|
|
990
1156
|
disposeAuthorizer?.();
|
|
991
1157
|
disposeAuthorizer = undefined;
|
|
992
1158
|
disposeBrokerService?.();
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
|
|
4
|
+
|
|
5
|
+
export type UserReviewOutcome =
|
|
6
|
+
| "allow"
|
|
7
|
+
| "deny"
|
|
8
|
+
| "defer"
|
|
9
|
+
| "auto_confirm"
|
|
10
|
+
| "needs_confirmation"
|
|
11
|
+
| "circuit_breaker"
|
|
12
|
+
| "unavailable";
|
|
13
|
+
|
|
14
|
+
export type UserReviewNotice = {
|
|
15
|
+
message: string;
|
|
16
|
+
type: "info" | "warning" | "error";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Collapse whitespace and bound length for status/notify text. */
|
|
20
|
+
export function compactReviewText(value: unknown, max = 90): string {
|
|
21
|
+
return String(value ?? "")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim()
|
|
24
|
+
.slice(0, max);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function reviewTargetFromRequest(request: {
|
|
28
|
+
resolvedPath?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
destination?: string;
|
|
31
|
+
command?: string;
|
|
32
|
+
toolName?: string;
|
|
33
|
+
skillName?: string;
|
|
34
|
+
operation?: string;
|
|
35
|
+
}): string | undefined {
|
|
36
|
+
const target =
|
|
37
|
+
request.resolvedPath ??
|
|
38
|
+
request.path ??
|
|
39
|
+
request.destination ??
|
|
40
|
+
request.command ??
|
|
41
|
+
request.toolName ??
|
|
42
|
+
request.skillName ??
|
|
43
|
+
request.operation;
|
|
44
|
+
const compact = compactReviewText(target, 90);
|
|
45
|
+
return compact || undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Footer/status bar text while the model review is in flight. */
|
|
49
|
+
export function buildUserReviewStatus(
|
|
50
|
+
surface: string,
|
|
51
|
+
target?: string,
|
|
52
|
+
): string {
|
|
53
|
+
const compact = compactReviewText(target, 48);
|
|
54
|
+
return compact
|
|
55
|
+
? `auto-review: reviewing ${surface} · ${compact}`
|
|
56
|
+
: `auto-review: reviewing ${surface}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* User-facing (not agent-facing) notice after a review decision.
|
|
61
|
+
* Kept short so TUI toasts remain readable.
|
|
62
|
+
*/
|
|
63
|
+
export function buildUserReviewNotice(input: {
|
|
64
|
+
outcome: UserReviewOutcome;
|
|
65
|
+
surface: string;
|
|
66
|
+
target?: string;
|
|
67
|
+
rationale?: string;
|
|
68
|
+
}): UserReviewNotice {
|
|
69
|
+
const target = compactReviewText(input.target, 70);
|
|
70
|
+
const rationale = compactReviewText(input.rationale, 120);
|
|
71
|
+
const subject = target ? `${input.surface} · ${target}` : input.surface;
|
|
72
|
+
const why = rationale ? ` — ${rationale}` : "";
|
|
73
|
+
|
|
74
|
+
switch (input.outcome) {
|
|
75
|
+
case "allow":
|
|
76
|
+
return {
|
|
77
|
+
type: "info",
|
|
78
|
+
message: `Auto-review allowed ${subject}${why}`,
|
|
79
|
+
};
|
|
80
|
+
case "auto_confirm":
|
|
81
|
+
return {
|
|
82
|
+
type: "info",
|
|
83
|
+
message: `Auto-review allowed ${subject}; auto-confirming the local dialog${why}`,
|
|
84
|
+
};
|
|
85
|
+
case "needs_confirmation":
|
|
86
|
+
return {
|
|
87
|
+
type: "info",
|
|
88
|
+
message: `Auto-review allowed ${subject}; local confirmation is still required${why}`,
|
|
89
|
+
};
|
|
90
|
+
case "defer":
|
|
91
|
+
return {
|
|
92
|
+
type: "info",
|
|
93
|
+
message: `Auto-review deferred ${subject} to you${why}`,
|
|
94
|
+
};
|
|
95
|
+
case "deny":
|
|
96
|
+
return {
|
|
97
|
+
type: "warning",
|
|
98
|
+
message: `Auto-review denied ${subject}${why}`,
|
|
99
|
+
};
|
|
100
|
+
case "circuit_breaker":
|
|
101
|
+
return {
|
|
102
|
+
type: "warning",
|
|
103
|
+
message: `Auto-review stopped after repeated denials this turn${
|
|
104
|
+
why || " — use /approve for one exact retry"
|
|
105
|
+
}`,
|
|
106
|
+
};
|
|
107
|
+
case "unavailable":
|
|
108
|
+
return {
|
|
109
|
+
type: "error",
|
|
110
|
+
message: `Auto-review is unavailable${why}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Best-effort toast; never throws into the authorizer path. */
|
|
116
|
+
export function notifyUserReview(
|
|
117
|
+
ctx: ExtensionContext | undefined,
|
|
118
|
+
notice: UserReviewNotice,
|
|
119
|
+
): void {
|
|
120
|
+
if (!ctx?.hasUI) return;
|
|
121
|
+
try {
|
|
122
|
+
ctx.ui.notify(notice.message, notice.type);
|
|
123
|
+
} catch {
|
|
124
|
+
// UI delivery is observational and must not change a decision.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Best-effort footer status; pass undefined to clear. */
|
|
129
|
+
export function setUserReviewStatus(
|
|
130
|
+
ctx: ExtensionContext | undefined,
|
|
131
|
+
text: string | undefined,
|
|
132
|
+
key = USER_REVIEW_STATUS_KEY,
|
|
133
|
+
): void {
|
|
134
|
+
if (!ctx?.hasUI) return;
|
|
135
|
+
try {
|
|
136
|
+
ctx.ui.setStatus?.(key, text);
|
|
137
|
+
} catch {
|
|
138
|
+
// UI delivery is observational and must not change a decision.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"model": "cliproxyapi/codex-auto-review",
|
|
3
|
-
"reasoning": "medium",
|
|
4
|
-
"timeoutMs": 45000,
|
|
5
|
-
"maxTokens": 1600,
|
|
6
|
-
"retries": 1,
|
|
7
|
-
"maxUserTranscriptTokens": 1200,
|
|
8
|
-
"maxToolTranscriptTokens": 1200,
|
|
9
|
-
"maxRelevantResultTokens": 800,
|
|
10
|
-
"failureMode": "deny",
|
|
11
|
-
"grantTtlMs": 60000,
|
|
12
|
-
"autoConfirmBoundedAllows": ["external_directory"]
|
|
13
|
-
}
|