pi-jev-guard 0.1.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.
@@ -0,0 +1,692 @@
1
+ import type { ExtensionAPI, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import type { Model, Provider } from "@earendil-works/pi-ai";
3
+ import type { Api } from "@earendil-works/pi-ai";
4
+ import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek";
5
+ import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
6
+ import { registerJevAskTool } from "../src/ask.ts";
7
+ import {
8
+ configSnapshot,
9
+ loadConfig,
10
+ parseUpstreamRef,
11
+ resolveBackend,
12
+ saveConfig,
13
+ type JevConfig,
14
+ } from "../src/config.ts";
15
+ import { createReviewer, createTypedAsk } from "../src/factory.ts";
16
+ import { createMetrics } from "../src/metrics.ts";
17
+ import { registerJevTool } from "../src/tools.ts";
18
+ import {
19
+ STATIC_DEEPSEEK_FLASH,
20
+ buildOverlaidProvider,
21
+ isTwinId,
22
+ twinIdFor,
23
+ } from "../src/automatic/overlay.ts";
24
+ import type { GateVerdictReport } from "../src/automatic/guardian.ts";
25
+ import { DEFAULT_GATE_RULES } from "../src/reviewer.ts";
26
+ import { elideWithMarker } from "../src/reviewer.ts";
27
+ import {
28
+ TOOL_POLICY_REVISION,
29
+ checkFilePath,
30
+ checkShellCommand,
31
+ } from "../src/tools-policy.ts";
32
+ import {
33
+ OUTPUT_QUESTIONS,
34
+ buildOutputState,
35
+ createVerdictCache,
36
+ evaluateOutput,
37
+ outputKey,
38
+ toolResultText,
39
+ type OutputVerdict,
40
+ } from "../src/output-judge.ts";
41
+ import {
42
+ EXFIL_QUESTIONS,
43
+ buildExfilState,
44
+ evaluateExfil,
45
+ hasNetworkTool,
46
+ lastUserText,
47
+ type ExfilVerdict,
48
+ } from "../src/exfil.ts";
49
+
50
+ export interface GateStatus {
51
+ active: boolean;
52
+ /** Ref twin "provider/id__jev" quando attivo. */
53
+ twin?: string;
54
+ /** Ref upstream originale "provider/id" quando attivo. */
55
+ upstream?: string;
56
+ reason: string;
57
+ }
58
+
59
+ export default function (pi: ExtensionAPI) {
60
+ let config: JevConfig = loadConfig();
61
+ // Full (3 regole) per tool/comandi/diagnostica; gate (difetti concreti) per provider.
62
+ let handle = createReviewer(configSnapshot(config));
63
+ let gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
64
+ let typedHandle = createTypedAsk(configSnapshot(config));
65
+ let outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
66
+ let exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
67
+ let lastOutput: { tool: string; verdict: OutputVerdict; at: number } | undefined;
68
+ let lastGate: { report: GateVerdictReport; at: number } | undefined;
69
+
70
+ function recordGateVerdict(report: GateVerdictReport): void {
71
+ lastGate = { report, at: Date.now() };
72
+ }
73
+ let lastOutputErrorAt = 0;
74
+ let lastExfilErrorAt = 0;
75
+ const metrics = createMetrics();
76
+
77
+ // Opt-in: nessun overlay all'avvio. Si crea solo con /jev upstream
78
+ // (o ripristino automatico quando la config dice automatic).
79
+ let overlaidProviderId: string | undefined;
80
+ let upstreamRefs = new Map<string, Provider>();
81
+ let twinTarget: { provider: string; model: string } | undefined;
82
+
83
+ const getConfig = () => config;
84
+ const getReview = () => handle.review;
85
+ const getGateReview = () => gateHandle.review;
86
+ const getInfo = () => ({ backend: handle.backend, model: handle.model });
87
+
88
+ function getGateStatus(): GateStatus {
89
+ if (overlaidProviderId && twinTarget) {
90
+ return {
91
+ active: true,
92
+ twin: `${twinTarget.provider}/${twinIdFor(twinTarget.model)}`,
93
+ upstream: `${twinTarget.provider}/${twinTarget.model}`,
94
+ reason: `overlay attivo su ${overlaidProviderId} (stessa auth del provider)`,
95
+ };
96
+ }
97
+ return {
98
+ active: false,
99
+ reason: "nessun guarded attivo — usa /jev upstream <provider> <model>",
100
+ };
101
+ }
102
+
103
+ function isGuardedModel(provider: string | undefined, modelId: string | undefined): boolean {
104
+ if (!overlaidProviderId || !twinTarget || !provider || !modelId) return false;
105
+ return (
106
+ provider === twinTarget.provider &&
107
+ modelId === twinIdFor(twinTarget.model) &&
108
+ isTwinId(modelId)
109
+ );
110
+ }
111
+
112
+ function refresh() {
113
+ config = loadConfig();
114
+ handle = createReviewer(configSnapshot(config));
115
+ gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
116
+ typedHandle = createTypedAsk(configSnapshot(config));
117
+ outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
118
+ exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
119
+ return config;
120
+ }
121
+
122
+ /** Provider upstream vero, spacchettando il nostro overlay se già attivo. */
123
+ function liveUpstreamProvider(
124
+ registry: ModelRegistry,
125
+ providerId: string,
126
+ ): Provider | undefined {
127
+ if (overlaidProviderId === providerId) {
128
+ return upstreamRefs.get(providerId);
129
+ }
130
+ return registry.getProvider(providerId);
131
+ }
132
+
133
+ function snapshotUpstream(
134
+ registry: ModelRegistry,
135
+ providerId: string,
136
+ modelId: string,
137
+ ): { provider: Provider; model: Model<Api> } | { error: string } {
138
+ const provider = liveUpstreamProvider(registry, providerId);
139
+ if (!provider) {
140
+ return { error: `Provider non trovato: ${providerId}` };
141
+ }
142
+ const model = provider
143
+ .getModels()
144
+ .find((m) => m.id === modelId && !isTwinId(m.id));
145
+ if (!model) {
146
+ return { error: `Modello non trovato: ${providerId}/${modelId}` };
147
+ }
148
+ return { provider, model: model as Model<Api> };
149
+ }
150
+
151
+ function applyOverlay(
152
+ snapshot: { provider: Provider; model: Model<Api> },
153
+ persist: boolean,
154
+ ): UpstreamResult {
155
+ const providerId = snapshot.provider.id;
156
+ try {
157
+ // Rimuovi overlay precedente su provider diverso (ripristina builtin).
158
+ if (overlaidProviderId && overlaidProviderId !== providerId) {
159
+ try {
160
+ pi.unregisterProvider(overlaidProviderId);
161
+ } catch {
162
+ // ignora
163
+ }
164
+ upstreamRefs.delete(overlaidProviderId);
165
+ }
166
+ // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
167
+ if (!upstreamRefs.has(providerId)) {
168
+ upstreamRefs.set(providerId, snapshot.provider);
169
+ }
170
+ const overlay = buildOverlaidProvider({
171
+ upstream: upstreamRefs.get(providerId) as Provider,
172
+ twinModelIds: [snapshot.model.id],
173
+ getConfig,
174
+ getReview: getGateReview,
175
+ onVerdict: recordGateVerdict,
176
+ });
177
+ pi.registerProvider(overlay);
178
+ overlaidProviderId = providerId;
179
+ twinTarget = { provider: providerId, model: snapshot.model.id };
180
+ config = {
181
+ ...config,
182
+ automatic: {
183
+ ...config.automatic,
184
+ upstreamProvider: providerId,
185
+ upstreamModel: snapshot.model.id,
186
+ },
187
+ };
188
+ if (persist) {
189
+ try {
190
+ saveConfig(config);
191
+ } catch (error) {
192
+ return {
193
+ ok: false,
194
+ error: `Upstream aggiornato in memoria ma salvataggio fallito: ${
195
+ error instanceof Error ? error.message : String(error)
196
+ }`,
197
+ };
198
+ }
199
+ }
200
+ return { ok: true, twin: `${providerId}/${twinIdFor(snapshot.model.id)}` };
201
+ } catch (error) {
202
+ return {
203
+ ok: false,
204
+ error: error instanceof Error ? error.message : String(error),
205
+ };
206
+ }
207
+ }
208
+
209
+ /** Crea o sposta il twin guarded (persistente). */
210
+ function setUpstream(
211
+ registry: ModelRegistry,
212
+ providerId: string,
213
+ modelId: string,
214
+ ): UpstreamResult {
215
+ const found = snapshotUpstream(registry, providerId, modelId);
216
+ if ("error" in found) return { ok: false, error: found.error };
217
+ return applyOverlay(found, true);
218
+ }
219
+
220
+ /** Riapplica l'ultimo upstream salvato in config. */
221
+ function refreshUpstream(registry: ModelRegistry): UpstreamResult {
222
+ const found = snapshotUpstream(
223
+ registry,
224
+ config.automatic.upstreamProvider,
225
+ config.automatic.upstreamModel,
226
+ );
227
+ if ("error" in found) return { ok: false, error: found.error };
228
+ return applyOverlay(found, false);
229
+ }
230
+
231
+ /** Rimuove l'overlay (il builtin torna da solo). */
232
+ function removeGuarded(): { ok: boolean; error?: string } {
233
+ if (!overlaidProviderId) return { ok: false, error: "nessun guarded attivo" };
234
+ try {
235
+ pi.unregisterProvider(overlaidProviderId);
236
+ } catch (error) {
237
+ return {
238
+ ok: false,
239
+ error: error instanceof Error ? error.message : String(error),
240
+ };
241
+ }
242
+ upstreamRefs.delete(overlaidProviderId);
243
+ overlaidProviderId = undefined;
244
+ twinTarget = undefined;
245
+ return { ok: true };
246
+ }
247
+
248
+ /** Seleziona il twin attivo come modello di sessione. */
249
+ async function switchToGuarded(
250
+ registry: ModelRegistry,
251
+ ): Promise<{ ok: boolean; error?: string; model?: string }> {
252
+ if (!overlaidProviderId || !twinTarget) {
253
+ return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
254
+ }
255
+ const twinId = twinIdFor(twinTarget.model);
256
+ const model = registry.find(overlaidProviderId, twinId);
257
+ if (!model) {
258
+ return { ok: false, error: `twin non in registry: ${overlaidProviderId}/${twinId}` };
259
+ }
260
+ try {
261
+ const ok = await pi.setModel(model);
262
+ if (!ok) {
263
+ return { ok: false, error: `auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}` };
264
+ }
265
+ return { ok: true, model: `${overlaidProviderId}/${model.id}` };
266
+ } catch (error) {
267
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
268
+ }
269
+ }
270
+
271
+ registerJevTool(pi, {
272
+ review: getReview,
273
+ config: getConfig,
274
+ metrics,
275
+ });
276
+
277
+ registerJevAskTool(pi, {
278
+ ask: () => typedHandle.ask,
279
+ config: getConfig,
280
+ keyPresent: () => resolveBackend(getConfig()).apiKeyPresent,
281
+ });
282
+
283
+ registerJevCommand(pi, {
284
+ config: getConfig,
285
+ setMode: (mode) => {
286
+ config = { ...config, mode };
287
+ handle = createReviewer(configSnapshot(config));
288
+ gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
289
+ typedHandle = createTypedAsk(configSnapshot(config));
290
+ try {
291
+ saveConfig(config);
292
+ } catch {
293
+ // Resta in memoria anche se il salvataggio fallisce.
294
+ }
295
+ return config;
296
+ },
297
+ reload: refresh,
298
+ review: getReview,
299
+ reviewerInfo: getInfo,
300
+ metrics,
301
+ gateStatus: getGateStatus,
302
+ setUpstream,
303
+ refreshUpstream,
304
+ removeGuarded,
305
+ switchToGuarded,
306
+ getLastOutput: () => lastOutput,
307
+ getLastGate: () => lastGate,
308
+ });
309
+
310
+ // Seed headless: --print risolve --model prima di session_start, quindi per
311
+ // il solo caso deepseek/deepseek-flash si pre-registra un overlay statico
312
+ // (sostituito dal clone live appena il registry è disponibile).
313
+ // Richiede JEV_AUTO_UPSTREAM: senza, nessun guarded (opt-in).
314
+ const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim().toLowerCase();
315
+ if (bootAuto === "deepseek/deepseek-flash") {
316
+ try {
317
+ const base = deepseekProvider();
318
+ const seedUpstream: Provider = {
319
+ ...base,
320
+ getModels: () => [...base.getModels(), { ...STATIC_DEEPSEEK_FLASH }],
321
+ };
322
+ const overlay = buildOverlaidProvider({
323
+ upstream: seedUpstream,
324
+ twinModelIds: ["deepseek-flash"],
325
+ getConfig,
326
+ getReview: getGateReview,
327
+ onVerdict: recordGateVerdict,
328
+ });
329
+ pi.registerProvider(overlay);
330
+ upstreamRefs.set("deepseek", seedUpstream);
331
+ overlaidProviderId = "deepseek";
332
+ twinTarget = { provider: "deepseek", model: "deepseek-flash" };
333
+ } catch {
334
+ overlaidProviderId = undefined;
335
+ twinTarget = undefined;
336
+ }
337
+ }
338
+
339
+ pi.on("session_start", async (_event, ctx) => {
340
+ const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
341
+ if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
342
+ try {
343
+ const parsed = parseUpstreamRef([auto]);
344
+ if ("error" in parsed) {
345
+ if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM non valido: ${parsed.error}`, "warning");
346
+ } else {
347
+ const found = snapshotUpstream(ctx.modelRegistry, parsed.provider, parsed.model);
348
+ if ("error" in found) {
349
+ if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM fallito: ${found.error}`, "warning");
350
+ } else {
351
+ applyOverlay(found, false);
352
+ }
353
+ }
354
+ } catch {
355
+ // Resta opt-in manuale.
356
+ }
357
+ }
358
+ // Config automatic senza guarded = contraddittorio: ripristina + seleziona.
359
+ try {
360
+ if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
361
+ if (!overlaidProviderId || !twinTarget) {
362
+ const restored = refreshUpstream(ctx.modelRegistry);
363
+ if (!restored.ok) {
364
+ if (ctx.hasUI) {
365
+ ctx.ui.notify(`Jev automatic senza guarded: ${restored.error} (diagnostica sola).`, "warning");
366
+ }
367
+ return;
368
+ }
369
+ }
370
+ if (!overlaidProviderId || !twinTarget) return;
371
+ const twinId = twinIdFor(twinTarget.model);
372
+ if (ctx.model?.provider === overlaidProviderId && ctx.model?.id === twinId) return;
373
+ const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
374
+ if (!twin) return;
375
+ const switched = await pi.setModel(twin);
376
+ if (ctx.hasUI) {
377
+ ctx.ui.notify(
378
+ switched
379
+ ? `Jev automatic: selezionato ${overlaidProviderId}/${twinId}.`
380
+ : `Jev automatic: auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}.`,
381
+ switched ? "info" : "warning",
382
+ );
383
+ }
384
+ } catch {
385
+ // Non bloccare l'avvio sessione.
386
+ }
387
+ });
388
+
389
+ // Enforcement a livello selezione: in automatic non si può restare
390
+ // su un modello non guarded (revert immediato al twin).
391
+ pi.on("model_select", async (event, ctx) => {
392
+ try {
393
+ if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
394
+ if (!overlaidProviderId || !twinTarget) return;
395
+ if (isGuardedModel(event.model.provider, event.model.id)) return;
396
+
397
+ const twinId = twinIdFor(twinTarget.model);
398
+ const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
399
+ const fallback =
400
+ twin ??
401
+ (event.previousModel && isGuardedModel(event.previousModel.provider, event.previousModel.id)
402
+ ? event.previousModel
403
+ : undefined);
404
+ if (fallback) {
405
+ await pi.setModel(fallback);
406
+ if (ctx.hasUI) {
407
+ ctx.ui.notify(
408
+ `Jev automatic: modello non protetto rifiutato, ripristinato ${fallback.provider}/${fallback.id}. Per usarlo esplicitamente: /jev mode on-demand.`,
409
+ "warning",
410
+ );
411
+ }
412
+ } else if (ctx.hasUI) {
413
+ ctx.ui.notify(
414
+ "Jev automatic senza guarded disponibile: enforcement assente (diagnostica sola).",
415
+ "warning",
416
+ );
417
+ }
418
+ } catch {
419
+ // Mai rompere il cambio modello per l'enforcement.
420
+ }
421
+ });
422
+
423
+ // Status TUI durante il gate: lo streaming guarded non emette nulla finché
424
+ // Jev non approva; mostra stato invece di silenzio ambiguo.
425
+ pi.on("message_start", async (event, ctx) => {
426
+ try {
427
+ if (config.mode !== "automatic" || !ctx.hasUI) return;
428
+ const msg: any = event.message;
429
+ if (msg?.role !== "assistant") return;
430
+ const provider: string | undefined =
431
+ typeof msg?.provider === "string" ? msg.provider : ctx.model?.provider;
432
+ const id: string | undefined =
433
+ typeof msg?.model === "string" ? msg.model : (ctx.model as any)?.id;
434
+ if (provider && id && isGuardedModel(provider, id)) {
435
+ ctx.ui.setStatus("jev", "Jev: verifica in corso (output trattenuto)");
436
+ }
437
+ } catch {
438
+ // Mai rompere il flusso per lo status.
439
+ }
440
+ });
441
+
442
+ // Policy pre-esecuzione tool (solo automatic).
443
+ pi.on("tool_call", async (event, ctx) => {
444
+ try {
445
+ if (config.mode !== "automatic") return;
446
+ if (!config.toolsPolicy.enabled) return;
447
+
448
+ if (event.toolName === "bash" || event.toolName === "powershell") {
449
+ const input = event.input as { command?: unknown };
450
+ const verdict = checkShellCommand(input.command, event.toolName);
451
+ if (verdict.blocked) {
452
+ pi.appendEntry("jev-tool-block", {
453
+ tool: event.toolName,
454
+ ruleId: verdict.ruleId,
455
+ revision: TOOL_POLICY_REVISION,
456
+ });
457
+ if (ctx.hasUI) {
458
+ ctx.ui.notify(`Jev tool policy: bloccato ${event.toolName} (${verdict.reason})`, "warning");
459
+ }
460
+ return {
461
+ block: true,
462
+ reason: `Bloccato da jev tool policy (${verdict.ruleId}): ${verdict.reason}.`,
463
+ };
464
+ }
465
+ // E-lite: comandi con tool di rete → giudizio semantico (fail-open).
466
+ const exfil = await exfilCheck(event.toolName, input.command, ctx);
467
+ if (exfil) return exfil;
468
+ return;
469
+ }
470
+
471
+ if (event.toolName === "write" || event.toolName === "edit") {
472
+ const input = event.input as { path?: unknown };
473
+ const verdict = checkFilePath(input.path);
474
+ if (verdict.blocked) {
475
+ pi.appendEntry("jev-tool-block", {
476
+ tool: event.toolName,
477
+ ruleId: verdict.ruleId,
478
+ revision: TOOL_POLICY_REVISION,
479
+ });
480
+ if (ctx.hasUI) {
481
+ ctx.ui.notify(`Jev tool policy: bloccato ${event.toolName} (${verdict.reason})`, "warning");
482
+ }
483
+ return {
484
+ block: true,
485
+ reason: `Bloccato da jev tool policy (${verdict.ruleId}): ${verdict.reason}.`,
486
+ };
487
+ }
488
+ return;
489
+ }
490
+ } catch {
491
+ // Fail-open qui: mai rompere tool legittimi per un errore della policy.
492
+ }
493
+ });
494
+
495
+ async function exfilCheck(
496
+ toolName: string,
497
+ command: unknown,
498
+ ctx: ExtensionContext,
499
+ ): Promise<{ block: true; reason: string } | undefined> {
500
+ if (!config.exfilCheck.enabled) return undefined;
501
+ if (!config.exfilCheck.tools.includes(toolName)) return undefined;
502
+ if (typeof command !== "string" || !hasNetworkTool(command)) return undefined;
503
+ if (!resolveBackend(config).apiKeyPresent) return undefined;
504
+
505
+ let verdict: ExfilVerdict | undefined;
506
+ try {
507
+ verdict = await exfilCache.getOr(outputKey(toolName, command), async () => {
508
+ const asked = await typedHandle.ask(
509
+ buildExfilState({
510
+ cwd: ctx.cwd,
511
+ toolName,
512
+ command,
513
+ userRequest: lastUserText(ctx.sessionManager.buildContextEntries()),
514
+ }),
515
+ EXFIL_QUESTIONS,
516
+ ctx.signal ?? undefined,
517
+ );
518
+ const t = config.exfilCheck;
519
+ return evaluateExfil(asked, {
520
+ destructive: t.blockOn.destructive,
521
+ exfiltration: t.blockOn.exfiltration,
522
+ beyondScope: t.blockOn.beyondScope,
523
+ impact: t.blockOn.impact,
524
+ minConfidence: t.minConfidence,
525
+ });
526
+ });
527
+ } catch (error) {
528
+ if (ctx.signal?.aborted) return undefined;
529
+ const now = Date.now();
530
+ if (now - lastExfilErrorAt >= 60_000) {
531
+ lastExfilErrorAt = now;
532
+ try {
533
+ ctx.ui.notify(
534
+ `Jev exfil check: ${error instanceof Error ? error.message : String(error)} (failing open)`,
535
+ "error",
536
+ );
537
+ } catch {
538
+ // ignora
539
+ }
540
+ }
541
+ return undefined;
542
+ }
543
+ if (!verdict?.flagged) return undefined;
544
+
545
+ pi.appendEntry("jev-exfil-flag", {
546
+ tool: toolName,
547
+ reasons: verdict.reasons,
548
+ model: verdict.model,
549
+ });
550
+ const summary = verdict.reasons.join(", ");
551
+ if (!ctx.hasUI) {
552
+ if (config.exfilCheck.blockWithoutUI) {
553
+ return { block: true, reason: `Bloccato da jev exfil check (headless): ${summary}.` };
554
+ }
555
+ return undefined;
556
+ }
557
+ const allow = await ctx.ui.confirm(
558
+ "Jev: comando di rete sospetto",
559
+ `${toolName}\n${summary}\n\nEseguire comunque?`,
560
+ );
561
+ return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
562
+ }
563
+
564
+ // Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
565
+ // secret nel testo e classifica i fallimenti. Non blocca mai: allega
566
+ // una riga al risultato che il modello legge.
567
+ pi.on("tool_result", async (event, ctx) => {
568
+ try {
569
+ if (config.mode !== "automatic") return;
570
+ if (!config.outputJudge.enabled) return;
571
+ if (!config.outputJudge.tools.includes(event.toolName)) return;
572
+ if (!resolveBackend(config).apiKeyPresent) return;
573
+ const text = toolResultText(event.content);
574
+ if (!text.trim()) return;
575
+
576
+ let verdict: OutputVerdict | undefined;
577
+ try {
578
+ verdict = await outputCache.getOr(outputKey(event.toolName, text), async () => {
579
+ const asked = await typedHandle.ask(
580
+ buildOutputState({
581
+ cwd: ctx.cwd,
582
+ toolName: event.toolName,
583
+ input: event.input,
584
+ output: text,
585
+ isError: event.isError,
586
+ outputChars: config.outputJudge.outputChars,
587
+ }),
588
+ OUTPUT_QUESTIONS,
589
+ ctx.signal ?? undefined,
590
+ );
591
+ return evaluateOutput(asked, config.outputJudge);
592
+ });
593
+ } catch (error) {
594
+ if (ctx.signal?.aborted) return;
595
+ throttledOutputError(ctx, error);
596
+ return;
597
+ }
598
+ if (!verdict?.notice) return;
599
+
600
+ lastOutput = { tool: event.toolName, verdict, at: Date.now() };
601
+ if (ctx.hasUI) {
602
+ ctx.ui.setStatus("jev", `Jev: ${verdict.kind} (${event.toolName})`);
603
+ if (verdict.kind === "leak") {
604
+ ctx.ui.notify(
605
+ `Jev: ${event.toolName} output may carry a secret (${verdict.leaksSecret.toFixed(2)})`,
606
+ "warning",
607
+ );
608
+ }
609
+ }
610
+ return {
611
+ content: [...event.content, { type: "text", text: `[jev-guard] ${verdict.notice}` }],
612
+ };
613
+ } catch {
614
+ // Fail-open: mai rompere i risultati tool.
615
+ }
616
+ });
617
+
618
+ function throttledOutputError(ctx: { ui: { notify: (m: string, t: "error") => void } }, error: unknown): void {
619
+ const now = Date.now();
620
+ if (now - lastOutputErrorAt < 60_000) return;
621
+ lastOutputErrorAt = now;
622
+ try {
623
+ ctx.ui.notify(
624
+ `Jev output judge: ${error instanceof Error ? error.message : String(error)} (failing open)`,
625
+ "error",
626
+ );
627
+ } catch {
628
+ // ignora
629
+ }
630
+ }
631
+
632
+ pi.on("message_end", async (event, ctx) => {
633
+ try {
634
+ if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
635
+ if (config.mode !== "automatic") return;
636
+ if (event.message.role !== "assistant") return;
637
+
638
+ const msg = event.message as { provider?: string; model?: string };
639
+ if (isGuardedModel(msg.provider, msg.model)) {
640
+ pi.appendEntry("jev-guarded", {
641
+ twin: twinTarget ? `${twinTarget.provider}/${twinIdFor(twinTarget.model)}` : undefined,
642
+ upstream: twinTarget ? `${twinTarget.provider}/${twinTarget.model}` : undefined,
643
+ });
644
+ return;
645
+ }
646
+
647
+ const gate = getGateStatus();
648
+ if (config.automatic.requireGuardedModel && !gate.active) {
649
+ if (ctx.hasUI) {
650
+ ctx.ui.notify(
651
+ "Jev automatic senza guarded attivo: enforcement assente (diagnostica sola). Usa /jev upstream <provider> <model> e seleziona il modello col suffisso __jev.",
652
+ "warning",
653
+ );
654
+ }
655
+ }
656
+
657
+ const candidate = event.message.content
658
+ .flatMap((block: any) =>
659
+ block && block.type === "text" && typeof block.text === "string"
660
+ ? [block.text]
661
+ : [],
662
+ )
663
+ .join("\n\n");
664
+
665
+ if (!candidate.trim()) return;
666
+
667
+ const snapshot = configSnapshot(config);
668
+ const result = await handle.review(
669
+ {
670
+ requirements: "Satisfy the user's explicit request without defects.",
671
+ candidate: elideWithMarker(candidate, snapshot.limits.maxCandidateChars),
672
+ },
673
+ ctx.signal ?? undefined,
674
+ );
675
+ metrics.record(result);
676
+
677
+ if (ctx.hasUI && result.status !== "pass") {
678
+ ctx.ui.notify(`Jev diagnostic ${result.status}: ${JSON.stringify(result.checks)}`, "warning");
679
+ }
680
+
681
+ pi.appendEntry("jev-diagnostic", {
682
+ status: result.status,
683
+ backend: result.backend,
684
+ model: result.model,
685
+ errorCode: result.errorCode,
686
+ elapsedMs: result.elapsedMs,
687
+ });
688
+ } catch {
689
+ // Mai rompere il flusso per un diagnostico.
690
+ }
691
+ });
692
+ }