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.
- package/README.md +142 -0
- package/config/jev-config.example.json +61 -0
- package/extensions/index.ts +692 -0
- package/package.json +39 -0
- package/skills/jev-review/SKILL.md +25 -0
- package/src/adapters/openrouter.ts +289 -0
- package/src/adapters/typesafe.ts +244 -0
- package/src/ask.ts +189 -0
- package/src/automatic/gate.ts +45 -0
- package/src/automatic/guardian.ts +320 -0
- package/src/automatic/overlay.ts +247 -0
- package/src/automatic/recovery.ts +32 -0
- package/src/automatic/serialize.ts +278 -0
- package/src/cache.ts +56 -0
- package/src/commands.ts +408 -0
- package/src/config.ts +385 -0
- package/src/exfil.ts +180 -0
- package/src/factory.ts +140 -0
- package/src/markdown.ts +26 -0
- package/src/metrics.ts +69 -0
- package/src/output-judge.ts +176 -0
- package/src/policy.ts +32 -0
- package/src/reviewer.ts +350 -0
- package/src/tools-policy.ts +128 -0
- package/src/tools.ts +43 -0
|
@@ -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
|
+
}
|